@gajae-code/coding-agent 0.13.0 → 0.13.2

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 (119) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/config/file-lock.d.ts +2 -0
  3. package/dist/types/config/keybindings.d.ts +2 -2
  4. package/dist/types/config/model-registry.d.ts +4 -0
  5. package/dist/types/config/models-config-schema.d.ts +6 -0
  6. package/dist/types/config/settings-schema.d.ts +17 -5
  7. package/dist/types/config/settings.d.ts +10 -1
  8. package/dist/types/edit/streaming.d.ts +5 -5
  9. package/dist/types/eval/py/kernel.d.ts +2 -2
  10. package/dist/types/eval/py/runtime.d.ts +5 -1
  11. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
  12. package/dist/types/gjc-runtime/tmux-sessions.d.ts +3 -1
  13. package/dist/types/modes/components/model-selector.d.ts +3 -2
  14. package/dist/types/runtime/process-lifecycle.d.ts +12 -31
  15. package/dist/types/sdk/broker/identity.d.ts +2 -1
  16. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +17 -1
  17. package/dist/types/sdk/broker/lifecycle.d.ts +11 -6
  18. package/dist/types/sdk/broker/startup-failure.d.ts +32 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  20. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +4 -0
  22. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +10 -1
  23. package/dist/types/sdk/bus/reconciliation-store.d.ts +2 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -2
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +17 -1
  26. package/dist/types/sdk/host/query/cursor.d.ts +4 -0
  27. package/dist/types/sdk/host/query/handlers.d.ts +6 -0
  28. package/dist/types/sdk/host/session-runtime.d.ts +7 -6
  29. package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
  30. package/dist/types/sdk/index.d.ts +1 -0
  31. package/dist/types/sdk/turn-result.d.ts +37 -0
  32. package/dist/types/session/agent-session.d.ts +8 -11
  33. package/dist/types/session/blob-store.d.ts +6 -3
  34. package/dist/types/session/internal/managed-session-storage.d.ts +13 -0
  35. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +12 -3
  36. package/dist/types/task/executor.d.ts +4 -7
  37. package/dist/types/tools/index.d.ts +1 -1
  38. package/dist/types/tools/read.d.ts +1 -1
  39. package/dist/types/tools/search.d.ts +1 -1
  40. package/dist/types/utils/edit-mode.d.ts +69 -0
  41. package/package.json +8 -8
  42. package/scripts/generate-hotkeys-docs.ts +8 -6
  43. package/src/commands/sdk.ts +23 -2
  44. package/src/config/file-lock.ts +25 -5
  45. package/src/config/keybindings.ts +4 -2
  46. package/src/config/model-registry.ts +20 -0
  47. package/src/config/models-config-schema.ts +1 -0
  48. package/src/config/settings-schema.ts +29 -22
  49. package/src/config/settings.ts +42 -10
  50. package/src/coordinator-mcp/server.ts +87 -106
  51. package/src/edit/index.ts +5 -19
  52. package/src/edit/renderer.ts +3 -2
  53. package/src/edit/streaming.ts +28 -15
  54. package/src/eval/py/executor.ts +108 -10
  55. package/src/eval/py/kernel.ts +86 -12
  56. package/src/eval/py/runtime.ts +200 -16
  57. package/src/exec/bash-executor.ts +36 -37
  58. package/src/gjc-runtime/gc-runtime.ts +25 -3
  59. package/src/gjc-runtime/managed-owner-supervisor.ts +27 -1
  60. package/src/gjc-runtime/session-state-sidecar.ts +110 -4
  61. package/src/gjc-runtime/tmux-sessions.ts +34 -5
  62. package/src/internal-urls/docs-index.generated.ts +9 -9
  63. package/src/modes/acp/acp-agent.ts +10 -2
  64. package/src/modes/components/model-selector.ts +28 -8
  65. package/src/modes/controllers/plan-mode-controller.ts +3 -3
  66. package/src/modes/controllers/selector-controller.ts +4 -2
  67. package/src/modes/interactive-mode.ts +4 -0
  68. package/src/runtime/process-lifecycle.ts +126 -27
  69. package/src/runtime-mcp/transports/stdio.ts +11 -6
  70. package/src/sdk/acp/adapter.ts +18 -10
  71. package/src/sdk/broker/broker.ts +55 -4
  72. package/src/sdk/broker/ensure.ts +10 -7
  73. package/src/sdk/broker/identity.ts +10 -0
  74. package/src/sdk/broker/lifecycle-ledger.ts +56 -6
  75. package/src/sdk/broker/lifecycle.ts +53 -10
  76. package/src/sdk/broker/process-incarnation.ts +6 -1
  77. package/src/sdk/broker/session-index.ts +28 -11
  78. package/src/sdk/broker/startup-failure.ts +101 -0
  79. package/src/sdk/broker/transport.ts +1 -0
  80. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  81. package/src/sdk/bus/chat-daemon-runtime.ts +8 -1
  82. package/src/sdk/bus/conversation-store.ts +171 -50
  83. package/src/sdk/bus/index.ts +101 -47
  84. package/src/sdk/bus/kind-aware-reconciliation.ts +52 -2
  85. package/src/sdk/bus/notification-service.ts +3 -3
  86. package/src/sdk/bus/reconciliation-store.ts +16 -0
  87. package/src/sdk/bus/telegram-daemon-contract.ts +13 -2
  88. package/src/sdk/bus/telegram-daemon.ts +151 -41
  89. package/src/sdk/bus/topic-registry.ts +38 -9
  90. package/src/sdk/cli/session-cli.ts +20 -7
  91. package/src/sdk/client/discovery.ts +23 -2
  92. package/src/sdk/host/query/cursor.ts +4 -0
  93. package/src/sdk/host/query/handlers.ts +99 -100
  94. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  95. package/src/sdk/host/session-runtime.test.ts +98 -1
  96. package/src/sdk/host/session-runtime.ts +57 -15
  97. package/src/sdk/host/websocket-transport.lifecycle.test.ts +192 -2
  98. package/src/sdk/host/websocket-transport.ts +54 -30
  99. package/src/sdk/index.ts +1 -0
  100. package/src/sdk/mcp/server.ts +19 -6
  101. package/src/sdk/protocol/operation-inventory.generated.json +1 -1
  102. package/src/sdk/protocol/operation-registry.ts +8 -12
  103. package/src/sdk/session.ts +5 -14
  104. package/src/sdk/turn-result.ts +53 -0
  105. package/src/session/agent-session.ts +112 -69
  106. package/src/session/blob-store.ts +91 -21
  107. package/src/session/internal/managed-session-storage.ts +136 -21
  108. package/src/session/session-manager.ts +408 -81
  109. package/src/session/session-storage.ts +68 -10
  110. package/src/slash-commands/builtin-registry.ts +43 -5
  111. package/src/slash-commands/helpers/fast-status-report.ts +24 -8
  112. package/src/task/executor.ts +25 -8
  113. package/src/task/index.ts +5 -4
  114. package/src/tools/bash.ts +11 -9
  115. package/src/tools/index.ts +1 -1
  116. package/src/tools/read.ts +12 -11
  117. package/src/tools/search.ts +7 -7
  118. package/src/utils/edit-mode.ts +180 -9
  119. package/src/web/search/providers/insane.ts +55 -1
