fix(android): qualify background capabilities and emulator coverage

Deliver install confirmation, live core status, location foreground-service state and WallpaperService metadata. Add the SDK instrumentation smoke across API 26/29/30/33/36, keep size debt within the existing manifest, and document Android consent and provider-URI custody.
This commit is contained in:
Ouroboros 2026-09-14 12:47:25 +03:00
parent bdb534db36
commit 2d2f3250fb
17 changed files with 308 additions and 84 deletions

View file

@ -828,7 +828,7 @@ jobs:
strategy:
fail-fast: false
matrix:
api-level: [26, 30, 33, 36]
api-level: [26, 29, 30, 33, 36]
permissions:
contents: read
steps:
@ -842,6 +842,10 @@ jobs:
shell: bash
run: |
set -euo pipefail
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-ouroboros-kvm.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0" \
"emulator" "system-images;android-${{ matrix.api-level }};default;x86_64"
echo "no" | avdmanager create avd --force --name "obo-api-${{ matrix.api-level }}" \
@ -858,6 +862,11 @@ jobs:
--out "$ROOT/build" --keystore "$ROOT/test.keystore" --key-alias ouroboros-host \
--keystore-pass-file "$ROOT/password" --create-development-key \
--version-code "$GITHUB_RUN_NUMBER" --version-name "$(cat VERSION)"
python android/host/build.py --sdk "$ANDROID_HOME" --java-home "$JAVA_HOME" \
--out "$ROOT/device" --project android/tests/device \
--keystore "$ROOT/test.keystore" --key-alias ouroboros-host \
--keystore-pass-file "$ROOT/password" \
--version-code "$GITHUB_RUN_NUMBER" --version-name "$(cat VERSION)"
- name: Boot emulator and smoke install/start
uses: reactivecircus/android-emulator-runner@e93c997dcab69054e999e3924af3d21c4a736e13 # v2.9.0
with:
@ -867,10 +876,24 @@ jobs:
emulator-options: -no-window -no-audio -no-boot-anim
disable-animations: true
script: |
set -euo pipefail
adb install -r "$RUNNER_TEMP/android-smoke/build/Ouroboros.apk"
adb install -r "$RUNNER_TEMP/android-smoke/device/app.apk"
adb shell am start -n ai.ouroboros.android/.MainActivity
sleep 2
adb shell pm path ai.ouroboros.android
adb shell dumpsys package ai.ouroboros.android | grep -E 'versionName|versionCode'
adb shell am force-stop ai.ouroboros.android
adb logcat -c
set +e
timeout 60s adb shell am instrument -w ai.ouroboros.android.device.smoke/ai.ouroboros.android.device.DeviceSdkSmoke >"$RUNNER_TEMP/android-smoke/instrument.txt" 2>&1
instrument_status=$?
set -e
cat "$RUNNER_TEMP/android-smoke/instrument.txt"
adb logcat -d -s OuroborosDeviceSdkSmoke:I '*:S' | tee "$RUNNER_TEMP/android-smoke/device-log.txt"
grep -q 'OBO_DEVICE_SDK_SMOKE=PASS' "$RUNNER_TEMP/android-smoke/instrument.txt" \
|| grep -q 'OBO_DEVICE_SDK_SMOKE=PASS' "$RUNNER_TEMP/android-smoke/device-log.txt"
test "$instrument_status" -eq 0
# The publisher key is used only on the trusted tag path. Root/boot/device
# acceptance is separate from these exact-byte artifact checks.

View file

