@fitchmultz/pi-subagents 0.50.0 → 0.51.0

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 (169) hide show
  1. package/CHANGELOG.md +51 -2
  2. package/README.md +36 -5
  3. package/dist/agent/contracts.js +6 -24
  4. package/dist/agent/host.js +3 -16
  5. package/dist/agent/index.js +207 -173
  6. package/dist/agent/journal-tail.js +115 -156
  7. package/dist/agent/launch.js +20 -8
  8. package/dist/agent/legacy.js +65 -23
  9. package/dist/agent/notifications.js +138 -47
  10. package/dist/agent/observation-state.js +246 -0
  11. package/dist/agent/observation-view.js +258 -0
  12. package/dist/agent/observation.js +346 -0
  13. package/dist/agent/observe-card.js +243 -0
  14. package/dist/agent/observe-chrome.js +85 -0
  15. package/dist/agent/observe-client.js +285 -0
  16. package/dist/agent/observe-layout.js +65 -0
  17. package/dist/agent/observe-reading.js +84 -0
  18. package/dist/agent/observe-screen.js +400 -96
  19. package/dist/agent/observe-transcript.js +382 -0
  20. package/dist/agent/observe.js +250 -43
  21. package/dist/agent/process.js +198 -25
  22. package/dist/agent/receipts.js +92 -20
  23. package/dist/agent/runner.js +125 -20
  24. package/dist/agent/shell.js +2 -1
  25. package/dist/agent/storage.js +29 -12
  26. package/dist/agent/tool-schemas.js +20 -0
  27. package/dist/agent/typebox.js +10 -5
  28. package/dist/agent/usage.js +0 -115
  29. package/dist/agent/view.js +46 -16
  30. package/dist/agent/wake.js +99 -0
  31. package/dist/build-info.js +1 -1
  32. package/dist/intercom-client.d.ts +26 -0
  33. package/dist/intercom-client.js +45 -0
  34. package/dist/pi-intercom/agent-dir.d.ts +1 -0
  35. package/dist/pi-intercom/bridge-session.js +2 -1
  36. package/dist/pi-intercom/broker/broker.js +27 -21
  37. package/dist/pi-intercom/broker/client-last.d.ts +13 -0
  38. package/dist/pi-intercom/broker/client-last.js +17 -5
  39. package/dist/pi-intercom/broker/client.d.ts +60 -0
  40. package/dist/pi-intercom/broker/client.js +47 -16
  41. package/dist/pi-intercom/broker/connection.d.ts +29 -0
  42. package/dist/pi-intercom/broker/framing.d.ts +17 -0
  43. package/dist/pi-intercom/broker/framing.js +10 -7
  44. package/dist/pi-intercom/broker/guard.js +23 -0
  45. package/dist/pi-intercom/broker/last.d.ts +26 -0
  46. package/dist/pi-intercom/broker/last.js +57 -32
  47. package/dist/pi-intercom/broker/paths.d.ts +13 -0
  48. package/dist/pi-intercom/broker/paths.js +35 -12
  49. package/dist/pi-intercom/broker/pid.js +5 -1
  50. package/dist/pi-intercom/broker/server-connection.js +19 -0
  51. package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
  52. package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
  53. package/dist/pi-intercom/broker/targets.d.ts +9 -0
  54. package/dist/pi-intercom/connection.js +10 -2
  55. package/dist/pi-intercom/identity.js +6 -5
  56. package/dist/pi-intercom/inbound-delivery.js +16 -60
  57. package/dist/pi-intercom/inbound-journal.js +83 -6
  58. package/dist/pi-intercom/inbound-record.js +18 -1
  59. package/dist/pi-intercom/inbound.js +12 -38
  60. package/dist/pi-intercom/index.js +3 -0
  61. package/dist/pi-intercom/last-message.js +4 -16
  62. package/dist/pi-intercom/lifecycle.js +88 -3
  63. package/dist/pi-intercom/outbound.js +5 -15
  64. package/dist/pi-intercom/reply-tracker.js +7 -6
  65. package/dist/pi-intercom/reply-wait.js +2 -2
  66. package/dist/pi-intercom/session-targets.d.ts +24 -0
  67. package/dist/pi-intercom/tool-registration.js +4 -1
  68. package/dist/pi-intercom/tool-results.js +5 -7
  69. package/dist/pi-intercom/tool-schemas.js +12 -26
  70. package/dist/pi-intercom/types.d.ts +212 -0
  71. package/dist/pi-intercom/types.js +54 -3
  72. package/dist/pi-intercom/validation.d.ts +12 -0
  73. package/dist/pi-intercom/validation.js +3 -0
  74. package/dist/shared/bounded-json.js +151 -0
  75. package/dist/shared/compact-cards.js +66 -0
  76. package/dist/shared/journal-frames.js +3 -6
  77. package/dist/shared/json-projection.js +267 -129
  78. package/dist/shared/native-journal.js +123 -56
  79. package/dist/shared/post-exit-stdio-guard.js +120 -7
  80. package/dist/shared/tui-action.js +29 -0
  81. package/dist/shared/unknown.d.ts +9 -0
  82. package/docs/code-quality.md +2 -0
  83. package/docs/development.md +40 -1
  84. package/docs/intercom-bridge.md +9 -4
  85. package/docs/intercom.md +226 -6
  86. package/docs/performance.md +87 -11
  87. package/docs/reference.md +157 -20
  88. package/docs/retired-history-maintenance.md +252 -0
  89. package/package.json +28 -15
  90. package/scripts/archive-retired-history.mjs +464 -0
  91. package/scripts/build.mjs +48 -28
  92. package/scripts/measure-size.mjs +1 -1
  93. package/scripts/native-package-smoke.mjs +153 -12
  94. package/scripts/package-smoke.mjs +80 -11
  95. package/scripts/prepare.mjs +2 -2
  96. package/scripts/real-pi-live.mjs +377 -0
  97. package/scripts/real-pi-processes.mjs +6 -1
  98. package/scripts/real-pi-smoke.mjs +6 -282
  99. package/scripts/run-tests.mjs +4 -2
  100. package/scripts/typescript.mjs +35 -0
  101. package/skills/pi-intercom/SKILL.md +2 -1
  102. package/skills/pi-subagents/SKILL.md +23 -5
  103. package/src/agent/contracts.ts +8 -34
  104. package/src/agent/host.ts +19 -20
  105. package/src/agent/index.ts +224 -190
  106. package/src/agent/journal-tail.ts +141 -153
  107. package/src/agent/launch.ts +33 -9
  108. package/src/agent/legacy.ts +85 -31
  109. package/src/agent/notifications.ts +192 -67
  110. package/src/agent/observation-state.ts +277 -0
  111. package/src/agent/observation-view.ts +267 -0
  112. package/src/agent/observation.ts +405 -0
  113. package/src/agent/observe-card.ts +304 -0
  114. package/src/agent/observe-chrome.ts +170 -0
  115. package/src/agent/observe-client.ts +318 -0
  116. package/src/agent/observe-layout.ts +79 -0
  117. package/src/agent/observe-reading.ts +97 -0
  118. package/src/agent/observe-screen.ts +443 -129
  119. package/src/agent/observe-transcript.ts +434 -0
  120. package/src/agent/observe.ts +304 -71
  121. package/src/agent/process.ts +240 -34
  122. package/src/agent/receipts.ts +114 -24
  123. package/src/agent/runner.ts +125 -27
  124. package/src/agent/shell.ts +2 -1
  125. package/src/agent/storage.ts +27 -11
  126. package/src/agent/tool-schemas.ts +33 -0
  127. package/src/agent/typebox.ts +12 -7
  128. package/src/agent/usage.ts +0 -130
  129. package/src/agent/view.ts +70 -20
  130. package/src/agent/wake.ts +107 -0
  131. package/src/intercom-client.ts +108 -0
  132. package/src/pi-intercom/bridge-session.ts +2 -2
  133. package/src/pi-intercom/broker/broker.ts +27 -23
  134. package/src/pi-intercom/broker/client-last.ts +25 -12
  135. package/src/pi-intercom/broker/client.ts +55 -33
  136. package/src/pi-intercom/broker/framing.ts +11 -8
  137. package/src/pi-intercom/broker/guard.ts +33 -0
  138. package/src/pi-intercom/broker/last.ts +86 -33
  139. package/src/pi-intercom/broker/paths.ts +39 -12
  140. package/src/pi-intercom/broker/pid.ts +6 -1
  141. package/src/pi-intercom/broker/server-connection.ts +25 -0
  142. package/src/pi-intercom/connection.ts +21 -10
  143. package/src/pi-intercom/identity.ts +8 -5
  144. package/src/pi-intercom/inbound-delivery.ts +20 -66
  145. package/src/pi-intercom/inbound-journal.ts +103 -7
  146. package/src/pi-intercom/inbound-record.ts +21 -4
  147. package/src/pi-intercom/inbound.ts +18 -49
  148. package/src/pi-intercom/index.ts +3 -0
  149. package/src/pi-intercom/last-message.ts +10 -19
  150. package/src/pi-intercom/lifecycle.ts +117 -14
  151. package/src/pi-intercom/outbound.ts +7 -16
  152. package/src/pi-intercom/reply-tracker.ts +8 -6
  153. package/src/pi-intercom/reply-wait.ts +3 -2
  154. package/src/pi-intercom/runtime-types.ts +0 -1
  155. package/src/pi-intercom/tool-registration.ts +4 -1
  156. package/src/pi-intercom/tool-results.ts +10 -9
  157. package/src/pi-intercom/tool-schemas.ts +12 -44
  158. package/src/pi-intercom/types.ts +115 -4
  159. package/src/pi-intercom/validation.ts +4 -0
  160. package/src/shared/bounded-json.ts +162 -0
  161. package/src/shared/compact-cards.ts +118 -0
  162. package/src/shared/journal-frames.ts +3 -19
  163. package/src/shared/json-projection.ts +267 -147
  164. package/src/shared/native-journal.ts +157 -75
  165. package/src/shared/post-exit-stdio-guard.ts +165 -7
  166. package/src/shared/tui-action.ts +39 -0
  167. package/tsconfig.client.json +8 -0
  168. package/tsconfig.json +1 -0
  169. package/src/shared/stream-json.d.ts +0 -10
