@selesai/code 0.13.38 → 0.13.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/core/intercom-rpc.d.ts +135 -0
  3. package/dist/core/intercom-rpc.js +64 -0
  4. package/dist/core/release-rpc.d.ts +137 -0
  5. package/dist/core/release-rpc.js +77 -0
  6. package/dist/core/resource-loader.d.ts +3 -0
  7. package/dist/core/resource-loader.js +3 -0
  8. package/dist/core/subagent-rpc.d.ts +110 -0
  9. package/dist/core/subagent-rpc.js +60 -0
  10. package/dist/extensions/agent-browser.ts +9 -0
  11. package/dist/extensions/pi-graft/cli.test.ts +5 -5
  12. package/dist/extensions/pi-graft/cli.ts +10 -5
  13. package/dist/extensions/pi-graft/commands.test.ts +5 -5
  14. package/dist/extensions/pi-graft/graft.integration.test.ts +1 -1
  15. package/dist/extensions/pi-graft/lifecycle.test.ts +26 -26
  16. package/dist/extensions/pi-graft/prompt.test.ts +1 -1
  17. package/dist/extensions/pi-graft/state.test.ts +9 -9
  18. package/dist/extensions/pi-graft/test-support.ts +1 -1
  19. package/dist/extensions/pi-graft/tools.test.ts +1 -1
  20. package/dist/extensions/pi-intercom/README.md +15 -0
  21. package/dist/extensions/pi-intercom/broker/broker.ts +277 -13
  22. package/dist/extensions/pi-intercom/broker/client.ts +6 -2
  23. package/dist/extensions/pi-intercom/broker/mailbox-persistence.test.ts +523 -0
  24. package/dist/extensions/pi-intercom/broker/paths.test.ts +4 -4
  25. package/dist/extensions/pi-intercom/broker/paths.ts +4 -2
  26. package/dist/extensions/pi-intercom/broker/spawn.test.ts +1 -3
  27. package/dist/extensions/pi-intercom/extension-api.ts +10 -0
  28. package/dist/extensions/pi-intercom/index.ts +188 -8
  29. package/dist/extensions/pi-intercom/intercom.integration.test.ts +280 -0
  30. package/dist/extensions/pi-intercom/package.json +1 -1
  31. package/dist/extensions/pi-intercom/release-readiness.test.ts +116 -0
  32. package/dist/extensions/pi-intercom/release-readiness.ts +93 -0
  33. package/dist/extensions/pi-subagents/docs/extension-api.md +8 -0
  34. package/dist/extensions/pi-subagents/docs/observability.md +8 -0
  35. package/dist/extensions/pi-subagents/src/extension/index.ts +51 -1
  36. package/dist/extensions/pi-subagents/src/extension/observability.ts +164 -0
  37. package/dist/extensions/pi-subagents/src/extension/release-readiness.ts +367 -0
  38. package/dist/extensions/pi-subagents/src/extension/rpc.ts +8 -1
  39. package/dist/extensions/pi-subagents/src/integrations/pi-web-session-liveness.ts +7 -1
  40. package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +1 -44
  41. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +6 -0
  42. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +13 -3
  43. package/dist/extensions/pi-subagents/src/runs/background/result-delivery-ownership.ts +269 -7
  44. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +11 -10
  45. package/dist/extensions/pi-subagents/src/runs/background/retained-nested-route-tracker.ts +15 -3
  46. package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +64 -0
  47. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +69 -6
  48. package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -1
  49. package/dist/extensions/pi-subagents/src/shared/owner-record.ts +107 -0
  50. package/dist/extensions/pi-subagents/src/shared/process-identity.ts +87 -0
  51. package/dist/extensions/pi-subagents/src/shared/types.ts +9 -0
  52. package/dist/extensions/pi-subagents/test/integration/async-attention-clearing.test.ts +191 -0
  53. package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +63 -0
  54. package/dist/extensions/pi-subagents/test/integration/stop-all-background.test.ts +142 -0
  55. package/dist/extensions/pi-subagents/test/support/fake-child-session.ts +8 -0
  56. package/dist/extensions/pi-subagents/test/unit/observability.test.ts +198 -0
  57. package/dist/extensions/pi-subagents/test/unit/process-identity.test.ts +75 -0
  58. package/dist/extensions/pi-subagents/test/unit/release-readiness.test.ts +715 -0
  59. package/dist/extensions/pi-subagents/test/unit/result-takeover.test.ts +332 -0
  60. package/dist/extensions/tokenin-onboarding.ts +142 -3
  61. package/dist/index.d.ts +6 -1
  62. package/dist/index.js +7 -0
  63. package/dist/main.d.ts +6 -0
  64. package/dist/main.js +1 -1
  65. package/dist/modes/index.d.ts +1 -1
  66. package/dist/modes/rpc/intercom-bridge.d.ts +14 -0
  67. package/dist/modes/rpc/intercom-bridge.js +75 -0
  68. package/dist/modes/rpc/intercom-bridge.test.d.ts +1 -0
  69. package/dist/modes/rpc/intercom-bridge.test.js +65 -0
  70. package/dist/modes/rpc/release-bridge.d.ts +35 -0
  71. package/dist/modes/rpc/release-bridge.js +159 -0
  72. package/dist/modes/rpc/release-bridge.test.d.ts +1 -0
  73. package/dist/modes/rpc/release-bridge.test.js +347 -0
  74. package/dist/modes/rpc/rpc-client.d.ts +34 -10
  75. package/dist/modes/rpc/rpc-client.js +45 -2
  76. package/dist/modes/rpc/rpc-intercom-transport.test.d.ts +1 -0
  77. package/dist/modes/rpc/rpc-intercom-transport.test.js +85 -0
  78. package/dist/modes/rpc/rpc-mode.d.ts +1 -13
  79. package/dist/modes/rpc/rpc-mode.js +239 -8
  80. package/dist/modes/rpc/rpc-release-transport.test.d.ts +1 -0
  81. package/dist/modes/rpc/rpc-release-transport.test.js +329 -0
  82. package/dist/modes/rpc/rpc-subagent-transport.test.d.ts +1 -0
  83. package/dist/modes/rpc/rpc-subagent-transport.test.js +60 -0
  84. package/dist/modes/rpc/rpc-types.d.ts +12 -2
  85. package/dist/modes/rpc/rpc-types.js +0 -6
  86. package/dist/modes/rpc/subagent-bridge.d.ts +23 -0
  87. package/dist/modes/rpc/subagent-bridge.js +93 -0
  88. package/dist/modes/rpc/test-fixtures/intercom-host.d.ts +1 -0
  89. package/dist/modes/rpc/test-fixtures/intercom-host.js +13 -0
  90. package/docs/rpc-subagent.schema.json +491 -0
  91. package/docs/rpc.md +210 -1
  92. package/package.json +2 -1
