@gajae-code/coding-agent 0.13.0 → 0.13.1

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 (113) hide show
  1. package/CHANGELOG.md +18 -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 +11 -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/slash-commands/helpers/fast-status-report.d.ts +12 -3
  35. package/dist/types/task/executor.d.ts +4 -7
  36. package/dist/types/tools/index.d.ts +1 -1
  37. package/dist/types/tools/read.d.ts +1 -1
  38. package/dist/types/tools/search.d.ts +1 -1
  39. package/dist/types/utils/edit-mode.d.ts +69 -0
  40. package/package.json +8 -8
  41. package/scripts/generate-hotkeys-docs.ts +8 -6
  42. package/src/commands/sdk.ts +23 -2
  43. package/src/config/file-lock.ts +25 -5
  44. package/src/config/keybindings.ts +4 -2
  45. package/src/config/model-registry.ts +20 -0
  46. package/src/config/models-config-schema.ts +1 -0
  47. package/src/config/settings-schema.ts +29 -22
  48. package/src/config/settings.ts +42 -10
  49. package/src/coordinator-mcp/server.ts +84 -106
  50. package/src/edit/index.ts +5 -19
  51. package/src/edit/renderer.ts +3 -2
  52. package/src/edit/streaming.ts +28 -15
  53. package/src/eval/py/executor.ts +108 -10
  54. package/src/eval/py/kernel.ts +86 -12
  55. package/src/eval/py/runtime.ts +200 -16
  56. package/src/exec/bash-executor.ts +36 -37
  57. package/src/gjc-runtime/gc-runtime.ts +25 -3
  58. package/src/gjc-runtime/managed-owner-supervisor.ts +27 -1
  59. package/src/gjc-runtime/session-state-sidecar.ts +110 -4
  60. package/src/gjc-runtime/tmux-sessions.ts +34 -5
  61. package/src/internal-urls/docs-index.generated.ts +6 -6
  62. package/src/modes/acp/acp-agent.ts +10 -2
  63. package/src/modes/components/model-selector.ts +28 -8
  64. package/src/modes/controllers/plan-mode-controller.ts +3 -3
  65. package/src/modes/controllers/selector-controller.ts +4 -2
  66. package/src/runtime/process-lifecycle.ts +126 -27
  67. package/src/runtime-mcp/transports/stdio.ts +11 -6
  68. package/src/sdk/acp/adapter.ts +18 -10
  69. package/src/sdk/broker/broker.ts +55 -4
  70. package/src/sdk/broker/ensure.ts +10 -7
  71. package/src/sdk/broker/identity.ts +10 -0
  72. package/src/sdk/broker/lifecycle-ledger.ts +56 -6
  73. package/src/sdk/broker/lifecycle.ts +53 -10
  74. package/src/sdk/broker/session-index.ts +28 -11
  75. package/src/sdk/broker/startup-failure.ts +101 -0
  76. package/src/sdk/broker/transport.ts +1 -0
  77. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  78. package/src/sdk/bus/chat-daemon-runtime.ts +8 -1
  79. package/src/sdk/bus/conversation-store.ts +171 -50
  80. package/src/sdk/bus/index.ts +101 -47
  81. package/src/sdk/bus/kind-aware-reconciliation.ts +52 -2
  82. package/src/sdk/bus/notification-service.ts +3 -3
  83. package/src/sdk/bus/reconciliation-store.ts +16 -0
  84. package/src/sdk/bus/telegram-daemon-contract.ts +11 -2
  85. package/src/sdk/bus/telegram-daemon.ts +131 -29
  86. package/src/sdk/bus/topic-registry.ts +38 -9
  87. package/src/sdk/cli/session-cli.ts +20 -7
  88. package/src/sdk/client/discovery.ts +23 -2
  89. package/src/sdk/host/query/cursor.ts +4 -0
  90. package/src/sdk/host/query/handlers.ts +99 -100
  91. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  92. package/src/sdk/host/session-runtime.test.ts +98 -1
  93. package/src/sdk/host/session-runtime.ts +57 -15
  94. package/src/sdk/host/websocket-transport.lifecycle.test.ts +192 -2
  95. package/src/sdk/host/websocket-transport.ts +54 -30
  96. package/src/sdk/index.ts +1 -0
  97. package/src/sdk/mcp/server.ts +19 -6
  98. package/src/sdk/protocol/operation-inventory.generated.json +1 -1
  99. package/src/sdk/protocol/operation-registry.ts +8 -12
  100. package/src/sdk/session.ts +5 -14
  101. package/src/sdk/turn-result.ts +53 -0
  102. package/src/session/agent-session.ts +112 -69
  103. package/src/session/blob-store.ts +91 -21
  104. package/src/session/session-manager.ts +408 -81
  105. package/src/session/session-storage.ts +68 -10
  106. package/src/slash-commands/helpers/fast-status-report.ts +24 -8
  107. package/src/task/executor.ts +25 -8
  108. package/src/task/index.ts +0 -2
  109. package/src/tools/index.ts +1 -1
  110. package/src/tools/read.ts +12 -11
  111. package/src/tools/search.ts +7 -7
  112. package/src/utils/edit-mode.ts +180 -9
  113. package/src/web/search/providers/insane.ts +55 -1
@@ -5,6 +5,8 @@
5
5
 
6
6
  import { sanitizePromptFailure } from "../prompt-failure";
