@devspecai/pi-plugin 0.0.0-stage → 0.10.3

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 (106) hide show
  1. package/CHANGELOG.md +899 -0
  2. package/LICENSE +21 -0
  3. package/README.md +103 -2
  4. package/extensions/.gitkeep +0 -0
  5. package/extensions/core/activation-recovery.ts +135 -0
  6. package/extensions/core/active-deliveries.ts +171 -0
  7. package/extensions/core/active-session-plans.ts +374 -0
  8. package/extensions/core/adapter-overlap.ts +247 -0
  9. package/extensions/core/assistant-speech.ts +44 -0
  10. package/extensions/core/attach-context.ts +25 -0
  11. package/extensions/core/attachment-limits.ts +20 -0
  12. package/extensions/core/attachments.ts +487 -0
  13. package/extensions/core/auth-store.ts +91 -0
  14. package/extensions/core/automation-run-retry.ts +31 -0
  15. package/extensions/core/background-journal.ts +85 -0
  16. package/extensions/core/background-observer.ts +279 -0
  17. package/extensions/core/bond-recovery.ts +65 -0
  18. package/extensions/core/bond-state.ts +80 -0
  19. package/extensions/core/clear-conversation.ts +53 -0
  20. package/extensions/core/client-versions.ts +56 -0
  21. package/extensions/core/client.ts +704 -0
  22. package/extensions/core/command-offer.ts +88 -0
  23. package/extensions/core/command-runner.ts +62 -0
  24. package/extensions/core/command-turn.ts +88 -0
  25. package/extensions/core/commit-ledger.ts +195 -0
  26. package/extensions/core/commit-stamp.ts +98 -0
  27. package/extensions/core/config.ts +63 -0
  28. package/extensions/core/connection-context.ts +28 -0
  29. package/extensions/core/context-budget.ts +175 -0
  30. package/extensions/core/control-result-outbox.ts +80 -0
  31. package/extensions/core/control.ts +156 -0
  32. package/extensions/core/ctx-guard.ts +33 -0
  33. package/extensions/core/debug.ts +136 -0
  34. package/extensions/core/delivery-queue.ts +92 -0
  35. package/extensions/core/dispatch.ts +108 -0
  36. package/extensions/core/duck.ts +128 -0
  37. package/extensions/core/git-observer.ts +382 -0
  38. package/extensions/core/github-identity.ts +26 -0
  39. package/extensions/core/implementation-contract.ts +773 -0
  40. package/extensions/core/instructions.ts +40 -0
  41. package/extensions/core/interaction-events.ts +834 -0
  42. package/extensions/core/json-schema.ts +16 -0
  43. package/extensions/core/local-activity.ts +34 -0
  44. package/extensions/core/mcp-config-guard.ts +117 -0
  45. package/extensions/core/model-catalog.ts +76 -0
  46. package/extensions/core/model-surface.ts +125 -0
  47. package/extensions/core/notice-reply.ts +215 -0
  48. package/extensions/core/owner-inject.ts +148 -0
  49. package/extensions/core/post-session-receipt.ts +70 -0
  50. package/extensions/core/preempt.ts +100 -0
  51. package/extensions/core/project-choice.ts +145 -0
  52. package/extensions/core/project-scope.ts +335 -0
  53. package/extensions/core/project-selection-ui.ts +202 -0
  54. package/extensions/core/provenance-refs.ts +158 -0
  55. package/extensions/core/push-channel.ts +281 -0
  56. package/extensions/core/question-receipt-lifecycle.ts +158 -0
  57. package/extensions/core/recall.ts +121 -0
  58. package/extensions/core/remote-payload.ts +1329 -0
  59. package/extensions/core/remote-session-arg.ts +24 -0
  60. package/extensions/core/repository-context.ts +35 -0
  61. package/extensions/core/request-chain.ts +1244 -0
  62. package/extensions/core/result-projection.ts +939 -0
  63. package/extensions/core/result.ts +100 -0
  64. package/extensions/core/review-context.ts +70 -0
  65. package/extensions/core/review-watch.ts +94 -0
  66. package/extensions/core/room-access.ts +12 -0
  67. package/extensions/core/room-context.ts +178 -0
  68. package/extensions/core/room-reader.ts +308 -0
  69. package/extensions/core/room-store.ts +381 -0
  70. package/extensions/core/room-sync.ts +79 -0
  71. package/extensions/core/room-tools.ts +86 -0
  72. package/extensions/core/room-unread.ts +173 -0
  73. package/extensions/core/session-events.ts +62 -0
  74. package/extensions/core/session-polls.ts +292 -0
  75. package/extensions/core/settings.ts +39 -0
  76. package/extensions/core/status-line.ts +432 -0
  77. package/extensions/core/still-to-discuss.ts +124 -0
  78. package/extensions/core/subagent-fleet.ts +238 -0
  79. package/extensions/core/system-notice.ts +227 -0
  80. package/extensions/core/telemetry.ts +177 -0
  81. package/extensions/core/tool-selection.ts +72 -0
  82. package/extensions/core/trail-events.ts +247 -0
  83. package/extensions/core/trail-projection.ts +373 -0
  84. package/extensions/core/trail.ts +465 -0
  85. package/extensions/core/turn-lifecycle.ts +472 -0
  86. package/extensions/core/version-mismatch.ts +165 -0
  87. package/extensions/core/work-prompt.ts +44 -0
  88. package/extensions/core/work-state.ts +179 -0
  89. package/extensions/devspec-adapter-guard.ts +106 -0
  90. package/extensions/devspec-auth.ts +191 -0
  91. package/extensions/devspec-brand.ts +140 -0
  92. package/extensions/devspec-commit-ledger.ts +477 -0
  93. package/extensions/devspec-context.ts +146 -0
  94. package/extensions/devspec-memory.ts +136 -0
  95. package/extensions/devspec-provenance-assistance.ts +233 -0
  96. package/extensions/devspec-remote.ts +5152 -0
  97. package/extensions/devspec-tools.ts +552 -0
  98. package/extensions/devspec-work.ts +244 -0
  99. package/extensions/generated/README.md +18 -0
  100. package/extensions/generated/mcp-catalog.ts +15811 -0
  101. package/package.json +71 -4
  102. package/prompts/.gitkeep +0 -0
  103. package/runtime/commit-stamp.mjs +279 -0
  104. package/runtime/git-observer-shim.mjs +390 -0
  105. package/skills/.gitkeep +0 -0
  106. package/themes/.gitkeep +0 -0
