@yaag/runtime 0.1.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 (71) hide show
  1. package/package.json +25 -0
  2. package/src/agent-names.ts +20 -0
  3. package/src/agent-usage.ts +72 -0
  4. package/src/agent.ts +130 -0
  5. package/src/args-validation.ts +11 -0
  6. package/src/ask-activity.ts +84 -0
  7. package/src/ask-contract-identity.ts +96 -0
  8. package/src/ask-exchange-events.ts +60 -0
  9. package/src/ask-exchange-options.ts +32 -0
  10. package/src/ask-exchange.ts +291 -0
  11. package/src/ask-hash.ts +86 -0
  12. package/src/ask-limit.ts +189 -0
  13. package/src/ask-output-steering.ts +69 -0
  14. package/src/ask-output-tail.ts +166 -0
  15. package/src/ask-output.ts +109 -0
  16. package/src/ask-settlement.ts +37 -0
  17. package/src/ask-turn.ts +70 -0
  18. package/src/cassette-loader.ts +131 -0
  19. package/src/cassette-publish.ts +55 -0
  20. package/src/cassette-replay.ts +178 -0
  21. package/src/cassette-schema.ts +152 -0
  22. package/src/cassette.ts +275 -0
  23. package/src/checkpoint-dir.ts +89 -0
  24. package/src/connection.ts +123 -0
  25. package/src/define-agent.ts +83 -0
  26. package/src/define-run.ts +69 -0
  27. package/src/errors.ts +115 -0
  28. package/src/events.ts +143 -0
  29. package/src/extension-package.ts +88 -0
  30. package/src/extension-paths.ts +66 -0
  31. package/src/extension-source.ts +60 -0
  32. package/src/fake-transport.ts +240 -0
  33. package/src/frame-gap.ts +41 -0
  34. package/src/frame-queue.ts +52 -0
  35. package/src/git-facts.ts +32 -0
  36. package/src/idle-watch.ts +154 -0
  37. package/src/index.ts +96 -0
  38. package/src/jsonl.ts +42 -0
  39. package/src/live-transport.ts +210 -0
  40. package/src/node-decoder-subagent.ts +67 -0
  41. package/src/node-decoder-workflow.ts +74 -0
  42. package/src/node-decoder.ts +23 -0
  43. package/src/node-decoders.ts +9 -0
  44. package/src/node-details.ts +70 -0
  45. package/src/node-path.ts +36 -0
  46. package/src/node-tracker.ts +143 -0
  47. package/src/pi-state.ts +108 -0
  48. package/src/prompt-gist.ts +13 -0
  49. package/src/prompt.ts +80 -0
  50. package/src/reap.ts +59 -0
  51. package/src/recording-transport.ts +97 -0
  52. package/src/replay-divergence.ts +155 -0
  53. package/src/replay-transport.ts +72 -0
  54. package/src/resume-preconditions.ts +59 -0
  55. package/src/resume-transport.ts +165 -0
  56. package/src/run-checkpoint.ts +93 -0
  57. package/src/run-context.ts +19 -0
  58. package/src/run.ts +274 -0
  59. package/src/skill-probe.ts +247 -0
  60. package/src/skill-restriction-transport.ts +76 -0
  61. package/src/spawn.ts +241 -0
  62. package/src/summary-agent.ts +310 -0
  63. package/src/summary-nodes.ts +77 -0
  64. package/src/summary.ts +213 -0
  65. package/src/tool-probe-extension.ts +17 -0
  66. package/src/tool-probe.ts +141 -0
  67. package/src/transport.ts +178 -0
  68. package/src/types.ts +130 -0
  69. package/src/validation-errors.ts +70 -0
  70. package/src/wire-constants.ts +24 -0
  71. package/src/worktree-transport.ts +125 -0
