@gajae-code/coding-agent 0.12.11 → 0.12.12

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 (112) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/types/cli/notify-cli.d.ts +42 -1
  3. package/dist/types/cli/update-cli.d.ts +9 -3
  4. package/dist/types/commands/notify.d.ts +6 -0
  5. package/dist/types/config/file-lock.d.ts +3 -0
  6. package/dist/types/coordinator/contract.d.ts +1 -1
  7. package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
  8. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  9. package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
  10. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
  11. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  12. package/dist/types/main.d.ts +6 -2
  13. package/dist/types/modes/components/session-selector.d.ts +1 -1
  14. package/dist/types/modes/utils/host-status.d.ts +23 -0
  15. package/dist/types/runtime-mcp/types.d.ts +2 -0
  16. package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
  17. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
  18. package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  20. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
  21. package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
  22. package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
  23. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
  24. package/dist/types/sdk/bus/notification-service.d.ts +10 -0
  25. package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
  26. package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
  27. package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
  28. package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
  29. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +24 -7
  31. package/dist/types/sdk/bus/telegram-daemon.d.ts +104 -16
  32. package/dist/types/sdk/bus/telegram-reference.d.ts +10 -1
  33. package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
  34. package/dist/types/sdk/host/host.d.ts +52 -0
  35. package/dist/types/sdk/lifecycle-session.d.ts +6 -0
  36. package/dist/types/sdk/session-activation.d.ts +41 -0
  37. package/dist/types/sdk/session.d.ts +18 -0
  38. package/dist/types/sdk/startup-capability.d.ts +8 -1
  39. package/dist/types/session/agent-session.d.ts +7 -0
  40. package/dist/types/session/artifacts.d.ts +2 -2
  41. package/dist/types/session/session-manager.d.ts +23 -5
  42. package/dist/types/tools/index.d.ts +8 -0
  43. package/dist/types/utils/npm-registry.d.ts +71 -0
  44. package/examples/extensions/README.md +38 -4
  45. package/examples/extensions/ooo-bridge.ts +15 -0
  46. package/package.json +8 -8
  47. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  48. package/src/async/job-manager.ts +9 -2
  49. package/src/cli/notify-cli.ts +226 -2
  50. package/src/cli/update-cli.ts +90 -23
  51. package/src/commands/notify.ts +27 -3
  52. package/src/commands/sdk.ts +1 -0
  53. package/src/config/file-lock-gc.ts +18 -0
  54. package/src/config/file-lock.ts +61 -16
  55. package/src/config/model-profiles.ts +31 -15
  56. package/src/coordinator/contract.ts +1 -0
  57. package/src/coordinator-mcp/question-state.ts +2 -0
  58. package/src/coordinator-mcp/server.ts +358 -7
  59. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  60. package/src/extensibility/extensions/index.ts +1 -0
  61. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
  62. package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
  63. package/src/extensibility/extensions/runner.ts +15 -2
  64. package/src/extensibility/extensions/types.ts +2 -0
  65. package/src/gjc-runtime/launch-tmux.ts +53 -1
  66. package/src/internal-urls/docs-index.generated.ts +11 -10
  67. package/src/main.ts +203 -67
  68. package/src/modes/DESIGN.md +8 -5
  69. package/src/modes/components/footer.ts +10 -18
  70. package/src/modes/components/session-selector.ts +26 -3
  71. package/src/modes/components/status-line/gh.ts +23 -2
  72. package/src/modes/controllers/event-controller.ts +3 -0
  73. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  74. package/src/modes/controllers/input-controller.ts +11 -0
  75. package/src/modes/controllers/selector-controller.ts +8 -2
  76. package/src/modes/interactive-mode.ts +7 -1
  77. package/src/modes/utils/host-status.ts +38 -0
  78. package/src/runtime-mcp/types.ts +2 -0
  79. package/src/sdk/broker/lifecycle.ts +60 -3
  80. package/src/sdk/bus/chat-daemon-cli.ts +42 -0
  81. package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
  82. package/src/sdk/bus/chat-daemon-control.ts +16 -3
  83. package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
  84. package/src/sdk/bus/control-plane-frames.ts +51 -0
  85. package/src/sdk/bus/conversation-store.ts +29 -1
  86. package/src/sdk/bus/existing-thread-readiness.ts +155 -0
  87. package/src/sdk/bus/index.ts +70 -0
  88. package/src/sdk/bus/notification-service.ts +28 -1
  89. package/src/sdk/bus/rich-render.ts +3 -2
  90. package/src/sdk/bus/slack-daemon.ts +99 -4
  91. package/src/sdk/bus/slack-live-provider.ts +32 -2
  92. package/src/sdk/bus/slack-provider.ts +11 -0
  93. package/src/sdk/bus/slack-thread-binding.ts +641 -0
  94. package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
  95. package/src/sdk/bus/telegram-daemon-contract.ts +24 -7
  96. package/src/sdk/bus/telegram-daemon.ts +1961 -497
  97. package/src/sdk/bus/telegram-reference.ts +42 -6
  98. package/src/sdk/bus/topic-registry.ts +674 -181
  99. package/src/sdk/host/host.ts +180 -1
  100. package/src/sdk/lifecycle-session.ts +8 -2
  101. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  102. package/src/sdk/session-activation.ts +127 -0
  103. package/src/sdk/session.ts +139 -28
  104. package/src/sdk/startup-capability.ts +10 -1
  105. package/src/session/agent-session.ts +220 -9
  106. package/src/session/artifacts.ts +56 -13
  107. package/src/session/internal/managed-session-storage.ts +206 -7
  108. package/src/session/session-manager.ts +148 -38
  109. package/src/setup/provider-presets.json +1 -1
  110. package/src/task/index.ts +92 -44
  111. package/src/tools/index.ts +8 -0
  112. package/src/utils/npm-registry.ts +660 -0