@@ -1,12 +1,12 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
- import { receiptId } from "./receipts.ts";
4
+ import { completionReceipts } from "./receipts.ts";
5
5
  import { readOutputPage } from "../shared/output-page.ts";
6
- import { isRecord } from "../shared/unknown.ts";
6
+ import { journalStamp } from "../shared/native-journal.ts";
7
7
  import type { Launch, Result } from "./contracts.ts";
8
8
  import { launchAt, list, runDirectory } from "./storage.ts";
9
- import { readResult } from "./usage.ts";
9
+ import { readCanonicalResult, readResult } from "./usage.ts";
10
10
 
11
11
  function queuedAdmissions(): Set<string> {
12
12
  const store = globalThis as Record<string, unknown>;
@@ -25,7 +25,7 @@ function queuedAdmissions(): Set<string> {
25
25
  return queued;
26
26
  }
27
27
  const admissions = queuedAdmissions();
28
- export function hold(owner: string, id: string): boolean {
28
+ function hold(owner: string, id: string): boolean {
29
29
  const key = `${owner}/${id}`;
30
30
  if (admissions.has(key)) {
31
31
  return false;
@@ -39,93 +39,218 @@ export function release(owner: string, id: string): void {
39
39
  export function isHeld(owner: string, id: string): boolean {
40
40
  return admissions.has(`${owner}/${id}`);
41
41
  }
42
- export function settle(owner: string, pending: boolean): void {
42
+ /** Returns whether any admission was released for reconciliation. */
43
+ export function settle(owner: string, pending: boolean): boolean {
43
44
  if (pending) {
44
- return;
45
+ return false;
45
46
  }
47
+ let released = false;
46
48
  for (const key of admissions) {
47
49
  if (key.startsWith(`${owner}/`)) {
48
50
  admissions.delete(key);
51
+ released = true;
49
52
  }
50
53
  }
54
+ return released;
51
55
  }
52
- function matches(value: unknown, launch: Launch): boolean {
53
- return (
54
- isRecord(value) &&
55
- value.owner === launch.owner &&
56
- value.id === launch.id &&
57
- value.completed === true
58
- );
56
+ export function published(ctx: ExtensionContext, launch: Launch): string | undefined {
57
+ return completionReceipts(ctx.sessionManager.getSessionFile(), launch.owner).get(launch.id);
59
58
  }
60
- function toolReceipt(message: unknown, launch: Launch): boolean {
61
- if (!isRecord(message) || message.role !== "toolResult") {
62
- return false;
63
- }
64
- return (
65
- (message.toolName === "delegate" || message.toolName === "agent_runs") &&
66
- isRecord(message.details) &&
67
- message.details.receivedResult === true &&
68
- matches(message.details, launch)
69
- );
70
- }
71
- function receipt(body: unknown, launch: Launch): boolean {
72
- if (!isRecord(body)) {
73
- return false;
74
- }
75
- if (body.type === "custom_message" && body.customType === "agent-completion") {
76
- return matches(body.details, launch);
77
- }
78
- return body.type === "message" && toolReceipt(body.message, launch);
59
+ export interface Completion {
60
+ readonly owner: string;
61
+ readonly id: string;
62
+ readonly message: Parameters<ExtensionAPI["sendMessage"]>[0];
79
63
  }
80
- export function published(ctx: ExtensionContext, launch: Launch): string | undefined {
81
- return receiptId(ctx.sessionManager.getSessionFile(), launch.owner, (body) =>
82
- receipt(body, launch),
83
- );
64
+ export function deliver(
65
+ pi: ExtensionAPI,
66
+ ctx: ExtensionContext,
67
+ notices: readonly Completion[],
68
+ ): void {
69
+ const idle = ctx.isIdle();
70
+ for (const [index, notice] of notices.entries()) {
71
+ hold(notice.owner, notice.id);
72
+ try {
73
+ // Pi defers settlement-triggered runs separately. Publish the idle batch first,
74
+ // then trigger once so no unstarted wakeup can outlive its admission hold.
75
+ pi.sendMessage(notice.message, {
76
+ triggerTurn: !idle || index === notices.length - 1,
77
+ deliverAs: "steer",
78
+ });
79
+ } catch (error) {
80
+ release(notice.owner, notice.id);
81
+ throw error;
82
+ }
83
+ }
84
84
  }
85
- function send(pi: ExtensionAPI, base: string, launch: Launch, result: Result): void {
85
+ function notification(base: string, launch: Launch, result: Result): Completion {
86
86
  const file = path.join(runDirectory(base, launch.id), "output.txt");
87
87
  const page = fs.existsSync(file) ? readOutputPage(file, { offset: 0, length: 32768 }) : undefined;
88
88
  const more =
89
89
  page?.nextOffset !== undefined
90
90
  ? `\n[More output: agent_runs inspect id=${launch.id} offset=${page.nextOffset}; ${page.size} bytes total.]`
91
91
  : "";
92
- hold(launch.owner, launch.id);
93
- try {
94
- pi.sendMessage(
95
- {
96
- customType: "agent-completion",
97
- display: true,
98
- content: `Agent run ${launch.id}: ${result.status}. ${result.error ?? ""}\n${page?.text ?? ""}${more}\nNative journal: ${launch.sessionFile}`,
99
- details: {
100
- owner: launch.owner,
101
- id: launch.id,
102
- completed: true,
103
- result,
104
- outputFile: file,
105
- page,
106
- },
92
+ return {
93
+ owner: launch.owner,
94
+ id: launch.id,
95
+ message: {
96
+ customType: "agent-completion",
97
+ display: true,
98
+ content: `Agent run ${launch.id}: ${result.status}. ${result.error ?? ""}\n${page?.text ?? ""}${more}\nNative journal: ${launch.sessionFile}`,
99
+ details: {
100
+ owner: launch.owner,
101
+ id: launch.id,
102
+ completed: true,
103
+ result,
104
+ outputFile: file,
105
+ page,
107
106
  },
108
- { triggerTurn: true, deliverAs: "followUp" },
109
- );
110
- } catch (error) {
111
- admissions.delete(`${launch.owner}/${launch.id}`);
112
- throw error;
107
+ },
108
+ };
109
+ }
110
+ interface PendingJournal {
111
+ readonly file: string;
112
+ readonly stamp: string | undefined;
113
+ }
114
+ interface Loaded {
115
+ readonly launch: Launch;
116
+ readonly result: Result | undefined;
117
+ readonly journal?: PendingJournal;
118
+ }
119
+ interface Settled {
120
+ readonly launch: string;
121
+ readonly result: string;
122
+ readonly pending?: Loaded;
123
+ readonly endedAt: number;
124
+ readonly createdAt: number;
125
+ }
126
+ interface Completed {
127
+ readonly id: string;
128
+ readonly endedAt: number;
129
+ readonly createdAt: number;
130
+ readonly loaded?: Loaded;
131
+ }
132
+ // Validated settled runs by file stamp, so polling does not reparse unchanged history.
133
+ const settledRuns = new Map<string, Map<string, Settled>>();
134
+ function stamp(file: string): string | undefined {
135
+ const stat = fs.statSync(file, { bigint: true, throwIfNoEntry: false });
136
+ return stat === undefined ? undefined : journalStamp(stat);
137
+ }
138
+ function pendingJournal(file: string): PendingJournal | undefined {
139
+ try {
140
+ return { file, stamp: stamp(file) };
141
+ } catch {
142
+ // Unavailable metadata cannot validate a cached attempt or hide execution.
143
+ return undefined;
113
144
  }
114
145
  }
115
- export function notify(pi: ExtensionAPI, ctx: ExtensionContext, base: string, owner: string): void {
116
- for (const id of list(base)) {
117
- const launch = launchAt(base, id, owner);
118
- const result = readResult(base, launch);
119
- if (!result) {
120
- continue;
146
+ function unchangedPending(pending: Loaded | undefined): boolean {
147
+ if (pending === undefined) {
148
+ return true;
149
+ }
150
+ const journal = pending.journal;
151
+ if (journal === undefined) {
152
+ return false;
153
+ }
154
+ const current = pendingJournal(journal.file);
155
+ return current !== undefined && current.stamp === journal.stamp;
156
+ }
157
+ function load(base: string, id: string, owner: string): Loaded {
158
+ const launch = launchAt(base, id, owner);
159
+ const result = readCanonicalResult(base, launch);
160
+ if (result?.accountingPending === undefined) {
161
+ return { launch, result };
162
+ }
163
+ // Only pending runs need this pre-read stamp to observe concurrent publication.
164
+ // readResult revalidates canonical state afterward: a miss intentionally reads result.json twice.
165
+ const journal = pendingJournal(launch.sessionFile);
166
+ return { launch, result: readResult(base, launch), journal };
167
+ }
168
+ function cacheableResult(loaded: Loaded): boolean {
169
+ return loaded.result?.accountingPending === undefined || loaded.journal !== undefined;
170
+ }
171
+ interface Scan {
172
+ readonly base: string;
173
+ readonly owner: string;
174
+ readonly known: ReadonlyMap<string, Settled>;
175
+ readonly settled: Map<string, Settled>;
176
+ }
177
+ function completedRun({ base, owner, known, settled }: Scan, id: string): Completed | undefined {
178
+ const directory = runDirectory(base, id);
179
+ const launch = stamp(path.join(directory, "launch.json"));
180
+ const result = stamp(path.join(directory, "result.json"));
181
+ const prior = known.get(id);
182
+ if (
183
+ prior !== undefined &&
184
+ prior.launch === launch &&
185
+ prior.result === result &&
186
+ unchangedPending(prior.pending)
187
+ ) {
188
+ settled.set(id, prior);
189
+ return { id, ...prior, loaded: prior.pending };
190
+ }
191
+ const loaded = load(base, id, owner);
192
+ if (loaded.result === undefined) {
193
+ return;
194
+ }
195
+ const order = { endedAt: loaded.result.endedAt, createdAt: loaded.launch.createdAt };
196
+ // Pending accounting retries on child changes, even after the completion receipt.
197
+ // Ordinary settled history retains its launch/result-only idle probes.
198
+ const pending = loaded.result.accountingPending === undefined ? undefined : loaded;
199
+ if (launch !== undefined && result !== undefined && cacheableResult(loaded)) {
200
+ settled.set(id, { launch, result, pending, ...order });
201
+ }
202
+ return { id, ...order, loaded };
203
+ }
204
+ function completed(base: string, owner: string, awaited: Set<string>): Completed[] {
205
+ const key = JSON.stringify([base, owner]);
206
+ const scan = {
207
+ base,
208
+ owner,
209
+ known: settledRuns.get(key) ?? new Map<string, Settled>(),
210
+ settled: new Map<string, Settled>(),
211
+ };
212
+ const runs = list(base).flatMap((id) => {
213
+ const run = completedRun(scan, id);
214
+ if (run === undefined) {
215
+ awaited.add(path.join(runDirectory(base, id), "result.json"));
216
+ } else if (run.loaded?.result?.accountingPending !== undefined) {
217
+ awaited.add(run.loaded.launch.sessionFile);
121
218
  }
122
- const key = `${owner}/${id}`;
123
- if (published(ctx, launch) !== undefined) {
219
+ return run ?? [];
220
+ });
221
+ settledRuns.delete(key);
222
+ settledRuns.set(key, scan.settled);
223
+ if (settledRuns.size > 8) {
224
+ settledRuns.delete(settledRuns.keys().next().value ?? key);
225
+ }
226
+ return runs.sort((a, b) => {
227
+ if (a.endedAt !== b.endedAt) {
228
+ return a.endedAt - b.endedAt;
229
+ }
230
+ return a.createdAt !== b.createdAt ? a.createdAt - b.createdAt : a.id.localeCompare(b.id);
231
+ });
232
+ }
233
+ export interface Reconciliation {
234
+ readonly notices: Completion[];
235
+ /** Canonical files whose publication can change what this owner must deliver. */
236
+ readonly awaited: ReadonlySet<string>;
237
+ }
238
+ export function notifications(ctx: ExtensionContext, base: string, owner: string): Reconciliation {
239
+ const notices: Completion[] = [];
240
+ const awaited = new Set<string>();
241
+ const runs = completed(base, owner, awaited);
242
+ const receipts =
243
+ runs.length === 0 ? undefined : completionReceipts(ctx.sessionManager.getSessionFile(), owner);
244
+ for (const run of runs) {
245
+ const key = `${owner}/${run.id}`;
246
+ if (receipts?.get(run.id) !== undefined) {
124
247
  admissions.delete(key);
125
248
  continue;
126
249
  }
127
- if (!admissions.has(key)) {
128
- send(pi, base, launch, result);
250
+ const loaded = admissions.has(key) ? undefined : (run.loaded ?? load(base, run.id, owner));
251
+ if (loaded?.result !== undefined) {
252
+ notices.push(notification(base, loaded.launch, loaded.result));
129
253
  }
130
254
  }
255
+ return { notices, awaited };
131
256
  }
@@ -0,0 +1,277 @@
1
+ import type {
2
+ AgentSession,
3
+ AgentSessionEvent,
4
+ SessionMessageEntry,
5
+ ToolExecutionEndEvent,
6
+ ToolExecutionStartEvent,
7
+ ToolExecutionUpdateEvent,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
10
+ import { boundedJson } from "../shared/bounded-json.ts";
11
+
12
+ const VALUE_BUDGET = { bytes: 64 * 1024, nodes: 4096, depth: 32 };
13
+ /** Longer native identifiers are left to the journal rather than keyed live. */
14
+ export const NAME_CHARS = 1024;
15
+ const RETAINED = { settlements: 128, tools: 256 };
16
+
17
+ type MessageEvent = Extract<
18
+ AgentSessionEvent,
19
+ { type: "message_start" | "message_update" | "message_end" }
20
+ >;
21
+ export type ToolEvent = ToolExecutionStartEvent | ToolExecutionUpdateEvent | ToolExecutionEndEvent;
22
+ const WRITING = { text: "writing", thinking: "thinking", toolCall: "calling" } as const;
23
+
24
+ /** A live in-flight assistant message: a transient native reference, never a receipt. */
25
+ export interface LiveMessage {
26
+ readonly key: number;
27
+ message: AssistantMessage;
28
+ ended: boolean;
29
+ }
30
+ /** A native message found in the session tree: `[seq, entry id, live message key, tool call id]`. */
31
+ export interface Settlement {
32
+ readonly seq: number;
33
+ readonly json: string;
34
+ }
35
+
36
+ export class LiveTool {
37
+ partial?: unknown;
38
+ rev = 0;
39
+ ended?: { readonly isError: boolean; readonly result: unknown };
40
+ readonly id: string;
41
+ readonly name: string;
42
+ readonly args: unknown;
43
+ private argsJson?: string;
44
+ private partialJson?: { readonly rev: number; readonly json: string };
45
+ private resultJson?: string;
46
+ constructor(id: string, name: string, args: unknown) {
47
+ this.id = id;
48
+ this.name = name;
49
+ this.args = args;
50
+ }
51
+ /** Replaceable progress: only the latest reference is projected at the next flush. */
52
+ progress(value: unknown): void {
53
+ this.partial = value;
54
+ this.rev++;
55
+ }
56
+ projectedArgs(project: (value: unknown) => string): string {
57
+ this.argsJson ??= project(this.args);
58
+ return this.argsJson;
59
+ }
60
+ projectedPartial(project: (value: unknown) => string): string {
61
+ if (this.partialJson?.rev !== this.rev) {
62
+ this.partialJson = { rev: this.rev, json: project(this.partial) };
63
+ }
64
+ return this.partialJson.json;
65
+ }
66
+ projectedResult(project: (value: unknown) => string): string {
67
+ this.resultJson ??= project(this.ended?.result);
68
+ return this.resultJson;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Single-writer actor state. Sparse tool hooks always keep the tool table, because native state
74
+ * retains neither progress, unpublished parallel results nor nested calls. Token-rate message
75
+ * events arrive only while a detail viewer observes; attaching seeds from public native state.
76
+ */
77
+ export class LiveState {
78
+ version = 0;
79
+ activityVersion = 0;
80
+ message?: LiveMessage;
81
+ readonly tools = new Map<string, LiveTool>();
82
+ readonly settlements: Settlement[] = [];
83
+ settled = 0;
84
+ /** Live items dropped by a retention bound or a branch change; viewers resynchronize. */
85
+ evicted = 0;
86
+ events = 0;
87
+ toolEvents = 0;
88
+ progressUpdates = 0;
89
+ projections = 0;
90
+ unavailable = 0;
91
+ private observing = false;
92
+ private nextKey = 0;
93
+ private queued = false;
94
+ private closed = false;
95
+ private watermark: string | null;
96
+ private readonly session: AgentSession;
97
+ private readonly changed: () => void;
98
+ private readonly failed: (error: unknown) => void;
99
+ constructor(session: AgentSession, changed: () => void, failed: (error: unknown) => void) {
100
+ this.session = session;
101
+ this.changed = changed;
102
+ this.failed = failed;
103
+ this.watermark = session.sessionManager.getLeafId();
104
+ }
105
+ /** Start or stop the transient message view; tools persist across viewers. */
106
+ observe(on: boolean): void {
107
+ this.resolve();
108
+ this.observing = on;
109
+ this.message = undefined;
110
+ this.settlements.length = 0;
111
+ if (on) {
112
+ const streaming = this.session.state.streamingMessage;
113
+ if (streaming?.role === "assistant") {
114
+ this.assistant("message_start", streaming);
115
+ }
116
+ }
117
+ this.version++;
118
+ this.activityVersion++;
119
+ }
120
+ apply(event: AgentSessionEvent): void {
121
+ this.events++;
122
+ if (
123
+ event.type === "message_start" ||
124
+ event.type === "message_update" ||
125
+ event.type === "message_end"
126
+ ) {
127
+ this.messageEvent(event);
128
+ }
129
+ }
130
+ private messageEvent(event: MessageEvent): void {
131
+ if (event.message.role === "assistant") {
132
+ this.assistant(event.type, event.message);
133
+ }
134
+ if (event.type === "message_end" && !this.queued) {
135
+ this.queued = true;
136
+ // Native message_end listeners run just before the same job appends the entry.
137
+ queueMicrotask(this.settle);
138
+ }
139
+ }
140
+ private assistant(type: MessageEvent["type"], message: AssistantMessage): void {
141
+ let live = this.message;
142
+ if (type === "message_start" || live === undefined) {
143
+ // A previous message still here was never found in the session tree.
144
+ this.evicted += live === undefined ? 0 : 1;
145
+ live = { key: ++this.nextKey, message, ended: false };
146
+ this.message = live;
147
+ }
148
+ live.message = message;
149
+ live.ended = type === "message_end";
150
+ this.version++;
151
+ }
152
+ tool(event: ToolEvent): void {
153
+ this.toolEvents++;
154
+ this.version++;
155
+ if (event.type === "tool_execution_start") {
156
+ this.resolve();
157
+ this.startTool(event.toolCallId, event.toolName, event.args);
158
+ return;
159
+ }
160
+ const tool = this.tools.get(event.toolCallId);
161
+ if (event.type === "tool_execution_update") {
162
+ tool?.progress(event.partialResult);
163
+ this.progressUpdates++;
164
+ } else if (tool) {
165
+ tool.ended = { isError: event.isError, result: event.result };
166
+ this.activityVersion++;
167
+ }
168
+ }
169
+ private startTool(id: string, name: string, args: unknown): void {
170
+ if (id.length > NAME_CHARS) {
171
+ this.unavailable++;
172
+ return;
173
+ }
174
+ const oldest = this.tools.size >= RETAINED.tools ? this.tools.keys().next() : undefined;
175
+ if (oldest?.done === false) {
176
+ this.tools.delete(oldest.value);
177
+ this.evicted++;
178
+ }
179
+ this.tools.set(id, new LiveTool(id, name, args));
180
+ this.activityVersion++;
181
+ }
182
+ private readonly settle = (): void => {
183
+ this.queued = false;
184
+ try {
185
+ if (!this.closed && this.resolve()) {
186
+ this.changed();
187
+ }
188
+ } catch (error) {
189
+ this.failed(error);
190
+ }
191
+ };
192
+ /**
193
+ * Hand native messages appended since the last walk over to their session entries. A live
194
+ * message without an entry is unresolved publication and stays live. Returns whether anything
195
+ * changed.
196
+ */
197
+ resolve(): boolean {
198
+ const { appended, departed } = this.appended();
199
+ this.evicted += departed && this.observing ? 1 : 0;
200
+ for (const entry of appended) {
201
+ this.publish(entry, !departed && this.observing);
202
+ }
203
+ this.settlements.splice(0, Math.max(0, this.settlements.length - RETAINED.settlements));
204
+ const changed = appended.length > 0;
205
+ this.version += changed ? 1 : 0;
206
+ this.activityVersion += changed ? 1 : 0;
207
+ return changed;
208
+ }
209
+ /** Message entries since the previous walk, in journal order; each append is visited once. */
210
+ private appended(): { appended: SessionMessageEntry[]; departed: boolean } {
211
+ const manager = this.session.sessionManager;
212
+ const appended: SessionMessageEntry[] = [];
213
+ let entry = manager.getLeafEntry();
214
+ const leaf = entry?.id ?? null;
215
+ while (entry !== undefined && entry.id !== this.watermark) {
216
+ if (entry.type === "message") {
217
+ appended.push(entry);
218
+ }
219
+ entry = entry.parentId === null ? undefined : manager.getEntry(entry.parentId);
220
+ }
221
+ // A leaf off the previous branch walked to the root once; its entries are not new.
222
+ const departed = entry === undefined && this.watermark !== null;
223
+ this.watermark = leaf;
224
+ return { appended: appended.reverse(), departed };
225
+ }
226
+ private publish({ id, message }: SessionMessageEntry, settle: boolean): void {
227
+ const call = message.role === "toolResult" ? message.toolCallId : "";
228
+ const tool = call.length > 0 && call.length <= NAME_CHARS ? call : null;
229
+ // A published result also retires the calls it made through ctx.executeTool().
230
+ const retired = (key: string): boolean =>
231
+ tool !== null && (key === tool || key.startsWith(`${tool}/`));
232
+ for (const live of [...this.tools.keys()].filter(retired)) {
233
+ this.tools.delete(live);
234
+ }
235
+ const key = this.message?.message === message ? this.message.key : null;
236
+ this.message = key === null ? this.message : undefined;
237
+ if (settle) {
238
+ const seq = ++this.settled;
239
+ this.settlements.push({ seq, json: JSON.stringify([seq, id, key, tool]) });
240
+ }
241
+ }
242
+ dispose(): void {
243
+ this.closed = true;
244
+ }
245
+ readonly project = (value: unknown): string => {
246
+ this.projections++;
247
+ const result = boundedJson(value, VALUE_BUDGET);
248
+ if ("json" in result) {
249
+ return `{"json":${result.json}}`;
250
+ }
251
+ this.unavailable++;
252
+ return `{"unavailable":"${result.unavailable}"}`;
253
+ };
254
+ /** Sampled from public native state, so compact peers never need token-rate events. */
255
+ activity(busy: boolean): string {
256
+ const { session } = this;
257
+ const running = [...this.tools.values()].filter((tool) => tool.ended === undefined);
258
+ const streaming = session.state.streamingMessage;
259
+ const content = streaming?.role === "assistant" ? streaming.content : undefined;
260
+ const last = content?.at(-1)?.type;
261
+ const phases: Array<[boolean, string]> = [
262
+ [running.length > 0, "tool"],
263
+ [content !== undefined, last === undefined ? "responding" : WRITING[last]],
264
+ [session.isCompacting, "compacting"],
265
+ [session.isRetrying, "retrying"],
266
+ [busy || session.isStreaming, "working"],
267
+ ];
268
+ const phase = phases.find(([active]) => active)?.[1] ?? "idle";
269
+ return JSON.stringify({
270
+ phase,
271
+ tools: running.slice(0, 3).map((tool) => tool.name.slice(0, 64)),
272
+ running: running.length,
273
+ model: this.session.model?.id,
274
+ thinking: this.session.thinkingLevel,
275
+ });
276
+ }
277
+ }