@@ -0,0 +1,116 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { intercomReleaseBlockers, stopIntercom, type IntercomStopState } from "./release-readiness.ts";
4
+ import { ReplyTracker } from "./reply-tracker.ts";
5
+ import type { Message, SessionInfo } from "./types.ts";
6
+
7
+ function createSession(id: string, name?: string): SessionInfo {
8
+ return { id, name, cwd: "/tmp/project", model: "test-model", pid: 1, startedAt: 1, lastActivity: 1 };
9
+ }
10
+
11
+ function createMessage(id: string, expectsReply: boolean): Message {
12
+ return { id, timestamp: 1, expectsReply, content: { text: "hello" } };
13
+ }
14
+
15
+ const idle = { pendingAsks: [], outboundAsk: null, pendingOutboxRequests: 0 };
16
+
17
+ test("an idle intercom has no release blockers", () => {
18
+ assert.deepEqual(intercomReleaseBlockers(idle), []);
19
+ });
20
+
21
+ test("unanswered inbound asks block; plain messages and answered asks do not", () => {
22
+ const tracker = new ReplyTracker();
23
+ tracker.recordIncomingMessage(createSession("planner-id", "planner"), createMessage("ask-1", true), 1000);
24
+ tracker.recordIncomingMessage(createSession("other-id"), createMessage("ask-2", true), 2000);
25
+ tracker.recordIncomingMessage(createSession("fyi-id", "fyi"), createMessage("note-1", false), 1500);
26
+ assert.deepEqual(intercomReleaseBlockers({ ...idle, pendingAsks: tracker.listPending(2500) }), [
27
+ { source: "intercom", kind: "inbound_ask", id: "ask-1", detail: "Ask from planner is awaiting our reply", since: 1000 },
28
+ { source: "intercom", kind: "inbound_ask", id: "ask-2", detail: "Ask from other-id is awaiting our reply", since: 2000 },
29
+ ]);
30
+ tracker.markReplied("ask-1");
31
+ assert.deepEqual(intercomReleaseBlockers({ ...idle, pendingAsks: tracker.listPending(2500) }).map(blocker => blocker.id), ["ask-2"]);
32
+ });
33
+
34
+ test("a blocking outbound ask is reported with its reply id and start time", () => {
35
+ assert.deepEqual(intercomReleaseBlockers({ ...idle, outboundAsk: { from: "planner", replyTo: "reply-9", startedAt: 4242 } }), [
36
+ { source: "intercom", kind: "outbound_ask", id: "reply-9", detail: "Blocking ask to planner is awaiting a reply", since: 4242 },
37
+ ]);
38
+ // A missing start time is omitted rather than invented.
39
+ const [blocker] = intercomReleaseBlockers({ ...idle, outboundAsk: { from: "planner", replyTo: "reply-9" } });
40
+ assert.equal("since" in blocker!, false);
41
+ });
42
+
43
+ test("unsettled outbox requests block", () => {
44
+ assert.deepEqual(intercomReleaseBlockers({ ...idle, pendingOutboxRequests: 2 }), [
45
+ { source: "intercom", kind: "outbound_message", detail: "2 outbound message request(s) have not settled" },
46
+ ]);
47
+ });
48
+
49
+ test("blockers are bounded and never carry invalid timestamps", () => {
50
+ const long = "x".repeat(600);
51
+ const [blocker] = intercomReleaseBlockers({
52
+ ...idle,
53
+ pendingAsks: [{ from: createSession("id", long), message: createMessage(long, true), receivedAt: Number.NaN }],
54
+ });
55
+ assert.equal(blocker!.id!.length, 256);
56
+ assert.equal(blocker!.detail.length, 512);
57
+ assert.equal("since" in blocker!, false);
58
+ });
59
+
60
+ const farDeadline = () => Date.now() + 5_000;
61
+ const stopState = (overrides: Partial<IntercomStopState> = {}): IntercomStopState => ({ pendingAsks: [], outboundAsk: null, cancelOutboundAsk: () => undefined, ...overrides });
62
+
63
+ test("stop: an idle intercom has nothing to stop", () => {
64
+ assert.deepEqual(stopIntercom(stopState(), { deadline: farDeadline() }), []);
65
+ });
66
+
67
+ test("stop: cancels the blocking outbound ask through the client cancel and reports it", () => {
68
+ const cancelled: Array<{ from: string; replyTo: string }> = [];
69
+ const items = stopIntercom(
70
+ stopState({ outboundAsk: { from: "planner", replyTo: "reply-9", startedAt: 1 }, cancelOutboundAsk: ask => { cancelled.push(ask); } }),
71
+ { deadline: farDeadline() },
72
+ );
73
+ assert.deepEqual(cancelled, [{ from: "planner", replyTo: "reply-9" }]);
74
+ assert.deepEqual(items, [{ source: "intercom", kind: "outbound_ask", id: "reply-9", detail: "Cancelled blocking ask to planner", ok: true }]);
75
+ });
76
+
77
+ test("stop: reports a failing cancel as ok:false instead of throwing", () => {
78
+ const items = stopIntercom(
79
+ stopState({ outboundAsk: { from: "planner", replyTo: "reply-9" }, cancelOutboundAsk: () => { throw new Error("socket closed"); } }),
80
+ { deadline: farDeadline() },
81
+ );
82
+ assert.deepEqual(items, [{ source: "intercom", kind: "outbound_ask", id: "reply-9", detail: "Failed to cancel blocking ask to planner: socket closed", ok: false, error: "socket closed" }]);
83
+ });
84
+
85
+ test("stop: inbound asks cannot be cancelled by the receiver and are reported ok:false, left untouched", () => {
86
+ const tracker = new ReplyTracker();
87
+ tracker.recordIncomingMessage(createSession("planner-id", "planner"), createMessage("ask-1", true), 1000);
88
+ tracker.recordIncomingMessage(createSession("fyi-id", "fyi"), createMessage("note-1", false), 1500);
89
+ const items = stopIntercom(stopState({ pendingAsks: tracker.listPending(2000) }), { deadline: farDeadline() });
90
+ assert.deepEqual(items, [{
91
+ source: "intercom", kind: "inbound_ask", id: "ask-1",
92
+ detail: "cannot cancel inbound: ask from planner is awaiting our reply and keeps waiting", ok: false, error: "cannot cancel inbound",
93
+ }]);
94
+ assert.equal(tracker.listPending(2000).length, 1, "the ask is still pending");
95
+ });
96
+
97
+ test("stop: appends the recipient-side outcome when the best-effort message cancel settles within the deadline", async () => {
98
+ const items = await stopIntercom(
99
+ stopState({ outboundAsk: { from: "planner", replyTo: "reply-9" }, cancelOutboundAsk: () => Promise.resolve("recipient asked to drop it") }),
100
+ { deadline: farDeadline() },
101
+ );
102
+ assert.deepEqual(items, [{ source: "intercom", kind: "outbound_ask", id: "reply-9", detail: "Cancelled blocking ask to planner; recipient asked to drop it", ok: true }]);
103
+ });
104
+
105
+ test("stop: a hanging or rejecting recipient-side cancel does not change the result or outlive the deadline", async () => {
106
+ const hanging = await stopIntercom(
107
+ stopState({ outboundAsk: { from: "planner", replyTo: "reply-9" }, cancelOutboundAsk: () => new Promise<string>(() => undefined) }),
108
+ { deadline: Date.now() + 40 },
109
+ );
110
+ assert.deepEqual(hanging.map(item => [item.kind, item.ok, item.detail]), [["outbound_ask", true, "Cancelled blocking ask to planner"]]);
111
+ const rejecting = await stopIntercom(
112
+ stopState({ outboundAsk: { from: "planner", replyTo: "reply-9" }, cancelOutboundAsk: () => Promise.reject(new Error("Cancel timeout")) }),
113
+ { deadline: farDeadline() },
114
+ );
115
+ assert.deepEqual(rejecting.map(item => [item.kind, item.ok]), [["outbound_ask", true]]);
116
+ });
@@ -0,0 +1,93 @@
1
+ import type { ReleaseBlocker, StopAllBackgroundItem } from "@selesai/code";
2
+ import type { IntercomContext } from "./reply-tracker.ts";
3
+
4
+ export interface IntercomReleaseState {
5
+ /** Inbound asks still awaiting OUR reply (`ReplyTracker.listPending()`). */
6
+ pendingAsks: readonly IntercomContext[];
7
+ /** The module-level blocking outbound ask (`replyWaiter`), if one is in flight. */
8
+ outboundAsk: { from: string; replyTo: string; startedAt?: number } | null;
9
+ /** Outbound outbox requests from other extensions that have not settled. */
10
+ pendingOutboxRequests: number;
11
+ }
12
+
13
+ const clip = (value: string, max: number) => value.length > max ? value.slice(0, max) : value;
14
+
15
+ /** Release blockers owned by the intercom extension. Pure: callers pass a snapshot of live state. */
16
+ export function intercomReleaseBlockers(state: IntercomReleaseState): ReleaseBlocker[] {
17
+ const blockers: ReleaseBlocker[] = [];
18
+ for (const ask of state.pendingAsks) {
19
+ const sender = ask.from.name?.trim() || ask.from.id;
20
+ blockers.push({
21
+ source: "intercom", kind: "inbound_ask", id: clip(ask.message.id, 256),
22
+ detail: clip(`Ask from ${sender} is awaiting our reply`, 512),
23
+ ...(Number.isFinite(ask.receivedAt) && ask.receivedAt >= 0 ? { since: Math.trunc(ask.receivedAt) } : {}),
24
+ });
25
+ }
26
+ if (state.outboundAsk) {
27
+ const { from, replyTo, startedAt } = state.outboundAsk;
28
+ blockers.push({
29
+ source: "intercom", kind: "outbound_ask", id: clip(replyTo, 256),
30
+ detail: clip(`Blocking ask to ${from} is awaiting a reply`, 512),
31
+ ...(startedAt !== undefined && Number.isFinite(startedAt) && startedAt >= 0 ? { since: Math.trunc(startedAt) } : {}),
32
+ });
33
+ }
34
+ if (state.pendingOutboxRequests > 0) {
35
+ blockers.push({ source: "intercom", kind: "outbound_message", detail: `${state.pendingOutboxRequests} outbound message request(s) have not settled` });
36
+ }
37
+ return blockers;
38
+ }
39
+
40
+ export interface IntercomStopState {
41
+ /** Inbound asks still awaiting OUR reply (`ReplyTracker.listPending()`). */
42
+ pendingAsks: readonly IntercomContext[];
43
+ /** The module-level blocking outbound ask (`replyWaiter`), if one is in flight. */
44
+ outboundAsk: { from: string; replyTo: string; startedAt?: number } | null;
45
+ /**
46
+ * Cancels the outbound ask: tells the broker (`client.cancelAsk`, drops the ask edge and its pending-ask record) and rejects the
47
+ * local waiter so the blocked tool call returns. Synchronous. Optionally returns the settled outcome of asking the recipient to
48
+ * drop the message (`client.cancelMessage`); that part is best-effort because the recipient may already have acted on it.
49
+ */
50
+ cancelOutboundAsk(ask: { from: string; replyTo: string }): void | PromiseLike<string | undefined>;
51
+ }
52
+
53
+ /**
54
+ * Kill-switch handler. Outbound asks have a cancel (broker `cancel_ask` / `cancel_message`, exposed by the client), so they are
55
+ * cancelled. Inbound asks waiting on THIS session cannot be cancelled by the receiver (the protocol has no "decline"), so they are
56
+ * left alone and reported as `ok:false` so the caller can show who is still waiting. Never throws.
57
+ */
58
+ export function stopIntercom(state: IntercomStopState, request: { deadline: number }, now: () => number = Date.now): StopAllBackgroundItem[] | PromiseLike<StopAllBackgroundItem[]> {
59
+ const items: StopAllBackgroundItem[] = [];
60
+ let pendingCancel: PromiseLike<string | undefined> | undefined;
61
+ const ask = state.outboundAsk;
62
+ let outbound: StopAllBackgroundItem | undefined;
63
+ if (ask) {
64
+ const base = { source: "intercom", kind: "outbound_ask", id: clip(ask.replyTo, 256) };
65
+ try {
66
+ const outcome = state.cancelOutboundAsk({ from: ask.from, replyTo: ask.replyTo });
67
+ outbound = { ...base, detail: clip(`Cancelled blocking ask to ${ask.from}`, 512), ok: true };
68
+ if (outcome) pendingCancel = outcome;
69
+ } catch (error) {
70
+ const text = error instanceof Error ? error.message : String(error);
71
+ outbound = { ...base, detail: clip(`Failed to cancel blocking ask to ${ask.from}: ${text}`, 512), ok: false, error: clip(text, 256) };
72
+ }
73
+ items.push(outbound);
74
+ }
75
+ for (const inbound of state.pendingAsks) {
76
+ const sender = inbound.from.name?.trim() || inbound.from.id;
77
+ items.push({
78
+ source: "intercom", kind: "inbound_ask", id: clip(inbound.message.id, 256),
79
+ detail: clip(`cannot cancel inbound: ask from ${sender} is awaiting our reply and keeps waiting`, 512), ok: false, error: "cannot cancel inbound",
80
+ });
81
+ }
82
+ if (!pendingCancel || !outbound) return items;
83
+ const finished = outbound;
84
+ const budget = Math.max(0, request.deadline - now());
85
+ return new Promise<StopAllBackgroundItem[]>((resolve) => {
86
+ const timer = setTimeout(() => resolve(items), budget);
87
+ pendingCancel!.then((note) => {
88
+ clearTimeout(timer);
89
+ if (note) finished.detail = clip(`${finished.detail}; ${note}`, 512);
90
+ resolve(items);
91
+ }, () => { clearTimeout(timer); resolve(items); });
92
+ });
93
+ }
@@ -546,3 +546,11 @@ The main runtime files in this repository:
546
546
  | `src/intercom/intercom-bridge.ts` | Runtime intercom bridge instructions and diagnostics. |