@@ -0,0 +1,310 @@
1
+ import type { AgentActivity } from "./events.ts";
2
+ import type { NodeInfo } from "./summary-nodes.ts";
3
+ import type { TokenBreakdown, WorktreeResolution } from "./transport.ts";
4
+
5
+ export type { AgentActivity } from "./events.ts";
6
+ export type { NodeInfo } from "./summary-nodes.ts";
7
+
8
+ /** The observer-facing lifecycle state of an Agent. */
9
+ export type AgentState = "idle" | "asking" | "exited";
10
+
11
+ /** Identity and accounting facts that apply in every observer Agent state. */
12
+ interface AgentInfoBase {
13
+ readonly model: string | null;
14
+ readonly cwd: string | null;
15
+ readonly branch: string | null;
16
+ /** pi's session file for this Agent, when the spawn reported one; a Peek reads it. */
17
+ readonly sessionFile: string | null;
18
+ readonly activity: AgentActivity | null;
19
+ readonly tokens: TokenBreakdown | null;
20
+ readonly cost: number | null;
21
+ readonly incomplete: boolean;
22
+ readonly stateChangedAt: number | null;
23
+ /** Timestamp ordering only cumulative live accounting, never lifecycle state. */
24
+ readonly usageUpdatedAt: number | null;
25
+ readonly askStartedAt: number | null;
26
+ /** This Agent's bounded Nested Node table, in first-seen order (spec §3). */
27
+ readonly nodes: readonly NodeInfo[];
28
+ /** Exited Nested Nodes dropped to keep the table bounded. */
29
+ readonly finishedNodesPruned: number;
30
+ }
31
+
32
+ /** An Agent between Asks; its latest settled Ask identity, if any, is retained. */
33
+ export interface IdleAgentInfo extends AgentInfoBase {
34
+ readonly state: "idle";
35
+ readonly askIndex: number | null;
36
+ readonly promptGist: string | null;
37
+ }
38
+
39
+ /** An Agent with one active Ask, whose identity and start timestamp are known. */
40
+ export interface AskingAgentInfo extends AgentInfoBase {
41
+ readonly state: "asking";
42
+ readonly askIndex: number;
43
+ readonly promptGist: string;
44
+ /** Whether the active Ask is served from Cassette playback. */
45
+ readonly replayed: boolean;
46
+ /** Null only for a legacy unstamped Ask start; no timestamp is synthesized. */
47
+ readonly askStartedAt: number | null;
48
+ }
49
+
50
+ /** A terminal Agent observation retaining its final usage and latest Ask identity. */
51
+ export interface ExitedAgentInfo extends AgentInfoBase {
52
+ readonly state: "exited";
53
+ readonly askIndex: number | null;
54
+ readonly promptGist: string | null;
55
+ }
56
+
57
+ /**
58
+ * The discriminated observer projection for one Agent.
59
+ *
60
+ * Transition helpers below are pure and clock-free. Stamped state events older
61
+ * than the folded state are ignored; unstamped legacy events use stream order.
62
+ */
63
+ export type AgentInfo = IdleAgentInfo | AskingAgentInfo | ExitedAgentInfo;
64
+
65
+ /** Internal fold record, deliberately identical to the public observer projection. */
66
+ export type AgentRecord = AgentInfo;
67
+
68
+ /** Cumulative assistant-completion usage observed while an Agent is still live. */
69
+ export interface AgentUsageObservation {
70
+ readonly tokens: TokenBreakdown;
71
+ readonly cost: number;
72
+ }
73
+
74
+ /** Final usage and optional worktree identity reported when an Agent exits. */
75
+ export interface AgentExitObservation {
76
+ readonly tokens: TokenBreakdown | null;
77
+ readonly cost: number | null;
78
+ readonly incomplete: boolean;
79
+ readonly worktree?: WorktreeResolution;
80
+ }
81
+
82
+ /** Produces an idle placeholder for lifecycle events received before a spawn. */
83
+ function placeholderAgent(): IdleAgentInfo {
84
+ return {
85
+ model: null,
86
+ cwd: null,
87
+ branch: null,
88
+ sessionFile: null,
89
+ state: "idle",
90
+ askIndex: null,
91
+ promptGist: null,
92
+ activity: null,
93
+ tokens: null,
94
+ cost: null,
95
+ incomplete: false,
96
+ stateChangedAt: null,
97
+ usageUpdatedAt: null,
98
+ askStartedAt: null,
99
+ nodes: [],
100
+ finishedNodesPruned: 0,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Reconciles spawn identity without reopening a terminal Agent or losing facts
106
+ * learned from a prior exit. A late spawn fills only missing identity fields.
107
+ */
108
+ export function spawnAgent(
109
+ current: AgentRecord | undefined,
110
+ identity: {
111
+ readonly model: string;
112
+ readonly cwd: string;
113
+ readonly branch?: string;
114
+ readonly sessionFile?: string;
115
+ },
116
+ at: number | null,
117
+ ): AgentRecord {
118
+ if (current === undefined) {
119
+ return {
120
+ ...placeholderAgent(),
121
+ model: identity.model,
122
+ cwd: identity.cwd,
123
+ branch: identity.branch ?? null,
124
+ sessionFile: identity.sessionFile ?? null,
125
+ stateChangedAt: at,
126
+ };
127
+ }
128
+ return {
129
+ ...current,
130
+ model: current.model ?? identity.model,
131
+ cwd: current.cwd ?? identity.cwd,
132
+ branch: current.branch ?? identity.branch ?? null,
133
+ sessionFile: current.sessionFile ?? identity.sessionFile ?? null,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Folds an Ask start into the asking arm when it is not older than the folded
139
+ * Agent state or Ask identity. Legacy unstamped events retain arrival order.
140
+ */
141
+ export function startAsk(
142
+ current: AgentRecord | undefined,
143
+ ask: { readonly index: number; readonly promptGist: string; readonly replayed: boolean },
144
+ at: number | null,
145
+ ): AgentRecord {
146
+ const agent = current ?? placeholderAgent();
147
+ if (!canChangeAskState(agent, at) || (agent.askIndex !== null && ask.index < agent.askIndex)) {
148
+ return agent;
149
+ }
150
+ return {
151
+ ...agent,
152
+ state: "asking",
153
+ askIndex: ask.index,
154
+ promptGist: ask.promptGist,
155
+ replayed: ask.replayed,
156
+ activity: null,
157
+ stateChangedAt: at,
158
+ askStartedAt: at,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Folds an Ask-scoped activity only into its current asking Agent. Older stamped
164
+ * observations, mismatched Ask identities, and terminal states are ignored.
165
+ */
166
+ export function setActivity(
167
+ current: AgentRecord | undefined,
168
+ index: number,
169
+ activity: AgentActivity,
170
+ at: number | null,
171
+ ): AgentRecord {
172
+ const agent = current ?? placeholderAgent();
173
+ if (
174
+ agent.state !== "asking" ||
175
+ agent.askIndex !== index ||
176
+ (at !== null && agent.stateChangedAt !== null && at < agent.stateChangedAt)
177
+ ) {
178
+ return agent;
179
+ }
180
+ return { ...agent, activity, stateChangedAt: at };
181
+ }
182
+
183
+ /**
184
+ * Folds an Ask settlement into the idle arm, retaining the settled Ask identity.
185
+ * Older stamped events are ignored and legacy unstamped events use stream order.
186
+ */
187
+ export function endAsk(
188
+ current: AgentRecord | undefined,
189
+ index: number,
190
+ at: number | null,
191
+ ): AgentRecord {
192
+ const agent = current ?? placeholderAgent();
193
+ if (
194
+ !canChangeAskState(agent, at) ||
195
+ (agent.askIndex !== null &&
196
+ (index < agent.askIndex || (agent.state === "asking" && agent.askIndex !== index)))
197
+ ) {
198
+ return agent;
199
+ }
200
+ return {
201
+ ...agent,
202
+ state: "idle",
203
+ askIndex: index,
204
+ activity: null,
205
+ stateChangedAt: at,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Folds a cumulative usage snapshot without changing Agent lifecycle state.
211
+ *
212
+ * Terminal Agent accounting is never replaced; equal usage timestamps retain
213
+ * stream order while older stamped observations are ignored.
214
+ */
215
+ export function setUsage(
216
+ current: AgentRecord | undefined,
217
+ observation: AgentUsageObservation,
218
+ at: number | null,
219
+ ): AgentRecord {
220
+ const agent = current ?? placeholderAgent();
221
+ if (
222
+ agent.state === "exited" ||
223
+ (at !== null && agent.usageUpdatedAt !== null && at < agent.usageUpdatedAt)
224
+ ) {
225
+ return agent;
226
+ }
227
+ return { ...agent, tokens: observation.tokens, cost: observation.cost, usageUpdatedAt: at };
228
+ }
229
+
230
+ /**
231
+ * Folds authoritative shutdown accounting into the terminal Agent state.
232
+ *
233
+ * Worktree identity is enriched even when a stale exit cannot alter state or
234
+ * usage, so late observations remain useful.
235
+ */
236
+ export function exitAgent(
237
+ current: AgentRecord | undefined,
238
+ observation: AgentExitObservation,
239
+ at: number | null,
240
+ ): AgentRecord {
241
+ const agent = enrichWorktree(current ?? placeholderAgent(), observation.worktree);
242
+ if (!canReplaceExit(agent, at)) return agent;
243
+ return {
244
+ ...agent,
245
+ state: "exited",
246
+ activity: null,
247
+ tokens: observation.tokens,
248
+ cost: observation.cost,
249
+ incomplete: observation.incomplete || observation.cost === null,
250
+ stateChangedAt: at,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Computes Run-wide accounting from every Agent's newest snapshot. A live Agent
256
+ * without an assistant completion contributes zero; an exited Agent without
257
+ * final stats preserves the established unknown-accounting semantics.
258
+ */
259
+ export function totalsFromAgents(agents: Readonly<Record<string, AgentRecord>>): {
260
+ readonly cost: number;
261
+ readonly tokens: TokenBreakdown | null;
262
+ readonly incomplete: boolean;
263
+ } {
264
+ const records = Object.values(agents);
265
+ return {
266
+ cost: records.reduce((total, agent) => total + (agent.cost ?? 0), 0),
267
+ tokens: records.some((agent) => agent.state === "exited" && agent.tokens === null)
268
+ ? null
269
+ : records.reduce(addTokens, zeroTokens()),
270
+ incomplete: records.some(
271
+ (agent) => agent.state === "exited" && (agent.incomplete || agent.cost === null),
272
+ ),
273
+ };
274
+ }
275
+
276
+ function enrichWorktree(agent: AgentRecord, worktree: WorktreeResolution | undefined): AgentRecord {
277
+ if (worktree === undefined) return agent;
278
+ return {
279
+ ...agent,
280
+ cwd: agent.cwd ?? worktree.cwd,
281
+ branch: agent.branch ?? worktree.branch,
282
+ };
283
+ }
284
+
285
+ function canChangeAskState(agent: AgentRecord, at: number | null): boolean {
286
+ return (
287
+ agent.state !== "exited" &&
288
+ (at === null || agent.stateChangedAt === null || at >= agent.stateChangedAt)
289
+ );
290
+ }
291
+
292
+ function canReplaceExit(agent: AgentRecord, at: number | null): boolean {
293
+ return at === null || agent.stateChangedAt === null || at >= agent.stateChangedAt;
294
+ }
295
+
296
+ function addTokens(total: TokenBreakdown, agent: AgentRecord): TokenBreakdown {
297
+ const tokens = agent.tokens;
298
+ if (tokens === null) return total;
299
+ return {
300
+ input: total.input + tokens.input,
301
+ output: total.output + tokens.output,
302
+ cacheRead: total.cacheRead + tokens.cacheRead,
303
+ cacheWrite: total.cacheWrite + tokens.cacheWrite,
304
+ total: total.total + tokens.total,
305
+ };
306
+ }
307
+
308
+ function zeroTokens(): TokenBreakdown {
309
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
310
+ }
@@ -0,0 +1,77 @@
1
+ import type { LifecycleEventBody, NodeState } from "./events.ts";
2
+ import type { AgentRecord } from "./summary-agent.ts";
3
+ import type { TokenBreakdown } from "./transport.ts";
4
+ import { AGENT_NODE_TABLE_MAX } from "./wire-constants.ts";
5
+
6
+ /** One Nested Node's folded row in an Agent's bounded node table. */
7
+ export interface NodeInfo {
8
+ readonly path: string;
9
+ readonly state: NodeState;
10
+ readonly activityGist: string | null;
11
+ readonly tokens: TokenBreakdown | null;
12
+ readonly cost: number | null;
13
+ readonly updatedAt: number | null;
14
+ }
15
+
16
+ type NodeUpdateEvent = Extract<LifecycleEventBody, { readonly type: "node_update" }>;
17
+
18
+ /**
19
+ * Folds one `node_update` into an Agent's bounded node table.
20
+ *
21
+ * Rows upsert by path and keep first-seen order. A stamped update older than
22
+ * the folded row is ignored, mirroring `setActivity`. Past
23
+ * `AGENT_NODE_TABLE_MAX` rows, exited nodes prune oldest-first into
24
+ * `finishedNodesPruned`. Running nodes are never pruned, so the table retains
25
+ * at most `max(AGENT_NODE_TABLE_MAX, simultaneously running node count)` rows:
26
+ * the row count is unbounded if upstream concurrency is unbounded. Only each
27
+ * row's snapshot size is bounded (spec §3).
28
+ */
29
+ export function applyNodeUpdate(
30
+ agent: AgentRecord,
31
+ event: NodeUpdateEvent,
32
+ at: number | null,
33
+ ): AgentRecord {
34
+ const index = agent.nodes.findIndex((node) => node.path === event.path);
35
+ const current = index === -1 ? undefined : agent.nodes[index];
36
+ if (
37
+ current !== undefined &&
38
+ at !== null &&
39
+ current.updatedAt !== null &&
40
+ at < current.updatedAt
41
+ ) {
42
+ return agent;
43
+ }
44
+ const node = nodeFrom(event, at);
45
+ const nodes =
46
+ current === undefined
47
+ ? [...agent.nodes, node]
48
+ : agent.nodes.map((existing, position) => (position === index ? node : existing));
49
+ return prune({ ...agent, nodes });
50
+ }
51
+
52
+ function nodeFrom(event: NodeUpdateEvent, at: number | null): NodeInfo {
53
+ return {
54
+ path: event.path,
55
+ state: event.state,
56
+ activityGist: event.activityGist ?? null,
57
+ tokens: event.usage?.tokens ?? null,
58
+ cost: event.usage?.cost ?? null,
59
+ updatedAt: at,
60
+ };
61
+ }
62
+
63
+ function prune(agent: AgentRecord): AgentRecord {
64
+ if (agent.nodes.length <= AGENT_NODE_TABLE_MAX) return agent;
65
+ const excess = agent.nodes.length - AGENT_NODE_TABLE_MAX;
66
+ const kept: NodeInfo[] = [];
67
+ let pruned = 0;
68
+ for (const node of agent.nodes) {
69
+ if (node.state !== "running" && pruned < excess) {
70
+ pruned += 1;
71
+ continue;
72
+ }
73
+ kept.push(node);
74
+ }
75
+ if (pruned === 0) return agent;
76
+ return { ...agent, nodes: kept, finishedNodesPruned: agent.finishedNodesPruned + pruned };
77
+ }
package/src/summary.ts ADDED
@@ -0,0 +1,213 @@
1
+ import type { LifecycleEvent, LifecycleEventBody, RunOutcome } from "./events.ts";
2
+ import type { AgentInfo } from "./summary-agent.ts";
3
+ import {
4
+ type AgentRecord,
5
+ endAsk,
6
+ exitAgent,
7
+ setActivity,
8
+ setUsage,
9
+ spawnAgent,
10
+ startAsk,
11
+ totalsFromAgents,
12
+ } from "./summary-agent.ts";
13
+ import { applyNodeUpdate } from "./summary-nodes.ts";
14
+ import type { TokenBreakdown } from "./transport.ts";
15
+
16
+ export type { NodeState, NodeUsage, RunOutcome } from "./events.ts";
17
+ export type {
18
+ AgentActivity,
19
+ AgentInfo,
20
+ AgentState,
21
+ AskingAgentInfo,
22
+ ExitedAgentInfo,
23
+ IdleAgentInfo,
24
+ NodeInfo,
25
+ } from "./summary-agent.ts";
26
+
27
+ /** The observer-facing lifecycle state of a Run. */
28
+ export type RunState = "running" | "ended";
29
+
30
+ /** Accounting and identity facts shared by all Run observer states. */
31
+ interface RunSummaryBase {
32
+ readonly program: string;
33
+ readonly startedAt: number | null;
34
+ readonly agents: Readonly<Record<string, AgentInfo>>;
35
+ readonly asksStarted: number;
36
+ readonly asksSettled: number;
37
+ readonly cost: number;
38
+ readonly tokens: TokenBreakdown | null;
39
+ readonly incomplete: boolean;
40
+ readonly durationMs: number;
41
+ readonly worstFrameGapMs: number;
42
+ }
43
+
44
+ /** A Run that has not settled; it has neither outcome nor compatibility result. */
45
+ export interface RunningRunSummary extends RunSummaryBase {
46
+ readonly runState: "running";
47
+ readonly outcome: null;
48
+ readonly ok: null;
49
+ }
50
+
51
+ /** A settled Run with its outcome, compatibility result, and ending event time. */
52
+ export interface EndedRunSummary extends RunSummaryBase {
53
+ readonly runState: "ended";
54
+ readonly outcome: RunOutcome;
55
+ readonly ok: boolean;
56
+ /** The `run_end.at` event fact used to reject strictly older stamped endings. */
57
+ readonly endedAt: number | null;
58
+ }
59
+
60
+ /**
61
+ * The pure, discriminated observer fold of a Run's Lifecycle Events.
62
+ *
63
+ * It stores event facts only, reads no clock, and never synthesizes durations:
64
+ * `run_end.durationMs` is authoritative. Unknown events are no-ops. Stamped
65
+ * state events use timestamp order (ties retain stream order); legacy unstamped
66
+ * bodies use arrival order because no timestamp can be compared.
67
+ */
68
+ export type RunSummary = RunningRunSummary | EndedRunSummary;
69
+
70
+ const ZERO_TOKENS: TokenBreakdown = {
71
+ input: 0,
72
+ output: 0,
73
+ cacheRead: 0,
74
+ cacheWrite: 0,
75
+ total: 0,
76
+ };
77
+
78
+ /** Returns the deterministic, running Summary for a Run that emitted nothing. */
79
+ export function initialSummary(): RunningRunSummary {
80
+ return {
81
+ program: "",
82
+ runState: "running",
83
+ outcome: null,
84
+ startedAt: null,
85
+ agents: {},
86
+ asksStarted: 0,
87
+ asksSettled: 0,
88
+ cost: 0,
89
+ tokens: ZERO_TOKENS,
90
+ incomplete: false,
91
+ durationMs: 0,
92
+ worstFrameGapMs: 0,
93
+ ok: null,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Folds one Lifecycle Event into a fresh Summary without mutating either input.
99
+ *
100
+ * A stamped `run_end` older than the folded terminal timestamp is a no-op;
101
+ * equal times retain stream order. Legacy unstamped endings retain arrival order
102
+ * and may replace a stamped ending because no ordering fact exists. A late
103
+ * `run_start` enriches program/start metadata but never reopens a settled Run.
104
+ */
105
+ export function applyEvent(summary: RunSummary, event: LifecycleEvent): RunSummary;
106
+ export function applyEvent(summary: RunSummary, event: LifecycleEventBody): RunSummary;
107
+ export function applyEvent(
108
+ summary: RunSummary,
109
+ event: LifecycleEvent | LifecycleEventBody,
110
+ ): RunSummary {
111
+ const at = "at" in event ? event.at : null;
112
+ switch (event.type) {
113
+ case "run_start":
114
+ return { ...summary, program: event.program, startedAt: at };
115
+ case "agent_spawn":
116
+ return withAgent(summary, event.agent, spawnAgent(summary.agents[event.agent], event, at));
117
+ case "ask_start":
118
+ return withAgent(
119
+ { ...summary, asksStarted: summary.asksStarted + 1 },
120
+ event.agent,
121
+ startAsk(summary.agents[event.agent], { ...event, replayed: event.replayed === true }, at),
122
+ );
123
+ case "ask_activity":
124
+ return updateActivity(summary, event, at);
125
+ case "node_update":
126
+ return updateNodes(summary, event, at);
127
+ case "agent_usage":
128
+ return withAgent(summary, event.agent, setUsage(summary.agents[event.agent], event, at));
129
+ case "ask_end":
130
+ return settleAsk(summary, event, at);
131
+ case "agent_exit":
132
+ return withAgent(summary, event.agent, exitAgent(summary.agents[event.agent], event, at));
133
+ case "run_end":
134
+ return endRun(summary, event, at);
135
+ default:
136
+ return summary;
137
+ }
138
+ }
139
+
140
+ function endRun(
141
+ summary: RunSummary,
142
+ event: Extract<LifecycleEventBody, { readonly type: "run_end" }>,
143
+ at: number | null,
144
+ ): RunSummary {
145
+ if (
146
+ summary.runState === "ended" &&
147
+ at !== null &&
148
+ summary.endedAt !== null &&
149
+ at < summary.endedAt
150
+ ) {
151
+ return summary;
152
+ }
153
+ return {
154
+ ...summary,
155
+ runState: "ended",
156
+ outcome: event.outcome,
157
+ ok: event.ok,
158
+ endedAt: at,
159
+ durationMs: event.durationMs,
160
+ worstFrameGapMs: event.worstFrameGapMs,
161
+ };
162
+ }
163
+
164
+ function updateActivity(
165
+ summary: RunSummary,
166
+ event: Extract<LifecycleEventBody, { readonly type: "ask_activity" }>,
167
+ at: number | null,
168
+ ): RunSummary {
169
+ const current = summary.agents[event.agent];
170
+ if (current === undefined) return summary;
171
+ const agent = setActivity(current, event.index, event.activity, at);
172
+ return current === agent
173
+ ? summary
174
+ : { ...summary, agents: { ...summary.agents, [event.agent]: agent } };
175
+ }
176
+
177
+ function updateNodes(
178
+ summary: RunSummary,
179
+ event: Extract<LifecycleEventBody, { readonly type: "node_update" }>,
180
+ at: number | null,
181
+ ): RunSummary {
182
+ const current = summary.agents[event.agent];
183
+ if (current === undefined) return summary;
184
+ const agent = applyNodeUpdate(current, event, at);
185
+ return current === agent
186
+ ? summary
187
+ : { ...summary, agents: { ...summary.agents, [event.agent]: agent } };
188
+ }
189
+
190
+ function settleAsk(
191
+ summary: RunSummary,
192
+ event: Extract<LifecycleEventBody, { readonly type: "ask_end" }>,
193
+ at: number | null,
194
+ ): RunSummary {
195
+ const next = withAgent(
196
+ { ...summary, asksSettled: summary.asksSettled + 1 },
197
+ event.agent,
198
+ endAsk(summary.agents[event.agent], event.index, at),
199
+ );
200
+ return {
201
+ ...next,
202
+ worstFrameGapMs:
203
+ event.maxFrameGapMs === undefined
204
+ ? next.worstFrameGapMs
205
+ : Math.max(next.worstFrameGapMs, event.maxFrameGapMs),
206
+ };
207
+ }
208
+
209
+ function withAgent(summary: RunSummary, name: string, agent: AgentRecord): RunSummary {
210
+ const agents = { ...summary.agents, [name]: agent };
211
+ const totals = totalsFromAgents(agents);
212
+ return { ...summary, agents, ...totals };
213
+ }
@@ -0,0 +1,17 @@
1
+ interface ToolProbeAPI {
2
+ getActiveTools(): string[];
3
+ on(event: "session_start", handler: (event: unknown, context: ToolProbeContext) => void): void;
4
+ }
5
+
6
+ interface ToolProbeContext {
7
+ readonly ui: { setStatus(key: string, text: string | undefined): void };
8
+ }
9
+
10
+ const STATUS_KEY = "yaag.tool-probe.v1";
11
+
12
+ /** Reports pi's post-composition active tool names through the RPC UI channel. */
13
+ export default function (pi: ToolProbeAPI): void {
14
+ pi.on("session_start", (_event, ctx) => {
15
+ ctx.ui.setStatus(STATUS_KEY, JSON.stringify(pi.getActiveTools()));
16
+ });
17
+ }