@@ -0,0 +1,921 @@
1
+ /**
2
+ * Request/response control channel for chat-daemon commands that must be
3
+ * executed *by* the running owner instead of terminating it.
4
+ *
5
+ * `control.json` is the lifecycle channel: every request the owner recognizes
6
+ * there ends its serving loop (stop/reload). Adopting an existing Slack root
7
+ * must not stop a healthy daemon, so operator commands travel on this separate
8
+ * per-request channel and are answered in place.
9
+ *
10
+ * Invariants:
11
+ * - Every request is addressed to an exact owner (`ownerId`, `pid`,
12
+ * `incarnation`, daemon `generation`). A daemon that is not that exact owner
13
+ * answers `owner_changed` and performs no work, so a replaced or restarted
14
+ * owner can never satisfy a request captured against its predecessor. The
15
+ * same tuple is re-proven inside the commit fence and echoed in the answer,
16
+ * so the submitter can verify who actually acted.
17
+ * - `<id>.response.json` is a single-winner arbitration object created with
18
+ * `O_CREAT|O_EXCL`. The serving daemon must win it *before* it commits, and a
19
+ * submitter that has given up must win it before reporting failure. Whoever
20
+ * loses learns so definitively, which removes the commit-vs-timeout race
21
+ * without any sleep: a definitive `cancelled` answer proves no dispatch can
22
+ * follow within this submission's race, and a lost cancellation is reported
23
+ * as `unknown` rather than as a failure the caller could act on. The
24
+ * arbitration is scoped to that race under the trust boundary below; it is
25
+ * not durable settlement across a crashed and resurrected daemon.
26
+ * - Documents live in an owner-only (`0700`) directory below the agent
27
+ * directory, are bounded, and are rejected unless they are ordinary regular
28
+ * files. This matches the product's existing same-UID local trust boundary;
29
+ * it is not a defence against a hostile process running as the same user.
30
+ * - Only identifiers travel through the channel. Tokens, message bodies, and
31
+ * control secrets are never written here.
32
+ *
33
+ * The channel proves *correlation*, never authorship: every field a response
34
+ * echoes is copied verbatim out of the plaintext request published beside it,
35
+ * so a definitive answer is only actionable once the caller corroborates the
36
+ * mutation against an authority outside this directory.
37
+ */
38
+
39
+ import * as crypto from "node:crypto";
40
+ import * as fs from "node:fs/promises";
41
+ import * as path from "node:path";
42
+ import { isEnoent } from "@gajae-code/utils";
43
+ import { type ChatDaemonKind, chatDaemonPaths } from "./chat-daemon-control";
44
+
45
+ export const CHAT_DAEMON_COMMAND_VERSION = 1;
46
+ export const DEFAULT_CHAT_DAEMON_COMMAND_TIMEOUT_MS = 15_000;
47
+ const DEFAULT_POLL_INTERVAL_MS = 25;
48
+ /** How long a completed response and an expired request stay readable before sweeping. */
49
+ const COMMAND_RETENTION_MS = 60_000;
50
+ const DEFAULT_REQUEST_TTL_MS = 30_000;
51
+ /** Bounded wait for a daemon that won the response claim before the submitter gave up. */
52
+ const DEFAULT_SETTLE_GRACE_MS = 5_000;
53
+ const REQUEST_SUFFIX = ".request.json";
54
+ const RESPONSE_SUFFIX = ".response.json";
55
+ const REQUEST_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
56
+ const MAX_COMMAND_FIELD_LENGTH = 256;
57
+ /** Command documents are identifiers only; anything larger is not ours. */
58
+ const MAX_COMMAND_ENTRY_BYTES = 8 * 1024;
59
+ const OWNER_ONLY_DIRECTORY_MODE = 0o700;
60
+ const OWNER_ONLY_FILE_MODE = 0o600;
61
+
62
+ export type ChatDaemonCommandName = "bind-thread";
63
+ export type ChatDaemonCommandStatus = "ok" | "rejected" | "owner_changed" | "expired" | "outcome_unknown";
64
+
65
+ /** Exact owner authority a request is addressed to. */
66
+ export interface ChatDaemonCommandOwner {
67
+ ownerId: string;
68
+ pid: number;
69
+ incarnation: string;
70
+ generation: number;
71
+ }
72
+
73
+ export interface ChatDaemonCommandRequest extends ChatDaemonCommandOwner {
74
+ version: typeof CHAT_DAEMON_COMMAND_VERSION;
75
+ requestId: string;
76
+ kind: ChatDaemonKind;
77
+ command: ChatDaemonCommandName;
78
+ sessionId: string;
79
+ rootTs: string;
80
+ createdAt: number;
81
+ expiresAt: number;
82
+ }
83
+
84
+ /**
85
+ * The answer to exactly one request.
86
+ *
87
+ * The owner tuple, session, and root are the *addressed* request's, echoed
88
+ * verbatim. A response therefore carries the complete request envelope, and a
89
+ * submitter can prove that the document in front of it is correlated with its
90
+ * own request rather than with a replayed or concurrent one.
91
+ *
92
+ * That is correlation, not authentication. Every echoed field is public: it is
93
+ * copied out of the plaintext request published in the same command directory,
94
+ * so a stale or planted document can satisfy the envelope without the addressed
95
+ * daemon ever running. A caller that needs a definitive outcome must corroborate
96
+ * it against an authority outside this directory (the durable mutation itself).
97
+ */
98
+ export interface ChatDaemonCommandResponse extends ChatDaemonCommandOwner {
99
+ version: typeof CHAT_DAEMON_COMMAND_VERSION;
100
+ requestId: string;
101
+ kind: ChatDaemonKind;
102
+ command: ChatDaemonCommandName;
103
+ sessionId: string;
104
+ rootTs: string;
105
+ status: ChatDaemonCommandStatus;
106
+ /** Machine-readable rejection category; never a message body or credential. */
107
+ code?: string;
108
+ endpointGeneration?: number;
109
+ teamId?: string;
110
+ channelId?: string;
111
+ completedAt: number;
112
+ }
113
+
114
+ /**
115
+ * Result of one handler dispatch, carrying explicit commit certainty.
116
+ *
117
+ * A failure is never just a code. `rejected` asserts that no mapping changed, so
118
+ * the caller may be told the binding definitively failed. `unknown` asserts the
119
+ * opposite: commit authority was exercised and the mapping may already be
120
+ * applied, so no definitive rejection may be reported for it. Handlers state
121
+ * this directly instead of leaving the channel to infer it from error text.
122
+ */
123
+ export type ChatDaemonCommandOutcome =
124
+ | {
125
+ ok: true;
126
+ sessionId: string;
127
+ endpointGeneration: number;
128
+ teamId: string;
129
+ channelId: string;
130
+ rootTs: string;
131
+ }
132
+ | { ok: false; certainty: "rejected"; code: string }
133
+ | { ok: false; certainty: "unknown"; code: string };
134
+
135
+ export interface ChatDaemonCommandBindInput {
136
+ sessionId: string;
137
+ rootTs: string;
138
+ /**
139
+ * Terminal authority for this exact request. It must be awaited inside the
140
+ * store fence, immediately before the commit, and the commit must be
141
+ * abandoned when it answers `false`. It re-proves the exact daemon owner
142
+ * tuple, that the request is still published and unexpired, and takes the
143
+ * single-winner response claim, after which no cancellation can succeed.
144
+ */
145
+ commitAuthority?: () => Promise<boolean>;
146
+ }
147
+
148
+ /** Daemon-side executor. Implemented by the runtime that owns the live transports. */
149
+ export interface ChatDaemonCommandHandler {
150
+ bindExistingRoot(input: ChatDaemonCommandBindInput): Promise<ChatDaemonCommandOutcome>;
151
+ }
152
+
153
+ function requestEntry(requestId: string): string {
154
+ return `${requestId}${REQUEST_SUFFIX}`;
155
+ }
156
+
157
+ function responseEntry(requestId: string): string {
158
+ return `${requestId}${RESPONSE_SUFFIX}`;
159
+ }
160
+
161
+ function isRecord(value: unknown): value is Record<string, unknown> {
162
+ return typeof value === "object" && value !== null && !Array.isArray(value);
163
+ }
164
+
165
+ function boundedString(value: unknown): value is string {
166
+ return typeof value === "string" && value.length > 0 && value.length <= MAX_COMMAND_FIELD_LENGTH;
167
+ }
168
+
169
+ function positiveInteger(value: unknown): value is number {
170
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
171
+ }
172
+
173
+ export function isChatDaemonCommandRequest(value: unknown): value is ChatDaemonCommandRequest {
174
+ if (!isRecord(value)) return false;
175
+ return (
176
+ value.version === CHAT_DAEMON_COMMAND_VERSION &&
177
+ typeof value.requestId === "string" &&
178
+ REQUEST_ID_PATTERN.test(value.requestId) &&
179
+ (value.kind === "discord" || value.kind === "slack") &&
180
+ value.command === "bind-thread" &&
181
+ boundedString(value.ownerId) &&
182
+ positiveInteger(value.pid) &&
183
+ boundedString(value.incarnation) &&
184
+ typeof value.generation === "number" &&
185
+ Number.isSafeInteger(value.generation) &&
186
+ value.generation >= 0 &&
187
+ boundedString(value.sessionId) &&
188
+ boundedString(value.rootTs) &&
189
+ typeof value.createdAt === "number" &&
190
+ Number.isFinite(value.createdAt) &&
191
+ typeof value.expiresAt === "number" &&
192
+ Number.isFinite(value.expiresAt)
193
+ );
194
+ }
195
+
196
+ export function isChatDaemonCommandResponse(value: unknown): value is ChatDaemonCommandResponse {
197
+ if (!isRecord(value)) return false;
198
+ return (
199
+ value.version === CHAT_DAEMON_COMMAND_VERSION &&
200
+ typeof value.requestId === "string" &&
201
+ REQUEST_ID_PATTERN.test(value.requestId) &&
202
+ (value.kind === "discord" || value.kind === "slack") &&
203
+ value.command === "bind-thread" &&
204
+ boundedString(value.ownerId) &&
205
+ positiveInteger(value.pid) &&
206
+ boundedString(value.incarnation) &&
207
+ typeof value.generation === "number" &&
208
+ Number.isSafeInteger(value.generation) &&
209
+ (value.status === "ok" ||
210
+ value.status === "rejected" ||
211
+ value.status === "owner_changed" ||
212
+ value.status === "expired" ||
213
+ value.status === "outcome_unknown") &&
214
+ (value.code === undefined || boundedString(value.code)) &&
215
+ boundedString(value.sessionId) &&
216
+ boundedString(value.rootTs) &&
217
+ (value.endpointGeneration === undefined || positiveInteger(value.endpointGeneration)) &&
218
+ (value.teamId === undefined || boundedString(value.teamId)) &&
219
+ (value.channelId === undefined || boundedString(value.channelId)) &&
220
+ typeof value.completedAt === "number" &&
221
+ Number.isFinite(value.completedAt)
222
+ );
223
+ }
224
+
225
+ /** Two request documents describe the same authorization only when every field agrees. */
226
+ function isSameChatDaemonCommandRequest(left: ChatDaemonCommandRequest, right: ChatDaemonCommandRequest): boolean {
227
+ return (
228
+ left.version === right.version &&
229
+ left.requestId === right.requestId &&
230
+ left.kind === right.kind &&
231
+ left.command === right.command &&
232
+ left.ownerId === right.ownerId &&
233
+ left.pid === right.pid &&
234
+ left.incarnation === right.incarnation &&
235
+ left.generation === right.generation &&
236
+ left.sessionId === right.sessionId &&
237
+ left.rootTs === right.rootTs &&
238
+ left.createdAt === right.createdAt &&
239
+ left.expiresAt === right.expiresAt
240
+ );
241
+ }
242
+
243
+ /**
244
+ * A response may influence caller behaviour only when its complete envelope is
245
+ * the request's own.
246
+ *
247
+ * This runs *before* any status is interpreted. A document that agrees on the
248
+ * request id but disagrees on the addressed daemon tuple, the command, or the
249
+ * session/root binding is stale or planted material: it is neither a success
250
+ * nor a trusted business rejection, and it is never allowed to settle the
251
+ * submission as an answer.
252
+ */
253
+ function answersRequest(response: ChatDaemonCommandResponse, request: ChatDaemonCommandRequest): boolean {
254
+ return (
255
+ response.version === request.version &&
256
+ response.requestId === request.requestId &&
257
+ response.kind === request.kind &&
258
+ response.command === request.command &&
259
+ response.ownerId === request.ownerId &&
260
+ response.pid === request.pid &&
261
+ response.incarnation === request.incarnation &&
262
+ response.generation === request.generation &&
263
+ response.sessionId === request.sessionId &&
264
+ response.rootTs === request.rootTs
265
+ );
266
+ }
267
+
268
+ /** Entry names are built from validated request ids; reject anything that could traverse. */
269
+ function assertSafeEntryName(name: string): string {
270
+ if (
271
+ name.length === 0 ||
272
+ name.length > 128 ||
273
+ name.includes("/") ||
274
+ name.includes("\\") ||
275
+ name.includes("\0") ||
276
+ name === "." ||
277
+ name === ".."
278
+ )
279
+ throw new Error("chat daemon command entry name is not addressable");
280
+ return name;
281
+ }
282
+
283
+ /**
284
+ * The owner-only directory the channel exchanges its documents in.
285
+ *
286
+ * `directory` is a plain pathname: this codebase already trusts same-UID local
287
+ * processes, and every document below it is bounded and validated, so the
288
+ * channel does not attempt hostile-filesystem hardening it cannot honestly
289
+ * provide from TypeScript.
290
+ */
291
+ export interface ChatDaemonCommandScope {
292
+ readonly directory: string;
293
+ }
294
+
295
+ export interface OpenChatDaemonCommandScopeInput {
296
+ agentDir: string;
297
+ kind: ChatDaemonKind;
298
+ /** Creates the daemon and command directories owner-only when absent. */
299
+ create?: boolean;
300
+ }
301
+
302
+ /** Capture the command directory, or report the channel unusable. */
303
+ export async function openChatDaemonCommandScope(
304
+ input: OpenChatDaemonCommandScopeInput,
305
+ ): Promise<ChatDaemonCommandScope | undefined> {
306
+ const directory = path.join(chatDaemonPaths(input.agentDir, input.kind).dir, "commands");
307
+ try {
308
+ if (input.create === true) {
309
+ await fs.mkdir(directory, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
310
+ await fs.chmod(directory, OWNER_ONLY_DIRECTORY_MODE);
311
+ }
312
+ const stats = await fs.lstat(directory);
313
+ if (!stats.isDirectory()) return undefined;
314
+ return { directory };
315
+ } catch {
316
+ return undefined;
317
+ }
318
+ }
319
+
320
+ function entryPath(scope: ChatDaemonCommandScope, name: string): string {
321
+ return path.join(scope.directory, assertSafeEntryName(name));
322
+ }
323
+
324
+ async function listScopedEntries(scope: ChatDaemonCommandScope): Promise<string[]> {
325
+ try {
326
+ return await fs.readdir(scope.directory);
327
+ } catch {
328
+ return [];
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Read one bounded regular-file document.
334
+ *
335
+ * A symlink, directory, FIFO, device, or oversized entry is not a command
336
+ * document and never becomes one: it is reported as absent rather than opened.
337
+ */
338
+ async function readScopedJson(scope: ChatDaemonCommandScope, name: string): Promise<unknown> {
339
+ const file = entryPath(scope, name);
340
+ try {
341
+ const stats = await fs.lstat(file);
342
+ if (!stats.isFile() || stats.size > MAX_COMMAND_ENTRY_BYTES) return undefined;
343
+ const text = await fs.readFile(file, "utf8");
344
+ if (text.length === 0) return undefined;
345
+ return JSON.parse(text);
346
+ } catch {
347
+ return undefined;
348
+ }
349
+ }
350
+
351
+ /** Age of an entry by its own (non-dereferenced) mtime, used only for retention sweeps. */
352
+ async function scopedEntryAgeMs(scope: ChatDaemonCommandScope, name: string, now: number): Promise<number | undefined> {
353
+ try {
354
+ return now - (await fs.lstat(entryPath(scope, name))).mtimeMs;
355
+ } catch {
356
+ return undefined;
357
+ }
358
+ }
359
+
360
+ async function scopedEntryExists(scope: ChatDaemonCommandScope, name: string): Promise<boolean> {
361
+ try {
362
+ await fs.lstat(entryPath(scope, name));
363
+ return true;
364
+ } catch (error) {
365
+ // An entry that cannot be classified is treated as present so nothing
366
+ // downstream proceeds on an unproven path.
367
+ return (error as NodeJS.ErrnoException).code !== "ENOENT";
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Create an entry exclusively.
373
+ *
374
+ * `O_CREAT|O_EXCL` is the arbitration primitive of this channel: exactly one of
375
+ * the submitter and the serving daemon can create a given response object, and
376
+ * the loser learns it definitively through `exists`.
377
+ */
378
+ async function claimScopedEntry(scope: ChatDaemonCommandScope, name: string): Promise<boolean> {
379
+ try {
380
+ const handle = await fs.open(entryPath(scope, name), "wx", OWNER_ONLY_FILE_MODE);
381
+ await handle.close();
382
+ return true;
383
+ } catch (error) {
384
+ const code = (error as NodeJS.ErrnoException).code;
385
+ if (code === "EEXIST") return false;
386
+ throw new Error(`chat daemon command entry could not be claimed (${code ?? "unknown"})`);
387
+ }
388
+ }
389
+
390
+ async function writeTemporary(scope: ChatDaemonCommandScope, value: unknown): Promise<string> {
391
+ const temporary = path.join(scope.directory, `${process.pid}.${crypto.randomUUID()}.tmp`);
392
+ const text = `${JSON.stringify(value)}\n`;
393
+ if (new TextEncoder().encode(text).byteLength > MAX_COMMAND_ENTRY_BYTES)
394
+ throw new Error("chat daemon command document is not bounded");
395
+ await fs.writeFile(temporary, text, { mode: OWNER_ONLY_FILE_MODE, flag: "wx" });
396
+ return temporary;
397
+ }
398
+
399
+ /** Replace an entry atomically. The document is complete before it is ever visible. */
400
+ async function writeScopedJson(scope: ChatDaemonCommandScope, name: string, value: unknown): Promise<void> {
401
+ const target = entryPath(scope, name);
402
+ const temporary = await writeTemporary(scope, value);
403
+ try {
404
+ await fs.rename(temporary, target);
405
+ } catch (error) {
406
+ await fs.unlink(temporary).catch(() => undefined);
407
+ throw error;
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Publish an entry that must not already exist.
413
+ *
414
+ * `link()` from a fully written temporary makes the document atomically visible
415
+ * under its final name and fails `exists` when the name is taken, so a replayed
416
+ * or planted identifier can never be silently overwritten.
417
+ */
418
+ async function publishScopedJsonExclusive(
419
+ scope: ChatDaemonCommandScope,
420
+ name: string,
421
+ value: unknown,
422
+ ): Promise<"published" | "exists"> {
423
+ const target = entryPath(scope, name);
424
+ const temporary = await writeTemporary(scope, value);
425
+ try {
426
+ await fs.link(temporary, target);
427
+ return "published";
428
+ } catch (error) {
429
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return "exists";
430
+ throw error;
431
+ } finally {
432
+ await fs.unlink(temporary).catch(() => undefined);
433
+ }
434
+ }
435
+
436
+ /** Returns whether the entry is gone: unlinked now, or already absent. */
437
+ async function unlinkScopedEntry(scope: ChatDaemonCommandScope, name: string): Promise<boolean> {
438
+ try {
439
+ await fs.unlink(entryPath(scope, name));
440
+ return true;
441
+ } catch (error) {
442
+ return isEnoent(error);
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Single-winner terminal authority for one request.
448
+ *
449
+ * The claim is the exclusive creation of `<id>.response.json`. Acquiring it is
450
+ * idempotent within a serve, and losing it is permanent for that request: the
451
+ * submitter cancelled, so nothing may be committed or published afterwards.
452
+ *
453
+ * A holder that then discovers it must not act releases the claim instead of
454
+ * publishing, so an abandoned command leaves no orphan behind.
455
+ */
456
+ class ChatDaemonResponseClaim {
457
+ #scope: ChatDaemonCommandScope;
458
+ #entry: string;
459
+ #state: "unclaimed" | "held" | "lost" = "unclaimed";
460
+
461
+ constructor(scope: ChatDaemonCommandScope, requestId: string) {
462
+ this.#scope = scope;
463
+ this.#entry = responseEntry(requestId);
464
+ }
465
+
466
+ get held(): boolean {
467
+ return this.#state === "held";
468
+ }
469
+
470
+ async acquire(): Promise<boolean> {
471
+ if (this.#state !== "unclaimed") return this.#state === "held";
472
+ try {
473
+ this.#state = (await claimScopedEntry(this.#scope, this.#entry)) ? "held" : "lost";
474
+ } catch {
475
+ this.#state = "lost";
476
+ }
477
+ return this.#state === "held";
478
+ }
479
+
480
+ async release(): Promise<void> {
481
+ if (this.#state !== "held") return;
482
+ this.#state = "lost";
483
+ await unlinkScopedEntry(this.#scope, this.#entry);
484
+ }
485
+
486
+ async publish(response: ChatDaemonCommandResponse): Promise<void> {
487
+ if (!(await this.acquire())) return;
488
+ await writeScopedJson(this.#scope, this.#entry, response);
489
+ }
490
+ }
491
+
492
+ /** Build a request addressed to one exact owner. */
493
+ export function buildChatDaemonCommandRequest(input: {
494
+ kind: ChatDaemonKind;
495
+ command: ChatDaemonCommandName;
496
+ owner: ChatDaemonCommandOwner;
497
+ sessionId: string;
498
+ rootTs: string;
499
+ now?: number;
500
+ ttlMs?: number;
501
+ requestId?: string;
502
+ }): ChatDaemonCommandRequest {
503
+ const createdAt = input.now ?? Date.now();
504
+ return {
505
+ version: CHAT_DAEMON_COMMAND_VERSION,
506
+ requestId: input.requestId ?? crypto.randomUUID(),
507
+ kind: input.kind,
508
+ command: input.command,
509
+ ownerId: input.owner.ownerId,
510
+ pid: input.owner.pid,
511
+ incarnation: input.owner.incarnation,
512
+ generation: input.owner.generation,
513
+ sessionId: input.sessionId,
514
+ rootTs: input.rootTs,
515
+ createdAt,
516
+ expiresAt: createdAt + (input.ttlMs ?? DEFAULT_REQUEST_TTL_MS),
517
+ };
518
+ }
519
+
520
+ export interface SubmitChatDaemonCommandInput {
521
+ agentDir: string;
522
+ kind: ChatDaemonKind;
523
+ owner: ChatDaemonCommandOwner;
524
+ command: ChatDaemonCommandName;
525
+ sessionId: string;
526
+ rootTs: string;
527
+ timeoutMs?: number;
528
+ pollIntervalMs?: number;
529
+ /** Bounded wait after a lost cancellation, before the outcome is reported unknown. */
530
+ settleGraceMs?: number;
531
+ ttlMs?: number;
532
+ now?: () => number;
533
+ sleep?: (ms: number) => Promise<void>;
534
+ requestId?: string;
535
+ }
536
+
537
+ /**
538
+ * Terminal result of one submission.
539
+ *
540
+ * `cancelled` is the only definitive failure: the submitter won the response
541
+ * claim, so the addressed owner cannot dispatch this request from here on within
542
+ * this submission's race. `unknown` means the daemon won that claim first and its
543
+ * answer was not observed; the caller must not report a definitive failure,
544
+ * because a commit may already be applied.
545
+ *
546
+ * `untrusted` means the response object under this identifier is a complete,
547
+ * well-formed document that does not carry this request's envelope. It is
548
+ * channel corruption — stale or planted — so it may not be read as an answer of
549
+ * any status, and because the identifier is now occupied the real outcome is
550
+ * unknowable from here; the caller must treat it exactly like `unknown`.
551
+ */
552
+ export type ChatDaemonCommandSubmission =
553
+ | { outcome: "answered"; response: ChatDaemonCommandResponse }
554
+ | { outcome: "cancelled" }
555
+ | { outcome: "unknown" }
556
+ | { outcome: "untrusted"; code: "response_envelope_mismatch" }
557
+ | { outcome: "unavailable"; code: "command_channel_unavailable" | "request_id_unavailable" };
558
+
559
+ /**
560
+ * Publish a command for the addressed owner and wait for its answer.
561
+ *
562
+ * The wait ends in exactly one of four states, and the commit-vs-timeout race is
563
+ * arbitrated by the exclusive creation of the response object rather than by any
564
+ * timing assumption.
565
+ */
566
+ export async function submitChatDaemonCommand(
567
+ input: SubmitChatDaemonCommandInput,
568
+ ): Promise<ChatDaemonCommandSubmission> {
569
+ const scope = await openChatDaemonCommandScope({ agentDir: input.agentDir, kind: input.kind, create: true });
570
+ if (!scope) return { outcome: "unavailable", code: "command_channel_unavailable" };
571
+ return await submitAgainstScope(scope, input);
572
+ }
573
+
574
+ async function submitAgainstScope(
575
+ scope: ChatDaemonCommandScope,
576
+ input: SubmitChatDaemonCommandInput,
577
+ ): Promise<ChatDaemonCommandSubmission> {
578
+ const now = input.now ?? Date.now;
579
+ const sleep = input.sleep ?? (async ms => await Bun.sleep(ms));
580
+ const timeoutMs = Math.max(input.timeoutMs ?? DEFAULT_CHAT_DAEMON_COMMAND_TIMEOUT_MS, 0);
581
+ const pollIntervalMs = Math.max(input.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, 1);
582
+ const settleGraceMs = Math.max(input.settleGraceMs ?? DEFAULT_SETTLE_GRACE_MS, 0);
583
+ const request = buildChatDaemonCommandRequest({
584
+ kind: input.kind,
585
+ command: input.command,
586
+ owner: input.owner,
587
+ sessionId: input.sessionId,
588
+ rootTs: input.rootTs,
589
+ now: now(),
590
+ ttlMs: input.ttlMs ?? Math.max(timeoutMs * 2, DEFAULT_REQUEST_TTL_MS),
591
+ requestId: input.requestId,
592
+ });
593
+ const requestName = requestEntry(request.requestId);
594
+ const responseName = responseEntry(request.requestId);
595
+ // Stale or planted material under this identifier must neither authorize an
596
+ // answer nor silently suppress the command: the submission fails closed and
597
+ // the caller re-runs with a fresh identifier.
598
+ if (await scopedEntryExists(scope, responseName)) return { outcome: "unavailable", code: "request_id_unavailable" };
599
+ let published: "published" | "exists";
600
+ try {
601
+ published = await publishScopedJsonExclusive(scope, requestName, request);
602
+ } catch {
603
+ return { outcome: "unavailable", code: "command_channel_unavailable" };
604
+ }
605
+ if (published === "exists") return { outcome: "unavailable", code: "request_id_unavailable" };
606
+ try {
607
+ const answered = await awaitChatDaemonCommandResponse({
608
+ scope,
609
+ request,
610
+ responseName,
611
+ deadline: now() + timeoutMs,
612
+ now,
613
+ sleep,
614
+ pollIntervalMs,
615
+ });
616
+ if (answered.kind === "untrusted") return { outcome: "untrusted", code: "response_envelope_mismatch" };
617
+ if (answered.kind === "answer") return { outcome: "answered", response: answered.response };
618
+ // Winning the response claim makes a dispatch by the addressed owner
619
+ // impossible for the rest of this submission's race, which is what turns a
620
+ // timeout into a definitive, mutation-free failure without a sleep.
621
+ if (await claimScopedEntry(scope, responseName)) return { outcome: "cancelled" };
622
+ const settled = await awaitChatDaemonCommandResponse({
623
+ scope,
624
+ request,
625
+ responseName,
626
+ deadline: now() + settleGraceMs,
627
+ now,
628
+ sleep,
629
+ pollIntervalMs,
630
+ });
631
+ if (settled.kind === "untrusted") return { outcome: "untrusted", code: "response_envelope_mismatch" };
632
+ return settled.kind === "answer" ? { outcome: "answered", response: settled.response } : { outcome: "unknown" };
633
+ } finally {
634
+ // Order matters: the request is retired before the settled response
635
+ // object. A daemon that re-creates a removed response claim then proves
636
+ // the request is gone and abandons, so no *later dispatch* can follow a
637
+ // settled submission.
638
+ //
639
+ // The scope of that guarantee is exactly this submission's race against a
640
+ // concurrent serve, under the same-UID trusted-pathname model documented
641
+ // at the top of this module. It is not durable settlement: it says nothing
642
+ // about a daemon that crashed mid-commit and is resurrected against
643
+ // replayed material, and nothing about a same-UID process that rewrites
644
+ // these documents. Both are out of scope here, and neither is what a
645
+ // caller may rely on for commit certainty — that comes only from
646
+ // corroborating the mutation itself.
647
+ // The response claim is what fences an already-settled request: a serve
648
+ // that finds no response entry re-claims the request and commits it. So the
649
+ // claim may only be released once the request is *proven* gone. When the
650
+ // request unlink fails the claim is deliberately left behind — a leaked
651
+ // response object is inert, whereas releasing it would let a submission the
652
+ // caller was told was `cancelled` commit later.
653
+ if (await unlinkScopedEntry(scope, requestName)) await unlinkScopedEntry(scope, responseName);
654
+ }
655
+ }
656
+
657
+ /** What one poll of the response object proved: an answer, corruption, or nothing yet. */
658
+ type ChatDaemonResponseObservation =
659
+ | { kind: "answer"; response: ChatDaemonCommandResponse }
660
+ | { kind: "untrusted" }
661
+ | { kind: "pending" };
662
+
663
+ async function awaitChatDaemonCommandResponse(input: {
664
+ scope: ChatDaemonCommandScope;
665
+ request: ChatDaemonCommandRequest;
666
+ responseName: string;
667
+ deadline: number;
668
+ now: () => number;
669
+ sleep: (ms: number) => Promise<void>;
670
+ pollIntervalMs: number;
671
+ }): Promise<ChatDaemonResponseObservation> {
672
+ for (;;) {
673
+ const document = await readScopedJson(input.scope, input.responseName);
674
+ if (document !== undefined) {
675
+ // An empty or unclassifiable entry is the in-flight claim placeholder and
676
+ // must keep the caller waiting. Any *complete* document is terminal
677
+ // material: either it is this request's exact answer, or it is stale or
678
+ // planted content that can never become one, and waiting for the deadline
679
+ // would only delay a fail-closed report.
680
+ if (!isChatDaemonCommandResponse(document) || !answersRequest(document, input.request))
681
+ return { kind: "untrusted" };
682
+ return { kind: "answer", response: document };
683
+ }
684
+ if (input.now() >= input.deadline) return { kind: "pending" };
685
+ await input.sleep(input.pollIntervalMs);
686
+ }
687
+ }
688
+
689
+ export interface ServeChatDaemonCommandsInput {
690
+ agentDir: string;
691
+ kind: ChatDaemonKind;
692
+ /** The serving daemon's own proven authority. */
693
+ ownerId: string;
694
+ pid: number;
695
+ incarnation: string;
696
+ generation: number;
697
+ handler: ChatDaemonCommandHandler;
698
+ /**
699
+ * Re-proves that this process still holds the persisted owner record. It is
700
+ * checked before any work is dispatched *and again* inside the commit fence,
701
+ * so a daemon that lost ownership after dispatch mutates nothing.
702
+ */
703
+ verifyOwnership?: () => Promise<boolean>;
704
+ now?: () => number;
705
+ }
706
+
707
+ /**
708
+ * Answer every pending request addressed to this exact owner. Requests aimed at
709
+ * any other owner identity are answered `owner_changed` without doing work, and
710
+ * expired requests are answered `expired`; neither performs a mutation.
711
+ */
712
+ export async function serveChatDaemonCommandsOnce(input: ServeChatDaemonCommandsInput): Promise<number> {
713
+ const scope = await openChatDaemonCommandScope({ agentDir: input.agentDir, kind: input.kind });
714
+ if (!scope) return 0;
715
+ return await serveChatDaemonCommandsAgainstScope(scope, input);
716
+ }
717
+
718
+ /**
719
+ * Answer every pending request against an already-captured command scope.
720
+ *
721
+ * Exposed so a caller that already holds the directory authority drives exactly
722
+ * the production path.
723
+ */
724
+ export async function serveChatDaemonCommandsAgainstScope(
725
+ scope: ChatDaemonCommandScope,
726
+ input: ServeChatDaemonCommandsInput,
727
+ ): Promise<number> {
728
+ const now = input.now ?? Date.now;
729
+ let served = 0;
730
+ for (const name of await listScopedEntries(scope)) {
731
+ if (name.endsWith(RESPONSE_SUFFIX)) {
732
+ await sweepCompletedResponse(scope, name, now());
733
+ continue;
734
+ }
735
+ if (!name.endsWith(REQUEST_SUFFIX)) continue;
736
+ const requestId = name.slice(0, -REQUEST_SUFFIX.length);
737
+ if (!REQUEST_ID_PATTERN.test(requestId)) continue;
738
+ const request = await readScopedJson(scope, name);
739
+ if (!isChatDaemonCommandRequest(request) || request.requestId !== requestId || request.kind !== input.kind) {
740
+ await unlinkScopedEntry(scope, name);
741
+ continue;
742
+ }
743
+ // A response object already exists for this identifier: the request is a
744
+ // replay of settled material, the submitter cancelled, or a stale/planted
745
+ // answer occupies the name. Either way this request may never authorize
746
+ // work, and a definitive answer for it was already reported elsewhere.
747
+ if (await scopedEntryExists(scope, responseEntry(requestId))) {
748
+ if (request.expiresAt + COMMAND_RETENTION_MS <= now()) await unlinkScopedEntry(scope, name);
749
+ continue;
750
+ }
751
+ const claim = new ChatDaemonResponseClaim(scope, requestId);
752
+ const answered = await answerChatDaemonCommand({ request, input, scope, claim, now: now() });
753
+ await claim.publish(answered);
754
+ await unlinkScopedEntry(scope, name);
755
+ served++;
756
+ }
757
+ return served;
758
+ }
759
+
760
+ /** Why the commit fence refused, so the answer names the real authority failure. */
761
+ type CommitFenceFailure = "owner_changed" | "cancelled" | "abandoned";
762
+
763
+ async function answerChatDaemonCommand(context: {
764
+ request: ChatDaemonCommandRequest;
765
+ input: ServeChatDaemonCommandsInput;
766
+ scope: ChatDaemonCommandScope;
767
+ claim: ChatDaemonResponseClaim;
768
+ now: number;
769
+ }): Promise<ChatDaemonCommandResponse> {
770
+ const { request, input, scope, claim } = context;
771
+ const now = input.now ?? Date.now;
772
+ // Every answer echoes the *addressed* request envelope, not the responding
773
+ // process's own identity. That is what lets a submitter validate the complete
774
+ // tuple before it interprets any status: an `owner_changed` answer published
775
+ // by a replacement daemon is still provably the answer to this request, while
776
+ // a document carrying some other tuple is provably not.
777
+ const envelope = {
778
+ version: CHAT_DAEMON_COMMAND_VERSION,
779
+ requestId: request.requestId,
780
+ kind: request.kind,
781
+ command: request.command,
782
+ ownerId: request.ownerId,
783
+ pid: request.pid,
784
+ incarnation: request.incarnation,
785
+ generation: request.generation,
786
+ sessionId: request.sessionId,
787
+ rootTs: request.rootTs,
788
+ completedAt: context.now,
789
+ } as const;
790
+ if (
791
+ request.ownerId !== input.ownerId ||
792
+ request.pid !== input.pid ||
793
+ request.incarnation !== input.incarnation ||
794
+ request.generation !== input.generation
795
+ )
796
+ return { ...envelope, status: "owner_changed" };
797
+ if (request.expiresAt <= context.now) return { ...envelope, status: "expired" };
798
+ // Ownership can lapse between capture and execution. Re-prove it here so a
799
+ // daemon that no longer holds the owner record performs no mutation.
800
+ if (input.verifyOwnership && !(await input.verifyOwnership())) return { ...envelope, status: "owner_changed" };
801
+ let fenceFailure: CommitFenceFailure | undefined;
802
+ /**
803
+ * The commit fence. It runs inside the caller's store lock, immediately
804
+ * before the mutation, and every check is local: the persisted owner record,
805
+ * the single-winner claim, then the still-published request material.
806
+ *
807
+ * The claim is taken *before* the request is re-read on purpose. A submitter
808
+ * that gives up claims the response, then removes its request and its own
809
+ * claim; taking the claim first and only then proving the request is still
810
+ * published closes the window in which a daemon could re-create the removed
811
+ * claim and commit behind a caller that already reported cancellation.
812
+ */
813
+ const commitAuthority = async (): Promise<boolean> => {
814
+ if (input.verifyOwnership && !(await input.verifyOwnership())) {
815
+ fenceFailure = "owner_changed";
816
+ return false;
817
+ }
818
+ if (!(await claim.acquire())) {
819
+ fenceFailure = "cancelled";
820
+ return false;
821
+ }
822
+ const current = await readScopedJson(scope, requestEntry(request.requestId));
823
+ if (
824
+ !isChatDaemonCommandRequest(current) ||
825
+ !isSameChatDaemonCommandRequest(current, request) ||
826
+ current.expiresAt <= now()
827
+ ) {
828
+ fenceFailure = "abandoned";
829
+ return false;
830
+ }
831
+ return true;
832
+ };
833
+ const answer = await dispatchChatDaemonCommand({
834
+ input,
835
+ request,
836
+ envelope,
837
+ commitAuthority,
838
+ claim,
839
+ fenceFailure: () => fenceFailure,
840
+ });
841
+ // An abandoned request has no reader left: its submitter already settled.
842
+ // Release the claim rather than publishing an answer nobody can consume.
843
+ if (fenceFailure === "abandoned") await claim.release();
844
+ return answer;
845
+ }
846
+
847
+ async function dispatchChatDaemonCommand(context: {
848
+ input: ServeChatDaemonCommandsInput;
849
+ request: ChatDaemonCommandRequest;
850
+ envelope: Omit<ChatDaemonCommandResponse, "status">;
851
+ commitAuthority: () => Promise<boolean>;
852
+ claim: ChatDaemonResponseClaim;
853
+ fenceFailure: () => CommitFenceFailure | undefined;
854
+ }): Promise<ChatDaemonCommandResponse> {
855
+ const { envelope, claim } = context;
856
+ // Whether the fence ever handed out commit authority. Once it has, the
857
+ // handler may have applied a mapping, and an exception carries no evidence
858
+ // either way — so a thrown failure after that point is indeterminate, not a
859
+ // rejection the caller may act on.
860
+ let authorized = false;
861
+ const commitAuthority = async (): Promise<boolean> => {
862
+ const granted = await context.commitAuthority();
863
+ authorized = authorized || granted;
864
+ return granted;
865
+ };
866
+ let outcome: ChatDaemonCommandOutcome;
867
+ try {
868
+ outcome = await context.input.handler.bindExistingRoot({
869
+ sessionId: context.request.sessionId,
870
+ rootTs: context.request.rootTs,
871
+ commitAuthority,
872
+ });
873
+ } catch {
874
+ outcome = authorized
875
+ ? { ok: false, certainty: "unknown", code: "binding_outcome_unknown" }
876
+ : { ok: false, certainty: "rejected", code: "binding_failed" };
877
+ }
878
+ if (!outcome.ok) {
879
+ // A handler that reports an indeterminate commit outranks everything: the
880
+ // mapping may already be applied, so no definitive answer may be published.
881
+ if (outcome.certainty === "unknown") return { ...envelope, status: "outcome_unknown", code: outcome.code };
882
+ // The fence is the authority on *why* a refused commit was refused: a
883
+ // handler-level error text can never outrank a proven authority change.
884
+ const failure = context.fenceFailure();
885
+ if (failure === "owner_changed") return { ...envelope, status: "owner_changed" };
886
+ if (failure !== undefined) return { ...envelope, status: "expired" };
887
+ return { ...envelope, status: "rejected", code: outcome.code };
888
+ }
889
+ // A success is only reportable when this serve holds the terminal claim; a
890
+ // handler that never passed the fence has not proven exact commit authority.
891
+ if (!claim.held) return { ...envelope, status: "rejected", code: "commit_authority_missing" };
892
+ // The answer must describe the binding that was asked for. A handler that
893
+ // reports some other session or root has not answered this request, and
894
+ // publishing it would produce a document the submitter must reject as
895
+ // untrusted rather than a usable outcome.
896
+ if (outcome.sessionId !== context.request.sessionId || outcome.rootTs !== context.request.rootTs)
897
+ return { ...envelope, status: "rejected", code: "binding_mismatch" };
898
+ return {
899
+ ...envelope,
900
+ status: "ok",
901
+ endpointGeneration: outcome.endpointGeneration,
902
+ teamId: outcome.teamId,
903
+ channelId: outcome.channelId,
904
+ };
905
+ }
906
+
907
+ /**
908
+ * Retire one completed response once its retention window has passed.
909
+ *
910
+ * An unparseable entry can be a live claim placeholder held by an in-flight
911
+ * commit, so only age retires it; deleting it early would break arbitration.
912
+ */
913
+ async function sweepCompletedResponse(scope: ChatDaemonCommandScope, name: string, now: number): Promise<void> {
914
+ const document = await readScopedJson(scope, name);
915
+ if (isChatDaemonCommandResponse(document)) {
916
+ if (now - document.completedAt >= COMMAND_RETENTION_MS) await unlinkScopedEntry(scope, name);
917
+ return;
918
+ }
919
+ const age = await scopedEntryAgeMs(scope, name, now);
920
+ if (age !== undefined && age >= COMMAND_RETENTION_MS) await unlinkScopedEntry(scope, name);
921
+ }