@ -8,6 +8,24 @@ Examples: {"method":"capabilities"}, {"method":"packages.inspect","params":{
Package installs are asynchronous: a successful call proves bytes were staged and commit was submitted,
not that Android completed the install. Query packages.install.status and packages.sessions. A lost response
is unknown and is never retried automatically.
source_uri must be readable by the Android app UID, not just Linux root.
To pass a Linux download, use content.write to transfer bytes to a writable
provider URI (an app-owned MediaStore entry or a granted document), then install
that URI. mode='wt' replaces contents; mode='wa' appends a subsequent chunk.
Root android-exec remains a separate native installation path.
location.state reports grants/providers. location.get accepts provider and
timeout_ms (1000..30000); its result includes age_ms and mock when a fix exists.
accessibility.windows accepts max_windows, max_nodes and max_depth; node_address
identifies a path in that snapshot, so inspect the current UI before acting.
accessibility.perform accepts action=click/set_text/scroll plus node_address;
set_text also needs text; scroll uses direction=forward/backward. Global actions
are back/home/notifications/quick_settings. action=gesture accepts gesture=tap/swipe,
x/y, and for swipe x2/y2 and duration_ms.
notifications.list defaults to metadata; include_text=true requests title/text.
packages.inspect on the host package lists declared activities/services. A declared
Quick Settings tile or wallpaper service is not proof that it is added or selected.
Intent params: action, data, type (MIME), package, component (package/class),
categories, numeric flags, extras. Extras and ContentValues use {"type":...,"value":...}
per key, e.g. {"length":{"type":"int","value":900}}. Bytes are base64, long values

View file

@ -6,6 +6,8 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
@ -44,7 +46,7 @@
</intent-filter>
</activity>
<service android:name=".CoreService" android:exported="false" android:process=":native"
android:foregroundServiceType="specialUse">
android:foregroundServiceType="specialUse|location">
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Owner control and status for the phone-resident Ouroboros agent" />
</service>
@ -63,6 +65,7 @@
</service>
<service android:name=".OuroborosWallpaperService" android:exported="true"
android:permission="android.permission.BIND_WALLPAPER">
<meta-data android:name="android.service.wallpaper" android:resource="@xml/wallpaper_service" />
<intent-filter>
<action android:name="android.service.wallpaper.WallpaperService" />
</intent-filter>
@ -85,7 +88,6 @@
android:exported="false" android:process=":native">
<intent-filter>
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<action android:name="android.intent.action.USER_UNLOCKED" />
</intent-filter>
</receiver>
</application>

View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<wallpaper xmlns:android="http://schemas.android.com/apk/res/android"
android:thumbnail="@drawable/icon" />

View file

@ -230,6 +230,7 @@ final class AndroidBridge implements Closeable {
PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL);
params.setInstallReason(PackageManager.INSTALL_REASON_USER);
params.setSize(digest.size);
int sessionId = installer.createSession(params);
PackageInstaller.Session session = installer.openSession(sessionId);
boolean commitSubmitted = false;
@ -295,8 +296,9 @@ final class AndroidBridge implements Closeable {
.put("package", nullable(info.getAppPackageName())).put("label", nullable(info.getAppLabel()))
.put("active", info.isActive()).put("sealed", info.isSealed())
.put("staged", Build.VERSION.SDK_INT >= 29 && info.isStaged())
.put("progress", info.getProgress()).put("size_bytes", info.getSize());
if (Build.VERSION.SDK_INT >= 29) result.put("created_ms", info.getCreatedMillis());
.put("progress", info.getProgress());
result.put("size_bytes", Build.VERSION.SDK_INT >= 27 ? info.getSize() : JSONObject.NULL);
if (Build.VERSION.SDK_INT >= 30) result.put("created_ms", info.getCreatedMillis());
return result;
}
@ -330,6 +332,7 @@ final class AndroidBridge implements Closeable {
return new JSONObject().put("protocol", 1).put("sdk", Build.VERSION.SDK_INT)
.put("package", context.getPackageName()).put("uid", android.os.Process.myUid())
.put("methods", new JSONArray(Arrays.asList(METHODS))).put("permissions", permissions(own, pm))
.put("can_request_package_installs", pm.canRequestPackageInstalls())
.put("typed_values", "null,string,boolean,int,long,float,double,uri,bytes,string[],int[],long[],bundle")
.put("provider_authority", "host_app_uid; root caller does not bypass Android provider permissions")
.put("root_commands", "android-exec").put("source_total_known", false)
@ -351,6 +354,8 @@ final class AndroidBridge implements Closeable {
android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED)
.put("permission_coarse", context.checkSelfPermission(
android.Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED)
.put("permission_background", Build.VERSION.SDK_INT < 29 || context.checkSelfPermission(
android.Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED)
.put("providers", providers).put("source_total_known", false);
}
@ -382,9 +387,16 @@ final class AndroidBridge implements Closeable {
manager.getCurrentLocation(provider, cancellation, calls, location -> {
result.set(location); done.countDown();
});
boolean completed = done.await(timeoutMs, TimeUnit.MILLISECONDS);
if (!completed) cancellation.cancel();
return locationResult(result.get(), completed, provider);
boolean completed;
try { completed = done.await(timeoutMs, TimeUnit.MILLISECONDS); }
finally { cancellation.cancel(); }
Location location = result.get();
if (location == null) return new JSONObject().put("provider", provider)
.put("available", false).put("fresh", false)
.put("reason", completed ? "provider_returned_null" : "no_fix_within_timeout")
.put("permission_background", Build.VERSION.SDK_INT < 29 || context.checkSelfPermission(
android.Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED);
return locationResult(location, true, provider);
}
private static JSONObject locationResult(Location location, boolean fresh, String provider) throws Exception {
@ -393,7 +405,9 @@ final class AndroidBridge implements Closeable {
if (location == null) return result.put("reason", fresh ? "no_fix_within_timeout" : "no_last_known_fix");
return result.put("latitude", location.getLatitude()).put("longitude", location.getLongitude())
.put("accuracy_m", location.hasAccuracy() ? location.getAccuracy() : JSONObject.NULL)
.put("time_ms", location.getTime());
.put("time_ms", location.getTime()).put("mock", location.isFromMockProvider())
.put("age_ms", Math.max(0L, (android.os.SystemClock.elapsedRealtimeNanos()
- location.getElapsedRealtimeNanos()) / 1000000L));
}
private static JSONArray permissions(PackageInfo info, PackageManager pm) throws Exception {

View file

@ -92,7 +92,7 @@ public final class CoreService extends Service {
// A late automatic check must not cancel a queued owner Start or Panic.
int request = ("start".equals(action) || "panic".equals(action))
? operation.incrementAndGet() : operation.get();
startForeground(1, notification("Проверяю состояние ядра"));
startForegroundOwnerNotification();
if (!"panic".equals(action)) {
currentNetwork = connectivity.getActiveNetwork();
updateNetworkDns(currentNetwork == null ? null : connectivity.getLinkProperties(currentNetwork));
@ -159,6 +159,21 @@ public final class CoreService extends Service {
return "panic".equals(action) ? START_NOT_STICKY : START_STICKY;
}
private void startForegroundOwnerNotification() {
Notification value = notification("Проверяю состояние ядра");
if (android.os.Build.VERSION.SDK_INT >= 34) {
int type = android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE;
boolean background = checkSelfPermission(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION)
== android.content.pm.PackageManager.PERMISSION_GRANTED;
if (background && (checkSelfPermission(android.Manifest.permission.ACCESS_COARSE_LOCATION)
== android.content.pm.PackageManager.PERMISSION_GRANTED
|| checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION)
== android.content.pm.PackageManager.PERMISSION_GRANTED))
type |= android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION;
startForeground(1, value, type);
} else startForeground(1, value);
}
@Override public void onDestroy() {
closed = true; dnsRevision.incrementAndGet();
if (networkCallback != null) connectivity.unregisterNetworkCallback(networkCallback);

View file

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="ai.ouroboros.android.device.smoke">
<uses-sdk android:minSdkVersion="26" android:targetSdkVersion="36" />
<instrumentation android:name="ai.ouroboros.android.device.DeviceSdkSmoke"
android:targetPackage="ai.ouroboros.android"
android:label="Ouroboros Android SDK smoke" />
</manifest>

View file

@ -0,0 +1,131 @@
package ai.ouroboros.android.device;
import android.app.Activity;
import android.app.Instrumentation;
import android.content.Context;
import android.content.pm.PackageInstaller;
import android.net.LocalSocket;
import android.net.LocalSocketAddress;
import android.os.Bundle;
import android.util.Log;
import java.io.Closeable;
import java.lang.reflect.Method;
import org.json.JSONArray;
import org.json.JSONObject;
/**
* Small on-device contract check for SDK-gated PackageInstaller readback.
*
* Own a bridge instance in the instrumented host process, create a session
* under its UID and call the actual RPC over the authenticated local socket.
* No APK bytes are committed or installed; no CoreService or root is needed.
*/
public final class DeviceSdkSmoke extends Instrumentation {
private static final String TAG = "OuroborosDeviceSdkSmoke";
private static final String PASS = "OBO_DEVICE_SDK_SMOKE=PASS";
private static final String FAIL = "OBO_DEVICE_SDK_SMOKE=FAIL";
@Override public void onCreate(Bundle arguments) {
super.onCreate(arguments);
start();
}
@Override public void onStart() {
super.onStart();
Bundle result = new Bundle();
int code = Activity.RESULT_OK;
int sessionId = -1;
Closeable bridge = null;
try {
Context target = getTargetContext();
Class<?> bridgeClass = target.getClassLoader().loadClass("ai.ouroboros.android.AndroidBridge");
Method startBridge = bridgeClass.getDeclaredMethod("start", Context.class);
startBridge.setAccessible(true);
bridge = (Closeable) startBridge.invoke(null, target);
PackageInstaller installer = target.getPackageManager().getPackageInstaller();
PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL);
sessionId = installer.createSession(params);
PackageInstaller.Session session = installer.openSession(sessionId);
session.close();
JSONObject capabilities = rpc("capabilities");
if (!capabilities.getJSONArray("methods").toString().contains("packages.sessions"))
throw new AssertionError("capabilities omitted packages.sessions");
if (!capabilities.has("can_request_package_installs"))
throw new AssertionError("capabilities omitted install special-access state");
JSONObject snapshot = rpc("packages.sessions");
JSONArray rows = snapshot.getJSONArray("rows");
boolean found = false;
for (int i = 0; i < rows.length(); i++) {
if (rows.getJSONObject(i).optInt("session_id", -1) == sessionId) {
found = true;
break;
}
}
if (!found) throw new AssertionError("created session missing from packages.sessions");
// The system picker parses WallpaperInfo; a declared service alone is insufficient.
boolean wallpaperFound = false;
for (android.content.pm.ResolveInfo info : target.getPackageManager().queryIntentServices(
new android.content.Intent(android.service.wallpaper.WallpaperService.SERVICE_INTERFACE)
.setPackage(target.getPackageName()), android.content.pm.PackageManager.GET_META_DATA)) {
new android.app.WallpaperInfo(target, info);
wallpaperFound = true;
}
if (!wallpaperFound) throw new AssertionError("host wallpaper metadata is unavailable");
result.putString("obo_marker", PASS);
result.putInt("session_id", sessionId);
result.putInt("sdk", android.os.Build.VERSION.SDK_INT);
} catch (Throwable error) {
code = Activity.RESULT_CANCELED;
result.putString("obo_marker", FAIL);
result.putString("error", error.toString());
Log.e(TAG, FAIL + " sdk=" + android.os.Build.VERSION.SDK_INT, error);
} finally {
if (sessionId >= 0) {
try { getTargetContext().getPackageManager().getPackageInstaller().abandonSession(sessionId); }
catch (Throwable error) {
code = Activity.RESULT_CANCELED;
result.putString("obo_marker", FAIL);
result.putString("cleanup_error", error.toString());
Log.e(TAG, FAIL + " could not abandon smoke session " + sessionId, error);
}
}
if (bridge != null) {
try { bridge.close(); }
catch (Throwable error) {
code = Activity.RESULT_CANCELED;
result.putString("obo_marker", FAIL);
result.putString("bridge_cleanup_error", error.toString());
Log.e(TAG, FAIL + " could not close smoke bridge", error);
}
}
if (code == Activity.RESULT_OK) Log.i(TAG, PASS + " sdk=" + android.os.Build.VERSION.SDK_INT
+ " session=" + sessionId);
sendStatus(2, result);
finish(code, result);
}
}
/** Call the running host bridge over its authenticated local socket. */
private static JSONObject rpc(String method) throws Exception {
LocalSocket socket = new LocalSocket();
try {
socket.setSoTimeout(10000);
socket.connect(new LocalSocketAddress("ai.ouroboros.android.rpc",
LocalSocketAddress.Namespace.ABSTRACT));
socket.getOutputStream().write((new JSONObject().put("id", 1).put("method", method)
.put("params", new JSONObject()).toString() + "\n").getBytes("UTF-8"));
socket.getOutputStream().flush();
StringBuilder response = new StringBuilder();
int value;
while ((value = socket.getInputStream().read()) != -1 && value != '\n') {
if (response.length() >= 1024 * 1024) throw new AssertionError("oversized bridge response");
response.append((char) value);
}
JSONObject envelope = new JSONObject(response.toString());
if (!envelope.optBoolean("ok", false)) throw new AssertionError(envelope.opt("error"));
return envelope.getJSONObject("result");
} finally { socket.close(); }
}
}

View file

@ -38,6 +38,11 @@ class AndroidHostTest(unittest.TestCase):
self.assertIn("android.intent.action.LOCKED_BOOT_COMPLETED", {
row.get(A + "name") for row in receiver.findall("intent-filter/action")
})
wallpaper = services[".OuroborosWallpaperService"]
metadata = wallpaper.find("meta-data")
self.assertEqual(metadata.get(A + "name"), "android.service.wallpaper")
self.assertEqual(metadata.get(A + "resource"), "@xml/wallpaper_service")
self.assertEqual(ET.parse(HOST / "res/xml/wallpaper_service.xml").getroot().tag, "wallpaper")
def test_location_bridge_has_state_and_bounded_current_fix_methods(self):
source = (HOST / "src/ai/ouroboros/android/AndroidBridge.java").read_text()
@ -45,6 +50,8 @@ class AndroidHostTest(unittest.TestCase):
self.assertIn('"location.get"', source)
self.assertIn("getCurrentLocation", source)
self.assertIn("no_fix_within_timeout", source)
self.assertIn("provider_returned_null", source)
self.assertIn("permission_background", source)
def test_cleartext_is_loopback_only(self):
manifest = ET.parse(HOST / "AndroidManifest.xml").getroot()
@ -85,11 +92,28 @@ class AndroidHostTest(unittest.TestCase):
self.assertIn("pending_user_action", callback)
self.assertNotIn("startActivity", callback)
def test_device_sdk_smoke_harness_has_target_and_explicit_pass_marker(self):
device = Path(__file__).resolve().parents[1] / "tests" / "device"
manifest = ET.parse(device / "AndroidManifest.xml").getroot()
instrumentation = manifest.find("instrumentation")
self.assertIsNotNone(instrumentation)
self.assertEqual(instrumentation.get(A + "targetPackage"), "ai.ouroboros.android")
self.assertEqual(instrumentation.get(A + "name"),
"ai.ouroboros.android.device.DeviceSdkSmoke")
source = (device / "src/ai/ouroboros/android/device/DeviceSdkSmoke.java").read_text()
self.assertIn("OBO_DEVICE_SDK_SMOKE=PASS", source)
self.assertIn("packages.sessions", source)
workflow = (Path(__file__).resolve().parents[2] / ".github/workflows/ci.yml").read_text()
self.assertIn("api-level: [26, 29, 30, 33, 36]", workflow)
self.assertIn("OBO_DEVICE_SDK_SMOKE=PASS", workflow)
def test_alarm_and_initial_data_capabilities_are_declared(self):
manifest = ET.parse(HOST / "AndroidManifest.xml").getroot()
permissions = {row.get(A + "name"): row for row in manifest.findall("uses-permission")}
self.assertIn("com.android.alarm.permission.SET_ALARM", permissions)
self.assertIn("android.permission.ACCESS_NETWORK_STATE", permissions)
self.assertIn("android.permission.ACCESS_BACKGROUND_LOCATION", permissions)
self.assertIn("android.permission.FOREGROUND_SERVICE_LOCATION", permissions)
self.assertEqual(permissions["android.permission.READ_EXTERNAL_STORAGE"].get(A + "maxSdkVersion"), "32")
for permission in ("READ_CONTACTS", "WRITE_CONTACTS", "READ_CALENDAR", "WRITE_CALENDAR",
"CAMERA", "RECORD_AUDIO", "ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION"):

View file

@ -9,6 +9,7 @@ import pytest
pytestmark = pytest.mark.serial
STUBS = {
"android/Manifest.java": "package android; public final class Manifest { public static final class permission { public static final String ACCESS_BACKGROUND_LOCATION=\"android.permission.ACCESS_BACKGROUND_LOCATION\", ACCESS_COARSE_LOCATION=\"android.permission.ACCESS_COARSE_LOCATION\", ACCESS_FINE_LOCATION=\"android.permission.ACCESS_FINE_LOCATION\"; }}",
"android/R.java": "package android; public final class R { public static class drawable { public static final int ic_menu_manage=1; }}",
"android/content/Intent.java": """package android.content; public class Intent {
private String action; public Intent() {} public Intent(Object c, Class<?> cls) {}
@ -18,6 +19,8 @@ STUBS = {
"android/os/Looper.java": "package android.os; public class Looper {}",
"android/os/Handler.java": "package android.os; public class Handler { public Handler(Looper l) {} }",
"android/os/Build.java": "package android.os; public class Build { public static class VERSION { public static int SDK_INT=36; }}",
"android/content/pm/PackageManager.java": "package android.content.pm; public class PackageManager { public static final int PERMISSION_GRANTED=0; }",
"android/content/pm/ServiceInfo.java": "package android.content.pm; public class ServiceInfo { public static final int FOREGROUND_SERVICE_TYPE_LOCATION=8, FOREGROUND_SERVICE_TYPE_SPECIAL_USE=1073741824; }",
"android/os/SystemClock.java": """package android.os; public class SystemClock {
private static long now; public static long elapsedRealtime() { now+=120000; return now; }}""",
"android/net/Network.java": "package android.net; public class Network {}",
@ -56,12 +59,15 @@ STUBS = {
"android/app/Service.java": """package android.app; public class Service {
public static final int START_STICKY=1, START_NOT_STICKY=2, STOP_FOREGROUND_REMOVE=1, STOP_FOREGROUND_DETACH=2;
public final NotificationManager notifications=new NotificationManager();
public final java.util.Set<String> grants=new java.util.HashSet<>(); public int foregroundType;
public int checkSelfPermission(String permission) { return grants.contains(permission) ? 0 : -1; }
public <T> T getSystemService(Class<T> cls) { return cls.cast(cls==NotificationManager.class
? notifications : new android.net.ConnectivityManager()); }
public android.os.Looper getMainLooper() { return new android.os.Looper(); }
public void onCreate() {} public void onDestroy() {}
public int onStartCommand(android.content.Intent i,int flags,int id) { return 0; }
public void startForeground(int id,Notification n) { notifications.messages.add(n.text); }
public void startForeground(int id,Notification n) { foregroundType=0; notifications.messages.add(n.text); }
public void startForeground(int id,Notification n,int type) { foregroundType=type; notifications.messages.add(n.text); }
public void stopForeground(int flags) {} public void stopSelf(int id) {}
public android.os.IBinder onBind(android.content.Intent i) { return null; }}""",
"org/json/JSONObject.java": """package org.json; public class JSONObject {
@ -153,6 +159,16 @@ public class LifecycleTest {
} else if (scenario.equals("sticky_status")) {
send(service,null,1); work.drain();
require(RuntimeClient.calls.stream().noneMatch(c->c.startsWith("start:")),"sticky restoration replayed Start");
} else if (scenario.equals("foreground_special_only") || scenario.equals("foreground_location")) {
if (scenario.equals("foreground_location")) {
service.grants.add("android.permission.ACCESS_BACKGROUND_LOCATION");
service.grants.add("android.permission.ACCESS_COARSE_LOCATION");
}
send(service,"status",1); work.drain();
int special=android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE;
require((service.foregroundType & special) != 0,"special-use foreground type missing");
require(((service.foregroundType & android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) != 0)
== scenario.equals("foreground_location"),"location foreground type did not follow background grant");
} else {
String[] actions=scenario.split("_"); send(service,actions[0],1); send(service,actions[1],2);
urgent.drain(); work.drain();
@ -212,3 +228,8 @@ def test_service_orders_owner_and_automatic_intents(lifecycle_java, scenario):
"panic_during_start_observation"])
def test_start_observation_is_distinct_from_action_failure(lifecycle_java, scenario):
lifecycle_java(scenario)
@pytest.mark.parametrize("scenario", ["foreground_special_only", "foreground_location"])
def test_foreground_service_type_discloses_background_location_grant(lifecycle_java, scenario):
lifecycle_java(scenario)

View file

@ -202,9 +202,21 @@ Three independent permissions are involved:
setup and the native menu reopens it. Android settings retain denial, revocation
and available limited-access choices. Root-side tools retain their separate,
broader authority.
- On Android 10 and later, unattended location needs the separate
**Allow all the time** grant. After granting it, start or refresh native
status so its foreground-service type includes location. Without that grant,
Android may restrict background requests; foreground queries remain available.
- **Provider/account authorization** permits model and service access through
the accounts you configure. Neither root nor an APK certificate supplies it.
`location.get` reports the location's timestamp, age and mock-provider flag so a
cached or test fix is distinguishable from a hardware observation. A timed-out
current request reports `no_fix_within_timeout`; a completed request without a
fix reports `provider_returned_null` and includes the background-grant state.
Neither result diagnoses the sensor or platform policy. Older last-known-only
reads can report `no_last_known_fix`. An SDK test-provider fix exercises callback
delivery but does not qualify GPS reception on a physical device.
In Settings → Available subagents, each coding session has its own access choice:
**Working files** (`workspace_write`, the default) or **Full system access** (`full`).
The tested Android kernel cannot create Codex's native sandbox, so a Codex coding
@ -233,6 +245,12 @@ notification that opens Android's original confirmation screen when tapped;
it remains incomplete until the system reports success or failure. Keep
Ouroboros notifications enabled for this handoff. A disabled or failed notification
is reported in `confirmation_delivery`; it is not successful consent.
For this path, enable **Install unknown apps** for Ouroboros in Android settings;
`capabilities.can_request_package_installs` reads that special access separately
from ordinary runtime permissions. The host can read only source URIs available
to its app UID. A Linux download can be written to a writable Android provider
URI with `content.write` and installed from that URI; a root-readable chroot path
does not itself grant the Android app access.
The manifest intentionally declares `QUERY_ALL_PACKAGES` for general installed-app
discovery and component inspection through Android's PackageManager. This is a

View file

@ -3137,8 +3137,17 @@ Keep reusable large downloads in the installer's durable cache.
`android-test` explicitly collects `android/tests`; ordinary `pytest tests/` does
not cover that directory. Portable source/transport fixtures and host compilation
are separate from physical root, boot, permissions, hardware and battery evidence.
The same-key instrumentation under `android/tests/device` owns a temporary SDK
bridge and an uncommitted PackageInstaller session. The emulator job executes
session readback on API 26/29/30/33/36 and accepts its explicit PASS only after
abandon and bridge cleanup. It requires neither root nor a provisioned Linux
core; it does not certify the phone bootstrap or owner consent UI.
Android release source/APK SBOMs describe those shipped bytes; installed dependency
pins/package inventories describe the provisioned phone. Neither invents the other.
The trusted tag-only `android-build` job reads `ANDROID_KEYSTORE_BASE64`,
`ANDROID_KEYSTORE_PASSWORD`, and `ANDROID_KEY_ALIAS` from repository secrets;
the branch/PR Android jobs use a disposable key and never publish it. A PR is
therefore source/build evidence, not a publisher-signed release claim.
## Platform Abstraction Rule
@ -3322,8 +3331,11 @@ and `web/tests/chat_history_integration.test.js`.
The Project work pointer is a navigation component over the existing Chat card
registry (`project_work_pointer.js`), updated inside the same viewport mutation
transaction. Preserve its loaded-window coverage disclosure; a represented
unfinished card is not independent proof of current execution. Its click changes
transaction. Its label names the card on one line (`projectWorkLabel`: coined
name, else title, capped; the `.project-work-pointer-label` CSS ellipsizes) and
never restates the card's full status headline; without a represented root card it is
hidden, not shown disabled. Preserve its loaded-window coverage disclosure; a
represented unfinished card is not independent proof of current execution. Its click changes
only the messages container's scroll position and existing reading intent, never
message routing. Dispose it with the chat; do not add a second card tree, poller
or task-state store for this navigation affordance.

View file

@ -36,7 +36,6 @@ def _loop():
of freezing whatever object a from-import saw at import time.
"""
from ouroboros import loop
return loop
@ -100,7 +99,6 @@ def announce_acceptance_settlement(usage_ctx: Any, request: Any, wave: dict) ->
if usage_ctx is None or not getattr(usage_ctx, "drive_root", None):
return
from ouroboros.owner_mailbox import write_task_message
try:
slots = wave.get("slots") or {}
write_task_message(
@ -117,7 +115,6 @@ def announce_acceptance_settlement(usage_ctx: Any, request: Any, wave: dict) ->
def prepare_acceptance_observation(ctx: Any, trace: dict, incoming: Any, messages: list, tool_schemas: list) -> None:
"""Present the current owner-source selector immediately before Main's send."""
from ouroboros.loop_acceptance import capture_acceptance_observation, acceptance_observation_prompt
observed = capture_acceptance_observation(ctx, trace, incoming)
if (_loop().get_task_review_mode() not in {"auto", "required"}
or not any(row.get("function", {}).get("name") == "task_acceptance_review" for row in tool_schemas)):
@ -128,7 +125,6 @@ def prepare_acceptance_observation(ctx: Any, trace: dict, incoming: Any, message
# successful cognitive tool call it can otherwise answer the selector itself,
# replacing the natural conversational response with acceptance bookkeeping.
from ouroboros.task_results import resolve_task_lineage
meta = getattr(ctx, "task_metadata", {})
meta = meta if isinstance(meta, dict) else {}
lineage = resolve_task_lineage(
@ -171,7 +167,6 @@ def wait_for_acceptance_feedback(tools: Any, limit_ctx: Any, trace: dict,
if not getattr(getattr(ctx, "_delivery_candidate", None), "control_episode_seen", False):
_loop()._arm_delivery_control(tools, limit_ctx, trace)
from ouroboros.owner_wait import wait_after_tools
wait_after_tools(ctx, limit_ctx.messages, trace, limit_ctx.accumulated_usage,
limit_ctx.round_idx, tool_schemas, seen, review_binding=binding)
@ -184,7 +179,6 @@ def advance_explicit_acceptance(tools: Any, limit_ctx: Any, trace: dict,
return
tools._ctx._acceptance_request_pending = None
from ouroboros.loop_delivery import apply_delivery_subject_decision
subject = request.get("acceptance_subject")
if subject is not None:
ok, reason = apply_delivery_subject_decision(tools, limit_ctx, trace, subject)
@ -277,7 +271,6 @@ def _build_host_acceptance_evidence(ctx: _TaskAcceptanceContext) -> Dict[str, An
"""Build the one bounded host packet shared by binding and reviewer input."""
from ouroboros.review_evidence import build_task_acceptance_evidence
from ouroboros.loop_delivery import delivery_subject_projection
committed_this_turn = any(
isinstance(call, dict)
and str(call.get("tool") or "") in ("commit_reviewed", "vcs_commit_reviewed")
@ -312,7 +305,6 @@ def _total_paid_acceptance_cycles(ctx: _TaskAcceptanceContext) -> Any:
SAME ledger the wallet claim counts (``claimed_cycles``); ``None`` when the
projection is unavailable (a descendant that may observe but not initialize)."""
from ouroboros.task_results import project_task_acceptance_review_capacity
return project_task_acceptance_review_capacity(
ctx.tools._ctx, task_id=str(ctx.task_id or ""),
).get("claimed_cycles")
@ -328,7 +320,6 @@ _RETRIEVING_ACCESS_DISCLOSURE = (
def _retrieving_packet_projection(evidence: Dict[str, Any]) -> Dict[str, Any]:
from ouroboros.review_dispatch import retrieving_acceptance_packet
return retrieving_acceptance_packet(evidence)
@ -350,7 +341,6 @@ def acceptance_retrieving_work_order(
from ouroboros.artifacts import task_artifact_dir_path
from ouroboros.outcome_receipt_store import verification_receipts_path
from ouroboros.review_execution import ReviewRouteKind, _render_prompt_parts, review_output_contract
request.session_root = session_root
request.policy["output_contract"] = review_output_contract(request)
request.policy["native_data_root"] = str(data_root)
@ -420,14 +410,12 @@ def _execute_task_acceptance_panel(ctx: _TaskAcceptanceContext) -> Any:
run_zero_physical_task_acceptance as _free_dispatch,
task_acceptance_preclaim_refusal,
)
def _refused(reason: str) -> Any:
return ReviewRunResult(
request={"surface": "task_acceptance", "task_id": str(ctx.task_id)},
actors=[], parsed_findings=[], aggregate_signal="DEGRADED", degraded=True,
degraded_reasons=[reason],
)
evidence = ctx.evidence or _build_host_acceptance_evidence(ctx)
try:
# R2: the SAME triad rows every other triad surface reads — each with
@ -490,12 +478,10 @@ def _execute_task_acceptance_panel(ctx: _TaskAcceptanceContext) -> Any:
# R52). The per-send wallet binding at dispatch still protects money.
from ouroboros.review_execution import ReviewRouteKind, panel_delivery_class, slot_delivery
from ouroboros.tools.review_helpers import review_wave_budget_gate
paid = [slot for slot in slots if getattr(slot, "route", None) is not ReviewRouteKind.AGENT_SESSION]
if paid:
try:
from ouroboros.review_substrate import _messages_char_count, _request_messages
_prompt_chars = max(
len(request.slot_session_tasks.get(slot.slot_id, "")) + len(request.policy["output_contract"])
if getattr(slot, "retrieves", False)
@ -539,7 +525,6 @@ def _execute_task_acceptance_panel(ctx: _TaskAcceptanceContext) -> Any:
try:
from ouroboros.review_cycles import review_max_cycles, review_max_cycles_source
from ouroboros.utils import append_jsonl, utc_now_iso
# TELEMETRY ONLY (owner R52): a panel that just cost money says what
# bounded it, how long it ran, how many panels the tree has bought and
# which deliveries it ran on — "21 paid panels" was invisible until
@ -597,7 +582,6 @@ def _record_host_acceptance_run(ctx: _TaskAcceptanceContext, result: Any) -> Dic
run_record["task_attempt"] = ctx.tools._ctx.task_attempt
run_record.update(ctx.review_binding or {})
from ouroboros.review_substrate import task_acceptance_is_clean
run_record["enforcement_impact"] = (
"allows_completion" if task_acceptance_is_clean(result) else "degrades_completion"
)
@ -622,7 +606,6 @@ def _set_applied_host_acceptance_impact(
run_record["enforcement_impact"] = "requires_revision"
return
from ouroboros.review_substrate import task_acceptance_is_clean
run_record["enforcement_impact"] = (
"allows_completion" if task_acceptance_is_clean(result) else "degrades_completion"
)
@ -634,7 +617,6 @@ def _finish_cyber_acceptance(ctx: _TaskAcceptanceContext, result: Any) -> bool:
from ouroboros.loop_delivery import delivery_subject_hash
from ouroboros.review_records import build_author_disposition
from ouroboros.review_substrate import build_improvement_capsule, task_acceptance_is_clean
pending = acceptance_run_pending(result)
if getattr(ctx.tools._ctx, "_acceptance_review_only", False):
if not pending and (capsule := build_improvement_capsule(result, rails_line=ctx.rails_line)):
@ -681,7 +663,6 @@ def _finish_advisory_author(ctx: _TaskAcceptanceContext) -> bool:
and run.get("feedback_delivered")), None)
disposition = str(stance.get("agent_disposition") or "")
from ouroboros.loop_delivery import delivery_evidence_fingerprint
if (not feedback or not intent or disposition not in {"accepted", "rejected", "partial", "deferred"}
or intent.get("review_binding_hash") != feedback.get("binding_hash")
or intent.get("tool_count") != len(ctx.llm_trace.get("tool_calls") or [])
@ -689,7 +670,6 @@ def _finish_advisory_author(ctx: _TaskAcceptanceContext) -> bool:
or intent.get("evidence_fingerprint") != delivery_evidence_fingerprint(ctx.tools._ctx, ctx.llm_trace)):
return False
from ouroboros.review_records import build_author_disposition
author = build_author_disposition(
disposition=disposition, rationale=str(stance.get("agent_rationale") or ""),
subject_hash=ctx.review_binding["binding_hash"],

View file

@ -5,6 +5,7 @@ BASELINE_SOURCE_SHA = "77d6827b7a72a632899bb6cc64a7e759aabcfaa6"
GIANT_PATHS = (
"devtools/benchmarks/osworld/run_cu_bridge_agent.py",
"devtools/benchmarks/osworld/run_step_agent.py",
"ouroboros/loop_acceptance_review.py",
"ouroboros/tools/git.py",
"server.py",
"skills/unix_computer_use/plugin.py",
@ -99,6 +100,7 @@ BAND_BASELINE_PATHS = (
)
BAND_PATHS = {
"ouroboros/tools/delegate.py": "D07 finisher DEL1 split brought the nanny-verb monolith DOWN from the 1600 hard cap into the band (1600->1263); terminal-evidence family extracted to tools/delegate_terminal_evidence.py, shrink-only direction",
"devtools/benchmarks/cybergym/cybergym_adapter.py": "Stateful campaign layer after the protocol split (ratchet heal); shrink next touch.",
"devtools/benchmarks/cybergym/cybergym_docker.py": "Docker runtime layer of the executor split: one container-machinery seam.",
"devtools/benchmarks/cybergym/cybergym_executor.py": "Executor assembly after docker/lifecycle/wire splits (ratchet heal); shrink next touch.",
@ -155,7 +157,6 @@ BAND_PATHS = {
"ouroboros/tools/browser.py": None,
"ouroboros/tools/commit_gate.py": "Grew INTO the band by the review-wave fix binding the actor reference (delivery class) into the commit review contract fingerprint \u2014 same-module contract identity, splitting it would separate the fingerprint from its gate.",
"ouroboros/tools/core.py": "D05 ledger split (rows 311-349): read/list and owner-chat delivery spans moved to core_file_tools/core_artifacts; facade re-enters the band from above (2283 -> 1373) and shrinks further when the residual catalog split lands",
"ouroboros/tools/delegate.py": "D07 finisher DEL1 split brought the nanny-verb monolith DOWN from the 1600 hard cap into the band (1600->1263); terminal-evidence family extracted to tools/delegate_terminal_evidence.py, shrink-only direction",
"ouroboros/tools/plan_review_runtime.py": "Entered the band from 986 lines: timeout custody synthesis joined the existing plan-review runtime owner while preserving profile-continuity disclosures and typed health facts during target integration.",
"ouroboros/tools/registry_core.py": "F3.1 typed-organ re-split (D04 rows 156/167/170/171/174/175): the tip ToolRegistry class body re-homed whole from the protected registry facade; the guard/dispatch surface already left for its sibling leaves, and the class shrinks further only with the ABI-8 post-release handler conversion.",
"ouroboros/tools/review.py": "D06 F2.3a re-entry by extraction: the multi-model fan-out moved to review_multi_model.py (1550->1269); the remaining single-owner review cycle machinery lands in the 1001-1500 band with headroom",
@ -225,9 +226,9 @@ BAND_PATHS = {
"web/modules/api_types.js": "The shared browser contract module now includes issue 265 publication-preflight types alongside the target settings and subagent contracts.",
"web/modules/chat_activity.js": "Existing task activity renderer consumes the shared quota/auth wait state; no parallel task card or lifecycle.",
"web/modules/harness_accounts.js": None,
"web/modules/subagents_settings.js": "The shared Available subagents editor now carries access choice and grouped source selection; keep this UI seam together while the next extraction shrinks it.",
"web/tests/reviewer_slots.test.js": "The reviewer-slot tests keep source selection and access serialization coverage together with their UI contract.",
"web/modules/log_events.js": None,
"web/modules/onboarding_wizard.js": "Multi-surface onboarding keeps its step projection and completion payload together.",
"web/modules/reviewer_slots.js": "Owner-approved 5A editor: per-row Direct model / Configured subagent source picker with read-only derived disclosure replaces the legacy Claude-SDK advisory input in the same module that owns reviewer-row editing.",
"web/modules/settings.js": None,
"web/modules/skills.js": "One installed-skill page controller owns independently settling primary/optional reads and current-generation menu, identity and badge updates; domain lifecycle, cards, hub truth and shared interactions remain separate owners.",
"web/tests/chat_instance_dom.test.js": "Entered the band from 1000 lines with the alias-free subagent cost pin (stage-2 fix wave): that regression reproduces only through the real createChatInstance card path, and this file owns the DOM harness that drives it; split when the next createChatInstance face lands.",
@ -245,5 +246,5 @@ BYTE_BASELINE_DEBT = {
BYTE_DEBT = {
"tests/test_devtools_benchmarks.py": 327840,
"web/modules/chat.js": 203362,
"web/modules/chat.js": 204824,
}

View file

@ -441,9 +441,6 @@ def _delegate_start(ctx: ToolContext, prompt: str, max_seconds: Optional[int] =
invocation_id = custody.new_invocation_id()
root = record_auth["target_root"]
if is_mutating_delegated_access(authority.access):
# C1: the run executes in a private snapshot of the authority target.
# Git/payload snapshots are registered before POST; directory copies
# belong to the engine, with the stable target kept separate.
target_root = record_auth["target_root"]
authority_source = record_auth["source"]
if authority_source == "skill_payload":
@ -472,13 +469,9 @@ def _delegate_start(ctx: ToolContext, prompt: str, max_seconds: Optional[int] =
scope_root = target_root if execution_root or directory_options else root
(project_id, owned_project_id, project_persistent) = resolve_registration(
gateway, scope_root, execution_root, getattr(authority, "access", ""))
if directory_options:
project_persistent = True
project_persistent |= bool(directory_options)
if authority.access == "full":
gateway.ensure_full_access(scope_root)
# Assignment plus host-authored instructions identify the invocation;
# retries replay the stored body byte-identically.
seconds = _bounded_max_seconds(ctx, max_seconds)
request_body = _start_request(ctx, route, authority, scope_root, text,
seconds, instructions, execution_root,

View file

@ -143,7 +143,7 @@ def test_android_ci_is_fork_safe_and_required_for_publication():
def test_android_ci_has_representative_emulator_matrix_without_calling_it_device_qualification():
workflow = (REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8")
smoke = workflow.split(" android-emulator-smoke:", 1)[1].split(" # The publisher key", 1)[0]
assert "api-level: [26, 30, 33, 36]" in smoke
assert "api-level: [26, 29, 30, 33, 36]" in smoke
assert "adb install -r" in smoke
assert "dumpsys package ai.ouroboros.android" in smoke
assert "SELinux" not in smoke

View file

@ -462,7 +462,6 @@ export function createChatInstance({
let lastLoadedHistoryRevision = 0;
// one-shot idle gate for Main's deferred first hydration.
let hydrationGatePromise = null;
// The server retains whole-history coverage independently of the DOM window.
let historyWindow = null;
let welcomeShown = false;
// Cross-instance hide/show position; visible mutations use live geometry.
@ -521,8 +520,6 @@ export function createChatInstance({
// Local user submissions awaiting server confirmation (clientMessageId
// -> { clientMessageId, timestamp }).
const pendingSubmissions = new Map();
// Bounded conclusions block late root typing and stale state snapshots; reusable
// logical task slots are cleared whenever their cycle settles.
const concludedDirectActivities = new Map();
const CONCLUDED_ACTIVITY_LEDGER_MAX = 200;
// Retryable queue-loss candidates plus process-local single-flight reads.
@ -560,9 +557,6 @@ export function createChatInstance({
else recordConcludedActivity(id);
settleTerminalRootChildren(id);
}
// A root proven terminal settles descendant cards a lost child terminal left
// open (#300): one single-flight durable read each, through the ordinary
// child-terminal path; no proven terminal fact = the child keeps its state.
function settleTerminalRootChildren(rootId) {
for (const [childId, info] of subagentChildParents) {
if (info.parentId !== rootId) continue;
@ -576,10 +570,7 @@ export function createChatInstance({
}).catch(() => {}).finally(() => managedTaskDetailReads.delete(childId));
}
}
// Finished task ids hidden from routine syncs until reload/reconnect rebuilds history.
const retiredTaskIds = new Set();
// The owner's last main-chat request, handed to the next live card it spawns so a
// "turn into project" conversion can name the project from it (P1).
let _pendingCardObjective = '';
let activeLiveGroupId = '';
let pendingReconnectSync = false; // Set when a fromReconnect sync arrives while one is already in-flight.
@ -1155,7 +1146,6 @@ export function createChatInstance({
});
}
// Durable cancel state wins over legacy status; only settled truth closes the card.
function reconcileCancelCardFromDetail(record, taskId, stored) {
return withStableViewport(() => {
if (!stored || !record || record.finished) return false;
@ -1243,10 +1233,6 @@ export function createChatInstance({
return record ? syncCancelRunButton(record) : false;
}
// One-way conversion (P3): the WHOLE card becomes a calm "project identity"
// chip. The live task is now owned by the project panel (it's bound there),
// so the main chat is freed — the card stops being a busy red task and
// recolors to the project fuchsia. Plain wording (no "ack"); click opens the panel.
function markCardConverted(record, project) {
return withStableViewport(() => markCardConvertedMutation(record, project));
}
@ -1405,9 +1391,6 @@ export function createChatInstance({
? explicitCardExpansion.get(normalizedGroupId)
: Boolean(options.isSubagent && nestedSubagentsExpanded);
root.dataset.expanded = initialExpanded ? '1' : '0';
// No "Turn into project" for: subagent cards, non-main panels, or a task that
// is ALREADY bound to a project (a project-chat follow-up) — see task_bindings
// from /api/state, surfaced on window.__ouroTaskBindings (P2).
const alreadyBound = !!(window.__ouroTaskBindings || {})[normalizedGroupId];
const projectActionHtml = (
isMain
@ -1511,9 +1494,6 @@ export function createChatInstance({
if (nowExpanded) record.expandedLineKeys.add(lineKey);
else record.expandedLineKeys.delete(lineKey);
renderLiveCardTimeline(record);
// P3: on expand, lazily fetch the genuinely-full output for a server-truncated
// line (the WS preview was capped at 4000); cached on the item so a re-render
// keeps it. Best-effort — the capped preview stays on failure.
if (nowExpanded) {
const item = record.items.find((it) => it.lineKey === lineKey);
if (item && item.truncated && item.fullRef && !item.fetchedFull && !item._fetchingFull) {
@ -1734,19 +1714,12 @@ export function createChatInstance({
window.addEventListener('ouro:page-shown', handlePageShown);
document.addEventListener('visibilitychange', handlePageShown);
// P3: fetch the genuinely-full text of a server-truncated timeline line (the WS
// preview is capped at 4000 chars) on demand, not over the socket; cache it on
// the item, re-render if the line is still expanded, and show it in a
// bounded-scroll box. Best-effort: the capped preview stays on failure.
async function fetchFullLineOutput(item, record) {
item._fetchingFull = true;
let changed = false;
try {
const resp = await apiFetch(`/api/tasks/${encodeURIComponent(item.fullRef)}`, { cache: 'no-store' });
const data = resp && typeof resp.json === 'function' ? await resp.json() : resp;
// Compose ALL available full fields — a subagent line can carry both a result AND a
// (separately truncated) trace_summary, so `result || trace_summary` would hide the
// full trace. Label each section when both are present.
const result = String((data && data.result) || '').trim();
const trace = String((data && data.trace_summary) || '').trim();
let full = '';
@ -1854,8 +1827,6 @@ export function createChatInstance({
const desiredPhase = desiredLiveCardPhase(record, activePhase);
setLiveCardPhase(record, desiredPhase.phase, desiredPhase.text, desiredPhase.className);
// A coined project name takes the title slot (the activity headline stays in the
// timeline); a child's title is its lineage identity; otherwise the activity headline.
const title = record.suggestedName || (record.isSubagent ? childTitle(record)
: (record.finished ? record.lastHumanHeadline || 'Task activity' : activeHeadline));
if (record.titleEl.textContent !== title) record.titleEl.textContent = title;
@ -2079,9 +2050,6 @@ export function createChatInstance({
return summary ? withTaskCostMeta(summary, evt, { rawTs }) : null;
}
// A child's title is its lineage identity plus, for twins (same displayed identity
// under one parent), the short id; re-projected on every title write and lineage
// change (terminal children included).
function childTitle(record) {
const twin = subagentTwin(subagentChildParents, record.groupId);
return subagentIdentityTitle(subagentChildParents.get(record.groupId))
@ -2501,7 +2469,6 @@ export function createChatInstance({
addMessage('Ouroboros has awakened', 'assistant', false, null, false, { ephemeral: true });
}
// Hydration triggers share one sticky request; reconnect/resync still refetch.
function awaitInitialHydration({ includeUser = false } = {}) {
if (initialHydrationPromise) return initialHydrationPromise;
initialHydrationPromise = syncHistory({ includeUser });
@ -2633,7 +2600,6 @@ export function createChatInstance({
}
} } finally { _syncPass1Active = false; _historyRow = null; }
// Pass 2 inserts cards at the first visible task message, then finishes them.
const insertedCardTaskIds = new Set();
function reorderDirtyCardIfNeeded(rec) {
if (!rec?._anchorOrderDirty || rec.isSubagent || !rec.root?.isConnected) return;
@ -2803,7 +2769,6 @@ export function createChatInstance({
finishLiveCard(tid, taskTerminalPhase(terminalRecord));
}
// Append disconnected visible cards after mid-task reload; skip trivial placeholders.
for (const [tid, rec] of liveCardRecords) {
reorderDirtyCardIfNeeded(rec);
if (rec && rec.root && !rec.root.isConnected && !retiredTaskIds.has(tid)) {
@ -3285,7 +3250,6 @@ export function createChatInstance({
});
}
// Ignore hidden/restoring scroll events so browser resets cannot corrupt saved intent.
messagesDiv?.addEventListener('scroll', () => {
if (!isInstanceVisible()) return;
if (_restoring) { updateScrollButton(); return; }
@ -3496,8 +3460,6 @@ export function createChatInstance({
}
const typingEl = document.createElement('div');
// Per-instance id (main stays 'typing-indicator'; panels get a unique id) so
// multiple open chat columns never collide on a duplicate DOM id.
typingEl.id = idPrefix === 'chat' ? 'typing-indicator' : `${idPrefix}-typing-indicator`;
typingEl.className = 'chat-bubble assistant typing-bubble';
typingEl.style.display = 'none';