547
547
  | `src/extension/schemas.ts` / `src/shared/types.ts` | Tool schemas, shared types, and event constants. |
548
548
  | `test/unit/` / `test/integration/` | Unit and loader-based integration tests. |
549
+
550
+ ## Desktop stdio observation v1
551
+
552
+ Headless/Desktop clients can send `{"type":"subagent_status","id":"status-1","version":1}` directly, without a model turn. The response's `data` contains `{version:1,available,capabilities:{snapshot:true,events},sessionId,epoch,revision,fleet,runs,omitted:{runs,children}}`. Missing backend is explicitly `available:false`; unsupported/missing versions fail. This read-only stdio contract does not expose spawn, management, steering, or stop.
553
+
554
+ `subagent_event` carries `{version:1,sessionId,epoch,revision,snapshot:<same data>}` after binding and on coalesced native state changes, even while the parent is idle. Always full-replace, not patch. The host session ID is a bare SDK ID; the binding epoch is shared with Intercom's nested `scope` and rotates on switch/reload. Discard obsolete scopes/revisions, request fresh status after reconnect, and never treat parent `agent_end` as child completion.
555
+
556
+ Opaque node keys are epoch-local, never filesystem paths; run/workflow identities appear only when native records supply them. Lifecycle status, optional actual supervisor-wait/control activity, task outcome, and process-terminal proof remain separate. No success, exit, timestamps, or missing node usage are fabricated. Native tracker-fed state is reused; this adapter performs no artifact scans or executor calls. Runs/recent history are bounded, not a permanent ledger: 32 roots, 64 nodes, 16 immediate children, depth 4, 64 KiB including envelope reserve; omission counts describe only excluded candidates in this display window. Fleet uses its existing 16-entry projection. See the [exact stdio schema and examples](../../../../docs/rpc.md#subagent-observability-desktop--headless-clients) and [JSON Schema](../../../../docs/rpc-subagent.schema.json).
@@ -303,3 +303,11 @@ Intercom delivery events:
303
303
  `src/extension/index.ts` registers the notification handler that consumes `subagent:async-complete`. Control/attention events are surfaced as visible parent notices and persisted for async runs. Native supervisor requests are delivered only to the exact parent session that spawned the child.
304
304
 
305
305
  `pi.events` is in-process only. It does not reach separate Pi processes or child subagents; use the file lifecycle artifacts or `pi-intercom` for cross-process coordination.
306
+
307
+ ## Desktop stdio observation v1
308
+
309
+ Headless/Desktop clients can send `{"type":"subagent_status","id":"status-1","version":1}` directly, without a model turn. The response's `data` contains `{version:1,available,capabilities:{snapshot:true,events},sessionId,epoch,revision,fleet,runs,omitted:{runs,children}}`. Missing backend is explicitly `available:false`; unsupported/missing versions fail. This read-only stdio contract does not expose spawn, management, steering, or stop.
310
+
311
+ `subagent_event` carries `{version:1,sessionId,epoch,revision,snapshot:<same data>}` after binding and on coalesced native state changes, even while the parent is idle. Always full-replace, not patch. The host session ID is a bare SDK ID; the binding epoch is shared with Intercom's nested `scope` and rotates on switch/reload. Discard obsolete scopes/revisions, request fresh status after reconnect, and never treat parent `agent_end` as child completion.
312
+
313
+ Opaque node keys are epoch-local, never filesystem paths; run/workflow identities appear only when native records supply them. Lifecycle status, optional actual supervisor-wait/control activity, task outcome, and process-terminal proof remain separate. No success, exit, timestamps, or missing node usage are fabricated. Native tracker-fed state is reused; this adapter performs no artifact scans or executor calls. Runs/recent history are bounded, not a permanent ledger: 32 roots, 64 nodes, 16 immediate children, depth 4, 64 KiB including envelope reserve; omission counts describe only excluded candidates in this display window. Fleet uses its existing 16-entry projection. See the [exact stdio schema and examples](../../../../docs/rpc.md#subagent-observability-desktop--headless-clients) and [JSON Schema](../../../../docs/rpc-subagent.schema.json).
@@ -30,6 +30,7 @@ import { resolveCurrentSessionId } from "../shared/session-identity.ts";
30
30
  import { getAgentDir } from "../shared/utils.ts";
31
31
  import { isStaleExtensionContextError, withCachedUiContext } from "../shared/extension-context.ts";
32
32
  import { currentCompletionOwnerId } from "../shared/completion-owner.ts";
33
+ import { ensureOwnerRecord } from "../shared/owner-record.ts";
33
34
  import { cleanupOldChainDirs } from "../shared/settings.ts";
34
35
  import { clearLegacyResultAnimationTimer, renderSubagentResult, renderSubagentSummary } from "../tui/render.ts";
35
36
  import { openSubagentFleet } from "../tui/fleet.ts";
@@ -59,7 +60,9 @@ import { registerHerdrStatusBridge, type HerdrStatusRun } from "../integrations/
59
60
  import { hasLiveSubagentWork, registerPiWebSessionLiveness } from "../integrations/pi-web-session-liveness.ts";
60
61
  import { createRetainedNestedRouteTracker } from "../runs/background/retained-nested-route-tracker.ts";
61
62
  import { listHerdrProjectPaneRoots, restoreHerdrProjectPaneSnapshots } from "../inspectors/herdr/project-panes.ts";
62
- import { registerSubagentRpcBridge } from "./rpc.ts";
63
+ import { buildFleetStatus, registerSubagentRpcBridge } from "./rpc.ts";
64
+ import { registerSubagentObservability } from "./observability.ts";
65
+ import { findUndeliveredResults, registerReleaseReadiness } from "./release-readiness.ts";
63
66
  import { clearSlashSnapshots, getSlashRenderableSnapshot, resolveSlashMessageDetails, restoreSlashFinalSnapshots, type SlashMessageDetails } from "../slash/slash-live-state.ts";
64
67
  import { resolveWaitToolConfig } from "../runs/background/subagent-wait.ts";
65
68
  import { registerWaitTool } from "../runs/background/wait-tool.ts";
@@ -425,6 +428,13 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
425
428
 
426
429
  DIRS.results = ensureAccessibleDir(DIRS.results);
427
430
  DIRS.async = ensureAccessibleDir(DIRS.async);
431
+ DIRS.owners = ensureAccessibleDir(DIRS.owners);
432
+ try {
433
+ // Idempotent per process: an extension reload shares the owner id and finds its record already on disk.
434
+ ensureOwnerRecord({ ownersDir: DIRS.owners });
435
+ } catch (error) {
436
+ console.error("Failed to write the subagent completion-owner record; finished background results cannot be taken over after this process exits:", error);
437
+ }
428
438
  cleanupOldChainDirs();
429
439
 
430
440
  const config = loadConfig();
@@ -743,6 +753,44 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
743
753
  },
744
754
  });
