@gajae-code/coding-agent 0.12.18 → 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 (100) hide show
  1. package/CHANGELOG.md +26 -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/bus/telegram-daemon-contract.d.ts +4 -2
  25. package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
  26. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  27. package/dist/types/sdk/lifecycle-session.d.ts +13 -2
  28. package/dist/types/sdk/prompt-failure.d.ts +13 -0
  29. package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
  30. package/dist/types/sdk/session-reconnect.d.ts +20 -0
  31. package/dist/types/sdk/startup-capability.d.ts +1 -1
  32. package/dist/types/session/agent-session.d.ts +14 -4
  33. package/dist/types/session/blob-store.d.ts +32 -0
  34. package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
  35. package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
  36. package/dist/types/session/session-manager.d.ts +301 -27
  37. package/dist/types/session/session-storage.d.ts +170 -0
  38. package/dist/types/task/executor.d.ts +2 -1
  39. package/dist/types/tools/bash.d.ts +1 -0
  40. package/dist/types/tui/output-block.d.ts +6 -0
  41. package/package.json +8 -8
  42. package/src/cli/args.ts +75 -34
  43. package/src/commands/acp.ts +7 -1
  44. package/src/commands/gc.ts +31 -2
  45. package/src/commands/sdk.ts +141 -7
  46. package/src/config/settings-schema.ts +43 -0
  47. package/src/coordinator-mcp/server.ts +6 -1
  48. package/src/export/html/index.ts +83 -39
  49. package/src/gjc-runtime/gc-runtime.ts +1283 -4
  50. package/src/internal-urls/docs-index.generated.ts +1 -1
  51. package/src/internal-urls/local-protocol.ts +14 -0
  52. package/src/main.ts +40 -9
  53. package/src/modes/acp/acp-agent.ts +564 -37
  54. package/src/modes/acp/terminal-auth.ts +2 -2
  55. package/src/modes/components/bash-execution.ts +14 -0
  56. package/src/modes/components/eval-execution.ts +14 -0
  57. package/src/modes/controllers/command-controller.ts +63 -8
  58. package/src/modes/utils/ui-helpers.ts +81 -11
  59. package/src/sdk/acp/adapter.ts +46 -45
  60. package/src/sdk/acp/index.ts +1 -1
  61. package/src/sdk/broker/broker.ts +392 -11
  62. package/src/sdk/broker/ensure.ts +131 -61
  63. package/src/sdk/broker/index.ts +1 -0
  64. package/src/sdk/broker/lifecycle-ledger.ts +59 -5
  65. package/src/sdk/broker/lifecycle.ts +278 -63
  66. package/src/sdk/broker/startup-budget.ts +103 -0
  67. package/src/sdk/bus/chat-daemon-control.ts +9 -3
  68. package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
  69. package/src/sdk/bus/conversation-store.ts +1 -1
  70. package/src/sdk/bus/discord-daemon.ts +2 -1
  71. package/src/sdk/bus/discord-live-provider.ts +34 -6
  72. package/src/sdk/bus/index.ts +118 -20
  73. package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
  74. package/src/sdk/bus/prompt-reconciliation.ts +4 -9
  75. package/src/sdk/bus/slack-daemon.ts +91 -18
  76. package/src/sdk/bus/telegram-daemon-contract.ts +4 -2
  77. package/src/sdk/bus/topic-registry.ts +7 -1
  78. package/src/sdk/cli/session-cli.ts +8 -1
  79. package/src/sdk/host/query/handlers.ts +1 -1
  80. package/src/sdk/host/query/revision-store.ts +48 -12
  81. package/src/sdk/host/session-runtime.test.ts +50 -0
  82. package/src/sdk/host/session-runtime.ts +12 -2
  83. package/src/sdk/lifecycle-session.ts +26 -3
  84. package/src/sdk/mcp/server.ts +6 -1
  85. package/src/sdk/prompt-failure.ts +42 -0
  86. package/src/sdk/prompt-watchdog.ts +162 -0
  87. package/src/sdk/session-reconnect.ts +43 -0
  88. package/src/sdk/startup-capability.ts +13 -4
  89. package/src/session/agent-session.ts +525 -222
  90. package/src/session/blob-store.ts +89 -0
  91. package/src/session/internal/managed-session-scope.ts +36 -26
  92. package/src/session/internal/managed-session-storage.ts +303 -32
  93. package/src/session/internal/session-memory-sidecar.ts +2160 -0
  94. package/src/session/session-manager.ts +7027 -394
  95. package/src/session/session-storage.ts +1134 -11
  96. package/src/task/executor.ts +17 -4
  97. package/src/tools/bash.ts +109 -7
  98. package/src/tools/find.ts +55 -26
  99. package/src/tools/todo-contract.ts +40 -8
  100. package/src/tui/output-block.ts +11 -1