@@ -0,0 +1,1244 @@
1
+ /**
2
+ * The request chain (item 7a4d04fd): every wake carries the request it continues.
3
+ *
4
+ * DevSpec used to learn who asked for a write from connection state that holds
5
+ * one current request, so work an agent did after a background job, a wait or
6
+ * a directed question finished was credited to whoever spoke last, and two
7
+ * people's requests on one agent bled into each other. The server now keeps
8
+ * the requests a connection is carrying (`report_request`) and every write
9
+ * names the one it serves (`request_ref`, host-only: the model never sees it).
10
+ * The Claude Code plugin implements the same rules (hooks/scripts/requests.mjs).
11
+ *
12
+ * - A **request** is a room command (opened with its delivery provenance_ref)
13
+ * or a prompt the owner typed at this terminal. It closes once its own work
14
+ * is finished: no run is carrying it, nothing it launched is still to wake
15
+ * the agent, and no directed question it asked is still waiting.
16
+ * - A **turn** is one Pi run. It is woken for one request (current), and other
17
+ * requests may reach it while it works (joined): a second command or typed
18
+ * prompt mid-run, several commands in one delivery, or a wake that carries
19
+ * another request. When the requests a turn carries would not all be
20
+ * recorded the same way (different people, or one person at the terminal
21
+ * and in a room, or nobody's request joined by somebody's) and the agent has
22
+ * not named one with `serve_request`, a write that records who asked is
23
+ * refused rather than guessed.
24
+ * - Every **launch** (a background task, an async subagent run, a directed
25
+ * question) is recorded against what its turn served: a request, "none"
26
+ * (the turn positively served nobody's request), or "unstated" (not known).
27
+ * Its wake continues that request while it is open; a launch recorded none
28
+ * stamps none; anything else — a closed request, an unstated launch, a wake
29
+ * nothing recorded — stamps nothing, so DevSpec resolves who asked as it
30
+ * always has. A missing record never becomes "none".
31
+ * - An **automation run** is positively nobody's request: its turn stamps
32
+ * "none", which DevSpec records as the automation while that run is running
33
+ * on the connection (else as the agent's own initiative). Left unstamped, a
34
+ * live command would win DevSpec's default and be credited instead.
35
+ *
36
+ * Pi loads every extension file with a fresh module graph, so the live chain
37
+ * and the typed-input ledger sit on globalThis under Symbol.for keys (the
38
+ * provenance-refs / bond-state pattern). Nothing here holds a token,
39
+ * capability, prompt or message body beyond the exact input texts the ledger
40
+ * needs for one run.
41
+ */
42
+
43
+ import { randomUUID } from "node:crypto";
44
+ import { backgroundTaskFromLaunch, isBackgroundLaunchTool } from "./background-observer.ts";
45
+ import { automaticBackgroundTaskId } from "./turn-lifecycle.ts";
46
+
47
+ export const REQUEST_LINK_ENTRY = "devspec-request-link";
48
+ /** The ref a turn that positively continues nobody's request carries. */
49
+ export const NO_REQUEST = "none";
50
+ /** A turn or launch whose request is not known here: its writes name nothing. */
51
+ export const UNSTATED = "unstated";
52
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
53
+ const LINK_CAP = 512;
54
+
55
+ export type CarriedRequestKind = "command" | "typed_prompt";
56
+
57
+ /** What a write is stamped with. null = stamp nothing (DevSpec's own resolution). */
58
+ export type RequestStamp = string | null;
59
+
60
+ /** The capability-authenticated report_request call. Throws on refusal or transport failure. */
61
+ export interface RequestTransport {
62
+ call(args: Record<string, unknown>): Promise<unknown>;
63
+ }
64
+
65
+ export interface RequestLinkIdentity {
66
+ id: string;
67
+ kind: CarriedRequestKind;
68
+ provenanceRef?: string;
69
+ messageId?: string;
70
+ /** A room command the connection's owner sent (its delivery authority is the owner's). */
71
+ fromOwner?: boolean;
72
+ commandTurnId?: string;
73
+ commandMessageId?: string;
74
+ }
75
+
76
+ /**
77
+ * Journal row for one launch: what it was recorded against (a request, "none",
78
+ * "unstated"), the other requests an undecided turn carried (`also`), and
79
+ * whether its end reaches the agent as a notification. `request: null` says its
80
+ * wake has come back (or it ended without one).
81
+ */
82
+ export interface RequestLinkEntry {
83
+ version: 1;
84
+ nativeSessionId: string;
85
+ connectionId: string;
86
+ key: string;
87
+ aliases?: string[];
88
+ request: RequestLinkIdentity | typeof NO_REQUEST | typeof UNSTATED | null;
89
+ also?: RequestLinkIdentity[];
90
+ notifies?: boolean;
91
+ }
92
+
93
+ /** A request the current turn carries, as a refusal lists it. */
94
+ export interface CarriedEntry {
95
+ id: string;
96
+ kind: CarriedRequestKind | "none";
97
+ messageId?: string;
98
+ current: boolean;
99
+ }
100
+
101
+ interface CarriedRequest extends RequestLinkIdentity {
102
+ /** The server's id: the host's own, or the one a command was already open under. */
103
+ serverId?: string;
104
+ requester?: string;
105
+ state: "opening" | "open" | "closing" | "closed";
106
+ /** DevSpec refused or could not be reached when it was opened: never carried. */
107
+ failed?: boolean;
108
+ /**
109
+ * DevSpec knows it but reports it already closed (it closed in a /reload gap,
110
+ * or with its connection): never carried, but a write that continues it may
111
+ * still name it — DevSpec resolves a closed request to its requester.
112
+ */
113
+ closedOnServer?: boolean;
114
+ ready: Promise<boolean>;
115
+ seq: number;
116
+ }
117
+
118
+ interface Launch {
119
+ /** A local request id, NO_REQUEST or UNSTATED. */
120
+ request: string;
121
+ also: string[];
122
+ notifies: boolean;
123
+ notified: boolean;
124
+ }
125
+
126
+ /** What a launch made now is recorded against. */
127
+ export interface LaunchSnapshot {
128
+ request: string;
129
+ also: string[];
130
+ }
131
+
132
+ /** How long a decision waits, in total, for the opens it depends on. */
133
+ export const OPEN_WAIT_MS = 3_000;
134
+
135
+ function record(value: unknown): Record<string, unknown> | null {
136
+ return value !== null && typeof value === "object" && !Array.isArray(value)
137
+ ? value as Record<string, unknown>
138
+ : null;
139
+ }
140
+
141
+ function boundedMap<K, V>(map: Map<K, V>, key: K, value: V): void {
142
+ map.delete(key);
143
+ map.set(key, value);
144
+ while (map.size > LINK_CAP) {
145
+ const oldest = map.keys().next();
146
+ if (oldest.done) break;
147
+ map.delete(oldest.value);
148
+ }
149
+ }
150
+
151
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, fallback: T): Promise<T> {
152
+ return new Promise((resolve) => {
153
+ const timer = setTimeout(() => resolve(fallback), timeoutMs);
154
+ if (typeof timer.unref === "function") timer.unref();
155
+ promise.then(
156
+ (value) => { clearTimeout(timer); resolve(value); },
157
+ () => { clearTimeout(timer); resolve(fallback); },
158
+ );
159
+ });
160
+ }
161
+
162
+ /** One wake's work keys; `terminal` means the work itself is finished. */
163
+ export interface WakeKey {
164
+ key: string;
165
+ terminal: boolean;
166
+ }
167
+
168
+ export const taskKey = (id: string): string => `task:${id}`;
169
+ export const subagentKey = (id: string): string => `subagent:${id}`;
170
+ export const questionKey = (id: string): string => `question:${id}`;
171
+
172
+ /**
173
+ * The requests one connection is carrying, what the current turn carries, and
174
+ * what each launch was made for. One instance per connection id; a different
175
+ * connection is different authority and gets a fresh chain.
176
+ */
177
+ export class RequestChain {
178
+ readonly connectionId: string;
179
+ private readonly transport: RequestTransport;
180
+ private readonly journal: ((entry: Omit<RequestLinkEntry, "version" | "nativeSessionId" | "connectionId">) => void) | null;
181
+ private readonly onError: (what: "open" | "close" | "restore" | "persist") => void;
182
+ private readonly requests = new Map<string, CarriedRequest>();
183
+ private readonly byProvenance = new Map<string, string>();
184
+ private readonly launches = new Map<string, Launch>();
185
+ /** Alternate spelling of a launch key (an async subagent's directory). */
186
+ private readonly aliases = new Map<string, string>();
187
+ /** The request the turn was woken for (local id), NO_REQUEST, UNSTATED, or null before any input. */
188
+ private current: string | null = null;
189
+ /** Requests that reached this turn besides the current one. */
190
+ private joined = new Set<string>();
191
+ /** The agent named the current request itself (serve_request) since anything last joined. */
192
+ private chosen = false;
193
+ private turnStarted = false;
194
+ /** Every request this turn carried at any point: none of them closes before the turn ends. */
195
+ private touched = new Set<string>();
196
+ /**
197
+ * The commands and typed prompts that reached this turn since it started or
198
+ * the agent last named one, one batch per message. A message none of whose
199
+ * requests DevSpec opened clears the current request: its writes are
200
+ * unstamped, never credited to whoever was current before.
201
+ */
202
+ private arrivals: string[][] = [];
203
+ /** Launches rebuilt from the journal whose work has not yet been checked against the host. */
204
+ private readonly restored = new Set<string>();
205
+ private seq = 0;
206
+ private disposed = false;
207
+ private readonly closing = new Set<Promise<void>>();
208
+
209
+ constructor(input: {
210
+ connectionId: string;
211
+ transport: RequestTransport;
212
+ journal?: (entry: Omit<RequestLinkEntry, "version" | "nativeSessionId" | "connectionId">) => void;
213
+ onError?: (what: "open" | "close" | "restore" | "persist") => void;
214
+ }) {
215
+ this.connectionId = input.connectionId;
216
+ this.transport = input.transport;
217
+ this.journal = input.journal ?? null;
218
+ this.onError = input.onError ?? (() => {});
219
+ }
220
+
221
+ // ---- Opening requests -----------------------------------------------------
222
+
223
+ /** A room command reached the agent: carry it (a command already carried is the request it already is). */
224
+ openCommand(input: { provenanceRef: string; messageId?: string; commandTurnId?: string; commandMessageId?: string; fromOwner?: boolean }): string | null {
225
+ if (this.disposed || !UUID.test(input.provenanceRef)) return null;
226
+ const ref = input.provenanceRef.toLowerCase();
227
+ const existing = this.byProvenance.get(ref);
228
+ if (existing && this.isOpen(existing)) return existing;
229
+ const request = this.create({ id: randomUUID(), kind: "command", provenanceRef: ref,
230
+ ...(input.messageId ? { messageId: input.messageId } : {}),
231
+ ...(input.fromOwner === true ? { fromOwner: true } : {}),
232
+ ...(input.commandTurnId ? { commandTurnId: input.commandTurnId } : {}),
233
+ ...(input.commandMessageId ? { commandMessageId: input.commandMessageId } : {}) });
234
+ boundedMap(this.byProvenance, ref, request.id);
235
+ return request.id;
236
+ }
237
+
238
+ /** The owner typed a prompt at this terminal: carry it as the owner's request. */
239
+ openTyped(): string | null {
240
+ if (this.disposed) return null;
241
+ return this.create({ id: randomUUID(), kind: "typed_prompt" }).id;
242
+ }
243
+
244
+ private create(identity: RequestLinkIdentity, verifyOnly = false): CarriedRequest {
245
+ const request: CarriedRequest = { ...identity, state: "opening", ready: Promise.resolve(false), seq: ++this.seq };
246
+ this.requests.set(request.id, request);
247
+ while (this.requests.size > LINK_CAP) {
248
+ const oldest = [...this.requests.values()].find((candidate) => candidate.state === "closed");
249
+ if (!oldest) break;
250
+ this.requests.delete(oldest.id);
251
+ }
252
+ request.ready = (async () => {
253
+ try {
254
+ const raw = await this.transport.call({
255
+ action: "open",
256
+ request_id: request.id,
257
+ kind: request.kind,
258
+ ...(request.kind === "command" && request.provenanceRef ? { provenance_ref: request.provenanceRef } : {}),
259
+ });
260
+ const receipt = record(raw);
261
+ const serverId = typeof receipt?.request_id === "string" && UUID.test(receipt.request_id) ? receipt.request_id : null;
262
+ if (!serverId || (receipt?.status !== "open" && receipt?.status !== "closed")) throw new Error("malformed request receipt");
263
+ request.serverId = serverId;
264
+ if (typeof receipt.requested_by_user_id === "string") request.requester = receipt.requested_by_user_id;
265
+ if (receipt.status !== "open") {
266
+ request.state = "closed";
267
+ request.closedOnServer = true;
268
+ return false;
269
+ }
270
+ if (request.state === "opening") request.state = "open";
271
+ return request.state === "open" || request.state === "closing";
272
+ } catch {
273
+ // Not carried: a turn woken for it stamps nothing, and it never makes a
274
+ // turn ambiguous. DevSpec resolves who asked as it did before requests.
275
+ request.state = "closed";
276
+ request.failed = true;
277
+ this.onError(verifyOnly ? "restore" : "open");
278
+ return false;
279
+ }
280
+ })();
281
+ return request;
282
+ }
283
+
284
+ private isOpen(id: string | null | undefined): boolean {
285
+ const state = id ? this.requests.get(id)?.state : undefined;
286
+ return state === "opening" || state === "open";
287
+ }
288
+
289
+ /** Open, or known to DevSpec as closed: a write that continues it may name it. */
290
+ private isStampable(id: string | null | undefined): boolean {
291
+ return this.isOpen(id) || Boolean(id && this.requests.get(id)?.closedOnServer);
292
+ }
293
+
294
+ /**
295
+ * Whether "nobody's request" may be offered: DevSpec refuses it while any
296
+ * open request on this connection is someone else's (a teammate's command),
297
+ * so it is offered only when every open request is the owner's own — typed
298
+ * here, or a room command the owner sent.
299
+ */
300
+ private noneAllowed(): boolean {
301
+ for (const request of this.requests.values()) {
302
+ if (!this.isOpen(request.id)) continue;
303
+ if (request.kind !== "typed_prompt" && request.fromOwner !== true) return false;
304
+ }
305
+ return true;
306
+ }
307
+
308
+ /** Wait, until a deadline, for the opens these requests depend on. */
309
+ private async settleOpens(ids: Iterable<string | null>, deadline: number): Promise<void> {
310
+ const pending = [...new Set(ids)].flatMap((id) => {
311
+ const request = id ? this.requests.get(id) : undefined;
312
+ return request?.state === "opening" ? [request.ready] : [];
313
+ });
314
+ const remaining = deadline - Date.now();
315
+ if (pending.length && remaining > 0) await withTimeout(Promise.all(pending).then(() => undefined), remaining, undefined);
316
+ }
317
+
318
+ private carriedIds(): (string | null)[] {
319
+ return [this.currentRequestId(), ...this.joined, ...this.arrivals.flat()];
320
+ }
321
+
322
+ // ---- The turn -------------------------------------------------------------
323
+
324
+ /** True once something this run carries has bound it (its first command, prompt, wake or notice). */
325
+ get turnActive(): boolean {
326
+ return this.turnStarted;
327
+ }
328
+
329
+ /** The local id of the request the current turn serves, if it is one. */
330
+ currentRequestId(): string | null {
331
+ return this.current && UUID.test(this.current) ? this.current : null;
332
+ }
333
+
334
+ /** A new turn: it serves the request that woke it, and carries `also` besides. */
335
+ startTurn(ref: string | null, also: readonly string[] = []): void {
336
+ if (this.disposed) return;
337
+ const previous = this.touched;
338
+ this.current = ref === NO_REQUEST || ref === UNSTATED || (ref && this.isStampable(ref)) ? ref : UNSTATED;
339
+ this.joined = new Set(also.filter((id) => id !== this.current && this.isOpen(id)));
340
+ this.chosen = false;
341
+ this.turnStarted = true;
342
+ this.arrivals = [];
343
+ this.touched = new Set([...(this.currentRequestId() ? [this.current!] : []), ...this.joined]);
344
+ for (const id of previous) if (!this.touched.has(id)) this.maybeClose(id);
345
+ }
346
+
347
+ /**
348
+ * Requests reached the agent (commands of one delivery oldest first, or a
349
+ * typed prompt). The first input of a run is woken for the newest and
350
+ * carries the rest; mid-run they join the turn.
351
+ */
352
+ arrive(ids: readonly string[]): void {
353
+ if (this.disposed) return;
354
+ if (ids.length === 0) { this.arriveUnknown(); return; }
355
+ if (!this.turnStarted) this.startTurn(ids[ids.length - 1]!, ids.slice(0, -1));
356
+ else for (const id of ids) this.join(id);
357
+ this.arrivals.push([...ids]);
358
+ }
359
+
360
+ /**
361
+ * An input whose request is not known here (a command or prompt DevSpec did
362
+ * not open, a command message with no binding, a wake nothing here
363
+ * launched): a turn it starts names nothing, and mid-run it clears the
364
+ * current request — never keeps the previous one.
365
+ */
366
+ arriveUnknown(): void {
367
+ if (this.disposed) return;
368
+ if (!this.turnStarted) this.startTurn(UNSTATED);
369
+ else { this.current = UNSTATED; this.chosen = false; }
370
+ }
371
+
372
+ /**
373
+ * Input that is positively nobody's request (an automation run, a launch made
374
+ * for nobody's): a turn it starts stamps "none". Mid-run it joins as
375
+ * nobody's: a request the turn was serving is still carried, so the turn
376
+ * carries both and is undecided until the agent names one.
377
+ */
378
+ arriveNobody(): void {
379
+ if (this.disposed) return;
380
+ if (!this.turnStarted) { this.startTurn(NO_REQUEST); return; }
381
+ if (this.current === NO_REQUEST) return;
382
+ const previous = this.currentRequestId();
383
+ this.current = NO_REQUEST;
384
+ this.chosen = false;
385
+ this.arrivals = [];
386
+ if (previous) this.joined.add(previous);
387
+ }
388
+
389
+ /** Input that is nobody's in particular (another extension's send): it starts an unstated turn and joins a running one unchanged. */
390
+ arriveUntyped(): void {
391
+ if (!this.disposed && !this.turnStarted) this.startTurn(UNSTATED);
392
+ }
393
+
394
+ private join(id: string): void {
395
+ if (!this.isOpen(id) || id === this.current) return;
396
+ this.joined.add(id);
397
+ this.touched.add(id);
398
+ this.chosen = false;
399
+ }
400
+
401
+ /** The run settled: every request it carried closes unless work it launched is still to wake. */
402
+ endTurn(): void {
403
+ const released = [...this.touched];
404
+ this.current = null;
405
+ this.joined = new Set();
406
+ this.chosen = false;
407
+ this.turnStarted = false;
408
+ this.touched = new Set();
409
+ this.arrivals = [];
410
+ for (const id of released) this.maybeClose(id);
411
+ }
412
+
413
+ // ---- What the turn carries ---------------------------------------------------
414
+
415
+ /**
416
+ * Who a request is from as a write would record it: a person, and whether at
417
+ * this terminal or in a room. A requester not yet known is its own person —
418
+ * never assumed to be the same as anyone else.
419
+ */
420
+ private attribution(request: CarriedRequest): string {
421
+ return `${request.requester ?? `unknown:${request.id}`}:${request.kind === "typed_prompt" ? "terminal" : "room"}`;
422
+ }
423
+
424
+ /**
425
+ * The requests the turn carries when they would not all be recorded the same
426
+ * way (whether or not the agent has named one); null when every write this
427
+ * turn makes has one answer.
428
+ */
429
+ private carriedNow(): CarriedEntry[] | null {
430
+ const carried: CarriedRequest[] = [];
431
+ const add = (id: string | null) => {
432
+ const request = id && this.isOpen(id) ? this.requests.get(id) : undefined;
433
+ if (request && !carried.includes(request)) carried.push(request);
434
+ };
435
+ add(this.currentRequestId());
436
+ for (const id of this.joined) add(id);
437
+ const nobody = this.current === NO_REQUEST && carried.length > 0;
438
+ if (new Set(carried.map((request) => this.attribution(request))).size + (nobody ? 1 : 0) < 2) return null;
439
+ return [
440
+ ...(nobody && this.noneAllowed() ? [{ id: NO_REQUEST, kind: "none" as const, current: true }] : []),
441
+ ...carried.map((request) => ({ id: request.id, kind: request.kind, ...(request.messageId ? { messageId: request.messageId } : {}), current: request.id === this.current })),
442
+ ];
443
+ }
444
+
445
+ /** The requests this turn carries that would be recorded differently, once their opens answer (bounded). */
446
+ async undecided(timeoutMs = OPEN_WAIT_MS): Promise<CarriedEntry[] | null> {
447
+ await this.settleOpens(this.carriedIds(), Date.now() + timeoutMs);
448
+ return this.carriedNow();
449
+ }
450
+
451
+ /**
452
+ * The requests this turn carries when they would not all be recorded the
453
+ * same way and the agent has not named one; null when every write this turn
454
+ * makes has one answer. One DevSpec did not open is not carried.
455
+ */
456
+ async ambiguity(timeoutMs = OPEN_WAIT_MS): Promise<CarriedEntry[] | null> {
457
+ if (this.chosen || this.joined.size === 0) return null;
458
+ const carried = await this.undecided(timeoutMs);
459
+ return this.chosen ? null : carried;
460
+ }
461
+
462
+ /**
463
+ * The agent names the request its writes now serve (serve_request): one of
464
+ * the requests this turn carries that would be recorded differently — the
465
+ * command with this message id, the prompt typed here, or nobody's when
466
+ * nobody's request is among them. Holds until another request reaches it.
467
+ */
468
+ async serve(input: { messageId?: unknown; terminal?: unknown; none?: unknown }, timeoutMs = OPEN_WAIT_MS): Promise<{ ok: true; serving: string } | { ok: false; error: string }> {
469
+ const messageId = typeof input.messageId === "string" && input.messageId.trim() ? input.messageId.trim().toLowerCase() : null;
470
+ const terminal = input.terminal === true;
471
+ const none = input.none === true;
472
+ if ([Boolean(messageId), terminal, none].filter(Boolean).length !== 1) {
473
+ return { ok: false, error: "Name exactly one: message_id, terminal: true, or none: true." };
474
+ }
475
+ const carried = await this.undecided(timeoutMs);
476
+ if (!carried) {
477
+ return { ok: false, error: "Nothing to name: every request this turn carries would be recorded the same way, so its writes already have one answer." };
478
+ }
479
+ const match = carried.filter((entry) => none ? entry.kind === "none"
480
+ : terminal ? entry.kind === "typed_prompt"
481
+ : entry.kind === "command" && entry.messageId?.toLowerCase() === messageId);
482
+ const ref = match.length ? match[match.length - 1]!.id : null;
483
+ if (!ref) return { ok: false, error: "That is not one of the requests this turn carries. Name one the refusal listed." };
484
+ const previous = this.currentRequestId();
485
+ this.current = ref;
486
+ this.chosen = true;
487
+ this.turnStarted = true;
488
+ this.arrivals = [];
489
+ // The request it was serving is still carried: naming it again switches back.
490
+ if (previous && previous !== ref) this.joined.add(previous);
491
+ if (UUID.test(ref)) this.touched.add(ref);
492
+ return { ok: true, serving: ref === NO_REQUEST ? "nobody's request: your own initiative" : this.describe(ref) };
493
+ }
494
+
495
+ private describe(id: string): string {
496
+ const request = this.requests.get(id);
497
+ if (!request) return id;
498
+ return request.kind === "typed_prompt" ? "the prompt your owner typed at this terminal" : `the room command with message_id ${request.messageId ?? "(unknown)"}`;
499
+ }
500
+
501
+ // ---- Launches and wakes -----------------------------------------------------
502
+
503
+ /**
504
+ * What a launch made now belongs to: the current request while it is open,
505
+ * "none" when the turn positively serves nobody's, "unstated" otherwise —
506
+ * with the other requests an undecided turn was carrying.
507
+ */
508
+ launchSnapshot(): LaunchSnapshot {
509
+ const cleared = this.arrivalFailed();
510
+ const request = cleared ? UNSTATED : this.current === NO_REQUEST ? NO_REQUEST
511
+ : this.currentRequestId() && this.isStampable(this.current) ? this.current! : UNSTATED;
512
+ const also = ((this.chosen ? null : this.carriedNow()) ?? []).map((entry) => entry.id).filter((id) => UUID.test(id) && id !== request);
513
+ return { request, also };
514
+ }
515
+
516
+ /** The same, once the carried opens have answered (bounded), so who asked is known. */
517
+ async launchSnapshotReady(timeoutMs = OPEN_WAIT_MS): Promise<LaunchSnapshot> {
518
+ await this.settleOpens(this.carriedIds(), Date.now() + timeoutMs);
519
+ return this.launchSnapshot();
520
+ }
521
+
522
+ /** Record a launch (every one, so its wake is never mistaken for one nothing here started). */
523
+ recordLaunch(key: string, snapshot: LaunchSnapshot, options: { aliases?: string[]; notifies?: boolean } = {}): void {
524
+ if (this.disposed || !key) return;
525
+ const launch: Launch = { request: snapshot.request, also: [...snapshot.also], notifies: options.notifies !== false, notified: false };
526
+ boundedMap(this.launches, key, launch);
527
+ const aliases = (options.aliases ?? []).filter(Boolean);
528
+ for (const alias of aliases) boundedMap(this.aliases, alias, key);
529
+ this.writeJournal(key, aliases, launch);
530
+ }
531
+
532
+ /** Work that ends without a wake (killed, a cancelled question, gone after a reload) stops holding its requests open. */
533
+ endLaunch(key: string): void {
534
+ const resolved = this.resolveAlias(key);
535
+ this.restored.delete(resolved);
536
+ const launch = this.launches.get(resolved);
537
+ if (!launch || launch.notified) return;
538
+ launch.notified = true;
539
+ this.writeJournal(resolved, [], null);
540
+ this.releaseLaunch(launch);
541
+ }
542
+
543
+ /** Every launch key and alias, for text correlation of a wake that carries no ids. */
544
+ knownKeys(): string[] {
545
+ return [...this.launches.keys(), ...this.aliases.keys()];
546
+ }
547
+
548
+ resolveAlias(key: string): string {
549
+ return this.aliases.get(key) ?? key;
550
+ }
551
+
552
+ /**
553
+ * A wake reached the agent. It continues the open requests its launches were
554
+ * made for — preferring any still open over one that has finished — or
555
+ * nobody's when a launch was made for nobody's; a wake that carries both is
556
+ * undecided until the agent names one. When none of that is known (finished
557
+ * requests, unstated launches, nothing here launched it) it names nothing.
558
+ * The first input of a run is woken for it; mid-run its requests join the
559
+ * turn. Terminal keys stop holding their requests open — after they joined,
560
+ * so nothing closes underneath the turn. Returns whether any key was one
561
+ * this chain launched.
562
+ *
563
+ * `alongside`: the notice reached the agent together with a prompt that
564
+ * already decided what the turn serves (it was the session leaf when that
565
+ * prompt started). Its requests join; it neither clears nor replaces.
566
+ */
567
+ wake(keys: readonly WakeKey[], options: { alongside?: boolean } = {}): boolean {
568
+ if (this.disposed) return false;
569
+ const matched = keys.map((entry) => ({ ...entry, key: this.resolveAlias(entry.key) }))
570
+ .filter((entry) => this.launches.has(entry.key));
571
+ if (matched.length === 0) {
572
+ if (!options.alongside) this.arriveUnknown();
573
+ return false;
574
+ }
575
+ const requests: string[] = [];
576
+ let closed: string | null = null;
577
+ let nobody = false;
578
+ for (const entry of matched) {
579
+ const launch = this.launches.get(entry.key)!;
580
+ if (launch.request === NO_REQUEST) nobody = true;
581
+ for (const id of [launch.request, ...launch.also]) {
582
+ if (UUID.test(id) && this.isOpen(id) && !requests.includes(id)) requests.push(id);
583
+ }
584
+ if (!closed && this.requests.get(launch.request)?.closedOnServer) closed = launch.request;
585
+ }
586
+ // A request DevSpec reports closed (it closed in a /reload gap) still names
587
+ // its requester: the wake continues it when nothing it carries is open.
588
+ const ref = nobody ? NO_REQUEST : requests[0] ?? closed ?? UNSTATED;
589
+ const also = nobody ? requests : requests.slice(1);
590
+ if (!this.turnStarted) {
591
+ this.startTurn(ref, also);
592
+ } else if (options.alongside) {
593
+ for (const id of requests) this.join(id);
594
+ } else {
595
+ if (ref === NO_REQUEST) this.arriveNobody();
596
+ else if (ref === UNSTATED) this.arriveUnknown();
597
+ for (const id of requests) this.join(id);
598
+ }
599
+ this.noteNotified(matched);
600
+ return true;
601
+ }
602
+
603
+ /** A notification reached the conversation: its work is done and stops holding its requests open. */
604
+ noteNotified(keys: readonly WakeKey[]): void {
605
+ for (const entry of keys) {
606
+ if (!entry.terminal) continue;
607
+ const key = this.resolveAlias(entry.key);
608
+ this.restored.delete(key);
609
+ const launch = this.launches.get(key);
610
+ if (!launch || launch.notified) continue;
611
+ launch.notified = true;
612
+ this.writeJournal(key, [], null);
613
+ this.releaseLaunch(launch);
614
+ }
615
+ }
616
+
617
+ private releaseLaunch(launch: Launch): void {
618
+ for (const id of [launch.request, ...launch.also]) if (UUID.test(id)) this.maybeClose(id);
619
+ }
620
+
621
+ private pending(id: string): boolean {
622
+ for (const launch of this.launches.values()) {
623
+ if (launch.notifies && !launch.notified && (launch.request === id || launch.also.includes(id))) return true;
624
+ }
625
+ return false;
626
+ }
627
+
628
+ private maybeClose(id: string): void {
629
+ const request = this.requests.get(id);
630
+ if (this.disposed || !request || this.touched.has(id) || this.pending(id)) return;
631
+ if (request.state !== "open" && request.state !== "opening") return;
632
+ request.state = "closing";
633
+ const closing = (async () => {
634
+ const opened = await request.ready;
635
+ if (!opened || !request.serverId) { request.state = "closed"; return; }
636
+ try {
637
+ await this.transport.call({ action: "close", request_id: request.serverId, reason: "host_finished" });
638
+ } catch {
639
+ // Recorded closed regardless: a request this host no longer carries is
640
+ // never stamped again, and DevSpec closes it with the connection anyway.
641
+ this.onError("close");
642
+ }
643
+ request.state = "closed";
644
+ })();
645
+ this.closing.add(closing);
646
+ void closing.finally(() => this.closing.delete(closing));
647
+ }
648
+
649
+ // ---- Writes -------------------------------------------------------------------
650
+
651
+ /**
652
+ * What a write made now carries: the current request's server id once it is
653
+ * open, "none" for a turn that positively serves nobody's request, or null —
654
+ * stamp nothing — for an unstated turn or a request whose open has not
655
+ * answered within the wait, or never landed.
656
+ */
657
+ async stamp(timeoutMs = OPEN_WAIT_MS): Promise<RequestStamp> {
658
+ await this.settleOpens([this.currentRequestId(), ...this.arrivals.flat()], Date.now() + timeoutMs);
659
+ if (this.arrivalFailed()) return null;
660
+ if (this.current === NO_REQUEST) return NO_REQUEST;
661
+ const id = this.currentRequestId();
662
+ const request = id ? this.requests.get(id) : undefined;
663
+ if (!request || request.failed || !request.serverId) return null;
664
+ return request.state === "open" || request.state === "closing" || request.closedOnServer ? request.serverId : null;
665
+ }
666
+
667
+ /**
668
+ * Why stamp() named nothing. "unstated": the turn continues no request this
669
+ * host knows of, which is a fact DevSpec records as such and never guesses
670
+ * past. "not_recorded": the request this turn serves never reached DevSpec
671
+ * (its open failed or has not answered), so the write loses who asked (the
672
+ * case found on 15 Oct, item 306fb64a note c9afcfce).
673
+ */
674
+ unstampedReason(): "unstated" | "not_recorded" {
675
+ if (this.arrivalFailed()) return "not_recorded";
676
+ if (this.current === NO_REQUEST) return "unstated";
677
+ const id = this.currentRequestId();
678
+ const request = id ? this.requests.get(id) : undefined;
679
+ if (!request) return "unstated";
680
+ return request.failed || !request.serverId ? "not_recorded" : "unstated";
681
+ }
682
+
683
+ private arrivalFailed(): boolean {
684
+ return this.arrivals.some((batch) => batch.length > 0 && batch.every((id) => this.requests.get(id)?.failed === true));
685
+ }
686
+
687
+ // ---- Lifecycle ------------------------------------------------------------------
688
+
689
+ /**
690
+ * Rebuild the launches a previous instance journaled on this branch, read in
691
+ * order: a launch whose notice already appears later in the branch has
692
+ * reached the agent and is not restored. Each remaining launch's requests are
693
+ * re-opened by their own ids to learn whether they are still carried.
694
+ * `noticeKeys` reads a branch entry's wake keys (custom messages only).
695
+ */
696
+ restore(entries: readonly unknown[], nativeSessionId: string, noticeKeys?: (entry: Record<string, unknown>, known: readonly string[]) => WakeKey[]): number {
697
+ if (this.disposed) return 0;
698
+ const latest = new Map<string, RequestLinkEntry>();
699
+ const aliases = new Map<string, string>();
700
+ for (const value of entries) {
701
+ const row = record(value);
702
+ if (!row) continue;
703
+ if (row.type === "custom" && row.customType === REQUEST_LINK_ENTRY) {
704
+ const entry = parseRequestLinkEntry(row.data);
705
+ if (!entry || entry.nativeSessionId !== nativeSessionId || entry.connectionId !== this.connectionId) continue;
706
+ latest.delete(entry.key);
707
+ latest.set(entry.key, entry);
708
+ for (const alias of entry.aliases ?? []) aliases.set(alias, entry.key);
709
+ } else if (row.type === "custom_message" && noticeKeys && latest.size > 0) {
710
+ let keys: WakeKey[] = [];
711
+ try { keys = noticeKeys(row, [...latest.keys(), ...aliases.keys()]); } catch { /* not a notice */ }
712
+ for (const key of keys) if (key.terminal) latest.delete(aliases.get(key.key) ?? key.key);
713
+ }
714
+ }
715
+ const carry = (identity: RequestLinkIdentity): string => {
716
+ const known = this.requests.get(identity.id);
717
+ if (known) return known.id;
718
+ const request = this.create(identity, true);
719
+ if (request.kind === "command" && request.provenanceRef) boundedMap(this.byProvenance, request.provenanceRef, request.id);
720
+ return request.id;
721
+ };
722
+ let restored = 0;
723
+ for (const entry of latest.values()) {
724
+ if (entry.request === null || this.launches.has(entry.key)) continue;
725
+ const request = typeof entry.request === "string" ? entry.request : carry(entry.request);
726
+ const also = (entry.also ?? []).map(carry);
727
+ this.launches.set(entry.key, { request, also, notifies: entry.notifies !== false, notified: false });
728
+ for (const alias of entry.aliases ?? []) boundedMap(this.aliases, alias, entry.key);
729
+ this.restored.add(entry.key);
730
+ restored += 1;
731
+ }
732
+ return restored;
733
+ }
734
+
735
+ /** Restored launches still waiting to be checked against what the host is actually running. */
736
+ restoredKeys(): string[] {
737
+ return [...this.restored];
738
+ }
739
+
740
+ /**
741
+ * Check restored launches against the host: one it reports no longer
742
+ * running is over (its wake will not come to this instance) and stops
743
+ * holding its requests open; one it cannot tell about waits for a later check.
744
+ */
745
+ reconcileRestored(running: (key: string) => boolean | undefined): number {
746
+ let ended = 0;
747
+ for (const key of [...this.restored]) {
748
+ const verdict = running(key);
749
+ if (verdict === undefined) continue;
750
+ this.restored.delete(key);
751
+ if (verdict === false) { this.endLaunch(key); ended += 1; }
752
+ }
753
+ return ended;
754
+ }
755
+
756
+ /** Process exit or reload: close what has nothing pending; work still to wake keeps its requests for after reload. */
757
+ async shutdown(timeoutMs = 3_000): Promise<void> {
758
+ this.endTurn();
759
+ await withTimeout(Promise.allSettled([...this.closing]).then(() => undefined), timeoutMs, undefined);
760
+ this.dispose();
761
+ }
762
+
763
+ /** Different authority (stop, revive, replacement connection): forget everything locally. */
764
+ dispose(): void {
765
+ this.disposed = true;
766
+ this.current = null;
767
+ this.joined = new Set();
768
+ this.turnStarted = false;
769
+ }
770
+
771
+ private identity(id: string): RequestLinkIdentity | null {
772
+ const request = this.requests.get(id);
773
+ if (!request) return null;
774
+ return {
775
+ id: request.id, kind: request.kind,
776
+ ...(request.provenanceRef ? { provenanceRef: request.provenanceRef } : {}),
777
+ ...(request.messageId ? { messageId: request.messageId } : {}),
778
+ ...(request.fromOwner ? { fromOwner: true } : {}),
779
+ ...(request.commandTurnId ? { commandTurnId: request.commandTurnId } : {}),
780
+ ...(request.commandMessageId ? { commandMessageId: request.commandMessageId } : {}),
781
+ };
782
+ }
783
+
784
+ private writeJournal(key: string, aliases: string[], launch: Launch | null): void {
785
+ if (!this.journal) return;
786
+ try {
787
+ const request = launch === null ? null
788
+ : launch.request === NO_REQUEST || launch.request === UNSTATED ? launch.request
789
+ : this.identity(launch.request) ?? UNSTATED;
790
+ const also = launch ? launch.also.map((id) => this.identity(id)).filter((value): value is RequestLinkIdentity => value !== null) : [];
791
+ this.journal({ key, ...(aliases.length ? { aliases } : {}), request,
792
+ ...(also.length ? { also } : {}), ...(launch ? { notifies: launch.notifies } : {}) });
793
+ } catch {
794
+ this.onError("persist");
795
+ }
796
+ }
797
+
798
+ /** Test/diagnostic view; never posted anywhere. */
799
+ snapshot(): { current: string | null; joined: string[]; chosen: boolean; requests: Array<{ id: string; kind: CarriedRequestKind; state: string }>; pending: string[] } {
800
+ return {
801
+ current: this.current, joined: [...this.joined], chosen: this.chosen,
802
+ requests: [...this.requests.values()].map((request) => ({ id: request.id, kind: request.kind, state: request.state })),
803
+ pending: [...this.launches.entries()].filter(([, launch]) => launch.notifies && !launch.notified).map(([key]) => key),
804
+ };
805
+ }
806
+ }
807
+
808
+ const LINK_KEYS = ["version", "nativeSessionId", "connectionId", "key", "aliases", "request", "also", "notifies"];
809
+ const REQUEST_KEYS = ["id", "kind", "provenanceRef", "messageId", "fromOwner", "commandTurnId", "commandMessageId"];
810
+
811
+ function parseIdentity(value: unknown): RequestLinkIdentity | null {
812
+ const row = record(value);
813
+ if (!row || Object.keys(row).some((key) => !REQUEST_KEYS.includes(key))) return null;
814
+ if (typeof row.id !== "string" || !UUID.test(row.id) || (row.kind !== "command" && row.kind !== "typed_prompt")) return null;
815
+ if (row.kind === "command" && (typeof row.provenanceRef !== "string" || !UUID.test(row.provenanceRef))) return null;
816
+ if (row.kind === "typed_prompt" && row.provenanceRef !== undefined) return null;
817
+ if (row.fromOwner !== undefined && row.fromOwner !== true) return null;
818
+ for (const field of ["messageId", "commandTurnId", "commandMessageId"] as const) {
819
+ if (row[field] !== undefined && (typeof row[field] !== "string" || !(row[field] as string) || (row[field] as string).length > 128)) return null;
820
+ }
821
+ return {
822
+ id: row.id, kind: row.kind,
823
+ ...(typeof row.provenanceRef === "string" ? { provenanceRef: row.provenanceRef } : {}),
824
+ ...(typeof row.messageId === "string" ? { messageId: row.messageId } : {}),
825
+ ...(row.fromOwner === true ? { fromOwner: true } : {}),
826
+ ...(typeof row.commandTurnId === "string" ? { commandTurnId: row.commandTurnId } : {}),
827
+ ...(typeof row.commandMessageId === "string" ? { commandMessageId: row.commandMessageId } : {}),
828
+ };
829
+ }
830
+
831
+ /** Strict allowlist parse: a malformed row restores nothing. */
832
+ export function parseRequestLinkEntry(value: unknown): RequestLinkEntry | null {
833
+ const row = record(value);
834
+ if (!row || row.version !== 1 || Object.keys(row).some((key) => !LINK_KEYS.includes(key))) return null;
835
+ for (const field of ["nativeSessionId", "connectionId", "key"] as const) {
836
+ if (typeof row[field] !== "string" || !row[field] || (row[field] as string).length > 512) return null;
837
+ }
838
+ let aliases: string[] | undefined;
839
+ if (row.aliases !== undefined) {
840
+ if (!Array.isArray(row.aliases) || row.aliases.length > 4 ||
841
+ row.aliases.some((alias) => typeof alias !== "string" || !alias || alias.length > 1024)) return null;
842
+ aliases = row.aliases as string[];
843
+ }
844
+ let request: RequestLinkEntry["request"];
845
+ if (row.request === null || row.request === NO_REQUEST || row.request === UNSTATED) request = row.request;
846
+ else {
847
+ request = parseIdentity(row.request);
848
+ if (!request) return null;
849
+ }
850
+ let also: RequestLinkIdentity[] | undefined;
851
+ if (row.also !== undefined) {
852
+ if (!Array.isArray(row.also) || row.also.length > 8) return null;
853
+ also = [];
854
+ for (const value of row.also) {
855
+ const identity = parseIdentity(value);
856
+ if (!identity) return null;
857
+ also.push(identity);
858
+ }
859
+ }
860
+ if (row.notifies !== undefined && typeof row.notifies !== "boolean") return null;
861
+ return {
862
+ version: 1,
863
+ nativeSessionId: row.nativeSessionId as string,
864
+ connectionId: row.connectionId as string,
865
+ key: row.key as string,
866
+ ...(aliases ? { aliases } : {}),
867
+ request,
868
+ ...(also ? { also } : {}),
869
+ ...(typeof row.notifies === "boolean" ? { notifies: row.notifies } : {}),
870
+ };
871
+ }
872
+
873
+ // ---- Launch and wake correlation ------------------------------------------
874
+
875
+ /**
876
+ * A background task a launch tool started (pi-background-tasks' receipt), and
877
+ * whether its end reaches the agent as a notification: it does unless the
878
+ * receipt says notifyOnCompletion false.
879
+ */
880
+ export function backgroundLaunch(toolName: string, result: unknown, isError = false): { id: string; notifies: boolean } | null {
881
+ if (isError) return null;
882
+ const id = backgroundTaskFromLaunch(toolName, result, isError)?.id ?? automaticBackgroundTaskId(toolName, result, isError);
883
+ if (!id) return null;
884
+ const outer = record(result);
885
+ const details = record(outer?.details) ?? outer;
886
+ return { id, notifies: record(details?.task)?.notifyOnCompletion !== false };
887
+ }
888
+
889
+ /**
890
+ * A call that would start work which wakes the agent later: a background task,
891
+ * an async subagent run (pi-subagents runs async unless told otherwise; an
892
+ * `action` call manages runs), or a directed question.
893
+ */
894
+ export function isLaunchCall(toolName: string, input: unknown): boolean {
895
+ const args = record(input) ?? {};
896
+ if (isBackgroundLaunchTool(toolName)) return true;
897
+ if (toolName === "subagent") return args.action === undefined && args.async !== false;
898
+ return toolName === "manage_directed_question" && args.action === "create";
899
+ }
900
+
901
+ /** pi-subagents' async launch receipt: details.asyncId (the run id) and details.asyncDir. */
902
+ export function subagentLaunch(toolName: string, result: unknown, isError = false): { id: string; aliases: string[] } | null {
903
+ if (isError || toolName !== "subagent") return null;
904
+ const outer = record(result);
905
+ const details = record(outer?.details) ?? outer;
906
+ const id = typeof details?.asyncId === "string" && details.asyncId.trim() ? details.asyncId.trim() : null;
907
+ if (!id || id.length > 256) return null;
908
+ const dir = typeof details?.asyncDir === "string" && details.asyncDir.trim() ? details.asyncDir.trim() : null;
909
+ return { id, aliases: dir && dir.length <= 1024 ? [dir] : [] };
910
+ }
911
+
912
+ /** A directed question this segment asked (manage_directed_question create). */
913
+ export function askedQuestionId(toolName: string, args: unknown, result: unknown, isError = false): string | null {
914
+ if (isError || toolName !== "manage_directed_question" || record(args)?.action !== "create") return null;
915
+ const outer = record(result);
916
+ const details = record(outer?.details) ?? outer;
917
+ const id = record(details?.question)?.id;
918
+ return typeof id === "string" && UUID.test(id) ? id : null;
919
+ }
920
+
921
+ /** A directed question this segment cancelled: it will never wake anyone. */
922
+ export function cancelledQuestionId(toolName: string, args: unknown, isError = false): string | null {
923
+ const input = record(args);
924
+ if (isError || toolName !== "manage_directed_question" || input?.action !== "cancel") return null;
925
+ return typeof input.question_id === "string" && UUID.test(input.question_id) ? input.question_id : null;
926
+ }
927
+
928
+ const SUBAGENT_TERMINAL = new Set(["completed", "failed", "stopped"]);
929
+ const TASK_TERMINAL = new Set(["completed", "failed", "killed"]);
930
+
931
+ /**
932
+ * The background task a pi-background-tasks notice names, whatever its status
933
+ * (`terminal` only for completed/failed/killed — the same states
934
+ * backgroundTaskNotificationId resolves).
935
+ */
936
+ export function backgroundNotificationTask(text: string, details?: unknown): { id: string; terminal: boolean } | null {
937
+ const row = record(details);
938
+ let id: string | null = null;
939
+ let status: string | null = null;
940
+ if (row) {
941
+ const task = record(row.task);
942
+ for (const value of [row.id, row.taskId, task?.id]) {
943
+ if (typeof value === "string" && value.trim()) { id = value.trim(); break; }
944
+ }
945
+ const value = row.status ?? task?.status;
946
+ if (typeof value === "string") status = value.trim().toLowerCase();
947
+ }
948
+ if (!id && !/<background-task-notification|<task-id>/i.test(text)) return null;
949
+ id ??= text.match(/<task-id>\s*([^<]+?)\s*<\/task-id>/i)?.[1]?.trim() ?? null;
950
+ status ??= text.match(/<status>\s*([^<]+?)\s*<\/status>/i)?.[1]?.trim().toLowerCase() ?? null;
951
+ if (!id || id.length > 200) return null;
952
+ return { id, terminal: status !== null && TASK_TERMINAL.has(status) };
953
+ }
954
+
955
+ /**
956
+ * The work keys a wake message carries. A background-task notice names its
957
+ * task (`task`, from backgroundNotificationTask); pi-subagents' notices carry
958
+ * details.runs with runId/asyncDir, and a workflow child's names its workflow
959
+ * run; a directed-question continuation carries details.event's question id.
960
+ * A notice without structured ids is matched, best effort, by any launched
961
+ * subagent id or directory its text names.
962
+ */
963
+ export function wakeKeys(input: {
964
+ text: string;
965
+ customType?: string | null;
966
+ details?: unknown;
967
+ task?: { id: string; terminal: boolean } | null;
968
+ questionCustomTypes?: readonly string[];
969
+ known?: readonly string[];
970
+ }): WakeKey[] {
971
+ const keys: WakeKey[] = [];
972
+ if (input.task) keys.push({ key: taskKey(input.task.id), terminal: input.task.terminal });
973
+ const details = record(input.details);
974
+ if (input.customType && input.questionCustomTypes?.includes(input.customType)) {
975
+ const questionId = record(details?.event)?.question_id;
976
+ if (typeof questionId === "string" && UUID.test(questionId)) keys.push({ key: questionKey(questionId), terminal: true });
977
+ return keys;
978
+ }
979
+ const trimmed = input.text.trim();
980
+ if (input.customType === "subagent-incremental-child-notify" || /^Workflow child /.test(trimmed)) {
981
+ // One child of a workflow settled; the workflow (its launch) goes on.
982
+ const run = trimmed.match(/^Workflow run: (\S+)$/m)?.[1];
983
+ if (run && run.length <= 256) keys.push({ key: subagentKey(run), terminal: false });
984
+ return keys;
985
+ }
986
+ const subagentText = /^(?:Background task|Detached foreground task) (?:completed|failed|paused|stopped): \*\*/.test(trimmed) ||
987
+ /^Background tasks completed \(\d+\)/.test(trimmed);
988
+ if (input.customType === "subagent-notify" || (!input.task && subagentText)) {
989
+ const runs = Array.isArray(details?.runs) ? details.runs : [];
990
+ for (const value of runs.slice(0, 64)) {
991
+ const run = record(value);
992
+ const status = typeof run?.status === "string" ? run.status.toLowerCase() : "";
993
+ const terminal = SUBAGENT_TERMINAL.has(status);
994
+ if (typeof run?.runId === "string" && run.runId.trim()) keys.push({ key: subagentKey(run.runId.trim()), terminal });
995
+ else if (typeof run?.asyncDir === "string" && run.asyncDir.trim()) keys.push({ key: run.asyncDir.trim(), terminal });
996
+ }
997
+ if (keys.length === 0 && input.known?.length) {
998
+ const terminal = /^(?:Background task|Detached foreground task) (?:completed|failed|stopped)\b/.test(trimmed) ||
999
+ /^Background tasks completed \(\d+\)/.test(trimmed);
1000
+ for (const key of input.known) {
1001
+ const needle = key.startsWith("subagent:") ? key.slice("subagent:".length) : key.startsWith("task:") || key.startsWith("question:") ? "" : key;
1002
+ if (needle && input.text.includes(needle)) keys.push({ key, terminal });
1003
+ }
1004
+ }
1005
+ }
1006
+ return keys;
1007
+ }
1008
+
1009
+ // ---- Typed input ----------------------------------------------------------
1010
+
1011
+ interface InputRecord {
1012
+ typed: boolean;
1013
+ text: string;
1014
+ behavior?: "steer" | "followUp";
1015
+ }
1016
+
1017
+ /**
1018
+ * Which user messages the owner typed (item 7a4d04fd). Pi's `input` event says
1019
+ * where input came from; the message_start that later carries it does not.
1020
+ * Every input is recorded in order — interactive and RPC input typed, the
1021
+ * extension's own sends untyped unless the extension marked them as sent on
1022
+ * the owner's behalf (/devspec.work) — and each user message that no command
1023
+ * binding claimed consumes one record: the one with its exact text, or else
1024
+ * the oldest typed one (Pi may have expanded a typed template or skill).
1025
+ * Automation runs, harness wakes and notices never arrive through `input` as
1026
+ * typed, so they are never admitted as typed turns.
1027
+ */
1028
+ export class TypedInputLedger {
1029
+ private records: InputRecord[] = [];
1030
+ private marks: string[] = [];
1031
+
1032
+ /** The extension sends this exact text because the owner asked for it at this terminal. */
1033
+ markTyped(text: string): void {
1034
+ this.marks.push(text);
1035
+ while (this.marks.length > 16) this.marks.shift();
1036
+ }
1037
+
1038
+ unmarkTyped(text: string): void {
1039
+ const index = this.marks.indexOf(text);
1040
+ if (index !== -1) this.marks.splice(index, 1);
1041
+ }
1042
+
1043
+ noteInput(event: { text?: unknown; source?: unknown; streamingBehavior?: unknown }): boolean {
1044
+ const text = typeof event.text === "string" ? event.text : "";
1045
+ const behavior = event.streamingBehavior === "steer" || event.streamingBehavior === "followUp" ? event.streamingBehavior : undefined;
1046
+ // An idle input starts its own run at once. An older idle record nobody
1047
+ // consumed belonged to input another handler swallowed; it must not be
1048
+ // inherited by the next message.
1049
+ if (!behavior) this.records = this.records.filter((entry) => entry.behavior !== undefined);
1050
+ let typed = event.source === "interactive" || event.source === "rpc";
1051
+ if (event.source === "extension") {
1052
+ const index = this.marks.indexOf(text);
1053
+ if (index !== -1) { this.marks.splice(index, 1); typed = true; }
1054
+ }
1055
+ this.records.push({ typed, text, ...(behavior ? { behavior } : {}) });
1056
+ while (this.records.length > 64) this.records.shift();
1057
+ return typed;
1058
+ }
1059
+
1060
+ /** A user message no command binding claimed: true when the owner typed it. */
1061
+ takeForMessage(text: string): boolean {
1062
+ let index = this.records.findIndex((entry) => entry.text === text);
1063
+ if (index === -1) index = this.records.findIndex((entry) => entry.typed);
1064
+ if (index === -1) return false;
1065
+ const [entry] = this.records.splice(index, 1);
1066
+ return entry?.typed === true;
1067
+ }
1068
+
1069
+ /** A user message claimed some other way (a command, an automation run): drop its record. */
1070
+ dropForMessage(text: string): void {
1071
+ const index = this.records.findIndex((entry) => entry.text === text);
1072
+ if (index !== -1) this.records.splice(index, 1);
1073
+ }
1074
+
1075
+ /** The run settled: anything not consumed by now never became a message. */
1076
+ expire(): void {
1077
+ this.records = [];
1078
+ }
1079
+
1080
+ pending(): number {
1081
+ return this.records.length;
1082
+ }
1083
+ }
1084
+
1085
+
1086
+ // ---- Process-wide stores --------------------------------------------------
1087
+
1088
+ interface ChainStore {
1089
+ chain: RequestChain | null;
1090
+ ledger: TypedInputLedger;
1091
+ /** Shows serve_request (devspec-remote owns the active tool set). */
1092
+ showServeTool: ((enabled: boolean) => void) | null;
1093
+ }
1094
+
1095
+ const STORE_KEY = Symbol.for("devspec.pi.request-chain.v1");
1096
+
1097
+ function store(): ChainStore {
1098
+ const root = globalThis as typeof globalThis & { [STORE_KEY]?: ChainStore };
1099
+ if (!root[STORE_KEY]) root[STORE_KEY] = { chain: null, ledger: new TypedInputLedger(), showServeTool: null };
1100
+ root[STORE_KEY].showServeTool ??= null;
1101
+ return root[STORE_KEY];
1102
+ }
1103
+
1104
+ export function installRequestChain(chain: RequestChain | null): void {
1105
+ store().chain = chain;
1106
+ }
1107
+
1108
+ export function activeRequestChain(): RequestChain | null {
1109
+ return store().chain;
1110
+ }
1111
+
1112
+ export function typedInputLedger(): TypedInputLedger {
1113
+ return store().ledger;
1114
+ }
1115
+
1116
+ export function installServeToolSwitch(show: ((enabled: boolean) => void) | null): void {
1117
+ store().showServeTool = show;
1118
+ }
1119
+
1120
+ /** The extension's own send on the owner's behalf (/devspec.work) is typed input. */
1121
+ export function markTypedSend(text: string): void {
1122
+ store().ledger.markTyped(text);
1123
+ }
1124
+
1125
+ export function unmarkTypedSend(text: string): void {
1126
+ store().ledger.unmarkTyped(text);
1127
+ }
1128
+
1129
+ // ---- Stamping a write ---------------------------------------------------------
1130
+
1131
+ /** Whether a tool's wire schema takes request_ref (the server's work-claim provenance tools). */
1132
+ export function declaresRequestRef(inputSchema: unknown): boolean {
1133
+ const properties = record(record(inputSchema)?.properties);
1134
+ return Boolean(properties && "request_ref" in properties);
1135
+ }
1136
+
1137
+ export const SERVE_REQUEST_TOOL = "serve_request";
1138
+
1139
+ function describeCarried(entry: CarriedEntry): string {
1140
+ if (entry.kind === "none") return `- nobody's request (your own initiative): ${SERVE_REQUEST_TOOL} with none: true`;
1141
+ if (entry.kind === "typed_prompt") return `- the prompt your owner typed at this terminal: ${SERVE_REQUEST_TOOL} with terminal: true`;
1142
+ return `- the room command with message_id ${entry.messageId ?? "(unknown)"}: ${SERVE_REQUEST_TOOL} with message_id "${entry.messageId ?? "<id>"}"`;
1143
+ }
1144
+
1145
+ const CARRIES = "This turn carries more than one request that would be recorded differently (different people, someone at this terminal and in a room, or a request and nobody's)";
1146
+
1147
+ /** A DevSpec write that records who asked, refused until the agent names a request. */
1148
+ export function ambiguityRefusal(carried: readonly CarriedEntry[]): string {
1149
+ return [
1150
+ `${CARRIES}, and DevSpec records who asked for this write.`,
1151
+ `Say which request it serves with ${SERVE_REQUEST_TOOL}, then make the call again. One of:`,
1152
+ ...carried.map(describeCarried),
1153
+ "Writes follow that request until another reaches you. Name each request before its writes when you switch.",
1154
+ ].join("\n");
1155
+ }
1156
+
1157
+ /** Work that would wake the agent later, refused until the agent names the request it serves. */
1158
+ export function launchRefusal(carried: readonly CarriedEntry[]): string {
1159
+ return [
1160
+ `${CARRIES}, and this starts work that continues one of them when it wakes you.`,
1161
+ `Say which request it serves with ${SERVE_REQUEST_TOOL}, then start it again. One of:`,
1162
+ ...carried.map(describeCarried),
1163
+ ].join("\n");
1164
+ }
1165
+
1166
+ /**
1167
+ * The request a DevSpec write serves (item 7a4d04fd), for a tool whose wire
1168
+ * schema takes request_ref: stamped from the turn, never from the model — a
1169
+ * model-supplied request_ref is always removed first. When the turn carries
1170
+ * requests that would be recorded differently and the agent has not named
1171
+ * one, the write is refused, unless it already cites the work it belongs to
1172
+ * (work_claim_ref) or the command it serves (provenance_ref), which DevSpec
1173
+ * reads first.
1174
+ */
1175
+ export async function stampRequestRef(input: {
1176
+ params: Record<string, unknown>;
1177
+ inputSchema: unknown;
1178
+ connectionId: string | undefined;
1179
+ timeoutMs?: number;
1180
+ }): Promise<{ args: Record<string, unknown>; unstamped?: UnstampedReason } | { deny: string }> {
1181
+ if (!declaresRequestRef(input.inputSchema)) return { args: input.params };
1182
+ const { request_ref: _model, ...params } = input.params;
1183
+ const shared = store();
1184
+ const chain = shared.chain;
1185
+ // Not connected to DevSpec: no requests are carried, so there is nothing to name.
1186
+ if (!chain || !input.connectionId) return { args: params };
1187
+ // The chain belongs to a connection this one replaced; its requests are not this connection's.
1188
+ if (chain.connectionId !== input.connectionId) return { args: params, unstamped: "connection_changed" };
1189
+ try {
1190
+ const cites = typeof params.work_claim_ref === "string" || typeof params.provenance_ref === "string";
1191
+ const carried = cites ? null : await chain.ambiguity(input.timeoutMs);
1192
+ if (carried) {
1193
+ shared.showServeTool?.(true);
1194
+ return { deny: ambiguityRefusal(carried) };
1195
+ }
1196
+ const ref = await chain.stamp(input.timeoutMs);
1197
+ if (ref) return { args: { ...params, request_ref: ref } };
1198
+ // A citation names the work itself, which DevSpec reads first.
1199
+ if (cites) return { args: params };
1200
+ const why = chain.unstampedReason();
1201
+ return why === "unstated" ? { args: params } : { args: params, unstamped: why };
1202
+ } catch {
1203
+ return { args: params, unstamped: "error" };
1204
+ }
1205
+ }
1206
+
1207
+ /**
1208
+ * Why a write that should have named its request went without one. The write
1209
+ * still goes: when DevSpec can't tell who asked, a change is never refused or
1210
+ * held for that (ADR "Protecting intent" §13). It is no longer silent: the
1211
+ * agent is told, so it can tell the person whose work it was.
1212
+ */
1213
+ export type UnstampedReason = "connection_changed" | "not_recorded" | "error";
1214
+
1215
+ export function unstampedNote(reason: UnstampedReason): string {
1216
+ const why = reason === "connection_changed"
1217
+ ? "this agent's DevSpec connection was replaced since the request reached it"
1218
+ : reason === "not_recorded"
1219
+ ? "the request never reached DevSpec"
1220
+ : "Pi could not work out which request it served";
1221
+ return `Note: DevSpec was not told which request this write served (${why}), so it records that it couldn't tell who asked. If this was for someone, tell them it was done for them.`;
1222
+ }
1223
+
1224
+ export const SERVE_REQUEST_DESCRIPTION =
1225
+ "Say which request your next DevSpec writes serve, when this turn carries requests from more than one person " +
1226
+ "(or from a person and nobody) and a write was refused for it. Pass exactly one: message_id of the room command, " +
1227
+ "terminal: true for the prompt your owner typed at this terminal, or none: true for your own initiative. " +
1228
+ "Writes follow it until another request reaches you.";
1229
+
1230
+ export const SERVE_REQUEST_SCHEMA = {
1231
+ type: "object",
1232
+ properties: {
1233
+ message_id: { type: "string", description: "The message_id of the room command your next writes serve, as the refusal listed it." },
1234
+ terminal: { type: "boolean", description: "true: the prompt your owner typed at this terminal." },
1235
+ none: { type: "boolean", description: "true: nobody's request (your own initiative)." },
1236
+ },
1237
+ additionalProperties: false,
1238
+ } as const;
1239
+
1240
+ /** The serve_request host tool joins the active set only while a turn carries more than one request. */
1241
+ export function withServeRequestToolActive(active: readonly string[], enabled: boolean): string[] {
1242
+ const rest = active.filter((name) => name !== SERVE_REQUEST_TOOL);
1243
+ return enabled ? [...rest, SERVE_REQUEST_TOOL] : rest;
1244
+ }