@gajae-code/coding-agent 0.12.19 → 0.12.20

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 (97) hide show
  1. package/CHANGELOG.md +20 -9
  2. package/dist/types/commands/gc.d.ts +4 -0
  3. package/dist/types/commands/sdk.d.ts +66 -0
  4. package/dist/types/config/settings-schema.d.ts +34 -0
  5. package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
  6. package/dist/types/modes/acp/acp-agent.d.ts +33 -1
  7. package/dist/types/modes/components/bash-execution.d.ts +7 -0
  8. package/dist/types/modes/components/eval-execution.d.ts +7 -0
  9. package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
  10. package/dist/types/sdk/acp/adapter.d.ts +9 -17
  11. package/dist/types/sdk/acp/index.d.ts +1 -1
  12. package/dist/types/sdk/broker/broker.d.ts +93 -1
  13. package/dist/types/sdk/broker/ensure.d.ts +17 -0
  14. package/dist/types/sdk/broker/index.d.ts +1 -0
  15. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
  16. package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
  17. package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
  18. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  19. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
  20. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +20 -9
  22. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
  23. package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
  24. package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
  25. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  26. package/dist/types/sdk/lifecycle-session.d.ts +13 -2
  27. package/dist/types/sdk/prompt-failure.d.ts +13 -0
  28. package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
  29. package/dist/types/sdk/session-reconnect.d.ts +20 -0
  30. package/dist/types/sdk/startup-capability.d.ts +1 -1
  31. package/dist/types/session/agent-session.d.ts +14 -4
  32. package/dist/types/session/blob-store.d.ts +32 -0
  33. package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
  34. package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
  35. package/dist/types/session/session-manager.d.ts +301 -27
  36. package/dist/types/session/session-storage.d.ts +170 -0
  37. package/dist/types/task/executor.d.ts +2 -1
  38. package/dist/types/tools/bash.d.ts +1 -0
  39. package/dist/types/tui/output-block.d.ts +6 -0
  40. package/package.json +8 -8
  41. package/src/cli/args.ts +75 -34
  42. package/src/commands/acp.ts +7 -1
  43. package/src/commands/gc.ts +31 -2
  44. package/src/commands/sdk.ts +141 -7
  45. package/src/config/settings-schema.ts +43 -0
  46. package/src/coordinator-mcp/server.ts +6 -1
  47. package/src/export/html/index.ts +83 -39
  48. package/src/gjc-runtime/gc-runtime.ts +1283 -4
  49. package/src/internal-urls/docs-index.generated.ts +1 -1
  50. package/src/internal-urls/local-protocol.ts +14 -0
  51. package/src/main.ts +40 -9
  52. package/src/modes/acp/acp-agent.ts +564 -37
  53. package/src/modes/acp/terminal-auth.ts +2 -2
  54. package/src/modes/components/bash-execution.ts +14 -0
  55. package/src/modes/components/eval-execution.ts +14 -0
  56. package/src/modes/controllers/command-controller.ts +63 -8
  57. package/src/modes/utils/ui-helpers.ts +81 -11
  58. package/src/sdk/acp/adapter.ts +46 -45
  59. package/src/sdk/acp/index.ts +1 -1
  60. package/src/sdk/broker/broker.ts +392 -11
  61. package/src/sdk/broker/ensure.ts +131 -61
  62. package/src/sdk/broker/index.ts +1 -0
  63. package/src/sdk/broker/lifecycle-ledger.ts +59 -5
  64. package/src/sdk/broker/lifecycle.ts +278 -63
  65. package/src/sdk/broker/startup-budget.ts +103 -0
  66. package/src/sdk/bus/chat-daemon-control.ts +9 -3
  67. package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
  68. package/src/sdk/bus/conversation-store.ts +1 -1
  69. package/src/sdk/bus/discord-daemon.ts +2 -1
  70. package/src/sdk/bus/discord-live-provider.ts +34 -6
  71. package/src/sdk/bus/index.ts +118 -20
  72. package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
  73. package/src/sdk/bus/prompt-reconciliation.ts +4 -9
  74. package/src/sdk/bus/slack-daemon.ts +91 -18
  75. package/src/sdk/cli/session-cli.ts +8 -1
  76. package/src/sdk/host/query/handlers.ts +1 -1
  77. package/src/sdk/host/query/revision-store.ts +48 -12
  78. package/src/sdk/host/session-runtime.test.ts +50 -0
  79. package/src/sdk/host/session-runtime.ts +12 -2
  80. package/src/sdk/lifecycle-session.ts +26 -3
  81. package/src/sdk/mcp/server.ts +6 -1
  82. package/src/sdk/prompt-failure.ts +42 -0
  83. package/src/sdk/prompt-watchdog.ts +162 -0
  84. package/src/sdk/session-reconnect.ts +43 -0
  85. package/src/sdk/startup-capability.ts +13 -4
  86. package/src/session/agent-session.ts +525 -222
  87. package/src/session/blob-store.ts +89 -0
  88. package/src/session/internal/managed-session-scope.ts +36 -26
  89. package/src/session/internal/managed-session-storage.ts +303 -32
  90. package/src/session/internal/session-memory-sidecar.ts +2160 -0
  91. package/src/session/session-manager.ts +7027 -394
  92. package/src/session/session-storage.ts +1134 -11
  93. package/src/task/executor.ts +17 -4
  94. package/src/tools/bash.ts +109 -7
  95. package/src/tools/find.ts +55 -26
  96. package/src/tools/todo-contract.ts +40 -8
  97. package/src/tui/output-block.ts +11 -1