@@ -66,6 +66,8 @@ export interface LifecycleDurableEffectsReceipt {
66
66
  }
67
67
 
68
68
  export interface LifecycleLedgerEntry {
69
+ operationKey?: string;
70
+ fingerprint?: string;
69
71
  version: typeof SDK_STATE_VERSION;
70
72
  identity: string;
71
73
  requestHash: string;
@@ -153,7 +155,9 @@ function isLifecycleLedgerEntry(value: unknown): value is LifecycleLedgerEntry {
153
155
  entry.state === "terminal_error" ||
154
156
  entry.state === "terminal_uncertain") &&
155
157
  typeof entry.ts === "number" &&
156
- Number.isSafeInteger(entry.ts)
158
+ Number.isSafeInteger(entry.ts) &&
159
+ (entry.operationKey === undefined || typeof entry.operationKey === "string") &&
160
+ (entry.fingerprint === undefined || typeof entry.fingerprint === "string")
157
161
  );
158
162
  }
159
163
  function canonicalJson(value: unknown): string {
@@ -661,6 +665,35 @@ export class LifecycleLedger {
661
665
  }
662
666
  return replacement !== undefined;
663
667
  }
668
+ findByOperationKey(operationKey: string): LifecycleLedgerEntry | undefined {
669
+ return [...this.#byIdentity.values()].find(entry => entry.operationKey === operationKey);
670
+ }
671
+ /**
672
+ * Legacy target-inclusive rows predate the operation/key index. Their opaque
673
+ * identities cannot establish that a different target is safe, so callers
674
+ * must reject rather than create a second admission.
675
+ */
676
+ hasLegacyIdentity(): boolean {
677
+ return [...this.#byIdentity.values()].some(entry => entry.operationKey === undefined);
678
+ }
679
+ async migrateIdentity(
680
+ from: string,
681
+ to: string,
682
+ metadata: { operationKey: string; fingerprint: string },
683
+ ): Promise<LifecycleLedgerEntry | undefined> {
684
+ return this.#mutate(async () => {
685
+ if (this.#byIdentity.has(to)) return this.#byIdentity.get(to);
686
+ const entry = this.#byIdentity.get(from);
687
+ if (!entry) return undefined;
688
+ const migrated = await this.#append({ ...entry, identity: to, ...metadata, ts: Date.now() });
689
+ // Retire the legacy row so hasLegacyIdentity() returns false and future
690
+ // unrelated lifecycle requests are not globally blocked. The legacy
691
+ // identity gets a metadata-bearing replacement row in the append-only
692
+ // log, superseding the original metadata-free entry in #byIdentity.
693
+ if (entry.operationKey === undefined) await this.#append({ ...entry, ...metadata, ts: Date.now() });
694
+ return migrated;
695
+ });
696
+ }
664
697
  get warnings(): readonly string[] {
665
698
  return this.#warnings;
666
699
  }
@@ -687,11 +720,19 @@ export class LifecycleLedger {
687
720
  this.#byteCount += line.length;
688
721
  return entry;
689
722
  }
690
- async begin(identity: string, requestHash: string): Promise<BeginResult> {
691
- return this.#mutate(async () => this.#begin(identity, requestHash));
723
+ async begin(
724
+ identity: string,
725
+ requestHash: string,
726
+ metadata: { operationKey?: string; fingerprint?: string } = {},
727
+ ): Promise<BeginResult> {
728
+ return this.#mutate(async () => this.#begin(identity, requestHash, metadata));
692
729
  }
693
730
 
694
- async #begin(identity: string, requestHash: string): Promise<BeginResult> {
731
+ async #begin(
732
+ identity: string,
733
+ requestHash: string,
734
+ metadata: { operationKey?: string; fingerprint?: string },
735
+ ): Promise<BeginResult> {
695
736
  const prior = this.#byIdentity.get(identity);
696
737
  if (!prior)
697
738
  return {
@@ -700,15 +741,24 @@ export class LifecycleLedger {
700
741
  version: SDK_STATE_VERSION,
701
742
  identity,
702
743
  requestHash,
744
+ operationKey: metadata.operationKey,
745
+ fingerprint: metadata.fingerprint,
703
746
  state: "accepted",
704
747
  ts: Date.now(),
705
748
  }),
706
749
  };
707
- if (prior.requestHash !== requestHash) return { kind: "idempotency_conflict" };
750
+ if (
751
+ prior.requestHash !== requestHash ||
752
+ (prior.operationKey !== undefined &&
753
+ metadata.operationKey !== undefined &&
754
+ prior.operationKey !== metadata.operationKey)
755
+ )
756
+ return { kind: "idempotency_conflict" };
708
757
  if (terminal(prior.state) || (prior.state === "effect_started" && this.#isCleanupPending(prior)))
709
758
  return { kind: "replay", entry: prior };
710
759
  if (prior.state === "terminal_uncertain") return { kind: "terminal_uncertain", entry: prior };
711
- // An accepted row has no durable side effect. Target serialization makes retrying it safe.
760
+ // Accepted proves admission only. The effect_started marker is the boundary
761
+ // after which retrying the operation is unsafe.
712
762
  if (prior.state === "accepted") return { kind: "new", entry: prior };
713
763
  return { kind: "in_progress", entry: prior };
714
764
  }
@@ -89,6 +89,8 @@ export {
89
89
 
90
90
  const POLL_MS = 50;
91
91
  const CLOSE_TIMEOUT_MS = 2_000;
92
+ const STARTUP_CLEANUP_PROOF_GRACE_MS = 500;
93
+
92
94
  const MAX_RECEIVED_AT_SKEW_MS = 5_000;
93
95
  const MAX_LIFECYCLE_METADATA_BYTES = 4096;
94
96
  const MAX_EFFECT_MARKER_LENGTH = 128;
@@ -1957,6 +1959,7 @@ type SignalTarget = {
1957
1959
  pid: number;
1958
1960
  lifecycleRequestId?: string;
1959
1961
  processIncarnation?: string;
1962
+ hostIncarnation?: string;
1960
1963
  };
1961
1964
 
1962
1965
  /**
@@ -1979,7 +1982,11 @@ async function hasDurableProcessIdentity(target: SignalTarget, id: string, expec
1979
1982
  marker.incarnation === processIncarnation(target.pid)
1980
1983
  );
1981
1984
  if (expected && (expected.pid !== target.pid || target.lifecycleRequestId !== expected.effectMarker)) return false;
1982
- return target.processIncarnation !== undefined && target.processIncarnation === processIncarnation(target.pid);
1985
+ // Hosts that pass an explicit incarnation record it as `processIncarnation`;
1986
+ // the index's append path auto-stamps `hostIncarnation` for everything else.
1987
+ // Either binding is the same pid-to-incarnation proof.
1988
+ const recorded = target.hostIncarnation ?? target.processIncarnation;
1989
+ return recorded !== undefined && recorded === processIncarnation(target.pid);
1983
1990
  }
1984
1991
 
1985
1992
  async function hasOwnedReadinessEvidence(
@@ -3062,7 +3069,9 @@ async function revalidateCloseGeneration(
3062
3069
  function isTransportFailure(error: unknown): error is SdkClientError {
3063
3070
  return (
3064
3071
  error instanceof SdkClientError &&
3065
- ["unavailable", "timeout", "connection_closed", "reconnect_exhausted"].includes(error.code)
3072
+ ["unavailable", "timeout", "connection_closed", "uncertain_after_send", "reconnect_exhausted"].includes(
3073
+ error.code,
3074
+ )
3066
3075
  );
3067
3076
  }
3068
3077
 
@@ -3438,6 +3447,11 @@ async function executeLifecycleResponse(
3438
3447
  let record = broker.index.listSessions().sessions.find(session => session.sessionId === id);
3439
3448
  if (operation === "session.close") {
3440
3449
  if (!record) return fail("not_found", "session is not indexed");
3450
+ // DR-1 retains closed/unregistered rows for inspect and offline tail.
3451
+ // Pre-DR-1 they vanished from the listing and close answered not_found;
3452
+ // a terminal row is the same already-gone outcome, never an escalation
3453
+ // target — signalling its recorded pid could hit a reused process.
3454
+ if (record.terminal) return fail("resource_gone", "Session is already closed and unregistered.");
3441
3455
  if (record.terminalUncertain)
3442
3456
  return fail("terminal_uncertain", "Session ownership is uncertain and cannot be closed safely.");
3443
3457
  const requestedAuthority = requestedCloseAuthority(input);
@@ -4248,6 +4262,23 @@ async function exactCleanupProof(
4248
4262
  : undefined;
4249
4263
  }
4250
4264
 
4265
+ async function waitForExactCleanupProof(
4266
+ broker: Broker,
4267
+ root: string | undefined,
4268
+ id: string | undefined,
4269
+ expected: EffectMarker | undefined,
4270
+ evidence: { artifact: LifecycleFailureArtifact } | undefined,
4271
+ ): Promise<LifecycleCleanupProof | undefined> {
4272
+ const timing = lifecycleTiming(broker);
4273
+ const deadline = timing.now() + STARTUP_CLEANUP_PROOF_GRACE_MS;
4274
+ for (;;) {
4275
+ const proof = await exactCleanupProof(broker, root, id, expected, evidence);
4276
+ if (proof) return proof;
4277
+ const remaining = deadline - timing.now();
4278
+ if (remaining <= 0) return undefined;
4279
+ await timing.sleep(Math.min(POLL_MS, remaining));
4280
+ }
4281
+ }
4251
4282
  function validateLifecycleDeleteMetadataBinding(
4252
4283
  broker: Broker,
4253
4284
  operation: string,
@@ -4361,7 +4392,7 @@ export async function executeLifecycle(
4361
4392
  expected,
4362
4393
  )
4363
4394
  : undefined;
4364
- const cleanupProof = await exactCleanupProof(broker, root, entry?.intendedSessionId, expected, evidence);
4395
+ const cleanupProof = await waitForExactCleanupProof(broker, root, entry?.intendedSessionId, expected, evidence);
4365
4396
  const startupFailure: LifecycleStartupFailureReceipt | undefined = evidence
4366
4397
  ? {
4367
4398
  artifactDigest: evidence.digest,
@@ -4580,18 +4611,30 @@ export interface ReapedSessionRegistration {
4580
4611
  /**
4581
4612
  * Drops every indexed session registration whose host process is provably gone.
4582
4613
  *
4583
- * Liveness is the session index's own `alive(pid)` probe (`listSessions().live`),
4584
- * which reports live for both a running pid and an EPERM pid, so an alien or
4585
- * unreadable process is never mistaken for a dead one. Registrations already
4586
- * marked `terminalUncertain` are left alone: their disposition belongs to the
4587
- * terminal-uncertainty reconciliation path, and silently dropping them would
4588
- * turn a fail-closed `session.close`/`session.delete` refusal into `not_found`.
4614
+ * Proof of death is positive, never inferred from a missing liveness proof:
4615
+ * `observeProcess` reports "exited" only on ESRCH or on a readable OS process
4616
+ * incarnation that differs from the recorded one (a reused pid). A stale or
4617
+ * missing heartbeat merely makes a session read as not-live — the host may
4618
+ * still be running ahead of the next heartbeat checkpoint pass, so it is never
4619
+ * grounds for a reap. EPERM and unreadable incarnations stay "uncertain", so an
4620
+ * alien or unreadable process is never mistaken for a dead one. Registrations
4621
+ * already terminal or marked `terminalUncertain` are left alone: their
4622
+ * disposition belongs to the terminal-uncertainty reconciliation path, and
4623
+ * silently dropping them would turn a fail-closed `session.close`/
4624
+ * `session.delete` refusal into `not_found`.
4589
4625
  */
4590
4626
  export async function reapDeadSessionRegistrations(
4591
4627
  broker: Pick<Broker, "index">,
4592
4628
  ): Promise<ReapedSessionRegistration[]> {
4593
4629
  await broker.index.refresh();
4594
- const dead = broker.index.listSessions().sessions.filter(session => !session.live && !session.terminalUncertain);
4630
+ const dead = broker.index
4631
+ .listSessions()
4632
+ .sessions.filter(
4633
+ session =>
4634
+ !session.terminal &&
4635
+ !session.terminalUncertain &&
4636
+ observeProcess(session.pid, session.hostIncarnation ?? session.processIncarnation) === "exited",
4637
+ );
4595
4638
  const reaped: ReapedSessionRegistration[] = [];
4596
4639
  for (const session of dead) {
4597
4640
  await broker.index.append({
@@ -40,7 +40,12 @@ export interface ProcessIncarnationOptions {
40
40
 
41
41
  function runProcessIncarnationCommand(command: string, args: readonly string[]): ProcessIncarnationCommandResult {
42
42
  try {
43
- const result = Bun.spawnSync([command, ...args], { stdin: "ignore", stdout: "pipe", stderr: "ignore" });
43
+ const result = Bun.spawnSync([command, ...args], {
44
+ stdin: "ignore",
45
+ stdout: "pipe",
46
+ stderr: "ignore",
47
+ windowsHide: true,
48
+ });
44
49
  return { exitCode: result.exitCode, stdout: Buffer.from(result.stdout).toString("utf8") };
45
50
  } catch {
46
51
  return undefined;
@@ -278,8 +278,10 @@ function auditRecords(events: SessionIndexEvent[], ts: number): SessionIndexAudi
278
278
  * highest (generation, indexSeq); only `session_deleted` hides a row (DR-1 retains
279
279
  * stopped/terminal credential-free rows so inspect/offline tail can work). Heartbeats
280
280
  * inherit locator/endpoint metadata from their identity's prior event. Liveness (C2)
281
- * requires a heartbeat observed within 2x the checkpoint interval AND a live host:
282
- * a missing heartbeat (e.g. after a broker restart) is unknown, never fresh forever.
281
+ * requires host-written evidence — a heartbeat, or the host's own `host_registered`
282
+ * append — observed within 2x the checkpoint interval AND a live host whose OS
283
+ * incarnation still matches: a missing or stale evidence trail (e.g. after a broker
284
+ * restart) is unknown, never fresh forever.
283
285
  */
284
286
  function reduceEvents(events: SessionIndexEvent[], now: number): IndexedSession[] {
285
287
  const { admitted } = admitEvents(events);
@@ -325,7 +327,15 @@ function reduceEvents(events: SessionIndexEvent[], now: number): IndexedSession[
325
327
  if (latest.type === "session_deleted") continue;
326
328
  const heartbeat = latestHeartbeatByIdentity.get(chosen.identity);
327
329
  const pidAlive = alive(latest.pid);
328
- const heartbeatFresh = heartbeat !== undefined && now - heartbeat.ts < 2 * SESSION_HEARTBEAT_INTERVAL_MS;
330
+ // Liveness evidence is host-written: a checkpointed heartbeat, or the
331
+ // `host_registered` event the host appended itself. Counting registration
332
+ // closes the up-to-one-interval window where a just-registered session
333
+ // would read not-live before the first C2 pass — without weakening the
334
+ // pid-reuse fence, because the incarnation match below is still required.
335
+ // Broker-written events (reconciliation, terminal records) are not host
336
+ // evidence and never refresh liveness.
337
+ const evidenceTs = Math.max(heartbeat?.ts ?? 0, latest.type === "host_registered" ? latest.ts : 0);
338
+ const heartbeatFresh = evidenceTs > 0 && now - evidenceTs < 2 * SESSION_HEARTBEAT_INTERVAL_MS;
329
339
  const recordedIncarnation = effectiveIncarnation(latest);
330
340
  const currentIncarnation = recordedIncarnation === undefined ? undefined : processIncarnation(latest.pid);
331
341
  const incarnationMatches = currentIncarnation !== undefined && currentIncarnation === recordedIncarnation;
@@ -351,6 +361,13 @@ function reduceEvents(events: SessionIndexEvent[], now: number): IndexedSession[
351
361
  return sessions;
352
362
  }
353
363
 
364
+ // Global launch bursts may queue behind legitimate long index transactions. Keep
365
+ // this bounded at one minute while the shared lock's exact dead-owner recovery runs.
366
+ const SESSION_INDEX_LOCK_OPTIONS = { retries: 600, retryDelayMs: 100 } as const;
367
+
368
+ function withSessionIndexLock<T>(agentDir: string, callback: () => Promise<T>): Promise<T> {
369
+ return withFileLock(logFor(agentDir), callback, SESSION_INDEX_LOCK_OPTIONS);
370
+ }
354
371
  function isValidSnapshot(snapshot: unknown): snapshot is { indexSeq: number; events: SessionIndexEvent[] } {
355
372
  if (!snapshot || typeof snapshot !== "object") return false;
356
373
  const { indexSeq, events } = snapshot as { indexSeq?: unknown; events?: unknown };
@@ -557,7 +574,7 @@ export class SessionIndex {
557
574
  group.promise = SessionIndex.#enqueue(indexPath, () => this.#prepareOpenGroup(indexPath, group!));
558
575
  }
559
576
  await group.promise;
560
- await SessionIndex.#enqueue(indexPath, () => withFileLock(logFor(this.#agentDir), () => this.#replayUnderLock()));
577
+ await SessionIndex.#enqueue(indexPath, () => withSessionIndexLock(this.#agentDir, () => this.#replayUnderLock()));
561
578
  return this;
562
579
  }
563
580
  async #prepareOpenGroup(indexPath: string, group: SessionIndexOpenGroup): Promise<void> {
@@ -571,7 +588,7 @@ export class SessionIndex {
571
588
  }
572
589
  async replay(): Promise<void> {
573
590
  const indexPath = path.resolve(logFor(this.#agentDir));
574
- await SessionIndex.#enqueue(indexPath, () => withFileLock(logFor(this.#agentDir), () => this.#replayUnderLock()));
591
+ await SessionIndex.#enqueue(indexPath, () => withSessionIndexLock(this.#agentDir, () => this.#replayUnderLock()));
575
592
  }
576
593
  async #replayUnderLock(): Promise<void> {
577
594
  const scan = await this.#scan();
@@ -762,14 +779,14 @@ export class SessionIndex {
762
779
  }),
763
780
  );
764
781
  if (!exists.some(Boolean)) return { status: "healthy", validPrefixSeq: 0, snapshotSeq: 0 };
765
- return await withFileLock(logFor(this.#agentDir), async () => (await this.#scan()).diagnosis);
782
+ return await withSessionIndexLock(this.#agentDir, async () => (await this.#scan()).diagnosis);
766
783
  });
767
784
  }
768
785
  async repair(): Promise<SessionIndexRepairResult> {
769
786
  const indexPath = path.resolve(logFor(this.#agentDir));
770
787
  return await SessionIndex.#enqueue(indexPath, async () => {
771
788
  await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
772
- return await withFileLock(logFor(this.#agentDir), async () => {
789
+ return await withSessionIndexLock(this.#agentDir, async () => {
773
790
  const scan = await this.#scan();
774
791
  if (scan.diagnosis.status === "unsupported") return { ...scan.diagnosis, repaired: false };
775
792
  if (scan.diagnosis.status === "healthy") return { ...scan.diagnosis, repaired: false };
@@ -852,7 +869,7 @@ export class SessionIndex {
852
869
  async refresh(): Promise<void> {
853
870
  const indexPath = path.resolve(logFor(this.#agentDir));
854
871
  await SessionIndex.#enqueue(indexPath, () =>
855
- withFileLock(logFor(this.#agentDir), () => this.#refreshUnderLock()),
872
+ withSessionIndexLock(this.#agentDir, () => this.#refreshUnderLock()),
856
873
  );
857
874
  }
858
875
  async #refreshUnderLock(): Promise<void> {
@@ -869,7 +886,7 @@ export class SessionIndex {
869
886
  const indexPath = path.resolve(logFor(this.#agentDir));
870
887
  return await SessionIndex.#enqueue(indexPath, async () => {
871
888
  await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
872
- return await withFileLock(logFor(this.#agentDir), async () => {
889
+ return await withSessionIndexLock(this.#agentDir, async () => {
873
890
  await this.#replayUnderLock();
874
891
  if (this.#corruptSuffix)
875
892
  throw new Error(
@@ -905,7 +922,7 @@ export class SessionIndex {
905
922
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return await callback();
906
923
  throw error;
907
924
  }
908
- return await withFileLock(logFor(this.#agentDir), async () => {
925
+ return await withSessionIndexLock(this.#agentDir, async () => {
909
926
  await this.#replayUnderLock();
910
927
  if (this.#corruptSuffix) throw new Error("Cannot use corrupt session index for artifact reclamation");
911
928
  return await callback();
@@ -915,7 +932,7 @@ export class SessionIndex {
915
932
  async snapshot(): Promise<void> {
916
933
  const indexPath = path.resolve(logFor(this.#agentDir));
917
934
  await SessionIndex.#enqueue(indexPath, () =>
918
- withFileLock(logFor(this.#agentDir), () => this.#snapshotUnderLock()),
935
+ withSessionIndexLock(this.#agentDir, () => this.#snapshotUnderLock()),
919
936
  );
920
937
  }
921
938
  async #snapshotUnderLock(): Promise<void> {
@@ -0,0 +1,101 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+
4
+ export interface BrokerStartupFailureMarker {
5
+ version: 1;
6
+ reason: string;
7
+ exitCode: number | null;
8
+ signal: string | null;
9
+ writtenAt: number;
10
+ }
11
+
12
+ const BROKER_STARTUP_FAILURE_FILE = "broker.startup-failure.json";
13
+ const MAX_BROKER_STARTUP_FAILURE_REASON = 512;
14
+
15
+ export function brokerStartupFailurePath(agentDir: string): string {
16
+ return path.join(agentDir, "sdk", BROKER_STARTUP_FAILURE_FILE);
17
+ }
18
+
19
+ function boundedMarker(reason: string, exitCode: number | null, signal: string | null): BrokerStartupFailureMarker {
20
+ return {
21
+ version: 1,
22
+ reason: reason.slice(0, MAX_BROKER_STARTUP_FAILURE_REASON),
23
+ exitCode,
24
+ signal,
25
+ writtenAt: Date.now(),
26
+ };
27
+ }
28
+
29
+ /** Best-effort durable marker write; never throws (diagnostics must not mask the exit itself). */
30
+ export async function writeBrokerStartupFailureMarker(
31
+ agentDir: string,
32
+ failure: { reason: string; exitCode: number | null; signal: string | null },
33
+ ): Promise<void> {
34
+ try {
35
+ await fs.mkdir(path.dirname(brokerStartupFailurePath(agentDir)), { recursive: true, mode: 0o700 });
36
+ await Bun.write(
37
+ brokerStartupFailurePath(agentDir),
38
+ JSON.stringify(boundedMarker(failure.reason, failure.exitCode, failure.signal)),
39
+ );
40
+ } catch {
41
+ // Best-effort only.
42
+ }
43
+ }
44
+
45
+ /** Reads a bounded startup-failure marker; `undefined` when absent or malformed. */
46
+ export async function readBrokerStartupFailureMarker(
47
+ agentDir: string,
48
+ ): Promise<BrokerStartupFailureMarker | undefined> {
49
+ try {
50
+ const file = Bun.file(brokerStartupFailurePath(agentDir));
51
+ if (!(await file.exists())) return undefined;
52
+ const raw = await file.text();
53
+ const value: unknown = JSON.parse(raw);
54
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
55
+ const marker = value as Partial<BrokerStartupFailureMarker>;
56
+ if (
57
+ marker.version !== 1 ||
58
+ typeof marker.reason !== "string" ||
59
+ marker.reason.length === 0 ||
60
+ (marker.exitCode !== null && typeof marker.exitCode !== "number") ||
61
+ (marker.signal !== null && typeof marker.signal !== "string") ||
62
+ typeof marker.writtenAt !== "number"
63
+ )
64
+ return undefined;
65
+ return marker as BrokerStartupFailureMarker;
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ }
70
+
71
+ /** Best-effort marker removal; used at spawn so a stale marker never misattributes an older failure. */
72
+ export async function clearBrokerStartupFailureMarker(agentDir: string): Promise<void> {
73
+ try {
74
+ const file = Bun.file(brokerStartupFailurePath(agentDir));
75
+ if (await file.exists()) await file.unlink();
76
+ } catch (error) {
77
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
78
+ // Best-effort only.
79
+ }
80
+ }
81
+ }
82
+
83
+ /** Typed error returned when a detached broker exits before discovery. */
84
+ export class BrokerStartupError extends Error {
85
+ readonly code = "broker_startup_failed";
86
+ readonly exitCode: number | null;
87
+ readonly signal: string | null;
88
+ readonly reason: string;
89
+ readonly stderrExcerpt?: string;
90
+
91
+ constructor(fields: { exitCode: number | null; signal: string | null; reason: string; stderrExcerpt?: string }) {
92
+ super(
93
+ `Detached SDK broker exited before discovery (code=${fields.exitCode}, signal=${fields.signal}): ${fields.reason}${fields.stderrExcerpt ? ` Broker stderr: ${fields.stderrExcerpt}` : ""}`,
94
+ );
95
+ this.name = "BrokerStartupError";
96
+ this.exitCode = fields.exitCode;
97
+ this.signal = fields.signal;
98
+ this.reason = fields.reason;
99
+ this.stderrExcerpt = fields.stderrExcerpt;
100
+ }
101
+ }
@@ -18,6 +18,7 @@ const BROKER_OPERATIONS = new Set([
18
18
  "elevation.issue",
19
19
  "elevation.status",
20
20
  "session.control",
21
+ "broker.lookup_lifecycle",
21
22
  ]);
22
23
  type RequestInput = Record<string, unknown>;
23
24
  type BrokerRequest = {
@@ -72,11 +72,12 @@ export type ChatDaemonAction = "stop" | "reload";
72
72
  * before the host heartbeat TTL expires and permanently loses its attachment. Discord
73
73
  * generation 29 / slack generation 28 advance the replay cursor only after a frame is
74
74
  * published, so an owner at an earlier generation acknowledges an event before delivering
75
- * it and loses that event for good the first time a surface refuses it.
75
+ * it and loses that event for good the first time a surface refuses it. Discord generation 30 /
76
+ * slack generation 29 invalidate existing owners after SDK discovery validation changes.
76
77
  */
77
78
  export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
78
- discord: 29,
79
- slack: 28,
79
+ discord: 30,
80
+ slack: 29,
80
81
  };
81
82
 
82
83
  export function chatDaemonGeneration(kind: ChatDaemonKind): number {
@@ -67,7 +67,14 @@ function chatDeliveryPhase(error: unknown): ChatDeliveryPhase | undefined {
67
67
  if (!(error instanceof SdkClientError)) return undefined;
68
68
  // `connection_closed` conveys no send-progress guarantee: SdkClient also emits it
69
69
  // when a pending, already-sent request loses its response.
70
- return ["connection_closed", "unavailable", "timeout", "reconnect_exhausted", "protocol_error"].includes(error.code)
70
+ return [
71
+ "connection_closed",
72
+ "unavailable",
73
+ "timeout",
74
+ "uncertain_after_send",
75
+ "reconnect_exhausted",
76
+ "protocol_error",
77
+ ].includes(error.code)
71
78
  ? "ambiguous"
72
79
  : undefined;
73
80
  }