@@ -578,7 +578,10 @@ export class TopicRegistry {
578
578
  : {}),
579
579
  ...(isValidBindingString(raw.archiveHostId) ? { archiveHostId: raw.archiveHostId } : {}),
580
580
  ...(isValidBindingGeneration(raw.archiveLeaseEpoch) ? { archiveLeaseEpoch: raw.archiveLeaseEpoch } : {}),
581
- ...(typeof raw.disconnectGraceExpiresAt === "number" && Number.isFinite(raw.disconnectGraceExpiresAt)
581
+ ...(raw.authorityState === "disconnect_grace" &&
582
+ !fenceSupersedesRecord &&
583
+ typeof raw.disconnectGraceExpiresAt === "number" &&
584
+ Number.isFinite(raw.disconnectGraceExpiresAt)
582
585
  ? { disconnectGraceExpiresAt: raw.disconnectGraceExpiresAt }
583
586
  : {}),
584
587
  ...(bindingMalformed ? { bindingMalformed: true as const } : {}),
@@ -1179,6 +1182,9 @@ export class TopicRegistry {
1179
1182
  } else {
1180
1183
  record.authorityEpoch = snapshot.authorityEpoch;
1181
1184
  record.authorityState = snapshot.authorityState;
1185
+ if (snapshot.authorityState === "disconnect_grace" && snapshot.record?.disconnectGraceExpiresAt !== undefined)
1186
+ record.disconnectGraceExpiresAt = snapshot.record.disconnectGraceExpiresAt;
1187
+ else delete record.disconnectGraceExpiresAt;
1182
1188
  this.rebuildInboundRoutes();
1183
1189
  }
1184
1190
  if (snapshot.fenceEpoch === undefined) this.epochs.delete(snapshot.sessionId);
@@ -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
  }