7
7
  import type { PromptReconciliationStatus, SdkPromptTerminalOutcome, TurnPromptReconciliation } from "../prompt-status";
8
+ import { sanitizeTurnResultContent, type TurnResultContent, type TurnResultPage } from "../turn-result";
9
+
8
10
  import {
9
11
  PROMPT_RECONCILIATION_ACTIVE_CAPACITY,
10
12
  PROMPT_RECONCILIATION_TERMINAL_CAPACITY,
@@ -17,6 +19,7 @@ export type { ReconciliationKind };
17
19
 
18
20
  export interface KindCorrelation extends PromptCorrelation {
19
21
  kind: ReconciliationKind;
22
+ content?: TurnResultContent;
20
23
  }
21
24
 
22
25
  export interface KindAwareReconciliation {
@@ -31,7 +34,9 @@ export interface KindAwareReconciliation {
31
34
  noteTransition(
32
35
  kind: ReconciliationKind,
33
36
  correlation: PromptCorrelation | undefined,
34
- frame: { type: "agent_start" | "agent_end" } | { type: "agent_failed"; error: unknown },
37
+ frame:
38
+ | { type: "agent_start" | "agent_end"; content?: TurnResultContent }
39
+ | { type: "agent_failed"; error: unknown; content?: TurnResultContent },
35
40
  ): Promise<void>;
36
41
  claimPendingOutcome(
37
42
  correlation: PromptCorrelation,
@@ -41,12 +46,17 @@ export interface KindAwareReconciliation {
41
46
  correlation: PromptCorrelation,
42
47
  outcome?: SdkPromptTerminalOutcome,
43
48
  recordError?: { code: string; message: string },
49
+ content?: unknown,
44
50
  ): Promise<void>;
45
51
  peekPendingOutcome(correlation: PromptCorrelation): SdkPromptTerminalOutcome | undefined;
46
52
  lookup(
47
53
  kind: ReconciliationKind,
48
54
  selector: { commandId?: string; turnId?: string; clientRef?: string },
49
55
  ): TurnPromptReconciliation;
56
+ lookupResult(
57
+ kind: ReconciliationKind,
58
+ selector: { commandId?: string; turnId?: string; clientRef?: string },
59
+ ): TurnResultPage;
50
60
  cleanup(): void;
51
61
  activeCount(kind: ReconciliationKind): number;
52
62
  /** Hydrate from durable store (call once at session host start). */
@@ -190,7 +200,9 @@ export function createKindAwareReconciliation(
190
200
  const noteTransition = async (
191
201
  kind: ReconciliationKind,
192
202
  correlation: PromptCorrelation | undefined,
193
- frame: { type: "agent_start" | "agent_end" } | { type: "agent_failed"; error: unknown },
203
+ frame:
204
+ | { type: "agent_start" | "agent_end"; content?: TurnResultContent }
205
+ | { type: "agent_failed"; error: unknown; content?: TurnResultContent },
194
206
  ) => {
195
207
  if (!correlation) return;
196
208
  await queueMutation(candidate => {
@@ -214,9 +226,13 @@ export function createKindAwareReconciliation(
214
226
  if (record.status !== "accepted") return { value: undefined, changed: false };
215
227
  record.status = "in_flight";
216
228
  record.startedAt = now();
229
+ if (frame.content) record.content = sanitizeTurnResultContent(frame.content.text);
230
+
217
231
  return { value: undefined, changed: true };
218
232
  }
219
233
  record.terminalAt = now();
234
+ if (frame.content) record.content = sanitizeTurnResultContent(frame.content.text);
235
+
220
236
  if (frame.type === "agent_failed") {
221
237
  record.status = "failed";
222
238
  record.error = sanitizePromptFailure(frame.error);
@@ -243,6 +259,7 @@ export function createKindAwareReconciliation(
243
259
  correlation: PromptCorrelation,
244
260
  outcome?: SdkPromptTerminalOutcome,
245
261
  recordError?: { code: string; message: string },
262
+ content?: unknown,
246
263
  ) => {
247
264
  await queueMutation(candidate => {
248
265
  const record = candidate.get(keyOf("prompt", correlation));
@@ -254,6 +271,7 @@ export function createKindAwareReconciliation(
254
271
  record.status = "failed";
255
272
  record.error = recordError ?? { code: finalOutcome.code, message: finalOutcome.message };
256
273
  } else record.status = "terminal_ok";
274
+ record.content = sanitizeTurnResultContent(content);
257
275
  record.outcome = finalOutcome;
258
276
  record.pendingOutcome = undefined;
259
277
  cleanupRecords(candidate);
@@ -301,6 +319,37 @@ export function createKindAwareReconciliation(
301
319
  return { status: "failed", ...terminal, error: record.error ?? sanitizePromptFailure(undefined) };
302
320
  };
303
321
 
322
+ const lookupResult = (
323
+ kind: ReconciliationKind,
324
+ selector: { commandId?: string; turnId?: string; clientRef?: string },
325
+ ): TurnResultPage => {
326
+ cleanup();
327
+ const key =
328
+ selector.clientRef !== undefined
329
+ ? clientRefIndex.get(refKey(kind, selector.clientRef))
330
+ : selector.commandId !== undefined && selector.turnId !== undefined
331
+ ? keyOf(kind, { commandId: selector.commandId, turnId: selector.turnId })
332
+ : undefined;
333
+ const record = key === undefined ? undefined : records.get(key);
334
+ if (!record) return { status: "unknown" };
335
+ const identity = {
336
+ kind: record.kind,
337
+ commandId: record.commandId,
338
+ turnId: record.turnId,
339
+ ...(record.clientRef !== undefined ? { clientRef: record.clientRef } : {}),
340
+ acceptedAt: record.acceptedAt,
341
+ };
342
+ if (record.status === "accepted") return { status: "accepted", ...identity };
343
+ if (record.status === "in_flight") return { status: "in_flight", ...identity, startedAt: record.startedAt };
344
+ const terminal = {
345
+ ...identity,
346
+ ...(record.startedAt !== undefined ? { startedAt: record.startedAt } : {}),
347
+ terminalAt: record.terminalAt as number,
348
+ ...(record.content !== undefined ? { content: record.content } : {}),
349
+ };
350
+ if (record.status === "terminal_ok") return { status: "terminal_ok", ...terminal };
351
+ return { status: "failed", ...terminal, error: record.error ?? sanitizePromptFailure(undefined) };
352
+ };
304
353
  const hydrateFromStore = async () => {
305
354
  if (!store) return;
306
355
  const run = async () => {
@@ -328,6 +377,7 @@ export function createKindAwareReconciliation(
328
377
  finalizePromptOutcome,
329
378
  peekPendingOutcome,
330
379
  lookup,
380
+ lookupResult,
331
381
  cleanup,
332
382
  activeCount,
333
383
  hydrateFromStore,
@@ -883,12 +883,12 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
883
883
  // `acquireDaemonOwnership` then answers `provisional`, which `ensureTelegramDaemonRunning`
884
884
  // reports as `blocked_identity`: no transport is ever attached and no
885
885
  // notification is ever published, while the owner keeps heartbeating. Reporting
886
- // that as OK is what makes the outage invisible; the generation is inventory
887
- // metadata that never forces a reload on its own, so the operator has to.
886
+ // that as OK is what makes the outage invisible. Recovery is platform-dependent,
887
+ // so health identifies the condition without prescribing an unsupported command.
888
888
  checks.push({
889
889
  name: "daemon",
890
890
  level: "warn",
891
- detail: `daemon pid ${daemon.pid} serves generation ${daemon.generation ?? "unknown"} but this build attaches only to generation ${daemon.currentGeneration}; it cannot attach a session transport — run \`gjc daemon reload\``,
891
+ detail: `daemon pid ${daemon.pid} serves generation ${daemon.generation ?? "unknown"} but this build attaches only to generation ${daemon.currentGeneration}; it cannot attach a session transport — inspect daemon lifecycle status before attempting recovery`,
892
892
  });
893
893
  } else {
894
894
  checks.push({ name: "daemon", level: "ok", detail: `daemon pid ${daemon.pid} alive with a fresh heartbeat` });
@@ -12,6 +12,8 @@
12
12
  import * as fs from "node:fs/promises";
13
13
  import * as path from "node:path";
14
14
  import type { PromptReconciliationStatus, SdkPromptTerminalOutcome } from "../prompt-status";
15
+ import { TURN_RESULT_CONTENT_MAX_BYTES, type TurnResultContent } from "../turn-result";
16
+
15
17
  import type { PromptCorrelation } from "./prompt-reconciliation";
16
18
 
17
19
  export const RECONCILIATION_STORE_VERSION = 1;
@@ -32,6 +34,7 @@ export interface DurableReconciliationRecord extends PromptCorrelation {
32
34
  pendingOutcome?: SdkPromptTerminalOutcome;
33
35
  /** Skill-only safe token; never skill args bodies. */
34
36
  skillName?: string;
37
+ content?: TurnResultContent;
35
38
  }
36
39
 
37
40
  export interface ReconciliationStoreDocument {
@@ -112,6 +115,19 @@ function isValidRecord(value: unknown): boolean {
112
115
  if (!isRecord(value.error)) return false;
113
116
  if (typeof value.error.code !== "string" || typeof value.error.message !== "string") return false;
114
117
  }
118
+ if (value.content !== undefined) {
119
+ if (
120
+ !isRecord(value.content) ||
121
+ value.content.version !== 1 ||
122
+ value.content.type !== "text" ||
123
+ typeof value.content.text !== "string" ||
124
+ typeof value.content.byteLength !== "number" ||
125
+ typeof value.content.truncated !== "boolean" ||
126
+ new TextEncoder().encode(value.content.text).length !== value.content.byteLength ||
127
+ value.content.byteLength > TURN_RESULT_CONTENT_MAX_BYTES
128
+ )
129
+ return false;
130
+ }
115
131
  return [outcome, pendingOutcome].every(candidate => {
116
132
  if (candidate === undefined) return true;
117
133
  if (!isRecord(candidate)) return false;
@@ -95,9 +95,18 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
95
95
  * the exact grace deadline when a failed archive publication rolls back.
96
96
  * Generation 59 publishes the owner's attached OPEN-socket count in the
97
97
  * heartbeat sidecar so `gjc notify health` can report a live daemon that is
98
- * attached to no registered endpoint (#4128).
98
+ * attached to no registered endpoint (#4128). Generation 60 contains transient
99
+ * heartbeat-sidecar publication failures: a Windows EPERM/EACCES/EBUSY on the
100
+ * sidecar rename is retried bounded under the ownership-lock fence and an
101
+ * exhausted publication keeps the daemon alive as `publish_failed` instead of
102
+ * escaping as an uncaught exception, while only a proven state/lock mismatch
103
+ * (`not_owner`) stops the owner (#4200). Generation 61 keeps authenticated
104
+ * same-session transports attached while predecessor archival converges and
105
+ * settles non-forum close responses. Generation 62 rejects replay-gap
106
+ * authority claims that do not match the requested cursor, answer bounds,
107
+ * and retained replay suffix.
99
108
  */
100
- export const DAEMON_GENERATION = 59;
109
+ export const DAEMON_GENERATION = 62;
101
110
 
102
111
  /**
103
112
  * Serving-compatibility boundary for daemon lifecycle requests. Epoch 5
@@ -531,7 +531,7 @@ function topicArchiveSettled(response: unknown): boolean {
531
531
  if (result.ok === true && result.result === true) return true;
532
532
  if (result.ok !== false || result.error_code !== 400 || typeof result.description !== "string") return false;
533
533
  const description = result.description.trim();
534
- return /^(?:Bad Request: )?(?:TOPIC_NOT_FOUND|THREAD_NOT_FOUND|topic (?:already|is already) closed|message thread (?:not found|is not modified))$/i.test(
534
+ return /^(?:Bad Request: )?(?:TOPIC_NOT_FOUND|THREAD_NOT_FOUND|the chat is not a (?:supergroup )?forum|topic (?:already|is already) closed|message thread (?:not found|is not modified))$/i.test(
535
535
  description,
536
536
  );
537
537
  }
@@ -1344,6 +1344,30 @@ export async function readOwnerFreshnessSnapshot(input: {
1344
1344
  };
1345
1345
  }
1346
1346
 
1347
+ /** Outcome of a steady heartbeat sidecar renewal. */
1348
+ export type OwnerHeartbeatSidecarRenewal =
1349
+ /** The sidecar was published under a still-matching ownership lock. */
1350
+ | "renewed"
1351
+ /** This process no longer matches the persisted state/lock; stop renewing. */
1352
+ | "not_owner"
1353
+ /**
1354
+ * Ownership still held, but the sidecar publication failed (e.g. a transient
1355
+ * Windows EPERM/EBUSY while another process holds the destination). The
1356
+ * daemon must stay alive and publish again on the next cycle (#4200); a
1357
+ * persistently failing publication eventually stales the heartbeat for
1358
+ * outside observers, whose takeover then surfaces here as `not_owner`.
1359
+ */
1360
+ | "publish_failed";
1361
+
1362
+ const HEARTBEAT_SIDECAR_PUBLISH_ATTEMPTS = 4;
1363
+ const HEARTBEAT_SIDECAR_PUBLISH_BACKOFF_MS = 25;
1364
+
1365
+ /** Windows sharing/permission failures from antivirus, indexing, or external file locks. */
1366
+ function isTransientSidecarPublishError(error: unknown): boolean {
1367
+ const code = (error as { code?: unknown } | null)?.code;
1368
+ return code === "EPERM" || code === "EACCES" || code === "EBUSY";
1369
+ }
1370
+
1347
1371
  /** Marker-free steady heartbeat renewal. The final lock reread fences a stale writer. */
1348
1372
  export async function renewOwnerHeartbeatSidecar(input: {
1349
1373
  settings: Settings;
@@ -1359,7 +1383,7 @@ export async function renewOwnerHeartbeatSidecar(input: {
1359
1383
  * rather than falsely claiming zero attachments.
1360
1384
  */
1361
1385
  attachedEndpoints?: number;
1362
- }): Promise<boolean> {
1386
+ }): Promise<OwnerHeartbeatSidecarRenewal> {
1363
1387
  const fsImpl = input.fs ?? nodeFs;
1364
1388
  const paths = daemonPaths(input.settings.getAgentDir());
1365
1389
  const state = await readJson<DaemonState>(fsImpl, paths.state);
@@ -1376,7 +1400,7 @@ export async function renewOwnerHeartbeatSidecar(input: {
1376
1400
  state.acquisitionId !== acquisitionId ||
1377
1401
  state.stoppedAt !== undefined
1378
1402
  )
1379
- return false;
1403
+ return "not_owner";
1380
1404
  const sidecar: OwnerHeartbeatSidecar = {
1381
1405
  pid,
1382
1406
  incarnation,
@@ -1386,22 +1410,48 @@ export async function renewOwnerHeartbeatSidecar(input: {
1386
1410
  ...(validAttachedEndpointCount(input.attachedEndpoints) ? { attachedEndpoints: input.attachedEndpoints } : {}),
1387
1411
  };
1388
1412
  const tmp = `${paths.heartbeat}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
1389
- await fsImpl.writeFile(tmp, `${JSON.stringify(sidecar, null, 2)}\n`, { mode: 0o600 });
1390
- await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
1413
+ try {
1414
+ await fsImpl.writeFile(tmp, `${JSON.stringify(sidecar, null, 2)}\n`, { mode: 0o600 });
1415
+ await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
1416
+ } catch (error) {
1417
+ // One failed staging write must not terminate the daemon (#4200).
1418
+ await fsImpl.unlink(tmp).catch(() => undefined);
1419
+ logger.warn(`notifications: heartbeat sidecar staging failed: ${sanitizeDiagnostic(String(error))}`);
1420
+ return "publish_failed";
1421
+ }
1391
1422
  const lock = await readOwnershipLock(fsImpl, paths.lock);
1392
1423
  if (!ownershipLockMatchesState(lock, state)) {
1393
1424
  await fsImpl.unlink(tmp).catch(() => undefined);
1394
- return false;
1425
+ return "not_owner";
1395
1426
  }
1396
- // Revalidate the exact lock after the temporary sidecar exists so a stale
1397
- // writer cannot publish after ownership moved during its write.
1398
- const finalLock = await readOwnershipLock(fsImpl, paths.lock);
1399
- if (!ownershipLockMatchesState(finalLock, state)) {
1400
- await fsImpl.unlink(tmp).catch(() => undefined);
1401
- return false;
1427
+ for (let attempt = 1; ; attempt++) {
1428
+ // Revalidate the exact lock after the temporary sidecar exists — and again
1429
+ // before every retry — so a stale writer cannot publish after ownership
1430
+ // moved during its write or its backoff.
1431
+ const finalLock = await readOwnershipLock(fsImpl, paths.lock);
1432
+ if (!ownershipLockMatchesState(finalLock, state)) {
1433
+ await fsImpl.unlink(tmp).catch(() => undefined);
1434
+ return "not_owner";
1435
+ }
1436
+ try {
1437
+ await fsImpl.rename(tmp, paths.heartbeat);
1438
+ return "renewed";
1439
+ } catch (error) {
1440
+ // Windows: a transient external lock on the destination (antivirus,
1441
+ // indexer) surfaces as EPERM on rename and used to escape as an
1442
+ // uncaught exception that killed the whole daemon (#4200). Retry a
1443
+ // bounded number of times under the lock fence, then keep the daemon
1444
+ // alive and let the next heartbeat cycle publish.
1445
+ if (attempt >= HEARTBEAT_SIDECAR_PUBLISH_ATTEMPTS || !isTransientSidecarPublishError(error)) {
1446
+ await fsImpl.unlink(tmp).catch(() => undefined);
1447
+ logger.warn(
1448
+ `notifications: heartbeat sidecar publication failed after ${attempt} attempt(s): ${sanitizeDiagnostic(String(error))}`,
1449
+ );
1450
+ return "publish_failed";
1451
+ }
1452
+ await Bun.sleep(HEARTBEAT_SIDECAR_PUBLISH_BACKOFF_MS * attempt);
1453
+ }
1402
1454
  }
1403
- await fsImpl.rename(tmp, paths.heartbeat);
1404
- return true;
1405
1455
  }
1406
1456
 
1407
1457
  function ownershipLockMatchesStoppedState(
@@ -3106,17 +3156,21 @@ export async function renewDaemonHeartbeat(input: {
3106
3156
  // The durable sidecar must exist and match the rebound ownership lock before
3107
3157
  // any observer can see ownershipPhase:"ready". A provisional state may be
3108
3158
  // fresh, but readiness waiters categorically refuse to attach to it.
3159
+ // The initial proof is strict: only a real publication counts. A
3160
+ // `publish_failed` here has no durable sidecar, so readiness must not be
3161
+ // claimed and the provisional ownership retires exactly as before.
3109
3162
  let sidecarRenewed = false;
3110
3163
  try {
3111
- sidecarRenewed = await renewOwnerHeartbeatSidecar({
3112
- settings: input.settings,
3113
- ownerId: input.ownerId,
3114
- acquisitionId,
3115
- fs: fsImpl,
3116
- now: () => heartbeatAt,
3117
- pid,
3118
- pidIncarnation: input.pidIncarnation,
3119
- });
3164
+ sidecarRenewed =
3165
+ (await renewOwnerHeartbeatSidecar({
3166
+ settings: input.settings,
3167
+ ownerId: input.ownerId,
3168
+ acquisitionId,
3169
+ fs: fsImpl,
3170
+ now: () => heartbeatAt,
3171
+ pid,
3172
+ pidIncarnation: input.pidIncarnation,
3173
+ })) === "renewed";
3120
3174
  } catch {
3121
3175
  sidecarRenewed = false;
3122
3176
  }
@@ -4764,6 +4818,8 @@ interface SessionSocket {
4764
4818
  pingTimer: ReturnType<typeof setInterval> | undefined;
4765
4819
  /** Correlation id for the startup replay barrier. */
4766
4820
  replayId: string;
4821
+ /** Sequence cursor sent by the current startup replay request. */
4822
+ replaySinceSeq: number;
4767
4823
  /** Queues live frames until startup replay is applied. */
4768
4824
  replayPending: boolean;
4769
4825
  replayQueue: Record<string, unknown>[];
@@ -6174,8 +6230,12 @@ export class TelegramNotificationDaemon {
6174
6230
  const endpointKey = endpointGenerationKey(endpoint.url, endpoint.token);
6175
6231
  const connected = this.sessions.get(sessionId);
6176
6232
  if (connected) {
6233
+ const recoveryRejectedAfterArchive =
6234
+ connected.recoveryLease?.state === "rejected" &&
6235
+ this.topics.get(sessionId)?.authorityState === "inactive";
6177
6236
  if (
6178
6237
  connected.endpointKey !== endpointKey ||
6238
+ recoveryRejectedAfterArchive ||
6179
6239
  (connected.ws.readyState === WebSocket.CONNECTING &&
6180
6240
  this.runtime.now() - connected.connectingSince >= CONNECTING_RECONNECT_MS)
6181
6241
  )
@@ -6265,6 +6325,7 @@ export class TelegramNotificationDaemon {
6265
6325
  awaitingNonce: undefined,
6266
6326
  pingTimer: undefined,
6267
6327
  replayId: `telegram-startup-replay:${sessionId}`,
6328
+ replaySinceSeq: 0,
6268
6329
  replayPending: false,
6269
6330
  replayQueue: [],
6270
6331
  };
@@ -6283,6 +6344,7 @@ export class TelegramNotificationDaemon {
6283
6344
  const persistedTopic = this.topics.get(sessionId);
6284
6345
  const replayCursor =
6285
6346
  persistedTopic?.endpointDigest === session.endpointDigest ? this.topics.replayCursor(sessionId) : undefined;
6347
+ session.replaySinceSeq = replayCursor?.seq ?? 0;
6286
6348
  if (session.ws.readyState === WebSocket.OPEN) {
6287
6349
  try {
6288
6350
  session.ws.send(
@@ -9714,7 +9776,7 @@ export class TelegramNotificationDaemon {
9714
9776
  for (const session of this.sessions.values()) if (session.ws.readyState === WebSocket.OPEN) attached += 1;
9715
9777
  return attached;
9716
9778
  }
9717
- private async renewOwnershipHeartbeat(): Promise<boolean> {
9779
+ private async renewOwnershipHeartbeat(): Promise<OwnerHeartbeatSidecarRenewal> {
9718
9780
  return renewOwnerHeartbeatSidecar({
9719
9781
  settings: this.opts.settings,
9720
9782
  ownerId: this.opts.ownerId,
@@ -9736,7 +9798,10 @@ export class TelegramNotificationDaemon {
9736
9798
  if (!this.running) return;
9737
9799
  void this.runtime
9738
9800
  .runExclusive("telegram-owner-heartbeat", async () => {
9739
- if (!(await this.renewOwnershipHeartbeat())) this.runtime.requestStop();
9801
+ // `publish_failed` keeps the daemon alive: ownership is still held
9802
+ // and the next interval republishes (#4200). Only a proven
9803
+ // ownership loss stops the owner.
9804
+ if ((await this.renewOwnershipHeartbeat()) === "not_owner") this.runtime.requestStop();
9740
9805
  })
9741
9806
  .catch(err => {
9742
9807
  logger.warn(`notifications: ownership heartbeat failed: ${sanitizeDiagnostic(String(err))}`);
@@ -9944,7 +10009,6 @@ export class TelegramNotificationDaemon {
9944
10009
  msg.generation >= 1 &&
9945
10010
  Number.isSafeInteger(msg.lastSeq) &&
9946
10011
  msg.lastSeq >= 0 &&
9947
- msg.gap === undefined &&
9948
10012
  Array.isArray(msg.events) &&
9949
10013
  msg.events.every((event: unknown) => event !== null && typeof event === "object" && !Array.isArray(event));
9950
10014
  const replayed: Record<string, unknown>[] = replayValid
@@ -9963,7 +10027,34 @@ export class TelegramNotificationDaemon {
9963
10027
  frame => typeof frame.sessionId !== "string" || !frame.sessionId.trim(),
9964
10028
  );
9965
10029
  const identityConflict = new Set(identities).size > 1;
9966
- if (!replayValid || malformedIdentity || identityConflict) {
10030
+ const retainedAtOrBelowGap =
10031
+ msg.gap !== null &&
10032
+ typeof msg.gap === "object" &&
10033
+ !Array.isArray(msg.gap) &&
10034
+ Number.isSafeInteger(msg.gap.toSeq) &&
10035
+ Array.isArray(msg.events) &&
10036
+ (msg.events as Record<string, unknown>[]).some(
10037
+ event =>
10038
+ Number.isSafeInteger(event.seq) &&
10039
+ (event.seq as number) >= 1 &&
10040
+ (event.seq as number) <= msg.gap.toSeq,
10041
+ );
10042
+ const sequenceGapWithIdentityProof =
10043
+ msg.gap !== null &&
10044
+ typeof msg.gap === "object" &&
10045
+ !Array.isArray(msg.gap) &&
10046
+ msg.gap.kind === "sequence_gap" &&
10047
+ Number.isSafeInteger(msg.gap.fromSeq) &&
10048
+ Number.isSafeInteger(msg.gap.toSeq) &&
10049
+ msg.gap.fromSeq === session.replaySinceSeq + 1 &&
10050
+ msg.gap.fromSeq <= msg.gap.toSeq &&
10051
+ msg.gap.toSeq <= msg.lastSeq &&
10052
+ !retainedAtOrBelowGap &&
10053
+ Array.isArray(msg.gap.resyncQueries) &&
10054
+ msg.gap.resyncQueries.every((query: unknown) => typeof query === "string") &&
10055
+ identityFrames.length > 0;
10056
+ const gapAllowsRecovery = msg.gap === undefined || sequenceGapWithIdentityProof;
10057
+ if (!replayValid || !gapAllowsRecovery || malformedIdentity || identityConflict) {
9967
10058
  // A replay result is the admission proof. Never clear its barrier or drain
9968
10059
  // queued frames after malformed/conflicting proof; the socket cannot fall
9969
10060
  // back to transport-local config rekeying.
@@ -10005,6 +10096,17 @@ export class TelegramNotificationDaemon {
10005
10096
  );
10006
10097
  if (this.sessions.get(session.sessionId) !== session) return;
10007
10098
  if (!recovered) {
10099
+ const authorityState = this.topics.get(replayCandidateSessionId)?.authorityState;
10100
+ if (
10101
+ replayCandidateSessionId === session.sessionId &&
10102
+ (authorityState === "archive_pending" || authorityState === "archive_exhausted")
10103
+ ) {
10104
+ // The old topic remains fenced, but the authenticated live transport
10105
+ // must stay attached while archive reconciliation retries.
10106
+ session.replayQueue = [];
10107
+ session.replayPending = false;
10108
+ return;
10109
+ }
10008
10110
  if (session.hostGeneration === msg.generation && session.recoveryLease?.state !== "pending")
10009
10111
  this.dropSession(session, "recovery_rejected");
10010
10112
  return;
@@ -12389,7 +12491,7 @@ export class TelegramNotificationDaemon {
12389
12491
  while (this.running) {
12390
12492
  if (await this.controlStopRequested()) break;
12391
12493
  if (
12392
- !(await renewOwnerHeartbeatSidecar({
12494
+ (await renewOwnerHeartbeatSidecar({
12393
12495
  settings: this.opts.settings,
12394
12496
  ownerId: this.opts.ownerId,
12395
12497
  acquisitionId: this.opts.ownerId,
@@ -12398,7 +12500,7 @@ export class TelegramNotificationDaemon {
12398
12500
  pid: this.opts.pid ?? process.pid,
12399
12501
  pidIncarnation: this.opts.pidIncarnation,
12400
12502
  attachedEndpoints: this.attachedEndpointCount(),
12401
- }))
12503
+ })) === "not_owner"
12402
12504
  )
12403
12505
  break;
12404
12506
  await this.runScan();
@@ -213,7 +213,7 @@ export function emptyTopicRegistryState(): TopicRegistryState {
213
213
  */
214
214
  export function parseTopicRegistryState(value: unknown): TopicRegistryState | undefined {
215
215
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
216
- const state = value as TopicRegistryState;
216
+ let state = value as TopicRegistryState;
217
217
  if (state.version !== undefined && state.version !== 2) throw new Error("unsupported future Telegram topic state");
218
218
  if (!state.topics || typeof state.topics !== "object" || Array.isArray(state.topics)) return undefined;
219
219
  if (state.version === undefined) {
@@ -239,6 +239,26 @@ export function parseTopicRegistryState(value: unknown): TopicRegistryState | un
239
239
 
240
240
  const isObject = (candidate: unknown): candidate is Record<string, unknown> =>
241
241
  !!candidate && typeof candidate === "object" && !Array.isArray(candidate);
242
+ state = {
243
+ ...state,
244
+ topics: Object.fromEntries(
245
+ Object.entries(state.topics).map(([sessionId, record]) => [
246
+ sessionId,
247
+ isObject(record) ? { ...record } : record,
248
+ ]),
249
+ ),
250
+ retiredTopics:
251
+ state.retiredTopics === undefined
252
+ ? undefined
253
+ : Object.fromEntries(
254
+ Object.entries(state.retiredTopics).map(([sessionId, records]) => [
255
+ sessionId,
256
+ Array.isArray(records)
257
+ ? records.map(record => (isObject(record) ? { ...record } : record))
258
+ : records,
259
+ ]),
260
+ ),
261
+ };
242
262
  const validTimestamp = (candidate: unknown): candidate is number =>
243
263
  typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0;
244
264
  const validEpoch = (candidate: unknown): candidate is number =>
@@ -303,12 +323,19 @@ export function parseTopicRegistryState(value: unknown): TopicRegistryState | un
303
323
  value => value !== undefined,
304
324
  ).length;
305
325
  if (leaseFieldCount !== 0 && leaseFieldCount !== 3) malformed();
306
- if (
307
- raw.authorityState === "disconnect_grace"
308
- ? raw.disconnectGraceExpiresAt === undefined || raw.orphanedAt === undefined
309
- : raw.disconnectGraceExpiresAt !== undefined
310
- )
311
- malformed();
326
+ if (raw.authorityState === "disconnect_grace") {
327
+ if (raw.disconnectGraceExpiresAt === undefined || raw.orphanedAt === undefined) malformed();
328
+ } else if (raw.disconnectGraceExpiresAt !== undefined) {
329
+ if (
330
+ raw.authorityState !== "archive_pending" &&
331
+ raw.authorityState !== "archive_exhausted" &&
332
+ raw.authorityState !== "inactive"
333
+ )
334
+ malformed();
335
+ // Older archive transitions retained the expired disconnect-grace marker,
336
+ // making the entire shared registry unreadable on the next daemon start.
337
+ delete raw.disconnectGraceExpiresAt;
338
+ }
312
339
  const hasBinding = hasAnyBinding(raw);
313
340
  const hasArchiveOnlyChatIdentity =
314
341
  (raw.authorityState === "archive_pending" ||
@@ -333,11 +360,13 @@ export function parseTopicRegistryState(value: unknown): TopicRegistryState | un
333
360
  for (const [sessionId, records] of Object.entries(state.retiredTopics ?? {})) {
334
361
  if (!isValidBindingString(sessionId) || !Array.isArray(records)) malformed();
335
362
  for (const [index, record] of records.entries()) {
336
- parseTopicRegistryState({
363
+ const recordKey = `${sessionId}:retired:${index}`;
364
+ const parsed = parseTopicRegistryState({
337
365
  version: 2,
338
366
  registryGeneration: 0,
339
- topics: { [`${sessionId}:retired:${index}`]: record },
367
+ topics: { [recordKey]: record },
340
368
  });
369
+ records[index] = parsed?.topics[recordKey] ?? malformed();
341
370
  }
342
371
  }
343
372
  for (const [sessionId, epoch] of Object.entries(state.fences ?? {}))
@@ -11,6 +11,7 @@ import {
11
11
  SdkClient,
12
12
  SdkClientError,
13
13
  SdkDiscoveryError,
14
+ type SdkSentRecord,
14
15
  } from "../client";
15
16
  import { PROMPT_CLIENT_REF_MAX_LENGTH } from "../prompt-status.js";
16
17
  import { validateAdapterControl } from "../protocol/adapter-validation";
@@ -962,13 +963,25 @@ async function runRawGlobal(
962
963
  throw new SdkSessionCliError("invalid_input", "--idempotency-key is required for lifecycle operations.", 2);
963
964
  const client = await connectBroker(agentDir);
964
965
  try {
965
- const timeoutMs = lifecycleRequestTimeoutMs(operation, input);
966
- const response = await client.global(operation, input, {
967
- idempotencyKey,
968
- ...(timeoutMs === undefined ? {} : { timeoutMs }),
969
- ...(args.elevationRequestId === undefined ? {} : { elevationRequestId: args.elevationRequestId }),
970
- });
971
- return args.showEndpointCredential ? response : stripSecretFields(response);
966
+ try {
967
+ const timeoutMs = lifecycleRequestTimeoutMs(operation, input);
968
+ const response = await client.global(operation, input, {
969
+ idempotencyKey,
970
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
971
+ ...(args.elevationRequestId === undefined ? {} : { elevationRequestId: args.elevationRequestId }),
972
+ });
973
+ return args.showEndpointCredential ? response : stripSecretFields(response);
974
+ } catch (error) {
975
+ if (
976
+ isLifecycleOperation(operation) &&
977
+ error instanceof SdkClientError &&
978
+ error.code === "uncertain_after_send" &&
979
+ error.details &&
980
+ typeof error.details === "object"
981
+ )
982
+ return stripSecretFields(await client.lookupLifecycle(error.details as SdkSentRecord));
983
+ throw error;
984
+ }
972
985
  } finally {
973
986
  await client.close();
974
987
  }
@@ -38,13 +38,34 @@ export type SdkSessionEndpointScope = "default" | "chat";
38
38
  function endpointDirectory(repo: string, scope: SdkSessionEndpointScope = "default"): string {
39
39
  return scope === "chat" ? path.join(repo, ".gjc", "state", "chat", "sdk") : path.join(repo, ".gjc", "state", "sdk");
40
40
  }
41
+ function isUsableSessionId(sessionId: string): boolean {
42
+ return (
43
+ sessionId.length > 0 &&
44
+ sessionId !== "." &&
45
+ sessionId !== ".." &&
46
+ !sessionId.includes("/") &&
47
+ !sessionId.includes("\\") &&
48
+ !sessionId.includes("\0")
49
+ );
50
+ }
41
51
 
42
52
  function parseEndpoint(sessionId: string, file: string, value: unknown): SdkSessionEndpoint {
53
+ if (!isUsableSessionId(sessionId))
54
+ throw new SdkDiscoveryError(file, "SDK endpoint discovery filename does not contain a usable session id.");
43
55
  if (!value || typeof value !== "object")
44
56
  throw new SdkDiscoveryError(file, "SDK endpoint discovery record must be an object.");
45
- const endpoint = value as { version?: unknown; url?: unknown; token?: unknown; pid?: unknown; stale?: unknown };
57
+ const endpoint = value as {
58
+ version?: unknown;
59
+ sessionId?: unknown;
60
+ url?: unknown;
61
+ token?: unknown;
62
+ pid?: unknown;
63
+ stale?: unknown;
64
+ };
46
65
  if (typeof endpoint.version === "number" && endpoint.version > 1)
47
66
  throw new SdkDiscoveryError(file, "Unsupported SDK endpoint discovery state version.");
67
+ if (endpoint.sessionId !== undefined && endpoint.sessionId !== sessionId)
68
+ throw new SdkDiscoveryError(file, "SDK endpoint discovery record session id does not match its filename.");
48
69
  if (typeof endpoint.url !== "string" || !endpoint.url)
49
70
  throw new SdkDiscoveryError(file, "SDK endpoint discovery record is invalid.");
50
71
  const stale = typeof endpoint.stale === "boolean" ? endpoint.stale : undefined;
@@ -108,7 +129,7 @@ export async function readSdkSessionEndpoint(
108
129
  sessionId: string,
109
130
  scope: SdkSessionEndpointScope = "default",
110
131
  ): Promise<SdkSessionEndpoint | null> {
111
- if (!sessionId || sessionId.includes(path.sep) || sessionId.includes("/")) return null;
132
+ if (!isUsableSessionId(sessionId)) return null;
112
133
  const file = path.join(endpointDirectory(repo, scope), `${sessionId}.json`);
113
134
  try {
114
135
  return parseEndpoint(sessionId, file, JSON.parse(await fs.readFile(file, "utf8")));
@@ -32,6 +32,10 @@ export interface CursorSelector {
32
32
  resourceKind?: string;
33
33
  resourceId?: string;
34
34
  itemId?: string;
35
+ kind?: string;
36
+ clientRef?: string;
37
+ commandId?: string;
38
+ turnId?: string;
35
39
  }
36
40
 
37
41
  export interface CursorPosition {