745
755
 
756
+ const observationFleetKeys = { sessionId: null as string | null, next: 0, keys: new Map<string, string>() };
757
+ const observability = registerSubagentObservability({
758
+ events: pi.events, state,
759
+ getHostSessionId: () => state.supervisorOwnerSessionId ?? null,
760
+ getWaitingChildren: () => supervisorChannel.pending.values(),
761
+ getFleet: () => buildFleetStatus(state, observationFleetKeys, state.currentSessionId),
762
+ });
763
+
764
+ // Release readiness (release:readiness:v1): keyed on the host session identity, like observability above.
765
+ const releaseReadiness = registerReleaseReadiness({
766
+ events: pi.events,
767
+ getHostSessionId: () => state.supervisorOwnerSessionId ?? null,
768
+ subagents: {
769
+ state,
770
+ hasPendingDelivery: () => completionNotifier.hasPendingDelivery(),
771
+ hasDeliveryDemand: hasResultDeliveryDemand,
772
+ listUndeliveredResults: () => findUndeliveredResults({
773
+ resultsDir: DIRS.results,
774
+ sessionIds: [state.currentSessionId, ...resultDeliveryOwnership.claimedSessionIds()],
775
+ owns: (sessionId, completionOwnerId) => resultDeliveryOwnership.owns(sessionId, completionOwnerId),
776
+ canTakeOver: (sessionId, completionOwnerId, runKey) => resultDeliveryOwnership.canTakeOver(sessionId, completionOwnerId, runKey),
777
+ }),
778
+ },
779
+ scheduler: {
780
+ armedSchedules: () => scheduledRunManager.armedSchedules(),
781
+ observedCompletionRunIds: () => scheduledRunManager.observedCompletionRunIds(),
782
+ },
783
+ // Kill switch (stop_all_background): same session gate as the readiness contributors above.
784
+ stopAll: {
785
+ subagents: {
786
+ state,
787
+ observedCompletionRunIds: () => scheduledRunManager.observedCompletionRunIds(),
788
+ stopRun: (target) => rpcBridge.stopRun(target),
789
+ },
790
+ scheduler: { disarmSessionSchedules: () => scheduledRunManager.disarmSessionOnlySchedules() },
791
+ },
792
+ });
793
+
746
794
  const rpcBridge = registerSubagentRpcBridge({
747
795
  events: pi.events,
748
796
  getContext: () => state.lastUiContext,
@@ -894,6 +942,8 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
894
942
  pi.events.on(SUBAGENT_STEERING_NOTICE_EVENT, steeringNoticeHandler),
895
943
  herdrStatusBridge.dispose,
896
944
  rpcBridge.dispose,
945
+ observability.dispose,
946
+ releaseReadiness.dispose,
897
947
  ];
898
948
 
899
949
  pi.on("tool_result", (event, ctx) => {
@@ -0,0 +1,164 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ SUBAGENT_OBSERVE_EVENT, SUBAGENT_RPC_MAX_BYTES,
4
+ type SubagentObserveRequest, type SubagentRpcNode, type SubagentRpcProjection, type SubagentRpcLifecycle,
5
+ } from "@selesai/code";
6
+ import type { SubagentState } from "../shared/types.ts";
7
+ import { sanitizeDisplayText, truncateDisplayText } from "../shared/display-text.ts";
8
+
9
+ interface Events { on(name: string, handler: (payload: unknown) => void): (() => void) | void }
10
+ interface WaitingChild { runId: string; childIndex: number; expectsReply: boolean }
11
+ const statuses = new Set(["queued", "pending", "running", "complete", "completed", "failed", "partial", "paused", "stopped", "rejected", "detached"]);
12
+ const record = (v: unknown): v is Record<string, unknown> => Boolean(v && typeof v === "object" && !Array.isArray(v));
13
+ const count = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
14
+ function text(v: unknown, max: number): string | undefined {
15
+ if (typeof v !== "string") return undefined;
16
+ return truncateDisplayText(sanitizeDisplayText(v.slice(0, 4096)), max) || undefined;
17
+ }
18
+ function identity(v: unknown): string | undefined {
19
+ return typeof v === "string" && v.length > 0 && v.length <= 128 && !/[\\/\r\n]/.test(v) ? v : undefined;
20
+ }
21
+ function tokens(v: unknown) {
22
+ if (!record(v) || !count(v.input) || !count(v.output) || !count(v.total)) return undefined;
23
+ return { input: v.input, output: v.output, total: v.total,
24
+ ...(count(v.window) ? { window: v.window } : {}), ...(count(v.windowPeak) ? { windowPeak: v.windowPeak } : {}) };
25
+ }
26
+
27
+ /** Simple adapter over runtime-owned memory. No artifact reads, executors, or new run state. */
28
+ export function registerSubagentObservability(options: {
29
+ events: Events;
30
+ state: SubagentState;
31
+ getHostSessionId(): string | null;
32
+ getWaitingChildren(): Iterable<WaitingChild>;
33
+ getFleet(): SubagentRpcProjection["fleet"];
34
+ }): { dispose(): void } {
35
+ const salt = randomUUID();
36
+ const key = (internal: string) => `node-${createHash("sha256").update(salt).update(internal).digest("hex").slice(0, 24)}`;
37
+ const subscribers = new Set<(projection: SubagentRpcProjection | null) => void>();
38
+ let timer: ReturnType<typeof setInterval> | undefined;
39
+ let disposed = false;
40
+ let boundSession: string | null = null;
41
+ const snapshot = (): SubagentRpcProjection => {
42
+ if (disposed) throw new Error("Subagent observation source disposed");
43
+ const { state } = options;
44
+ const sessionId = state.currentSessionId;
45
+ const waiting = new Set([...options.getWaitingChildren()].filter(r => r.expectsReply).map(r => `${r.runId}:${r.childIndex}`));
46
+ let nodes = 0;
47
+ const representedNativeIds = new Set<string>();
48
+ const omitted = { runs: 0, children: 0 };
49
+ const project = (raw: unknown, internal: string, depth: number, runId?: string, childIndex?: number, retained = false): SubagentRpcNode | undefined => {
50
+ if (!record(raw) || !statuses.has((raw.status ?? raw.state) as string) || nodes >= 64 || depth > 4) return undefined;
51
+ nodes++;
52
+ const node: SubagentRpcNode = { key: key(internal), status: (raw.status ?? raw.state) as SubagentRpcLifecycle, children: [], omittedChildren: 0 };
53
+ const nativeRunId = identity(raw.runId ?? raw.asyncId ?? raw.id);
54
+ if (nativeRunId) { node.runId = nativeRunId; representedNativeIds.add(nativeRunId); }
55
+ const workflowId = identity(raw.parentWorkflowRunId ?? (raw.mode === "workflow" ? nativeRunId : undefined));
56
+ if (workflowId) node.workflowId = workflowId;
57
+ const agent = text(raw.agent ?? raw.currentAgent, 96);
58
+ if (agent) node.agent = agent;
59
+ if (waiting.has(`${runId ?? nativeRunId}:${childIndex ?? raw.index ?? raw.currentIndex ?? 0}`)) node.activity = "waiting_supervisor";
60
+ else if ((raw.activityState ?? raw.currentActivityState) === "needs_attention") node.activity = "needs_attention";
61
+ for (const [field, value] of [["startedAt", raw.startedAt], ["updatedAt", raw.updatedAt ?? raw.lastUpdate], ["endedAt", raw.endedAt]] as const) if (count(value)) node[field] = value;
62
+ const progress = text(Array.isArray(raw.recentOutput) ? raw.recentOutput.slice(-1)[0] : raw.description, 256);
63
+ if (progress) node.progress = progress;
64
+ const currentTool = text(raw.currentTool, 96);
65
+ if (currentTool) node.currentTool = currentTool;
66
+ const usage = tokens(raw.totalTokens ?? raw.tokens) ?? (count(raw.inputTokens) && count(raw.outputTokens) && count(raw.tokens)
67
+ ? tokens({ input: raw.inputTokens, output: raw.outputTokens, total: raw.tokens, window: raw.window, windowPeak: raw.windowPeak }) : undefined);
68
+ if (usage) node.tokens = usage;
69
+ const error = text(raw.error, 256);
70
+ if (error) node.error = error;
71
+ const reason = text(raw.reason ?? raw.detachedReason, 128);
72
+ if (reason) node.reason = reason;
73
+ const execution = raw.execution;
74
+ if (record(execution) && statuses.has(execution.status as string)) node.outcome = {
75
+ status: execution.status as SubagentRpcLifecycle,
76
+ ...(typeof execution.success === "boolean" ? { success: execution.success } : {}),
77
+ ...(typeof execution.exitCode === "number" && Number.isSafeInteger(execution.exitCode) ? { exitCode: execution.exitCode } : {}),
78
+ };
79
+ const proof = raw.processTerminal;
80
+ if (record(proof) && ["pending", "observed", "unknown", "not-started"].includes(proof.state as string)) node.processTerminal = {
81
+ state: proof.state as NonNullable<SubagentRpcNode["processTerminal"]>["state"],
82
+ ...(count(proof.observedAt) ? { observedAt: proof.observedAt } : {}),
83
+ ...(text(proof.reason, 128) ? { reason: text(proof.reason, 128) } : {}),
84
+ };
85
+ const lists = [raw.activeChildren instanceof Map ? [...raw.activeChildren.values()].map(c => ({ ...c, status: "running" })) : [],
86
+ Array.isArray(raw.steps) ? raw.steps : [], Array.isArray(raw.children) ? raw.children : [], Array.isArray(raw.nestedChildren) ? raw.nestedChildren : []];
87
+ let offset = 0;
88
+ for (const [listIndex, list] of lists.entries()) for (const child of list) {
89
+ const index = record(child) && count(child.index) ? child.index : offset;
90
+ const childId = record(child) ? identity(child.runId ?? child.id ?? child.workflowKey) : undefined;
91
+ if (retained && childId && representedNativeIds.has(childId)) { offset++; continue; }
92
+ const childNode = node.children.length < 16 ? project(child, `${internal}:${listIndex}:${childId ?? index}`, depth + 1, nativeRunId ?? runId, index, retained) : undefined;
93
+ offset++;
94
+ if (childNode) node.children.push(childNode);
95
+ else { node.omittedChildren++; omitted.children++; }
96
+ }
97
+ return node;
98
+ };
99
+ const runs: SubagentRpcNode[] = [];
100
+ const add = (raw: unknown, internal: string, retained = false) => {
101
+ const node = runs.length < 32 ? project(raw, internal, 0, undefined, undefined, retained) : undefined;
102
+ if (node) runs.push(node); else omitted.runs++;
103
+ };
104
+ if (sessionId) {
105
+ for (const control of state.foregroundControls.values()) if (control.sessionId === sessionId) add({ ...control, status: "running" }, `foreground:${control.runId}`);
106
+ for (const run of state.foregroundRuns?.values() ?? []) if (run.sessionId === sessionId && !state.foregroundControls.has(run.runId)) {
107
+ // Retained per-child lifecycle is authoritative; the run has no aggregate verdict.
108
+ for (const child of run.children) add({ ...child, runId: run.runId }, `foreground:${run.runId}:${child.index}`);
109
+ }
110
+ const seen = new Set<string>();
111
+ for (const jobs of [state.asyncJobs, state.fleetJobs]) for (const job of jobs?.values() ?? []) {
112
+ if (job.sessionId !== sessionId || seen.has(job.asyncId)) continue;
113
+ seen.add(job.asyncId);
114
+ add(job, `async:${job.asyncId}`);
115
+ }
116
+ }
117
+ for (const [rootRunId, cached] of state.retainedForegroundNestedChildren ?? []) {
118
+ if (cached.sessionId !== sessionId || !state.retainedForegroundNestedRoutes?.has(rootRunId)) continue;
119
+ for (const descendant of cached.children) {
120
+ if (representedNativeIds.has(descendant.id)) continue;
121
+ // These are actual nested runs; the settled foreground root has no invented aggregate state.
122
+ add({ ...descendant, parentWorkflowRunId: cached.workflowId }, `retained:${descendant.id}`, true);
123
+ }
124
+ }
125
+ const projection = { fleet: options.getFleet(), runs, omitted };
126
+ while (Buffer.byteLength(JSON.stringify(projection), "utf8") > SUBAGENT_RPC_MAX_BYTES - 1024 && runs.length) {
127
+ const removed = runs.pop()!;
128
+ const removeOmissions = (n: SubagentRpcNode) => { omitted.children -= n.omittedChildren; n.children.forEach(removeOmissions); };
129
+ removeOmissions(removed);
130
+ omitted.runs++;
131
+ }
132
+ return projection;
133
+ };
134
+ let previous = "";
135
+ const unsubscribe = options.events.on(SUBAGENT_OBSERVE_EVENT, raw => {
136
+ const request = raw as SubagentObserveRequest;
137
+ if (disposed || request?.version !== 1 || request.sessionId !== options.getHostSessionId() || typeof request.accept !== "function") return;
138
+ boundSession = request.sessionId;
139
+ request.accept({ snapshot, subscribe(listener) {
140
+ subscribers.add(listener);
141
+ if (!timer) {
142
+ previous = JSON.stringify(snapshot());
143
+ timer = setInterval(() => {
144
+ if (options.getHostSessionId() !== boundSession) return;
145
+ const projection = snapshot();
146
+ const current = JSON.stringify(projection);
147
+ if (current === previous) return;
148
+ previous = current;
149
+ for (const subscriber of subscribers) subscriber(projection);
150
+ }, 250);
151
+ timer.unref?.();
152
+ }
153
+ return () => { subscribers.delete(listener); if (!subscribers.size && timer) { clearInterval(timer); timer = undefined; } };
154
+ } });
155
+ });
156
+ return { dispose() {
157
+ if (disposed) return;
158
+ disposed = true;
159
+ if (typeof unsubscribe === "function") unsubscribe();
160
+ if (timer) clearInterval(timer);
161
+ for (const subscriber of subscribers) subscriber(null);
162
+ subscribers.clear();
163
+ } };
164
+ }