@@ -405,7 +405,7 @@ export class QueryHandlers {
405
405
  const range =
406
406
  itemId === undefined
407
407
  ? await this.revisions.readStringRange(kind, id, revision, field, offset, TARGET_PAGE_BYTES)
408
- : await this.revisions.readIndexedStringRange(kind, id, revision, itemId, field, offset, TARGET_PAGE_BYTES);
408
+ : await this.revisions.readIndexedFieldRange(kind, id, revision, itemId, field, offset, TARGET_PAGE_BYTES);
409
409
  if (!range) return this.#error(request, "resource_gone");
410
410
  return this.#chunkRange(request, kind, id, revision, range, selector, {
411
411
  field,
@@ -312,8 +312,7 @@ export class RevisionStore {
312
312
  readonly #onReadRange?: (start: number, end: number) => void;
313
313
  #closing = false;
314
314
  #closePromise: Promise<void> | undefined;
315
- #writesInFlight = 0;
316
- #writesDrained: PromiseWithResolvers<void> | undefined;
315
+ readonly #pendingWrites = new Set<Promise<void>>();
317
316
 
318
317
  constructor(
319
318
  readonly sessionId: string,
@@ -326,10 +325,11 @@ export class RevisionStore {
326
325
 
327
326
  createRevision(resourceKind: string, resourceId: string, payload: unknown): Promise<string> {
328
327
  if (this.#closing) return Promise.reject(new RevisionStoreError("resource_gone", "snapshot store is closing"));
329
- this.#writesInFlight++;
328
+ const settled = Promise.withResolvers<void>();
329
+ this.#pendingWrites.add(settled.promise);
330
330
  return this.#createRevision(resourceKind, resourceId, payload).finally(() => {
331
- this.#writesInFlight--;
332
- if (this.#writesInFlight === 0) this.#writesDrained?.resolve();
331
+ this.#pendingWrites.delete(settled.promise);
332
+ settled.resolve();
333
333
  });
334
334
  }
335
335
 
@@ -468,6 +468,25 @@ export class RevisionStore {
468
468
  const range = item?.fields?.[field];
469
469
  return range ? this.#readStringRange(revision, range, offset, length) : undefined;
470
470
  }
471
+ async readIndexedFieldRange(
472
+ resourceKind: string,
473
+ resourceId: string,
474
+ id: string,
475
+ itemId: string,
476
+ field: string,
477
+ offset: number,
478
+ length: number,
479
+ ): Promise<{ body: string; complete: boolean; offset: number } | undefined> {
480
+ const revision = this.#resources.get(`${resourceKind}:${resourceId}`)?.find(item => item.id === id);
481
+ if (!revision) return undefined;
482
+ revision.lastAccessed = this.now();
483
+ const item = revision.index?.items?.find(candidate => candidate.entryId === itemId);
484
+ const range = item?.fields?.[field];
485
+ if (!range) return undefined;
486
+ return range.isString
487
+ ? this.#readStringRange(revision, range, offset, length)
488
+ : this.#readJsonRange(revision, range, offset, length);
489
+ }
471
490
  async describeIndexedItem(
472
491
  resourceKind: string,
473
492
  resourceId: string,
@@ -479,9 +498,7 @@ export class RevisionStore {
479
498
  if (!item) return undefined;
480
499
  return {
481
500
  itemId: item.entryId,
482
- fields: Object.entries(item.fields ?? {})
483
- .filter(([, range]) => range.isString)
484
- .map(([field]) => field),
501
+ fields: Object.keys(item.fields ?? {}),
485
502
  };
486
503
  }
487
504
  async readTranscriptBodyRange(
@@ -531,10 +548,7 @@ export class RevisionStore {
531
548
  if (this.#closePromise) return this.#closePromise;
532
549
  this.#closing = true;
533
550
  this.#closePromise = (async () => {
534
- if (this.#writesInFlight > 0) {
535
- this.#writesDrained ??= Promise.withResolvers<void>();
536
- await this.#writesDrained.promise;
537
- }
551
+ await Promise.all(this.#pendingWrites);
538
552
  this.#resources.clear();
539
553
  this.#pinIndex.clear();
540
554
  this.#memoryBytes = 0;
@@ -801,6 +815,28 @@ export class RevisionStore {
801
815
  return { isString: true, stringIndex };
802
816
  }
803
817
 
818
+ async #readJsonRange(
819
+ revision: Revision,
820
+ range: IndexedField,
821
+ offset: number,
822
+ length: number,
823
+ ): Promise<{ body: string; complete: boolean; offset: number } | undefined> {
824
+ if (offset < 0 || length <= 0) return undefined;
825
+ const bytes = range.end - range.start;
826
+ const requestedStart = Math.min(offset, bytes);
827
+ const source = await this.#readBytes(
828
+ revision,
829
+ range.start + requestedStart,
830
+ range.start + Math.min(bytes, requestedStart + length + 7),
831
+ );
832
+ const skipped = utf8ContinuationPrefixLength(source);
833
+ const start = requestedStart + skipped;
834
+ const body = source.subarray(skipped);
835
+ const end = utf8BoundaryAtOrBefore(body, Math.min(body.length, length));
836
+ if (end === 0 && start < bytes) return undefined;
837
+ return { body: body.subarray(0, end).toString("utf8"), complete: start + end === bytes, offset: start };
838
+ }
839
+
804
840
  async #readStringRange(
805
841
  revision: Revision,
806
842
  range: IndexedField,
@@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import {
6
+ createInvocationReconciliation,
6
7
  createSdkSessionRuntimeExtension,
7
8
  SessionSdkSessionRuntime,
8
9
  type SessionSdkTransport,
@@ -64,6 +65,55 @@ function extensionContext(sessionId: string, cwd: string): any {
64
65
  };
65
66
  }
66
67
 
68
+ test("preserves an agent failure code in host prompt reconciliation", async () => {
69
+ const reconciliation = createInvocationReconciliation();
70
+ const correlation = { commandId: "failed-command", turnId: "failed-turn" };
71
+ await reconciliation.noteAccepted("prompt", correlation, "failed-ref");
72
+ await reconciliation.noteTransition("prompt", correlation, {
73
+ type: "agent_failed",
74
+ error: Object.assign(new Error("provider unavailable"), { code: "provider_unavailable" }),
75
+ });
76
+ expect(reconciliation.lookup("prompt", { clientRef: "failed-ref" })).toMatchObject({
77
+ status: "failed",
78
+ error: { code: "provider_unavailable", message: "Prompt submission failed." },
79
+ });
80
+ });
81
+
82
+ test("redacts a persisted host failure during hydration", async () => {
83
+ const stateRoot = await mkdtemp(path.join(os.tmpdir(), "gjc-sdk-reconciliation-"));
84
+ const sessionId = "hydrated-failure";
85
+ try {
86
+ await Bun.write(
87
+ path.join(stateRoot, ".sdk-reconciliation", `${sessionId}.json`),
88
+ JSON.stringify({
89
+ version: 1,
90
+ sessionId,
91
+ records: [
92
+ {
93
+ kind: "prompt",
94
+ commandId: "persisted-command",
95
+ turnId: "persisted-turn",
96
+ status: "failed",
97
+ acceptedAt: 1,
98
+ terminalAt: Date.now(),
99
+ error: { code: "unsafe code!", message: "secret provider payload" },
100
+ },
101
+ ],
102
+ }),
103
+ );
104
+ const reconciliation = createInvocationReconciliation({ stateRoot, sessionId });
105
+ await reconciliation.hydrate();
106
+ expect(
107
+ reconciliation.lookup("prompt", { commandId: "persisted-command", turnId: "persisted-turn" }),
108
+ ).toMatchObject({
109
+ status: "failed",
110
+ error: { code: "internal", message: "Prompt submission failed." },
111
+ });
112
+ } finally {
113
+ await rm(stateRoot, { recursive: true, force: true });
114
+ }
115
+ });
116
+
67
117
  describe("SessionSdkSessionRuntime", () => {
68
118
  test("has no notification adapter or native notification import edge", async () => {
69
119
  const source = await readFile(new URL("./session-runtime.ts", import.meta.url), "utf8");
@@ -22,6 +22,7 @@ import {
22
22
  syntheticNamespaceCollision,
23
23
  } from "../model-profile-model";
24
24
  import { projectQ10Models } from "../models.js";
25
+ import { formatPromptFailureForLocalLog, sanitizePromptFailure } from "../prompt-failure";
25
26
  import { OPERATIONS } from "../protocol/operation-registry";
26
27
  import { type ControlSurface, dispatchControl } from "./control";
27
28
  import { SessionSdkHost, type SessionSdkHostOptions } from "./host";
@@ -265,7 +266,7 @@ export interface InvocationReconciliation {
265
266
  hydrate(): Promise<void>;
266
267
  }
267
268
 
268
- function createInvocationReconciliation(
269
+ export function createInvocationReconciliation(
269
270
  options: { stateRoot?: string; sessionId?: string } = {},
270
271
  ): InvocationReconciliation {
271
272
  const ACTIVE_CAPACITY = 256;
@@ -361,6 +362,7 @@ function createInvocationReconciliation(
361
362
  record.terminalAt = Date.now();
362
363
  record.error = { code: "process_restart", message: "Reconciliation incomplete after process restart." };
363
364
  }
365
+ if (record.status === "failed") record.error = sanitizePromptFailure(record.error);
364
366
  records.set(key(record.kind, record), { ...record });
365
367
  }
366
368
  }
@@ -416,7 +418,15 @@ function createInvocationReconciliation(
416
418
  } else {
417
419
  record.status = frame.type === "agent_failed" ? "failed" : "terminal_ok";
418
420
  record.terminalAt = Date.now();
419
- if (frame.type === "agent_failed") record.error = { code: "prompt_failed", message: "Invocation failed." };
421
+ if (frame.type === "agent_failed") {
422
+ logger.error("SDK invocation failed", {
423
+ kind,
424
+ commandId: correlation.commandId,
425
+ turnId: correlation.turnId,
426
+ error: formatPromptFailureForLocalLog(frame.error),
427
+ });
428
+ record.error = sanitizePromptFailure(frame.error);
429
+ }
420
430
  }
421
431
  await persist();
422
432
  },
@@ -13,11 +13,22 @@ export type CreateLifecycleAgentSessionResult =
13
13
  session: AgentSession;
14
14
  capability: SdkStartupCapability;
15
15
  rollback: SdkStartupRollbackTracker;
16
+ /**
17
+ * Starts the memory backend that construction deliberately skipped. The
18
+ * caller MUST run it only after readiness is published.
19
+ */
20
+ startDeferredMemoryBackend: () => Promise<void>;
16
21
  }
17
22
  | { capability: SdkStartupCapability; rollback: SdkStartupRollbackTracker; failure: SdkStartupFailure };
18
23
 
19
- /** Options accepted by lifecycle-only session construction. */
20
- export type CreateLifecycleAgentSessionOptions = CreateAgentSessionOptions & {
24
+ /**
25
+ * Options accepted by lifecycle-only session construction.
26
+ *
27
+ * `deferMemoryBackendStartup` is not accepted: lifecycle sessions are launched
28
+ * by the broker under a fixed readiness deadline, and memory startup runs
29
+ * unbounded LLM work, so deferral is an invariant rather than a caller choice.
30
+ */
31
+ export type CreateLifecycleAgentSessionOptions = Omit<CreateAgentSessionOptions, "deferMemoryBackendStartup"> & {
21
32
  /**
22
33
  * Startup budget for ACP lifecycle MCP launches, in milliseconds. Set only
23
34
  * when the lifecycle request supplies `mcpServers`; ordinary consumers keep
@@ -42,6 +53,11 @@ export async function createLifecycleAgentSession(
42
53
  const { mcpStartupTimeoutMs, readiness: _readiness, ...sessionOptions } = options;
43
54
  const internalOptions = {
44
55
  ...sessionOptions,
56
+ // Memory startup (rollout summarisation) issues one LLM request per
57
+ // claimed rollout, so its duration scales with the backlog. Keeping it
58
+ // inside the broker's readiness window is what kills the child at the
59
+ // cutoff; the host resumes it once readiness is published.
60
+ deferMemoryBackendStartup: true,
45
61
  [lifecycleStartupCapabilityOption]: capability,
46
62
  ...(mcpStartupTimeoutMs !== undefined ? { [lifecycleMcpStartupTimeoutOption]: mcpStartupTimeoutMs } : {}),
47
63
  } as CreateAgentSessionOptions & {
@@ -51,7 +67,14 @@ export async function createLifecycleAgentSession(
51
67
  const result = await createAgentSession(internalOptions);
52
68
  if (!result.session.extensionRunner)
53
69
  capability.settleFailure(capability.normalizeFailure("registration", "runner_absent"));
54
- return { session: result.session, capability, rollback };
70
+ if (!result.startDeferredMemoryBackend)
71
+ throw new Error("Lifecycle session construction did not return a deferred memory backend starter.");
72
+ return {
73
+ session: result.session,
74
+ capability,
75
+ rollback,
76
+ startDeferredMemoryBackend: result.startDeferredMemoryBackend,
77
+ };
55
78
  } catch (error) {
56
79
  const settled = capability.settleFailure(capability.normalizeFailure("registration", "failed", error));
57
80
  const failure =
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { getAgentDir } from "@gajae-code/utils";
3
3
  import { ensureBroker } from "../broker/ensure";
4
+ import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
4
5
  import { SdkClient, SdkClientError } from "../client/client";
5
6
  import {
6
7
  listSdkSessionEndpoints,
@@ -253,7 +254,11 @@ export function createSdkMcpServer(options: SdkMcpServerOptions = {}) {
253
254
  const broker = await readSdkBrokerDiscovery(agentDir);
254
255
  if (!broker) return { ok: false, error: { code: "not_found", message: "SDK broker not found" } };
255
256
  client = await connect(broker.url, broker.token);
256
- const response = await client.global(operation, input, { idempotencyKey });
257
+ const timeoutMs = lifecycleRequestTimeoutMs(operation, input);
258
+ const response = await client.global(operation, input, {
259
+ idempotencyKey,
260
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
261
+ });
257
262
  return isLifecycleOperation(operation) ? redactLifecycleCredentials(response) : response;
258
263
  } catch (error) {
259
264
  return resultError(error);
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Safe failure shaping shared by SDK transports and reconciliation stores.
3
+ * Provider error text is retained only in the local diagnostic log; wire and
4
+ * persisted reconciliation details expose a fixed redacted message.
5
+ */
6
+ export const PROMPT_FAILURE_CODE_MAX = 64;
7
+ const LOCAL_FAILURE_LOG_MAX = 16_384;
8
+
9
+ /** Safe-token code capped at 64; arbitrary failure text is never retained. */
10
+ export function sanitizePromptFailure(error: unknown): { code: string; message: string } {
11
+ let rawCode = "";
12
+ try {
13
+ const candidate = error as { code?: unknown } | undefined;
14
+ rawCode = typeof candidate?.code === "string" ? candidate.code : "";
15
+ } catch {
16
+ // Untrusted error records may expose throwing accessors.
17
+ }
18
+ const code = rawCode.length <= PROMPT_FAILURE_CODE_MAX && /^[A-Za-z0-9._-]+$/.test(rawCode) ? rawCode : "internal";
19
+ return { code, message: "Prompt submission failed." };
20
+ }
21
+
22
+ /** Best-effort local diagnostic text that never crosses the SDK boundary. */
23
+ export function formatPromptFailureForLocalLog(error: unknown): string {
24
+ try {
25
+ let detail: string;
26
+ if (error instanceof Error) {
27
+ const stack = error.stack;
28
+ detail = typeof stack === "string" ? stack : error.message;
29
+ } else if (typeof error === "string") detail = error;
30
+ else if (error !== null && typeof error === "object") {
31
+ const candidate = error as { code?: unknown; message?: unknown };
32
+ const code = typeof candidate.code === "string" ? candidate.code : undefined;
33
+ const message = typeof candidate.message === "string" ? candidate.message : undefined;
34
+ detail =
35
+ [code, message].filter((value): value is string => value !== undefined).join(": ") ||
36
+ "<object prompt failure>";
37
+ } else detail = String(error);
38
+ return detail.length <= LOCAL_FAILURE_LOG_MAX ? detail : `${detail.slice(0, LOCAL_FAILURE_LOG_MAX)}…`;
39
+ } catch {
40
+ return "<unserializable prompt failure>";
41
+ }
42
+ }
@@ -0,0 +1,162 @@
1
+ import { TOOL_TIMEOUTS } from "../tools/tool-timeouts";
2
+ import { HEARTBEAT_TTL_MS } from "./bus/daemon-paths";
3
+
4
+ /** Ceiling a caller may *request* for one tool call; `bash`/`ssh` cap out at 3600s. */
5
+ const MAX_TOOL_RUNTIME_MS = Math.max(...Object.values(TOOL_TIMEOUTS).map(config => config.max)) * 1_000;
6
+
7
+ /** Budget one tool call gets when nobody asks to extend it; `bash` defaults to 300s. */
8
+ const DEFAULT_TOOL_RUNTIME_MS = Math.max(...Object.values(TOOL_TIMEOUTS).map(config => config.default)) * 1_000;
9
+
10
+ /**
11
+ * Frame-free cost of one transport round trip while the producer is still healthy:
12
+ * the owner heartbeat has to lapse ({@link HEARTBEAT_TTL_MS}) before a reconnect is
13
+ * even attempted, and the reconnect itself gets a second window.
14
+ */
15
+ const SDK_RECONNECT_BUDGET_MS = 2 * HEARTBEAT_TTL_MS;
16
+
17
+ /**
18
+ * Inactivity bound for a prompt whose session host has no tool call in flight: after
19
+ * this much silence the host is treated as having stopped producing, so the prompt is
20
+ * settled instead of hanging the client forever.
21
+ *
22
+ * Every step of a live turn emits a frame (`agent_start`, message events, tool events,
23
+ * `agent_end`), so this bound only has to exceed the longest frame-free gap a healthy
24
+ * turn can produce *with nothing executing*: one model round trip, from the request
25
+ * leaving the host to the first streamed event coming back, possibly straddling a
26
+ * transport reconnect.
27
+ *
28
+ * `TOOL_TIMEOUTS` is this runtime's own statement of how long a single unattended step
29
+ * may block before it is declared stuck, and its slowest **default**
30
+ * ({@link DEFAULT_TOOL_RUNTIME_MS}) is the longest such wait nobody explicitly opted
31
+ * into. That is well above the agent's own model-stream watchdog (120s idle / 100s
32
+ * first event in `packages/ai/src/utils/idle-iterator.ts`), which aborts a stalled
33
+ * provider stream and republishes it as an error frame — itself a frame — long before
34
+ * this bound. {@link SDK_RECONNECT_BUDGET_MS} is added on top so a reconnect landing
35
+ * inside that gap cannot trip it either. Two providers widen their first-event
36
+ * fallback past this bound (alibaba-token-plan 600s, kimi-code 300s); a turn that
37
+ * emits nothing at all for this long on those is reported as abandoned rather than
38
+ * left `running`, and the session still accepts the next prompt.
39
+ *
40
+ * {@link MAX_TOOL_RUNTIME_MS} is deliberately *not* used here. `max` is what a caller
41
+ * may request, not what tools take, so sizing every gap for it produced a 63min bound
42
+ * that could never act as a safety net. Turns that legitimately reach that ceiling are
43
+ * covered by {@link ACP_PROMPT_TOOL_ACTIVITY_TIMEOUT_MS} instead, which is gated on
44
+ * evidence that a tool is actually executing.
45
+ */
46
+ export const ACP_PROMPT_INACTIVITY_TIMEOUT_MS = DEFAULT_TOOL_RUNTIME_MS + SDK_RECONNECT_BUDGET_MS;
47
+
48
+ /**
49
+ * Inactivity bound that applies only while the session host has an unfinished tool
50
+ * call — a `tool_execution_start`/`tool_execution_update` with no matching
51
+ * `tool_execution_end`.
52
+ *
53
+ * A long `bash` is protected by the evidence that it is running, not by a constant
54
+ * sized for the worst case, so this wide bound stands only for as long as that
55
+ * evidence does. It still terminates: no tool call may outlive
56
+ * {@link MAX_TOOL_RUNTIME_MS}, after which the host must publish `tool_execution_end`,
57
+ * so a host that dies mid-tool is still reported (one reconnect budget later).
58
+ */
59
+ export const ACP_PROMPT_TOOL_ACTIVITY_TIMEOUT_MS = MAX_TOOL_RUNTIME_MS + SDK_RECONNECT_BUDGET_MS;
60
+
61
+ /**
62
+ * Widest frame-free window a single provider call may legitimately occupy before the
63
+ * agent's own stream watchdog aborts it. Mirrors `ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS`
64
+ * in `packages/ai/src/utils/idle-iterator.ts` (600s), the largest per-provider first-event
65
+ * fallback of any built-in provider — `DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS` is 100s and
66
+ * `kimi-code` gets 300s. That constant is module-private there, so it is mirrored here and
67
+ * pinned against `getProviderFirstEventTimeoutFallbackMs` in the watchdog test: a change on
68
+ * either side fails loudly instead of silently inflating this bound.
69
+ */
70
+ const MAX_PROVIDER_FIRST_EVENT_TIMEOUT_MS = 600_000;
71
+
72
+ /**
73
+ * Inactivity bound that applies while a prompt is awaiting the model: a provider call is in
74
+ * flight and has not produced the first frame of its response yet.
75
+ *
76
+ * Tool execution had evidence and model inference did not, so a turn thinking after
77
+ * `agent_start` looked exactly like a dead producer and was killed at
78
+ * {@link ACP_PROMPT_INACTIVITY_TIMEOUT_MS}. Inference gets the same evidence-based
79
+ * tolerance: the wide bound stands only as long as a model call is observably unanswered.
80
+ *
81
+ * It still terminates. A provider stream that yields nothing within
82
+ * {@link MAX_PROVIDER_FIRST_EVENT_TIMEOUT_MS} is aborted by the agent's own stream watchdog,
83
+ * which republishes as an error/retry frame, so a host that dies mid-inference is still
84
+ * reported one reconnect budget later.
85
+ */
86
+ export const ACP_PROMPT_INFERENCE_TIMEOUT_MS = MAX_PROVIDER_FIRST_EVENT_TIMEOUT_MS + SDK_RECONNECT_BUDGET_MS;
87
+
88
+ /**
89
+ * Per-prompt view of what the session host is observably doing: which tool calls it has
90
+ * started but not finished, and whether it is waiting on a model call. Inbound frames are
91
+ * the only evidence the ACP side has, so "a tool is running" means exactly "a start was
92
+ * observed and its end was not", and "awaiting the model" means exactly "a model call was
93
+ * dispatched and nothing from its response has been observed".
94
+ */
95
+ export class PromptActivity {
96
+ readonly #running = new Set<string>();
97
+ #awaitingModel = false;
98
+
99
+ /** Folds one inbound frame's lifecycle event into the in-flight tool and model state. */
100
+ observe(eventType: string | undefined, toolCallId: string | undefined, messageRole: string | undefined): void {
101
+ this.#observeInference(eventType, messageRole);
102
+ if (toolCallId === undefined) return;
103
+ if (eventType === "tool_execution_start" || eventType === "tool_execution_update") this.#running.add(toolCallId);
104
+ else if (eventType === "tool_execution_end") this.#running.delete(toolCallId);
105
+ }
106
+
107
+ /**
108
+ * A turn is awaiting the model from every point where the host dispatches a provider
109
+ * call — `agent_start` for the turn's first call, `tool_execution_end` for the
110
+ * re-invocation that carries a tool result back — until the first frame of that
111
+ * response proves the model answered.
112
+ */
113
+ #observeInference(eventType: string | undefined, messageRole: string | undefined): void {
114
+ if (eventType === "agent_start" || eventType === "tool_execution_end") {
115
+ this.#awaitingModel = true;
116
+ return;
117
+ }
118
+ // Streamed content/thinking/tool-call deltas, and the tool call the model asked for,
119
+ // are only ever produced by an answering model.
120
+ if (eventType === "message_update" || eventType === "tool_execution_start") {
121
+ this.#awaitingModel = false;
122
+ return;
123
+ }
124
+ // Providers that do not stream emit no `message_update`, so the assistant message
125
+ // itself is the first proof. `message_start`/`message_end` also carry the user and
126
+ // tool-result messages the host echoes back, which prove nothing about the model.
127
+ if ((eventType === "message_start" || eventType === "message_end") && messageRole === "assistant")
128
+ this.#awaitingModel = false;
129
+ }
130
+
131
+ /** True while at least one started tool call has not reported an end. */
132
+ get running(): boolean {
133
+ return this.#running.size > 0;
134
+ }
135
+
136
+ /** True while a dispatched model call has not produced the first frame of its response. */
137
+ get awaitingModel(): boolean {
138
+ return this.#awaitingModel;
139
+ }
140
+
141
+ /** Bound that applies to the next frame-free gap, given what is observably executing. */
142
+ get inactivityBoundMs(): number {
143
+ if (this.running) return ACP_PROMPT_TOOL_ACTIVITY_TIMEOUT_MS;
144
+ return this.#awaitingModel ? ACP_PROMPT_INFERENCE_TIMEOUT_MS : ACP_PROMPT_INACTIVITY_TIMEOUT_MS;
145
+ }
146
+ }
147
+
148
+ /** Timer surface behind the prompt watchdog; tests substitute a virtual clock. */
149
+ export interface PromptWatchdogClock {
150
+ now(): number;
151
+ /** Runs `handler` after `delayMs`; the returned callback cancels the pending run. */
152
+ schedule(handler: () => void, delayMs: number): () => void;
153
+ }
154
+
155
+ export const systemPromptWatchdogClock: PromptWatchdogClock = {
156
+ now: () => Date.now(),
157
+ schedule(handler: () => void, delayMs: number): () => void {
158
+ const timer = setTimeout(handler, delayMs);
159
+ timer.unref?.();
160
+ return () => clearTimeout(timer);
161
+ },
162
+ };
@@ -0,0 +1,43 @@
1
+ import { HEARTBEAT_TTL_MS } from "./bus/daemon-paths";
2
+ import type { SdkClientOptions } from "./client";
3
+
4
+ type SessionReconnectOptions = Required<
5
+ Pick<SdkClientOptions, "reconnectAttempts" | "reconnectBackoffMs" | "reconnectMaxBackoffMs">
6
+ >;
7
+
8
+ const SESSION_RECONNECT_BACKOFF_MS = 250;
9
+ const SESSION_RECONNECT_MAX_BACKOFF_MS = 2_000;
10
+ /** Cover twice the host heartbeat TTL so a reaped stall still has room to recover. */
11
+ const SESSION_RECONNECT_BUDGET_MS = 2 * HEARTBEAT_TTL_MS;
12
+
13
+ function attemptsCovering(budgetMs: number): number {
14
+ let elapsed = 0;
15
+ let attempts = 0;
16
+ while (elapsed <= budgetMs) {
17
+ elapsed += Math.min(SESSION_RECONNECT_BACKOFF_MS * 2 ** attempts, SESSION_RECONNECT_MAX_BACKOFF_MS);
18
+ attempts++;
19
+ }
20
+ return attempts;
21
+ }
22
+
23
+ /**
24
+ * Reconnect budget for every long-lived SDK session client: ACP sessions and the
25
+ * chat daemon's attached sessions alike. Named for the ACP session that first
26
+ * needed it; it lives here because both live under the same host reaper and the
27
+ * bus layer must not import the ACP layer to reach it.
28
+ *
29
+ * Invariant: the client's total reconnect budget must outlive the host heartbeat
30
+ * TTL ({@link HEARTBEAT_TTL_MS}). The SDK host drops a session whose client has
31
+ * not ponged within that TTL, so a client that gives up reconnecting sooner turns
32
+ * every stall the host reaps into a permanently lost session ("SDK WebSocket
33
+ * reconnect attempts exhausted"). The transport defaults (3 attempts at 25ms base
34
+ * = 175ms total) are correct only for one-shot request clients.
35
+ *
36
+ * Backoff ramps 250 -> 500 -> 1000 and then holds at the 2s cap, so individual
37
+ * sleeps stay short and recovery is prompt once the host answers again.
38
+ */
39
+ export const ACP_SESSION_RECONNECT: SessionReconnectOptions = {
40
+ reconnectAttempts: attemptsCovering(SESSION_RECONNECT_BUDGET_MS),
41
+ reconnectBackoffMs: SESSION_RECONNECT_BACKOFF_MS,
42
+ reconnectMaxBackoffMs: SESSION_RECONNECT_MAX_BACKOFF_MS,
43
+ };
@@ -5,7 +5,14 @@ import {
5
5
  } from "../config/model-profile-contract";
6
6
  export type SdkStartupPhase = "registration" | "startup";
7
7
 
8
- export type SdkStartupReason = "disabled" | "ineligible" | "factory_absent" | "runner_absent" | "pending" | "failed";
8
+ export type SdkStartupReason =
9
+ | "disabled"
10
+ | "ineligible"
11
+ | "factory_absent"
12
+ | "runner_absent"
13
+ | "admission_timeout"
14
+ | "pending"
15
+ | "failed";
9
16
 
10
17
  export interface SdkStartupFailure {
11
18
  phase: SdkStartupPhase;
@@ -143,9 +150,11 @@ export function normalizeSdkStartupFailure(
143
150
  ? "SDK startup factory is unavailable."
144
151
  : reason === "runner_absent"
145
152
  ? "SDK startup extension runner is unavailable."
146
- : reason === "pending"
147
- ? "SDK startup did not complete before readiness cutoff."
148
- : FALLBACK_MESSAGE;
153
+ : reason === "admission_timeout"
154
+ ? "SDK host startup was not admitted before the queue wait cutoff."
155
+ : reason === "pending"
156
+ ? "SDK startup did not complete before readiness cutoff."
157
+ : FALLBACK_MESSAGE;
149
158
  const message = sanitizeSdkStartupMessage(error, knownSecrets);
150
159
  const profileError = isModelProfileError(error) ? { code: error.code, details: error.details } : {};
151
160
  return { phase, reason, message: message === FALLBACK_MESSAGE ? fallback : message, ...profileError };