gentle-pi 2.3.0 → 2.5.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 (153) hide show
  1. package/README.md +195 -11
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/agents/sdd-explore.md +1 -0
  4. package/assets/orchestrator-delegation.md +21 -10
  5. package/assets/orchestrator.md +8 -12
  6. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  7. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  10. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  12. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  13. package/docs/delegated-verification.md +25 -0
  14. package/docs/review-integration.md +1 -1
  15. package/docs/telemetry.md +38 -0
  16. package/extensions/ask-user-choice.ts +26 -20
  17. package/extensions/codegraph-tools.ts +94 -5
  18. package/extensions/gentle-agents.ts +588 -0
  19. package/extensions/gentle-ai.ts +1421 -143
  20. package/extensions/gentle-shell.ts +547 -0
  21. package/extensions/gentle-todo.ts +199 -0
  22. package/extensions/quiet-tools.ts +1 -1
  23. package/lib/agent-home.ts +8 -0
  24. package/lib/agents-config.ts +318 -0
  25. package/lib/agents-history.ts +80 -0
  26. package/lib/agents-protocol.ts +429 -0
  27. package/lib/agents-runner.ts +490 -0
  28. package/lib/agents-transcript.ts +87 -0
  29. package/lib/agents-view.ts +557 -0
  30. package/lib/agents-widget.ts +222 -0
  31. package/lib/gentle-ai-renderer.ts +142 -26
  32. package/lib/native-choice-list.ts +194 -0
  33. package/lib/native-fullscreen-interaction.ts +47 -0
  34. package/lib/native-pointer-region.ts +164 -0
  35. package/lib/native-review-cli.ts +103 -12
  36. package/lib/provider-contract-bundle.ts +88 -6
  37. package/lib/review-candidate-view-owner.ts +177 -0
  38. package/lib/review-candidate-view.ts +127 -35
  39. package/lib/review-consent-ui.ts +65 -0
  40. package/lib/review-host-relay.ts +146 -60
  41. package/lib/review-integration-v2.ts +92 -13
  42. package/lib/review-last-event-controller.ts +1 -0
  43. package/lib/review-relay-contract.ts +11 -0
  44. package/lib/review-repository.ts +2 -2
  45. package/lib/review-risk-assessment.ts +339 -0
  46. package/lib/review-session-standing-permission-ipc.ts +309 -0
  47. package/lib/review-session-standing-permission.ts +219 -0
  48. package/lib/sdd-preflight.ts +2 -2
  49. package/lib/shell-bar.ts +138 -0
  50. package/lib/shell-card.ts +136 -0
  51. package/lib/shell-changes-view.ts +205 -0
  52. package/lib/shell-changes.ts +210 -0
  53. package/lib/shell-gauge.ts +40 -0
  54. package/lib/shell-prompt.ts +119 -0
  55. package/lib/shell-todo.ts +280 -0
  56. package/lib/shell-usage-view.ts +76 -0
  57. package/lib/shell-usage.ts +246 -0
  58. package/lib/telemetry-trigger.ts +151 -0
  59. package/package.json +4 -4
  60. package/runtime/native-review-cli.mjs +102 -11
  61. package/runtime/review-integration-v2.mjs +92 -13
  62. package/runtime/review-relay-contract.mjs +11 -0
  63. package/runtime/review-risk-assessment.mjs +340 -0
  64. package/runtime/telemetry-trigger.mjs +152 -0
  65. package/scripts/build-runtime-modules.mjs +2 -0
  66. package/scripts/gentle-ai-installer.mjs +10 -10
  67. package/scripts/test-packed-runner.mjs +22 -0
  68. package/scripts/verify-package-files.mjs +18 -13
  69. package/skills/_shared/review-ledger-contract.md +9 -1
  70. package/skills/issue-creation/SKILL.md +53 -93
  71. package/tests/agents-config.test.ts +143 -0
  72. package/tests/agents-fake-child.ts +52 -0
  73. package/tests/agents-history.test.ts +54 -0
  74. package/tests/agents-protocol.test.ts +153 -0
  75. package/tests/agents-runner-process.test.ts +111 -0
  76. package/tests/agents-runner.test.ts +402 -0
  77. package/tests/agents-transcript.test.ts +30 -0
  78. package/tests/agents-view.test.ts +274 -0
  79. package/tests/agents-widget.test.ts +111 -0
  80. package/tests/ask-user-choice.test.ts +157 -3
  81. package/tests/codegraph-tools.test.ts +110 -1
  82. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  83. package/tests/fixtures/agents-process-child.mjs +23 -0
  84. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  85. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  86. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  87. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  89. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  92. package/tests/gentle-agents.test.ts +741 -0
  93. package/tests/gentle-ai-binary.test.ts +1 -1
  94. package/tests/gentle-ai-installer.test.ts +47 -47
  95. package/tests/gentle-ai-renderer.test.ts +65 -0
  96. package/tests/gentle-ai.test.ts +31 -14
  97. package/tests/gentle-card-text.ts +35 -0
  98. package/tests/gentle-shell.test.ts +527 -0
  99. package/tests/gentle-todo.test.ts +182 -0
  100. package/tests/issue-creation-skill.test.ts +103 -0
  101. package/tests/native-choice-list.test.ts +202 -0
  102. package/tests/native-fullscreen-interaction.test.ts +125 -0
  103. package/tests/native-pointer-region.test.ts +245 -0
  104. package/tests/native-review-capability-contract.test.ts +33 -1
  105. package/tests/native-review-cli.test.ts +40 -0
  106. package/tests/native-review-consent.test.ts +91 -0
  107. package/tests/native-review-parity-runtime.test.ts +8 -2
  108. package/tests/native-review-parity.test.ts +29 -22
  109. package/tests/orchestrator-budget.test.ts +71 -2
  110. package/tests/orchestrator-rdd-ownership.test.ts +10 -1
  111. package/tests/package-manifest.test.ts +134 -9
  112. package/tests/provider-contract-bundle.test.ts +76 -0
  113. package/tests/provider-contract-mirror.test.ts +19 -0
  114. package/tests/quiet-tool-rendering.test.ts +96 -37
  115. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  116. package/tests/rdd-status-line.test.ts +286 -0
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-candidate-view.test.ts +452 -6
  119. package/tests/review-contract-prompt.test.ts +142 -0
  120. package/tests/review-controller-native-recovery.test.ts +29 -4
  121. package/tests/review-controller-native-routing.test.ts +321 -4
  122. package/tests/review-controller-workspace-root.test.ts +45 -2
  123. package/tests/review-controller.test.ts +26 -1
  124. package/tests/review-host-relay-routing.test.ts +229 -11
  125. package/tests/review-host-relay.test.ts +195 -7
  126. package/tests/review-integration-v2-forward.test.ts +47 -0
  127. package/tests/review-integration-v2.test.ts +112 -0
  128. package/tests/review-last-event-closure.test.ts +7 -2
  129. package/tests/review-ledger-contract.test.ts +1 -1
  130. package/tests/review-relay-contract.test.ts +26 -0
  131. package/tests/review-repository.test.ts +28 -1
  132. package/tests/review-risk-assessment.test.ts +626 -0
  133. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  134. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  135. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  136. package/tests/review-session-standing-permission.test.ts +126 -0
  137. package/tests/runtime-harness.mjs +1 -0
  138. package/tests/shell-bar.test.ts +176 -0
  139. package/tests/shell-card.test.ts +118 -0
  140. package/tests/shell-changes-view.test.ts +146 -0
  141. package/tests/shell-changes.test.ts +182 -0
  142. package/tests/shell-prompt.test.ts +118 -0
  143. package/tests/shell-todo.test.ts +170 -0
  144. package/tests/shell-usage-view.test.ts +62 -0
  145. package/tests/shell-usage.test.ts +197 -0
  146. package/tests/telemetry-trigger.test.ts +349 -0
  147. package/tests/writer-edit-surface-scope.test.ts +153 -17
  148. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  149. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  150. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  151. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  152. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  153. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -0,0 +1,429 @@