@@ -349,7 +349,7 @@ export class ConversationStore<T extends ConversationRecord> {
349
349
  try {
350
350
  await this.#fs.writeFile(temporary, `${JSON.stringify(document)}\n`, { mode: 0o600 });
351
351
  await this.#fs.chmod(temporary, 0o600);
352
- const handle = await this.#fs.open(temporary, "r");
352
+ const handle = await this.#fs.open(temporary, "r+");
353
353
  try {
354
354
  await handle.sync();
355
355
  } finally {
@@ -107,6 +107,7 @@ export interface DiscordNotificationInput {
107
107
  threadName?: string;
108
108
  actionId?: string;
109
109
  options?: string[];
110
+ publicationId?: string;
110
111
  }
111
112
 
112
113
  type DiscordInboundEffectPayload =
@@ -326,7 +327,7 @@ export class DiscordNotificationDaemon {
326
327
  // original post and preserves the original component route.
327
328
  const effectId = pendingActionId
328
329
  ? authoritative.pendingActionEffectId!
329
- : `notification:${authoritative.threadId}:${randomUUID()}`;
330
+ : `notification:${authoritative.threadId}:${input.publicationId ?? randomUUID()}`;
330
331
  await this.#postEffect(effectId, authoritative, input.content, components, pendingActionId !== undefined);
331
332
 
332
333
  return authoritative;
@@ -13,6 +13,7 @@ const GATEWAY_INTENTS = 1 + 512 + 32_768;
13
13
  const MAX_RATE_LIMIT_RETRIES = 2;
14
14
  const NONCE_PREFIX = "<!-- gjc-thread-nonce:";
15
15
  const INVALID_SESSION_RECONNECT_DELAY_MS = 1_000;
16
+ const TERMINAL_GATEWAY_CLOSE_CODES: ReadonlySet<number> = new Set([4_004, 4_010, 4_011, 4_012, 4_013, 4_014]);
16
17
 
17
18
  const NONCE_SUFFIX = " -->";
18
19
 
@@ -329,15 +330,35 @@ export class DiscordLiveProvider implements DiscordProvider, DiscordDiagnosticPr
329
330
  socket.addEventListener("message", event => {
330
331
  void this.#handleGateway(socket, event).catch(error => this.#handleGatewayFailure(socket, error));
331
332
  });
332
- socket.addEventListener("close", () => this.#scheduleReconnect(socket));
333
+ socket.addEventListener("close", event => {
334
+ const code = typeof (event as CloseEvent).code === "number" ? (event as CloseEvent).code : 0;
335
+ if (TERMINAL_GATEWAY_CLOSE_CODES.has(code)) {
336
+ this.#gatewayError = new Error(`Discord gateway rejected the connection (close code ${code})`);
337
+ this.#heartbeat?.cancel();
338
+ this.#heartbeat = undefined;
339
+ this.#gatewayReady = false;
340
+ return;
341
+ }
342
+ this.#scheduleReconnect(socket);
343
+ });
333
344
  socket.addEventListener("error", () => {
334
345
  if (socket.readyState !== 3) socket.close();
335
346
  });
336
347
  }
337
348
 
338
349
  async #handleGateway(socket: DiscordGatewaySocket, event: Event): Promise<void> {
339
- const data = (event as MessageEvent).data;
340
- if (typeof data !== "string") return;
350
+ const raw = (event as MessageEvent).data;
351
+ const data =
352
+ typeof raw === "string"
353
+ ? raw
354
+ : raw instanceof ArrayBuffer
355
+ ? Buffer.from(raw).toString("utf8")
356
+ : ArrayBuffer.isView(raw)
357
+ ? Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString("utf8")
358
+ : raw instanceof Blob
359
+ ? await raw.text()
360
+ : undefined;
361
+ if (data === undefined) return;
341
362
  let frame: JsonRecord;
342
363
  try {
343
364
  frame = JSON.parse(data) as JsonRecord;
@@ -387,7 +408,7 @@ export class DiscordLiveProvider implements DiscordProvider, DiscordDiagnosticPr
387
408
  this.#resumeGatewayUrl = this.#string(payload, "resume_gateway_url");
388
409
  }
389
410
  if (frame.t === "READY" || frame.t === "RESUMED") this.#gatewayReady = true;
390
- const inbound = this.#inbound(frame.t, payload);
411
+ const inbound = await this.#inbound(frame.t, payload);
391
412
  if (inbound && !inbound.bot && inbound.authorId !== this.#botUserId) await this.#onEvent?.(inbound);
392
413
  }
393
414
  #handleGatewayFailure(socket: DiscordGatewaySocket, error: unknown): void {
@@ -526,14 +547,21 @@ export class DiscordLiveProvider implements DiscordProvider, DiscordDiagnosticPr
526
547
  this.#string(this.#record(data.channel), "parent_id")
527
548
  );
528
549
  }
529
- #inbound(type: unknown, data: JsonRecord): DiscordInboundEvent | undefined {
550
+ async #inbound(type: unknown, data: JsonRecord): Promise<DiscordInboundEvent | undefined> {
530
551
  if (type === "MESSAGE_CREATE") {
531
552
  const id = this.#string(data, "id");
532
553
  const guildId = this.#string(data, "guild_id");
533
554
  const threadId = this.#string(data, "channel_id");
534
555
  const author = this.#record(data.author);
535
556
  const authorId = this.#string(author, "id");
536
- const parentId = this.#parentId(data);
557
+ let parentId = this.#parentId(data);
558
+ if (!parentId && threadId) {
559
+ try {
560
+ parentId = this.#string(this.#record(await this.#request(`/channels/${threadId}`)), "parent_id");
561
+ } catch {
562
+ return undefined;
563
+ }
564
+ }
537
565
  if (!id || !guildId || !threadId || !parentId || !authorId) return undefined;
538
566
  return {
539
567
  id,
@@ -80,6 +80,7 @@ import {
80
80
  } from "../../tools/ask-answer-registry";
81
81
  import { acpFinalTextFromMessage } from "../acp/final-text";
82
82
  import { ensureBroker } from "../broker/ensure";
83
+ import { publishSessionHostRuntimeEvidence, type SessionHostRuntimePublication } from "../broker/lifecycle";
83
84
  import { SessionIndex } from "../broker/session-index";
84
85
  import { createSdkSurfaceFactory, type SessionSdkHost, SessionSdkSessionRuntime, shouldHostSdk } from "../host";
85
86
  import { type ControlSurface, dispatchControl } from "../host/control";
@@ -92,6 +93,7 @@ import {
92
93
  syntheticModelInputError,
93
94
  syntheticNamespaceCollision,
94
95
  } from "../model-profile-model";
96
+ import { formatPromptFailureForLocalLog, sanitizePromptFailure } from "../prompt-failure";
95
97
  import { PROMPT_CLIENT_REF_MAX_LENGTH, type SdkPromptTerminalOutcome } from "../prompt-status";
96
98
  import { OPERATIONS } from "../protocol/operation-registry";
97
99
  import {
@@ -127,7 +129,7 @@ import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMess
127
129
  import { createKindAwareReconciliation } from "./kind-aware-reconciliation";
128
130
  import { assertNativeRuntimeCompatibility } from "./native-runtime-compatibility";
129
131
  import { proposedTelegramIdentity } from "./notification-orchestration";
130
- import { createPromptReconciliation, sanitizePromptFailure } from "./prompt-reconciliation";
132
+ import { createPromptReconciliation } from "./prompt-reconciliation";
131
133
  import { createReconciliationStore } from "./reconciliation-store";
132
134
  import { NotificationSessionController, type NotificationSessionRuntime } from "./session-control";
133
135
  import type { SlackConversation } from "./slack-conversation";
@@ -153,6 +155,40 @@ export {
153
155
 
154
156
  const PROMPT_SETTLEMENT_DIAGNOSTIC_ENTRY_LIMIT = 8;
155
157
  const PROMPT_SETTLEMENT_DIAGNOSTIC_MAX_AGE_MS = 86_400_000;
158
+ /**
159
+ * Upper bound on the failure reason copied into the local operator log. Mirrors
160
+ * the 512-char bound documented for reconciliation failure messages so a runaway
161
+ * provider error cannot flood the log file.
162
+ */
163
+ const PROMPT_TERMINAL_FAILURE_REASON_LOG_MAX = 512;
164
+ type PromptTerminalDiagnostic = {
165
+ reason?: unknown;
166
+ loopStopReason?: string;
167
+ assistantStopReason?: string;
168
+ errorKind?: string;
169
+ intentionalCancellation?: boolean;
170
+ };
171
+
172
+ type PromptTerminalExtra = {
173
+ finalText?: string;
174
+ error?: { code: string; message: string };
175
+ diagnostic?: PromptTerminalDiagnostic;
176
+ diagnosticAlreadyLogged?: boolean;
177
+ };
178
+
179
+ function formatPromptTerminalFailureReason(reason: unknown): string {
180
+ let rawReason: string;
181
+ if (reason instanceof Error) rawReason = reason.message;
182
+ else if (typeof reason === "string") rawReason = reason;
183
+ else if (reason === undefined || reason === null) return "unreported";
184
+ else
185
+ try {
186
+ rawReason = String(reason);
187
+ } catch {
188
+ return "unreported";
189
+ }
190
+ return rawReason ? rawReason.slice(0, PROMPT_TERMINAL_FAILURE_REASON_LOG_MAX) : "unreported";
191
+ }
156
192
 
157
193
  export function formatPromptSettlementDiagnostic(
158
194
  proof: Extract<RunSettlementProof, { status: "unfenced" }>,
@@ -1170,6 +1206,8 @@ interface SessionRuntime {
1170
1206
  /** Terminal cleanup proof retained across retries; each owner is released at most once after proof. */
1171
1207
  hostStopped: boolean;
1172
1208
  serverStopped: boolean;
1209
+ /** This runtime's own host-liveness publication; only its teardown may retract it. */
1210
+ evidencePublication?: SessionHostRuntimePublication;
1173
1211
  brokerRegistrationReleased: boolean;
1174
1212
  /** Managed Telegram root registration released during terminal teardown. */
1175
1213
  notificationRootRegistration?: { settings: Settings; cwd: string; registrationToken: string };
@@ -1192,11 +1230,8 @@ interface SessionRuntime {
1192
1230
  terminalizePrompt: (
1193
1231
  correlation: { commandId: string; turnId: string },
1194
1232
  outcome: SdkPromptTerminalOutcome,
1195
- extra?: { finalText?: string },
1233
+ extra?: PromptTerminalExtra,
1196
1234
  ) => Promise<void>;
1197
- /** Records a correlated prompt terminal boundary after agent unwind. */
1198
- /** Atomically claims a correlated prompt terminal boundary after agent unwind. */
1199
- recordPromptTerminal: (correlation: { commandId: string; turnId: string } | undefined) => boolean;
1200
1235
  /** Transitions the authoritative reconciliation record at lifecycle ingress; terminal outcomes settle once. */
1201
1236
  notePromptReconciliation: (
1202
1237
  correlation: { commandId: string; turnId: string } | undefined,
@@ -3749,6 +3784,13 @@ export function createNotificationsExtension(
3749
3784
  await rt.server.stopAndWait();
3750
3785
  serverStopped = true;
3751
3786
  rt.serverStopped = true;
3787
+ // This runtime no longer serves an SDK endpoint, so it withdraws its
3788
+ // own evidence — and only its own. A predecessor's deferred teardown
3789
+ // runs while an identity successor is already serving, and clearing
3790
+ // that live runtime's reader would manufacture "no evidence" for a
3791
+ // host whose clients are still attached.
3792
+ rt.evidencePublication?.retract();
3793
+ rt.evidencePublication = undefined;
3752
3794
  } catch (e) {
3753
3795
  ownerReleaseFailures.push(e);
3754
3796
  logger.warn(`notifications: stop failed: ${String(e)}`);
@@ -4047,7 +4089,6 @@ export function createNotificationsExtension(
4047
4089
  connectionId: string;
4048
4090
  abandoned: boolean;
4049
4091
  failed: boolean;
4050
- error: unknown;
4051
4092
  terminal: boolean;
4052
4093
  retainCorrelation: boolean;
4053
4094
  /** Fatal/uncertain closure: transport-level only, never a semantic terminal. */
@@ -4246,7 +4287,6 @@ export function createNotificationsExtension(
4246
4287
  connectionId: requesterConnectionId,
4247
4288
  abandoned: false,
4248
4289
  failed: false,
4249
- error: undefined,
4250
4290
  terminal: false,
4251
4291
  retainCorrelation: trackReconciliation,
4252
4292
  createdAt: Date.now(),
@@ -4268,6 +4308,7 @@ export function createNotificationsExtension(
4268
4308
  provenance: "deadline",
4269
4309
  },
4270
4310
  { fence: true },
4311
+ { diagnostic: { reason: "Prompt deadline exceeded." } },
4271
4312
  );
4272
4313
  }, submission.deadlineMs);
4273
4314
  };
@@ -4354,7 +4395,7 @@ export function createNotificationsExtension(
4354
4395
  // run and prove settlement. A natural `agent_end`/`agent_failed` already unwound,
4355
4396
  // so aborting there would cancel the next turn instead of fencing this one.
4356
4397
  options: { fence?: boolean } = {},
4357
- extra?: { finalText?: string; error?: { code: string; message: string } },
4398
+ extra?: PromptTerminalExtra,
4358
4399
  ) => {
4359
4400
  const submission = promptSubmissions.get(promptSubmissionKey(correlation));
4360
4401
  if (!submission || submission.terminal || submission.phase !== "active") return;
@@ -4426,6 +4467,24 @@ export function createNotificationsExtension(
4426
4467
  }
4427
4468
  if (submission.deadlineTimer) clearTimeout(submission.deadlineTimer);
4428
4469
  if (!recordPromptTerminal(correlation) || !runtime) return;
4470
+ if (
4471
+ winner.kind === "failed" &&
4472
+ extra?.diagnostic?.intentionalCancellation !== true &&
4473
+ extra?.diagnosticAlreadyLogged !== true
4474
+ ) {
4475
+ const diagnostic = extra?.diagnostic;
4476
+ logger.error("sdk_prompt_terminal_failed", {
4477
+ sessionId: id,
4478
+ commandId: correlation.commandId,
4479
+ turnId: correlation.turnId,
4480
+ code: winner.code,
4481
+ provenance: winner.provenance,
4482
+ ...(diagnostic?.loopStopReason ? { loopStopReason: diagnostic.loopStopReason } : {}),
4483
+ ...(diagnostic?.assistantStopReason ? { assistantStopReason: diagnostic.assistantStopReason } : {}),
4484
+ ...(diagnostic?.errorKind ? { errorKind: diagnostic.errorKind } : {}),
4485
+ reason: formatPromptTerminalFailureReason(diagnostic?.reason),
4486
+ });
4487
+ }
4429
4488
  if (winner.kind === "failed") {
4430
4489
  emitPromptLifecycle(correlation, {
4431
4490
  type: "agent_failed",
@@ -4445,26 +4504,41 @@ export function createNotificationsExtension(
4445
4504
  }
4446
4505
  };
4447
4506
  const emitPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
4507
+ logger.error("SDK prompt submission failed", {
4508
+ commandId: correlation.commandId,
4509
+ turnId: correlation.turnId,
4510
+ error: formatPromptFailureForLocalLog(error),
4511
+ });
4448
4512
  const sanitized = sanitizePromptFailure(error);
4513
+ const outcome: SdkPromptTerminalOutcome = {
4514
+ kind: "failed",
4515
+ code: "prompt_failed",
4516
+ message: sanitized.message,
4517
+ provenance: "agent_failed",
4518
+ };
4519
+ // This rejection bypasses `agent_end`, so record its local-only reason at
4520
+ // the accepted submission failure boundary before terminalization begins.
4521
+ logger.error("sdk_prompt_terminal_failed", {
4522
+ sessionId: id,
4523
+ commandId: correlation.commandId,
4524
+ turnId: correlation.turnId,
4525
+ code: outcome.code,
4526
+ provenance: outcome.provenance,
4527
+ reason: formatPromptTerminalFailureReason(error),
4528
+ });
4449
4529
  void terminalizePrompt(
4450
4530
  correlation,
4451
- {
4452
- kind: "failed",
4453
- code: "prompt_failed",
4454
- message: sanitized.message,
4455
- provenance: "agent_failed",
4456
- },
4531
+ outcome,
4457
4532
  {},
4458
- // The normalized outcome is the ACP contract; the wire error keeps the
4459
- // existing sanitized discriminator so SDK clients keep branching on it.
4460
- { error: sanitized },
4533
+ // The normalized outcome and wire error retain the fixed safe token.
4534
+ // Mark the diagnostic as recorded so terminalization does not duplicate it.
4535
+ { error: sanitized, diagnostic: { reason: error }, diagnosticAlreadyLogged: true },
4461
4536
  );
4462
4537
  };
4463
4538
  const recordPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
4464
4539
  const submission = promptSubmissions.get(promptSubmissionKey(correlation));
4465
4540
  if (!submission) return;
4466
4541
  submission.failed = true;
4467
- submission.error = error;
4468
4542
  removePendingPromptCorrelation(correlation);
4469
4543
  if (
4470
4544
  runtime?.activePromptCorrelation?.commandId === correlation.commandId &&
@@ -4894,7 +4968,6 @@ export function createNotificationsExtension(
4894
4968
  bindPromptExecutionHandle,
4895
4969
  peekPromptPendingOutcome: correlation => kindReconciliation.peekPendingOutcome(correlation),
4896
4970
  terminalizePrompt: (correlation, outcome, extra) => terminalizePrompt(correlation, outcome, {}, extra),
4897
- recordPromptTerminal,
4898
4971
  notePromptReconciliation: (correlation, frame) => {
4899
4972
  void kindReconciliation.noteTransition("prompt", correlation, frame);
4900
4973
  },
@@ -5503,6 +5576,14 @@ export function createNotificationsExtension(
5503
5576
  };
5504
5577
  host.emitEvent({ kind: identityHeader.type, payload: identityHeader });
5505
5578
  const endpoint = await sdkRuntime.startTransport();
5579
+ // The native server owns the only authoritative view of this host's live
5580
+ // SDK client sockets; publish it so a detached session host can bound its
5581
+ // own lifetime without probing the OS (#4010). The handle is this
5582
+ // runtime's alone, so only this runtime's teardown can retract it.
5583
+ initializedRuntime.evidencePublication = publishSessionHostRuntimeEvidence({
5584
+ attachedClients: () => server.clientCount(),
5585
+ workInFlight: () => initializedRuntime.busy || initializedRuntime.pendingPromptCorrelations.length > 0,
5586
+ });
5506
5587
  ephemeralTurns.configureAuthority({
5507
5588
  sessionId: id,
5508
5589
  endpointDigest: endpointAuthorityDigest(endpoint.url, token),
@@ -6283,7 +6364,7 @@ export function createNotificationsExtension(
6283
6364
  message =>
6284
6365
  (message as { stopReason?: unknown }).stopReason === "error" ||
6285
6366
  (message as { stopReason?: unknown }).stopReason === "aborted",
6286
- ) as { stopReason?: "error" | "aborted"; errorMessage?: unknown } | undefined;
6367
+ ) as { stopReason?: "error" | "aborted"; errorMessage?: unknown; errorKind?: unknown } | undefined;
6287
6368
  const legacyCode =
6288
6369
  event.stopReason === "cancelled"
6289
6370
  ? "cancelled"
@@ -6292,8 +6373,25 @@ export function createNotificationsExtension(
6292
6373
  : terminalAssistant?.stopReason === "aborted"
6293
6374
  ? "aborted"
6294
6375
  : undefined;
6376
+ // The SDK wire outcome and ACP error envelope use a fixed safe token by
6377
+ // contract (see `sanitizePromptFailure`). Assistant failure messages may
6378
+ // still remain in the local session transcript; terminalization copies only
6379
+ // a bounded reason into the local operator log.
6295
6380
  void rt.terminalizePrompt(correlation, outcome, {
6296
6381
  ...(finalText ? { finalText } : {}),
6382
+ ...(outcome.kind === "failed"
6383
+ ? {
6384
+ diagnostic: {
6385
+ reason: terminalAssistant?.errorMessage,
6386
+ loopStopReason: event.stopReason ?? "none",
6387
+ assistantStopReason: terminalAssistant?.stopReason ?? "none",
6388
+ ...(event.stopReason === "cancelled" ? { intentionalCancellation: true } : {}),
6389
+ ...(typeof terminalAssistant?.errorKind === "string"
6390
+ ? { errorKind: terminalAssistant.errorKind }
6391
+ : {}),
6392
+ },
6393
+ }
6394
+ : {}),
6297
6395
  ...(outcome.kind === "failed" && legacyCode
6298
6396
  ? {
6299
6397
  // Only the legacy discriminator is preserved; provider text is never
@@ -2,13 +2,14 @@
2
2
  * Kind-aware invocation reconciliation (prompt | skill) with optional durable store.
3
3
  * Preserves Q26 admit/first-terminal/capacity/TTL semantics; indexes and caps are per-kind.
4
4
  */
5
+
6
+ import { sanitizePromptFailure } from "../prompt-failure";
5
7
  import type { PromptReconciliationStatus, SdkPromptTerminalOutcome, TurnPromptReconciliation } from "../prompt-status";
6
8
  import {
7
9
  PROMPT_RECONCILIATION_ACTIVE_CAPACITY,
8
10
  PROMPT_RECONCILIATION_TERMINAL_CAPACITY,
9
11
  PROMPT_RECONCILIATION_TERMINAL_TTL_MS,
10
12
  type PromptCorrelation,
11
- sanitizePromptFailure,
12
13
  } from "./prompt-reconciliation";
13
14
  import type { DurableReconciliationRecord, ReconciliationKind, ReconciliationStore } from "./reconciliation-store";
14
15
 
@@ -19,10 +19,13 @@
19
19
  * - Terminal transitions settle once: the first terminal outcome wins.
20
20
  */
21
21
 
22
+ import { PROMPT_FAILURE_CODE_MAX, sanitizePromptFailure } from "../prompt-failure";
23
+
24
+ export { PROMPT_FAILURE_CODE_MAX, sanitizePromptFailure };
25
+
22
26
  export const PROMPT_RECONCILIATION_ACTIVE_CAPACITY = 128;
23
27
  export const PROMPT_RECONCILIATION_TERMINAL_CAPACITY = 256;
24
28
  export const PROMPT_RECONCILIATION_TERMINAL_TTL_MS = 15 * 60_000;
25
- export const PROMPT_FAILURE_CODE_MAX = 64;
26
29
 
27
30
  export type PromptReconciliationStatus = "accepted" | "in_flight" | "terminal_ok" | "failed";
28
31
 
@@ -94,14 +97,6 @@ export interface PromptReconciliation {
94
97
  activeCount(): number;
95
98
  }
96
99
 
97
- /** Safe-token code capped at 64; arbitrary failure text is never retained. */
98
- export function sanitizePromptFailure(error: unknown): { code: string; message: string } {
99
- const candidate = error as { code?: unknown } | undefined;
100
- const rawCode = typeof candidate?.code === "string" ? candidate.code : "";
101
- const code = rawCode.length <= PROMPT_FAILURE_CODE_MAX && /^[A-Za-z0-9._-]+$/.test(rawCode) ? rawCode : "internal";
102
- return { code, message: "Prompt submission failed." };
103
- }
104
-
105
100
  export function createPromptReconciliation(options: { now?: () => number } = {}): PromptReconciliation {
106
101
  const now = options.now ?? Date.now;
107
102
  const records = new Map<string, PromptReconciliationRecord>();
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { SdkClientError } from "../client/client";
3
3
  import { readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
4
4
  import type { ChatDeliveryError } from "./chat-daemon-runtime";
@@ -35,7 +35,12 @@ class SlackReconciledAbsentEffectError extends Error {
35
35
  }
36
36
  }
37
37
 
38
- import { type ChatEffect, ChatEffectJournal, type ChatEffectLease } from "./chat-effect-journal";
38
+ import {
39
+ type ChatEffect,
40
+ ChatEffectJournal,
41
+ type ChatEffectLease,
42
+ MAX_TERMINAL_CHAT_EFFECTS,
43
+ } from "./chat-effect-journal";
39
44
  import { SlackProviderError } from "./slack-live-provider";
40
45
  import type {
41
46
  SlackMessageSearchResult,
@@ -44,6 +49,11 @@ import type {
44
49
  SlackSocketEnvelope,
45
50
  } from "./slack-provider";
46
51
 
52
+ function slackPublicationClientMsgId(publicationId: string): string {
53
+ const digest = createHash("sha256").update(publicationId).digest("hex");
54
+ return `${digest.slice(0, 8)}-${digest.slice(8, 12)}-${digest.slice(12, 16)}-${digest.slice(16, 20)}-${digest.slice(20, 32)}`;
55
+ }
56
+
47
57
  // Durable filesystem publication leases must outlast one event-loop and persistence turn.
48
58
  const MIN_PUBLICATION_LEASE_MS = 100;
49
59
 
@@ -440,7 +450,12 @@ export class SlackNotificationDaemon {
440
450
  throw new SlackThreadBindingError("root_not_found", "The Slack root was not found in the configured channel.");
441
451
  }
442
452
 
443
- async #postRoot(sessionId: string, body: string, endpointGeneration?: number): Promise<SlackRootPublication> {
453
+ async #postRoot(
454
+ sessionId: string,
455
+ body: string,
456
+ endpointGeneration?: number,
457
+ requestedClientMsgId?: string,
458
+ ): Promise<SlackRootPublication> {
444
459
  const endpoint = await this.#resolveEndpoint(sessionId);
445
460
  if (!endpoint || (endpointGeneration !== undefined && endpoint.generation !== endpointGeneration))
446
461
  throw new SlackEndpointBindingError("Slack root publication requires the current session endpoint.");
@@ -473,7 +488,7 @@ export class SlackNotificationDaemon {
473
488
  teamId: this.options.teamId,
474
489
  channelId: this.options.channelId,
475
490
  sessionId,
476
- clientMsgId: this.#randomId(),
491
+ clientMsgId: requestedClientMsgId ?? this.#randomId(),
477
492
  rootPublicationOwner: this.#publicationOwnerId,
478
493
  rootPublicationLeaseExpiresAt: now + this.#publicationLeaseMs,
479
494
  rootPublicationFence: (current?.rootPublicationFence ?? 0) + 1,
@@ -570,27 +585,38 @@ export class SlackNotificationDaemon {
570
585
  body: string,
571
586
  actionId?: string,
572
587
  endpointGeneration?: number,
588
+ publicationId?: string,
573
589
  ): Promise<SlackConversation> {
574
590
  const endpoint = await this.#resolveEndpoint(sessionId);
575
591
  if (!endpoint || (endpointGeneration !== undefined && endpoint.generation !== endpointGeneration))
576
592
  throw new SlackEndpointBindingError("Slack notification requires the current session endpoint.");
577
593
  const generation = endpoint.generation;
594
+ const rootPublication =
595
+ publicationId === undefined
596
+ ? undefined
597
+ : await this.#publicationAttempt(
598
+ `root:${sessionId}:${publicationId}`,
599
+ clientMsgId => `root:${sessionId}:${clientMsgId}`,
600
+ );
578
601
  const existing = await this.findSession(sessionId, false);
579
602
  const usedExistingRoot =
580
603
  existing?.record.state === "active" &&
581
604
  !!existing.record.rootTs &&
582
605
  existing.record.endpointGeneration === generation;
583
606
  let conversation: SlackConversation;
584
- let bodyWasUsedAsRoot = false;
607
+ let bodyWasUsedAsRoot =
608
+ usedExistingRoot &&
609
+ rootPublication !== undefined &&
610
+ existing.record.clientMsgId === rootPublication.clientMsgId;
585
611
  if (usedExistingRoot) {
586
612
  conversation = existing.record;
587
613
  } else {
588
614
  const publication =
589
615
  existing?.record.state === "active"
590
- ? await this.#resumeWithRootPublication(sessionId, body, generation)
591
- : await this.#postRoot(sessionId, body, generation);
616
+ ? await this.#resumeWithRootPublication(sessionId, body, generation, rootPublication?.clientMsgId)
617
+ : await this.#postRoot(sessionId, body, generation, rootPublication?.clientMsgId);
592
618
  conversation = publication.conversation;
593
- bodyWasUsedAsRoot = publication.created;
619
+ bodyWasUsedAsRoot ||= publication.created;
594
620
  }
595
621
  if (!conversation.rootTs) return conversation;
596
622
  const key = usedExistingRoot && existing ? existing.key : this.#intentKey(sessionId);
@@ -607,11 +633,21 @@ export class SlackNotificationDaemon {
607
633
  return active;
608
634
  }
609
635
  if (!actionId) {
610
- await this.#postDurable(`notification:${sessionId}:${this.#randomId()}`, sessionId, conversationGeneration, {
636
+ const publication =
637
+ publicationId === undefined
638
+ ? {
639
+ effectId: `notification:${sessionId}:${this.#randomId()}`,
640
+ clientMsgId: this.#randomId(),
641
+ }
642
+ : await this.#publicationAttempt(
643
+ `notification:${sessionId}:${publicationId}`,
644
+ clientMsgId => `notification:${sessionId}:${clientMsgId}`,
645
+ );
646
+ await this.#postDurable(publication.effectId, sessionId, conversationGeneration, {
611
647
  channel: conversation.channelId,
612
648
  threadTs: conversation.rootTs,
613
649
  text: body,
614
- clientMsgId: this.#randomId(),
650
+ clientMsgId: publication.clientMsgId,
615
651
  });
616
652
  return conversation;
617
653
  }
@@ -759,14 +795,28 @@ export class SlackNotificationDaemon {
759
795
  return true;
760
796
  }
761
797
 
762
- async resume(sessionId: string, body: string, endpointGeneration?: number): Promise<SlackConversation> {
763
- return (await this.#resumeWithRootPublication(sessionId, body, endpointGeneration)).conversation;
798
+ async resume(
799
+ sessionId: string,
800
+ body: string,
801
+ endpointGeneration?: number,
802
+ publicationId?: string,
803
+ ): Promise<SlackConversation> {
804
+ const rootPublication =
805
+ publicationId === undefined
806
+ ? undefined
807
+ : await this.#publicationAttempt(
808
+ `root:${sessionId}:${publicationId}`,
809
+ clientMsgId => `root:${sessionId}:${clientMsgId}`,
810
+ );
811
+ return (await this.#resumeWithRootPublication(sessionId, body, endpointGeneration, rootPublication?.clientMsgId))
812
+ .conversation;
764
813
  }
765
814
 
766
815
  async #resumeWithRootPublication(
767
816
  sessionId: string,
768
817
  body: string,
769
818
  endpointGeneration?: number,
819
+ requestedClientMsgId?: string,
770
820
  ): Promise<SlackRootPublication> {
771
821
  const inFlight = this.#rollovers.get(sessionId);
772
822
  if (inFlight) {
@@ -776,9 +826,9 @@ export class SlackNotificationDaemon {
776
826
  return { conversation: publication.conversation, created: false };
777
827
  if (endpointGeneration < activeGeneration)
778
828
  throw new SlackEndpointBindingError("Slack root belongs to a newer endpoint generation.");
779
- return await this.#resumeWithRootPublication(sessionId, body, endpointGeneration);
829
+ return await this.#resumeWithRootPublication(sessionId, body, endpointGeneration, requestedClientMsgId);
780
830
  }
781
- const rollover = this.#resumeRoot(sessionId, body, endpointGeneration);
831
+ const rollover = this.#resumeRoot(sessionId, body, endpointGeneration, requestedClientMsgId);
782
832
  this.#rollovers.set(sessionId, rollover);
783
833
  try {
784
834
  return await rollover;
@@ -787,7 +837,12 @@ export class SlackNotificationDaemon {
787
837
  }
788
838
  }
789
839
 
790
- async #resumeRoot(sessionId: string, body: string, endpointGeneration?: number): Promise<SlackRootPublication> {
840
+ async #resumeRoot(
841
+ sessionId: string,
842
+ body: string,
843
+ endpointGeneration?: number,
844
+ requestedClientMsgId?: string,
845
+ ): Promise<SlackRootPublication> {
791
846
  const endpoint = await this.#resolveEndpoint(sessionId);
792
847
  if (!endpoint || (endpointGeneration !== undefined && endpoint.generation !== endpointGeneration))
793
848
  throw new SlackEndpointBindingError("Slack root rollover requires the current session endpoint.");
@@ -802,7 +857,7 @@ export class SlackNotificationDaemon {
802
857
  previous = { key: previous.key, record: reconciled };
803
858
  waitedForRollover = true;
804
859
  }
805
- if (!previous) return await this.#postRoot(sessionId, body, generation);
860
+ if (!previous) return await this.#postRoot(sessionId, body, generation, requestedClientMsgId);
806
861
  if (previous.record.state !== "active") {
807
862
  if (
808
863
  previous.record.rootPublicationOwner !== this.#publicationOwnerId &&
@@ -812,7 +867,7 @@ export class SlackNotificationDaemon {
812
867
  await Bun.sleep(10);
813
868
  continue;
814
869
  }
815
- return await this.#postRoot(sessionId, body, generation);
870
+ return await this.#postRoot(sessionId, body, generation, requestedClientMsgId);
816
871
  }
817
872
  const previousGeneration = this.#requireEndpointGeneration(previous.record);
818
873
  if (previousGeneration > generation)
@@ -873,7 +928,7 @@ export class SlackNotificationDaemon {
873
928
  resumed.rootPublicationOwner === this.#publicationOwnerId &&
874
929
  resumed.rootPublicationFence === fence
875
930
  )
876
- return await this.#postRoot(sessionId, body, generation);
931
+ return await this.#postRoot(sessionId, body, generation, requestedClientMsgId);
877
932
  waitedForRollover = true;
878
933
  await Bun.sleep(10);
879
934
  }
@@ -1820,6 +1875,24 @@ export class SlackNotificationDaemon {
1820
1875
  );
1821
1876
  }
1822
1877
 
1878
+ /**
1879
+ * Reuse one provider identity while its outcome is pending or uncertain. Only a
1880
+ * terminal definite refusal advances to a fresh attempt identity, so safe retries
1881
+ * remain possible without turning an ambiguous acknowledgement into a duplicate.
1882
+ */
1883
+ async #publicationAttempt(
1884
+ baseId: string,
1885
+ effectIdForClientMsgId: (clientMsgId: string) => string,
1886
+ ): Promise<{ effectId: string; clientMsgId: string }> {
1887
+ for (let attempt = 1; attempt <= MAX_TERMINAL_CHAT_EFFECTS + 1; attempt++) {
1888
+ const occurrenceId = attempt === 1 ? baseId : `${baseId}:retry:${attempt}`;
1889
+ const clientMsgId = slackPublicationClientMsgId(occurrenceId);
1890
+ const effectId = effectIdForClientMsgId(clientMsgId);
1891
+ const existing = await this.#journal.read(effectId);
1892
+ if (existing?.state !== "terminal" || existing.receipt?.status === "posted") return { effectId, clientMsgId };
1893
+ }
1894
+ throw new Error("Slack publication exhausted its retained definite-failure identities");
1895
+ }
1823
1896
  async #providerEffectPayload(
1824
1897
  id: string,
1825
1898
  sessionId: string,
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import { createInterface } from "node:readline/promises";
3
3
  import { getAgentDir } from "@gajae-code/utils";
4
4
  import { ensureBroker } from "../broker/ensure";
5
+ import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
5
6
  import {
6
7
  listSdkSessionEndpoints,
7
8
  readSdkBrokerDiscovery,
@@ -242,7 +243,13 @@ export async function runSdkSessionCli(
242
243
  throw new SdkSessionCliError("invalid_input", "--idempotency-key is required for lifecycle operations.", 2);
243
244
  const client = await connectBroker(agentDir);
244
245
  try {
245
- writeOutput(await client.global(operation, input, { idempotencyKey }));
246
+ const timeoutMs = lifecycleRequestTimeoutMs(operation, input);
247
+ writeOutput(
248
+ await client.global(operation, input, {
249
+ idempotencyKey,
250
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
251
+ }),
252
+ );
246
253
  } finally {
247
254
  await client.close();
248
255
  }