1
+ import { sanitizeTerminalText } from "./terminal-theme.ts";
2
+
3
+ // Gentle Agents protocol. A child pi process streams RPC events; the host
4
+ // turns each one into a small typed delta, applies it to an append-only,
5
+ // bounded thread, and tells only the listeners of that task. Nothing here
6
+ // rebuilds a transcript, re-validates a whole snapshot, or fans out globally.
7
+
8
+ export const TASK_STATUS = {
9
+ QUEUED: "queued",
10
+ RUNNING: "running",
11
+ WAITING: "waiting",
12
+ COMPLETED: "completed",
13
+ FAILED: "failed",
14
+ CANCELLED: "cancelled",
15
+ TIMED_OUT: "timed_out",
16
+ } as const;
17
+
18
+ export type TaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS];
19
+
20
+ export const FINISHED_STATUSES: readonly TaskStatus[] = [TASK_STATUS.COMPLETED, TASK_STATUS.FAILED, TASK_STATUS.CANCELLED, TASK_STATUS.TIMED_OUT];
21
+
22
+ export const TASK_EVENT = {
23
+ TEXT: "text",
24
+ THINKING: "thinking",
25
+ TOOL_START: "tool_start",
26
+ TOOL_UPDATE: "tool_update",
27
+ TOOL_END: "tool_end",
28
+ TURN_END: "turn_end",
29
+ AGENT_END: "agent_end",
30
+ AGENT_SETTLED: "agent_settled",
31
+ ERROR: "error",
32
+ ASK: "ask",
33
+ NOTE: "note",
34
+ USAGE: "usage",
35
+ } as const;
36
+
37
+ export type TaskEventType = (typeof TASK_EVENT)[keyof typeof TASK_EVENT];
38
+
39
+ export const THREAD_ITEM = {
40
+ TEXT: "text",
41
+ THINKING: "thinking",
42
+ TOOL: "tool",
43
+ NOTE: "note",
44
+ } as const;
45
+
46
+ export type ThreadItemKind = (typeof THREAD_ITEM)[keyof typeof THREAD_ITEM];
47
+
48
+ export interface AskRequest {
49
+ id: string;
50
+ method: string;
51
+ title: string;
52
+ }
53
+
54
+ export interface TextEvent { type: typeof TASK_EVENT.TEXT; text: string }
55
+ export interface ThinkingEvent { type: typeof TASK_EVENT.THINKING; text: string }
56
+ export interface ToolStartEvent { type: typeof TASK_EVENT.TOOL_START; callId: string; name: string; args: Record<string, unknown> }
57
+ export interface ToolUpdateEvent { type: typeof TASK_EVENT.TOOL_UPDATE; callId: string; output: string }
58
+ export interface ToolEndEvent { type: typeof TASK_EVENT.TOOL_END; callId: string; output: string; isError: boolean }
59
+ export interface TurnEndEvent { type: typeof TASK_EVENT.TURN_END }
60
+ export interface AgentEndEvent {
61
+ type: typeof TASK_EVENT.AGENT_END;
62
+ text: string;
63
+ outcome: "success" | "error" | "aborted" | "empty";
64
+ diagnostic?: string;
65
+ }
66
+ export interface AgentSettledEvent { type: typeof TASK_EVENT.AGENT_SETTLED }
67
+ export interface ErrorEvent { type: typeof TASK_EVENT.ERROR; message: string }
68
+ export interface AskEvent { type: typeof TASK_EVENT.ASK; request: AskRequest }
69
+ export interface NoteEvent { type: typeof TASK_EVENT.NOTE; text: string }
70
+ export interface UsageEvent { type: typeof TASK_EVENT.USAGE; tokens: number; cost: number }
71
+
72
+ export type TaskEvent = TextEvent | ThinkingEvent | ToolStartEvent | ToolUpdateEvent | ToolEndEvent | TurnEndEvent | AgentEndEvent | AgentSettledEvent | ErrorEvent | AskEvent | NoteEvent | UsageEvent;
73
+
74
+ export interface TextItem { kind: typeof THREAD_ITEM.TEXT; text: string }
75
+ export interface ThinkingItem { kind: typeof THREAD_ITEM.THINKING; text: string }
76
+ export interface ToolItem { kind: typeof THREAD_ITEM.TOOL; callId: string; name: string; args: Record<string, unknown>; output: string; running: boolean; isError: boolean }
77
+ export interface NoteItem { kind: typeof THREAD_ITEM.NOTE; text: string }
78
+
79
+ export type ThreadItem = TextItem | ThinkingItem | ToolItem | NoteItem;
80
+
81
+ export interface ThreadLimits {
82
+ maxItems: number;
83
+ maxOutputChars: number;
84
+ }
85
+
86
+ export interface TaskThread {
87
+ items: ThreadItem[];
88
+ dropped: number;
89
+ version: number;
90
+ limits: ThreadLimits;
91
+ }
92
+
93
+ export interface TaskRecord {
94
+ id: string;
95
+ agent: string;
96
+ mode: string;
97
+ prompt: string;
98
+ /** One line for the card: what the subagent is doing. */
99
+ label: string;
100
+ cwd: string;
101
+ parentSessionId: string;
102
+ status: TaskStatus;
103
+ createdAt: number;
104
+ startedAt: number | null;
105
+ endedAt: number | null;
106
+ model: string;
107
+ thinking: string | undefined;
108
+ sessionPath: string | null;
109
+ error: string | null;
110
+ result: string | null;
111
+ lastStep: string;
112
+ lastActivityAt: number;
113
+ turns: number;
114
+ toolCalls: number;
115
+ tokens: number;
116
+ cost: number;
117
+ }
118
+
119
+ export interface TaskSummary {
120
+ running: number;
121
+ queued: number;
122
+ waiting: number;
123
+ finished: number;
124
+ }
125
+
126
+ export type TaskListener = (task: TaskRecord, thread: TaskThread) => void;
127
+ export type SummaryListener = (summary: TaskSummary) => void;
128
+
129
+ const DEFAULT_LIMITS: ThreadLimits = { maxItems: 400, maxOutputChars: 16_000 };
130
+ /** Child UI requests that block on an answer; everything else (notify, setStatus, setWidget) is noise here. */
131
+ export const DIALOG_METHODS: ReadonlySet<string> = new Set(["select", "confirm", "input", "editor"]);
132
+ const LABEL_MAX = 72;
133
+ const TEXT_CAP = 20_000;
134
+ const ELLIPSIS = "…";
135
+
136
+ function clean(value: unknown): string {
137
+ return typeof value === "string" ? sanitizeTerminalText(value) : "";
138
+ }
139
+
140
+ function contentText(content: unknown): string {
141
+ if (!Array.isArray(content)) return "";
142
+ return content
143
+ .map((part) => (part && typeof part === "object" && (part as { type?: string }).type === "text" ? clean((part as { text?: unknown }).text) : ""))
144
+ .filter((text) => text.length > 0)
145
+ .join("\n");
146
+ }
147
+
148
+ function keepTail(text: string, max: number): string {
149
+ return text.length <= max ? text : `${ELLIPSIS}${text.slice(text.length - max + 1)}`;
150
+ }
151
+
152
+ function terminalAssistant(messages: unknown): Omit<AgentEndEvent, "type"> {
153
+ if (!Array.isArray(messages)) return { text: "", outcome: "empty", diagnostic: "assistant returned no final report" };
154
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
155
+ const message = messages[index] as { role?: string; content?: unknown; stopReason?: unknown };
156
+ if (message?.role !== "assistant") continue;
157
+ const stopReason = clean(message.stopReason).toLowerCase();
158
+ // Do not preserve unbounded provider error payloads. The terminal reason is
159
+ // enough for an operator to distinguish failure from an empty report.
160
+ if (stopReason === "error") return { text: "", outcome: "error", diagnostic: "assistant reported an error" };
161
+ if (stopReason === "aborted") return { text: "", outcome: "aborted", diagnostic: "assistant aborted" };
162
+ const text = contentText(message.content);
163
+ return text.length > 0
164
+ ? { text, outcome: "success" }
165
+ : { text: "", outcome: "empty", diagnostic: "assistant returned no final report" };
166
+ }
167
+ return { text: "", outcome: "empty", diagnostic: "assistant returned no final report" };
168
+ }
169
+
170
+ type Raw = Record<string, unknown>;
171
+
172
+ function askRequest(raw: Raw): AskRequest {
173
+ const title = clean(raw.title) || clean(raw.message) || clean(raw.method);
174
+ return { id: String(raw.id ?? ""), method: String(raw.method ?? ""), title };
175
+ }
176
+
177
+ // One RPC line in, zero or more deltas out. Streaming deltas carry only the
178
+ // chunk; whole-message payloads that pi repeats on every update are ignored.
179
+ export function normalizeRpcEvent(raw: unknown): TaskEvent[] {
180
+ if (!raw || typeof raw !== "object") return [];
181
+ const event = raw as Raw;
182
+ switch (event.type) {
183
+ case "message_update": {
184
+ const inner = event.assistantMessageEvent as Raw | undefined;
185
+ if (!inner) return [];
186
+ if (inner.type === "text_delta") return [{ type: TASK_EVENT.TEXT, text: clean(inner.delta) }];
187
+ if (inner.type === "thinking_delta") return [{ type: TASK_EVENT.THINKING, text: clean(inner.delta) }];
188
+ if (inner.type === "error") {
189
+ const error = inner.error as Raw | undefined;
190
+ return [{ type: TASK_EVENT.ERROR, message: clean(error?.message) || clean(inner.reason) || "unknown error" }];
191
+ }
192
+ return [];
193
+ }
194
+ case "tool_execution_start":
195
+ return [{ type: TASK_EVENT.TOOL_START, callId: String(event.toolCallId ?? ""), name: clean(event.toolName), args: (event.args as Record<string, unknown>) ?? {} }];
196
+ case "tool_execution_update":
197
+ return [{ type: TASK_EVENT.TOOL_UPDATE, callId: String(event.toolCallId ?? ""), output: contentText((event.partialResult as Raw | undefined)?.content) }];
198
+ case "tool_execution_end":
199
+ return [{ type: TASK_EVENT.TOOL_END, callId: String(event.toolCallId ?? ""), output: contentText((event.result as Raw | undefined)?.content), isError: event.isError === true }];
200
+ case "message_end": {
201
+ const message = event.message as Raw | undefined;
202
+ const usage = message?.role === "assistant" ? (message.usage as Raw | undefined) : undefined;
203
+ if (!usage) return [];
204
+ const cost = usage.cost as Raw | undefined;
205
+ return [{ type: TASK_EVENT.USAGE, tokens: Number(usage.totalTokens ?? 0) || 0, cost: Number(cost?.total ?? 0) || 0 }];
206
+ }
207
+ case "turn_end":
208
+ return [{ type: TASK_EVENT.TURN_END }];
209
+ case "agent_end":
210
+ return [{ type: TASK_EVENT.AGENT_END, ...terminalAssistant(event.messages) }];
211
+ case "agent_settled":
212
+ return [{ type: TASK_EVENT.AGENT_SETTLED }];
213
+ case "extension_ui_request":
214
+ return DIALOG_METHODS.has(String(event.method)) ? [{ type: TASK_EVENT.ASK, request: askRequest(event) }] : [];
215
+ case "auto_retry_start":
216
+ return [{ type: TASK_EVENT.NOTE, text: `retrying (${String(event.attempt ?? "?")}/${String(event.maxAttempts ?? "?")})` }];
217
+ case "extension_error":
218
+ return [{ type: TASK_EVENT.NOTE, text: `extension error: ${clean(event.error)}` }];
219
+ default:
220
+ return [];
221
+ }
222
+ }
223
+
224
+ // The card shows one line per task: an explicit label, or the first sentence
225
+ // of the prompt clipped to a readable length.
226
+ export function taskLabel(prompt: string, label?: string): string {
227
+ const explicit = clean(label).replace(/\s+/g, " ").trim();
228
+ if (explicit.length > 0) return explicit.length > LABEL_MAX ? `${explicit.slice(0, LABEL_MAX - 1)}…` : explicit;
229
+ const firstLine = clean(prompt).split("\n").map((line) => line.trim()).find((line) => line.length > 0) ?? "";
230
+ const sentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine;
231
+ const compact = sentence.replace(/\s+/g, " ").replace(/[.:]$/, "");
232
+ return compact.length > LABEL_MAX ? `${compact.slice(0, LABEL_MAX - 1)}…` : compact;
233
+ }
234
+
235
+ export function emptyThread(limits: Partial<ThreadLimits> = {}): TaskThread {
236
+ return { items: [], dropped: 0, version: 0, limits: { ...DEFAULT_LIMITS, ...limits } };
237
+ }
238
+
239
+ function withItems(thread: TaskThread, items: ThreadItem[], dropped = thread.dropped): TaskThread {
240
+ return { ...thread, items, dropped, version: thread.version + 1 };
241
+ }
242
+
243
+ function push(thread: TaskThread, item: ThreadItem): TaskThread {
244
+ const items = [...thread.items, item];
245
+ const overflow = Math.max(0, items.length - thread.limits.maxItems);
246
+ return withItems(thread, overflow > 0 ? items.slice(overflow) : items, thread.dropped + overflow);
247
+ }
248
+
249
+ function appendText(thread: TaskThread, kind: typeof THREAD_ITEM.TEXT | typeof THREAD_ITEM.THINKING, raw: string): TaskThread {
250
+ const text = clean(raw);
251
+ if (text.length === 0) return thread;
252
+ const last = thread.items[thread.items.length - 1];
253
+ if (last && last.kind === kind) {
254
+ const merged = { ...last, text: keepTail(last.text + text, TEXT_CAP) };
255
+ return withItems(thread, [...thread.items.slice(0, -1), merged]);
256
+ }
257
+ return push(thread, { kind, text } as ThreadItem);
258
+ }
259
+
260
+ function updateTool(thread: TaskThread, callId: string, patch: Partial<ToolItem>): TaskThread {
261
+ const index = thread.items.findIndex((item) => item.kind === THREAD_ITEM.TOOL && item.callId === callId);
262
+ if (index < 0) return thread;
263
+ const current = thread.items[index] as ToolItem;
264
+ const next: ToolItem = { ...current, ...patch, output: keepTail(patch.output === undefined ? current.output : clean(patch.output), thread.limits.maxOutputChars) };
265
+ const items = [...thread.items];
266
+ items[index] = next;
267
+ return withItems(thread, items);
268
+ }
269
+
270
+ export function askNote(request: AskRequest): string {
271
+ return `asked: ${request.title}`;
272
+ }
273
+
274
+ export function applyTaskEvent(thread: TaskThread, event: TaskEvent): TaskThread {
275
+ switch (event.type) {
276
+ case TASK_EVENT.TEXT:
277
+ return appendText(thread, THREAD_ITEM.TEXT, event.text);
278
+ case TASK_EVENT.THINKING:
279
+ return appendText(thread, THREAD_ITEM.THINKING, event.text);
280
+ case TASK_EVENT.TOOL_START:
281
+ return push(thread, { kind: THREAD_ITEM.TOOL, callId: event.callId, name: event.name, args: event.args, output: "", running: true, isError: false });
282
+ case TASK_EVENT.TOOL_UPDATE:
283
+ return updateTool(thread, event.callId, { output: event.output });
284
+ case TASK_EVENT.TOOL_END:
285
+ return updateTool(thread, event.callId, { output: event.output, running: false, isError: event.isError });
286
+ case TASK_EVENT.ERROR:
287
+ return push(thread, { kind: THREAD_ITEM.NOTE, text: `error: ${event.message}` });
288
+ case TASK_EVENT.ASK:
289
+ return push(thread, { kind: THREAD_ITEM.NOTE, text: askNote(event.request) });
290
+ case TASK_EVENT.NOTE:
291
+ return push(thread, { kind: THREAD_ITEM.NOTE, text: clean(event.text) });
292
+ default:
293
+ return thread;
294
+ }
295
+ }
296
+
297
+ export function isFinished(status: TaskStatus): boolean {
298
+ return FINISHED_STATUSES.includes(status);
299
+ }
300
+
301
+ // What a task event means for the record itself: the step label the widget
302
+ // shows, the counters, and the waiting/running flip around user questions.
303
+ function recordPatch(task: TaskRecord, event: TaskEvent): Partial<TaskRecord> {
304
+ const resumed = task.status === TASK_STATUS.WAITING && event.type !== TASK_EVENT.ASK ? { status: TASK_STATUS.RUNNING } : {};
305
+ switch (event.type) {
306
+ case TASK_EVENT.TOOL_START:
307
+ return { ...resumed, lastStep: event.name, toolCalls: task.toolCalls + 1 };
308
+ case TASK_EVENT.TURN_END:
309
+ return { ...resumed, turns: task.turns + 1 };
310
+ case TASK_EVENT.AGENT_END:
311
+ return event.outcome === "success"
312
+ ? { ...resumed, result: event.text, error: null, lastStep: "responded" }
313
+ : { ...resumed, result: null, error: event.diagnostic ?? "assistant did not produce a final report", lastStep: event.diagnostic ?? "assistant failed" };
314
+ case TASK_EVENT.ERROR:
315
+ return { ...resumed, lastStep: `error: ${event.message}` };
316
+ case TASK_EVENT.ASK:
317
+ return { status: TASK_STATUS.WAITING, lastStep: askNote(event.request) };
318
+ case TASK_EVENT.TEXT:
319
+ return { ...resumed, lastStep: task.lastStep === "queued" || task.lastStep === "starting" ? "writing" : task.lastStep };
320
+ case TASK_EVENT.USAGE:
321
+ return { ...resumed, tokens: task.tokens + event.tokens, cost: task.cost + event.cost };
322
+ default:
323
+ return resumed;
324
+ }
325
+ }
326
+
327
+ export function summarize(tasks: Iterable<TaskRecord>): TaskSummary {
328
+ const summary: TaskSummary = { running: 0, queued: 0, waiting: 0, finished: 0 };
329
+ for (const task of tasks) {
330
+ if (task.status === TASK_STATUS.RUNNING) summary.running += 1;
331
+ else if (task.status === TASK_STATUS.QUEUED) summary.queued += 1;
332
+ else if (task.status === TASK_STATUS.WAITING) summary.waiting += 1;
333
+ else summary.finished += 1;
334
+ }
335
+ return summary;
336
+ }
337
+
338
+ export class TaskStore {
339
+ private readonly tasks = new Map<string, TaskRecord>();
340
+ private readonly threads = new Map<string, TaskThread>();
341
+ private readonly listeners = new Map<string, Set<TaskListener>>();
342
+ private readonly summaryListeners = new Set<SummaryListener>();
343
+ private readonly limits: Partial<ThreadLimits>;
344
+
345
+ constructor(limits: Partial<ThreadLimits> = {}) {
346
+ this.limits = limits;
347
+ }
348
+
349
+ add(task: TaskRecord): void {
350
+ this.tasks.set(task.id, task);
351
+ this.threads.set(task.id, emptyThread(this.limits));
352
+ this.notifySummary();
353
+ }
354
+
355
+ // Bring a task back from disk with its thread; a live task is never
356
+ // overwritten by a stored copy.
357
+ restore(task: TaskRecord, thread: TaskThread): boolean {
358
+ if (this.tasks.has(task.id)) return false;
359
+ this.tasks.set(task.id, task);
360
+ this.threads.set(task.id, { ...thread, limits: { ...DEFAULT_LIMITS, ...this.limits } });
361
+ this.notifySummary();
362
+ return true;
363
+ }
364
+
365
+ get(id: string): TaskRecord | undefined {
366
+ return this.tasks.get(id);
367
+ }
368
+
369
+ thread(id: string): TaskThread {
370
+ return this.threads.get(id) ?? emptyThread(this.limits);
371
+ }
372
+
373
+ list(parentSessionId?: string): TaskRecord[] {
374
+ return [...this.tasks.values()]
375
+ .filter((task) => parentSessionId === undefined || task.parentSessionId === parentSessionId)
376
+ .sort((a, b) => b.lastActivityAt - a.lastActivityAt || b.createdAt - a.createdAt);
377
+ }
378
+
379
+ summary(parentSessionId?: string): TaskSummary {
380
+ return summarize(this.list(parentSessionId));
381
+ }
382
+
383
+ // A status change is the only thing the summary cares about; everything
384
+ // that happens inside a task stays with that task's listeners.
385
+ update(id: string, patch: Partial<TaskRecord>): TaskRecord | undefined {
386
+ const current = this.tasks.get(id);
387
+ if (!current) return undefined;
388
+ const next = { ...current, ...patch };
389
+ this.tasks.set(id, next);
390
+ this.notifyTask(next);
391
+ if (next.status !== current.status) this.notifySummary();
392
+ return next;
393
+ }
394
+
395
+ apply(id: string, event: TaskEvent, at: number): TaskRecord | undefined {
396
+ const current = this.tasks.get(id);
397
+ if (!current) return undefined;
398
+ this.threads.set(id, applyTaskEvent(this.thread(id), event));
399
+ return this.update(id, { ...recordPatch(current, event), lastActivityAt: at });
400
+ }
401
+
402
+ subscribe(id: string, listener: TaskListener): () => void {
403
+ const set = this.listeners.get(id) ?? new Set<TaskListener>();
404
+ set.add(listener);
405
+ this.listeners.set(id, set);
406
+ return () => {
407
+ set.delete(listener);
408
+ if (set.size === 0) this.listeners.delete(id);
409
+ };
410
+ }
411
+
412
+ subscribeSummary(listener: SummaryListener): () => void {
413
+ this.summaryListeners.add(listener);
414
+ return () => this.summaryListeners.delete(listener);
415
+ }
416
+
417
+ private notifyTask(task: TaskRecord): void {
418
+ const set = this.listeners.get(task.id);
419
+ if (!set) return;
420
+ const thread = this.thread(task.id);
421
+ for (const listener of set) listener(task, thread);
422
+ }
423
+
424
+ private notifySummary(): void {
425
+ if (this.summaryListeners.size === 0) return;
426
+ const summary = this.summary();
427
+ for (const listener of this.summaryListeners) listener(summary);
428
+ }
429
+ }