@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
@@ -28,6 +28,7 @@ export interface ConversationStoreFs {
28
28
  readFile(file: string, encoding: "utf8"): Promise<string>;
29
29
  writeFile(file: string, data: string, options: { mode: number }): Promise<void>;
30
30
  rename(from: string, to: string): Promise<void>;
31
+ link(from: string, to: string): Promise<void>;
31
32
  unlink(file: string): Promise<void>;
32
33
  open(file: string, flags: string): Promise<ConversationStoreFileHandle>;
33
34
  stat?(file: string): Promise<{ mtimeMs: number }>;
@@ -46,11 +47,22 @@ export class ConversationLockTimeoutError extends Error {
46
47
  interface ConversationStoreLock {
47
48
  pid: number;
48
49
  incarnation: string;
50
+ isolateId?: string;
49
51
  timestamp: number;
52
+ /** Uniquely identifies one acquire; lets a release prove it owns the lock file. */
53
+ nonce?: string;
50
54
  }
51
55
 
52
56
  const nodeFs: ConversationStoreFs = fs;
53
- const UNPUBLISHED_LOCK_STALE_MS = 30_000;
57
+ /** Unique to this module evaluation, including one Bun worker isolate. */
58
+ const processIsolateId = randomUUID();
59
+
60
+ /**
61
+ * Lock files this process currently holds or is acquiring, across every store.
62
+ * The token lets a releasing predecessor preserve a successor's marker when
63
+ * the same path has already been reacquired.
64
+ */
65
+ const heldLockFiles = new Map<string, Set<string>>();
54
66
 
55
67
  export function conversationStorePath(agentDir: string, kind: string, fileName = "conversations.json"): string {
56
68
  return path.join(agentDir, "sdk", "daemons", kind, fileName);
@@ -214,108 +226,220 @@ export class ConversationStore<T extends ConversationRecord> {
214
226
  const tail = previous.then(() => gate.promise);
215
227
  this.#locks.set(this.#file, tail);
216
228
  await previous;
229
+ const lockFile = `${this.#file}.lock`;
217
230
  let fileLock: ConversationStoreFileHandle | undefined;
231
+ let lockToken: ConversationStoreLock | undefined;
218
232
  try {
219
- fileLock = await this.#acquireFileLock();
233
+ const acquired = await this.#acquireFileLock();
234
+ fileLock = acquired.handle;
235
+ lockToken = acquired.lock;
220
236
  return await operation();
221
237
  } finally {
222
238
  try {
223
- await fileLock?.close();
224
- if (fileLock)
225
- await this.#fs.unlink(`${this.#file}.lock`).catch(error => {
226
- if (!isMissing(error)) throw error;
227
- });
239
+ // A failed close must never skip the unlink: that would leak a
240
+ // well-formed lock whose pid is still alive and block every later
241
+ // waiter for the full timeout. The unlink is ownership-checked so
242
+ // it can only ever remove the lock this holder wrote.
243
+ if (fileLock) await fileLock.close().catch(() => undefined);
244
+ if (fileLock && lockToken) await this.#unlinkOwnedLock(lockFile, lockToken);
228
245
  } finally {
246
+ if (lockToken) this.#releaseHeldLock(lockFile, lockToken);
229
247
  gate.resolve();
230
248
  if (this.#locks.get(this.#file) === tail) this.#locks.delete(this.#file);
231
249
  }
232
250
  }
233
251
  }
234
252
 
235
- async #acquireFileLock(): Promise<ConversationStoreFileHandle> {
253
+ #releaseHeldLock(lockFile: string, lock: ConversationStoreLock): void {
254
+ const tokens = heldLockFiles.get(lockFile);
255
+ if (!tokens) return;
256
+ tokens.delete(lock.nonce!);
257
+ if (tokens.size === 0) heldLockFiles.delete(lockFile);
258
+ }
259
+
260
+ async #unlinkOwnedLock(lockFile: string, expected: ConversationStoreLock): Promise<boolean> {
261
+ let parsed: unknown;
262
+ try {
263
+ parsed = JSON.parse(await this.#fs.readFile(lockFile, "utf8"));
264
+ } catch (error) {
265
+ if (isMissing(error)) return false;
266
+ return false; // unreadable: never remove a lock we cannot identify
267
+ }
268
+ if (
269
+ !isConversationStoreLock(parsed) ||
270
+ parsed.pid !== expected.pid ||
271
+ parsed.incarnation !== expected.incarnation ||
272
+ parsed.isolateId !== expected.isolateId ||
273
+ parsed.timestamp !== expected.timestamp ||
274
+ parsed.nonce !== expected.nonce
275
+ )
276
+ return false; // a successor holds the path now; leave it
277
+ try {
278
+ await this.#fs.unlink(lockFile);
279
+ return true;
280
+ } catch (error) {
281
+ if (isMissing(error)) return false;
282
+ throw error;
283
+ }
284
+ }
285
+
286
+ async #acquireFileLock(): Promise<{ handle: ConversationStoreFileHandle; lock: ConversationStoreLock }> {
236
287
  const lockFile = `${this.#file}.lock`;
237
288
  const deadline = Date.now() + this.#lockTimeoutMs;
238
289
  await this.#fs.mkdir(this.#directory, { recursive: true, mode: 0o700 });
239
290
  for (;;) {
240
291
  try {
241
- const handle = await this.#fs.open(lockFile, "wx");
242
- try {
243
- const lock: ConversationStoreLock = {
244
- pid: this.#pid,
245
- incarnation: this.#pidIncarnation(this.#pid) ?? "unavailable",
246
- timestamp: this.#clock(),
247
- };
248
- await handle.writeFile(`${JSON.stringify(lock)}\n`, "utf8");
249
- await handle.sync();
250
- return handle;
251
- } catch (error) {
252
- await handle.close().catch(() => undefined);
253
- await this.#fs.unlink(lockFile).catch(() => undefined);
254
- throw error;
255
- }
292
+ return await this.#createLockFile(lockFile);
256
293
  } catch (error) {
257
294
  if (!isAlreadyExists(error)) throw error;
258
295
  if (await this.#reclaimStaleLock(lockFile)) continue;
296
+ if (await this.#reclaimSameProcessLeak(lockFile)) continue;
259
297
  if (Date.now() >= deadline) throw new ConversationLockTimeoutError(lockFile, this.#lockTimeoutMs);
260
298
  await this.#sleep(Math.min(10, Math.max(1, deadline - Date.now())));
261
299
  }
262
300
  }
263
301
  }
264
302
 
303
+ /**
304
+ * Removes a well-formed lock this process wrote but no longer holds. Healing
305
+ * is serialized by its own ownership-tracked reclaim lock, so concurrent
306
+ * waiters cannot delete a successor after the original leak is removed.
307
+ */
308
+ async #reclaimSameProcessLeak(lockFile: string): Promise<boolean> {
309
+ const expected = await this.#readLock(lockFile);
310
+ if (
311
+ !expected ||
312
+ expected.pid !== this.#pid ||
313
+ expected.isolateId !== processIsolateId ||
314
+ heldLockFiles.has(lockFile)
315
+ )
316
+ return false;
317
+
318
+ const reclaimFile = `${lockFile}.reclaim`;
319
+ const reclaimLock = await this.#acquireReclaimLock(reclaimFile);
320
+ if (!reclaimLock) return false;
321
+ try {
322
+ if (expected.pid !== this.#pid || expected.isolateId !== processIsolateId || heldLockFiles.has(lockFile))
323
+ return false;
324
+ return await this.#unlinkOwnedLock(lockFile, expected);
325
+ } catch {
326
+ return false;
327
+ } finally {
328
+ await reclaimLock.handle.close().catch(() => undefined);
329
+ try {
330
+ await this.#unlinkOwnedLock(reclaimFile, reclaimLock.lock).catch(() => undefined);
331
+ } finally {
332
+ this.#releaseHeldLock(reclaimFile, reclaimLock.lock);
333
+ }
334
+ }
335
+ }
336
+
265
337
  async #reclaimStaleLock(lockFile: string): Promise<boolean> {
266
338
  if (!(await this.#isStaleLock(lockFile))) return false;
267
339
  const reclaimFile = `${lockFile}.reclaim`;
268
340
  const reclaimLock = await this.#acquireReclaimLock(reclaimFile);
269
341
  if (!reclaimLock) return false;
270
342
  try {
271
- if (!(await this.#isStaleLock(lockFile))) return false;
272
- await this.#fs.unlink(lockFile).catch(() => undefined);
273
- return true;
343
+ const staleLock = await this.#readLock(lockFile);
344
+ if (!staleLock || !(await this.#isStaleLock(lockFile))) return false;
345
+ return await this.#unlinkOwnedLock(lockFile, staleLock);
346
+ } catch {
347
+ return false;
274
348
  } finally {
275
- await reclaimLock.close().catch(() => undefined);
276
- await this.#fs.unlink(reclaimFile).catch(() => undefined);
349
+ await reclaimLock.handle.close().catch(() => undefined);
350
+ try {
351
+ await this.#unlinkOwnedLock(reclaimFile, reclaimLock.lock).catch(() => undefined);
352
+ } finally {
353
+ this.#releaseHeldLock(reclaimFile, reclaimLock.lock);
354
+ }
277
355
  }
278
356
  }
279
- async #acquireReclaimLock(reclaimFile: string): Promise<ConversationStoreFileHandle | undefined> {
357
+
358
+ async #acquireReclaimLock(
359
+ reclaimFile: string,
360
+ ): Promise<{ handle: ConversationStoreFileHandle; lock: ConversationStoreLock } | undefined> {
280
361
  try {
281
362
  return await this.#createLockFile(reclaimFile);
282
363
  } catch (error) {
283
- if (!isAlreadyExists(error) || !(await this.#isStaleLock(reclaimFile))) return undefined;
284
- await this.#fs.unlink(reclaimFile).catch(() => undefined);
285
- try {
286
- return await this.#createLockFile(reclaimFile);
287
- } catch (retryError) {
288
- if (isAlreadyExists(retryError)) return undefined;
289
- throw retryError;
364
+ if (!isAlreadyExists(error)) return undefined;
365
+ if (await this.#reclaimStaleLock(reclaimFile)) {
366
+ try {
367
+ return await this.#createLockFile(reclaimFile);
368
+ } catch (retryError) {
369
+ if (isAlreadyExists(retryError)) return undefined;
370
+ throw retryError;
371
+ }
290
372
  }
373
+ if (await this.#reclaimSameProcessLeak(reclaimFile)) {
374
+ try {
375
+ return await this.#createLockFile(reclaimFile);
376
+ } catch (retryError) {
377
+ if (isAlreadyExists(retryError)) return undefined;
378
+ throw retryError;
379
+ }
380
+ }
381
+ return undefined;
291
382
  }
292
383
  }
293
- async #createLockFile(lockFile: string): Promise<ConversationStoreFileHandle> {
294
- const handle = await this.#fs.open(lockFile, "wx");
384
+
385
+ async #createLockFile(
386
+ lockFile: string,
387
+ ): Promise<{ handle: ConversationStoreFileHandle; lock: ConversationStoreLock }> {
388
+ const pendingFile = `${lockFile}.${this.#pid}.${randomUUID()}.pending`;
389
+ const handle = await this.#fs.open(pendingFile, "wx");
390
+ let lock: ConversationStoreLock | undefined;
391
+ let published = false;
295
392
  try {
296
- const lock: ConversationStoreLock = {
393
+ lock = {
297
394
  pid: this.#pid,
298
395
  incarnation: this.#pidIncarnation(this.#pid) ?? "unavailable",
396
+ isolateId: processIsolateId,
299
397
  timestamp: this.#clock(),
398
+ nonce: randomUUID(),
300
399
  };
400
+ const heldTokens = heldLockFiles.get(lockFile) ?? new Set<string>();
401
+ heldTokens.add(lock.nonce!);
402
+ heldLockFiles.set(lockFile, heldTokens);
301
403
  await handle.writeFile(`${JSON.stringify(lock)}\n`, "utf8");
302
404
  await handle.sync();
303
- return handle;
405
+ await this.#fs.link(pendingFile, lockFile);
406
+ published = true;
407
+ await this.#fs.unlink(pendingFile);
408
+ return { handle, lock };
304
409
  } catch (error) {
305
- await handle.close().catch(() => undefined);
306
- await this.#fs.unlink(lockFile).catch(() => undefined);
410
+ try {
411
+ await handle.close().catch(() => undefined);
412
+ try {
413
+ if (published && lock) await this.#unlinkOwnedLock(lockFile, lock);
414
+ } finally {
415
+ await this.#fs.unlink(pendingFile).catch(() => undefined);
416
+ }
417
+ } finally {
418
+ if (lock) this.#releaseHeldLock(lockFile, lock);
419
+ }
307
420
  throw error;
308
421
  }
309
422
  }
423
+
424
+ async #readLock(lockFile: string): Promise<ConversationStoreLock | undefined> {
425
+ try {
426
+ const parsed: unknown = JSON.parse(await this.#fs.readFile(lockFile, "utf8"));
427
+ return isConversationStoreLock(parsed) ? parsed : undefined;
428
+ } catch {
429
+ return undefined;
430
+ }
431
+ }
432
+
310
433
  async #isStaleLock(lockFile: string): Promise<boolean> {
311
434
  let parsed: unknown;
312
435
  try {
313
436
  parsed = JSON.parse(await this.#fs.readFile(lockFile, "utf8"));
314
- } catch (error) {
315
- if (isMissing(error)) return true;
316
- return await this.#isExpiredUnpublishedLock(lockFile);
437
+ } catch {
438
+ // An unreadable legacy marker may still have an open creator. It is
439
+ // never safe to reclaim it based on age alone.
440
+ return false;
317
441
  }
318
- if (!isConversationStoreLock(parsed)) return await this.#isExpiredUnpublishedLock(lockFile);
442
+ if (!isConversationStoreLock(parsed)) return false;
319
443
  const currentIncarnation = this.#pidIncarnation(parsed.pid);
320
444
  return (
321
445
  !this.#pidAlive(parsed.pid) ||
@@ -324,10 +448,6 @@ export class ConversationStore<T extends ConversationRecord> {
324
448
  (currentIncarnation !== undefined && currentIncarnation !== parsed.incarnation)))
325
449
  );
326
450
  }
327
- async #isExpiredUnpublishedLock(lockFile: string): Promise<boolean> {
328
- const stat = this.#fs.stat ? await this.#fs.stat(lockFile).catch(() => undefined) : undefined;
329
- return Boolean(stat && this.#clock() - stat.mtimeMs >= UNPUBLISHED_LOCK_STALE_MS);
330
- }
331
451
 
332
452
  async #readDocument(): Promise<ConversationStoreDocument<T>> {
333
453
  try {
@@ -424,6 +544,7 @@ function isConversationStoreLock(value: unknown): value is ConversationStoreLock
424
544
  Number.isSafeInteger(value.pid) &&
425
545
  value.pid > 0 &&
426
546
  typeof value.incarnation === "string" &&
547
+ (value.isolateId === undefined || typeof value.isolateId === "string") &&
427
548
  typeof value.timestamp === "number"
428
549
  );
429
550
  }
@@ -1168,6 +1168,8 @@ interface SessionRuntime {
1168
1168
  cursors: CursorRegistry;
1169
1169
  /** Current endpoint session identity; never re-key an existing host across a switch. */
1170
1170
  id: string;
1171
+ /** Discovery scope is fixed before publication; a live default endpoint is never rotated in place. */
1172
+ endpointScope: "default" | "chat";
1171
1173
  idleSeq: number;
1172
1174
  /** Interactive asks awaiting a remote answer, by action id. */
1173
1175
  pendingInteractive: Map<string, PendingInteractiveAsk>;
@@ -1179,6 +1181,8 @@ interface SessionRuntime {
1179
1181
  disposeGateListener: () => void;
1180
1182
  /** Whether notification-only delivery and answer resources are active. */
1181
1183
  notificationsActive: boolean;
1184
+ /** Provider ownership state is independent from the already-published core SDK runtime. */
1185
+ notificationOwnerState: "ready" | "retry" | "blocked";
1182
1186
  /** Rejects new SDK frames while a leased terminal response drains. */
1183
1187
  inboundFenced: boolean;
1184
1188
  /** Set as soon as terminal teardown is requested, before startup settles. */
@@ -2289,12 +2293,12 @@ function sdkQuerySurface(
2289
2293
  }),
2290
2294
  configOverrides: ReadonlyMap<string, unknown> = new Map(),
2291
2295
  settings: Settings | undefined = undefined,
2292
- promptStatusLookup: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown = () => ({
2293
- status: "unknown",
2294
- }),
2295
- skillStatusLookup: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown = () => ({
2296
- status: "unknown",
2297
- }),
2296
+ turnResultLookup: (selector: {
2297
+ kind: "prompt" | "skill";
2298
+ commandId?: string;
2299
+ turnId?: string;
2300
+ clientRef?: string;
2301
+ }) => unknown = () => ({ status: "unknown" }),
2298
2302
  ): SessionSurface {
2299
2303
  return createSdkSurfaceFactory({
2300
2304
  ctx,
@@ -2304,8 +2308,7 @@ function sdkQuerySurface(
2304
2308
  getLiveState,
2305
2309
  configOverrides,
2306
2310
  settings,
2307
- promptStatusLookup,
2308
- skillStatusLookup,
2311
+ turnResultLookup,
2309
2312
  hostTools: () => getInstalledDefinitions("host_tools") !== undefined,
2310
2313
  }).query;
2311
2314
  }
@@ -2941,7 +2944,12 @@ function sdkControlSurface(
2941
2944
  ...(trimmedClientRef ? { clientRef: trimmedClientRef } : {}),
2942
2945
  });
2943
2946
  } else if (skillRecon) {
2944
- void skillRecon.noteTransition(correlation, { type: "agent_end" });
2947
+ void skillRecon.noteTransition(correlation, {
2948
+ type: "agent_end",
2949
+ ...(typeof result === "string"
2950
+ ? { content: { version: 1, type: "text", text: result, byteLength: 0, truncated: false } }
2951
+ : {}),
2952
+ });
2945
2953
  }
2946
2954
  return result;
2947
2955
  },
@@ -3655,6 +3663,24 @@ export function createNotificationsExtension(
3655
3663
  await ensureConfiguredProviderDaemons(settings, cfg, options.ensureProviderDaemon);
3656
3664
  return "ready";
3657
3665
  }
3666
+
3667
+ function retainNotificationRootRegistration(
3668
+ runtime: SessionRuntime,
3669
+ settings: Settings,
3670
+ cwd: string,
3671
+ id: string,
3672
+ registrationToken: string,
3673
+ ): void {
3674
+ if (runtimes.get(id) === runtime && !runtime.stopping) {
3675
+ runtime.notificationRootRegistration = { settings, cwd, registrationToken };
3676
+ return;
3677
+ }
3678
+ runtime.notificationRootRegistration = { settings, cwd, registrationToken };
3679
+ cleanupRetries.set(id, runtime);
3680
+ void stopSession(id, "session", runtime).catch(error =>
3681
+ logger.warn(`notifications: late Telegram root unregister failed: ${String(error)}`),
3682
+ );
3683
+ }
3658
3684
  const identityControlOperations = new Set([
3659
3685
  "session.new",
3660
3686
  "session.fork",
@@ -4150,8 +4176,6 @@ export function createNotificationsExtension(
4150
4176
  ) => {
4151
4177
  await kindReconciliation.noteAccepted("prompt", correlation, clientRef);
4152
4178
  };
4153
- const lookupPromptStatus = (selector: { commandId?: string; turnId?: string; clientRef?: string }) =>
4154
- kindReconciliation.lookup("prompt", selector);
4155
4179
  const releasePromptAdmission = (clientRef?: string) => kindReconciliation.releaseAdmission("prompt", clientRef);
4156
4180
  const removePendingPromptCorrelation = (correlation: { commandId: string; turnId: string }) => {
4157
4181
  const pendingIndex = pendingPromptCorrelations.findIndex(
@@ -4469,7 +4493,7 @@ export function createNotificationsExtension(
4469
4493
  }
4470
4494
  }
4471
4495
  try {
4472
- await kindReconciliation.finalizePromptOutcome(correlation, winner, extra?.error);
4496
+ await kindReconciliation.finalizePromptOutcome(correlation, winner, extra?.error, extra?.finalText);
4473
4497
  } catch (error) {
4474
4498
  // The durable pending claim survives; publishing an unpersisted terminal
4475
4499
  // would contradict it, so fail the endpoint closed instead.
@@ -4587,8 +4611,7 @@ export function createNotificationsExtension(
4587
4611
  },
4588
4612
  configOverrides,
4589
4613
  settings,
4590
- lookupPromptStatus,
4591
- selector => kindReconciliation.lookup("skill", selector),
4614
+ selector => kindReconciliation.lookupResult(selector.kind, selector),
4592
4615
  ),
4593
4616
  id,
4594
4617
  revisions,
@@ -4943,6 +4966,7 @@ export function createNotificationsExtension(
4943
4966
  revisions,
4944
4967
  cursors,
4945
4968
  id,
4969
+ endpointScope: isolateChatEndpoint ? "chat" : "default",
4946
4970
  idleSeq: 0,
4947
4971
  pendingInteractive,
4948
4972
  brokerRegistrationActive: false,
@@ -4953,6 +4977,7 @@ export function createNotificationsExtension(
4953
4977
  disposeFileSink: () => {},
4954
4978
  disposeGateListener: () => {},
4955
4979
  notificationsActive: false,
4980
+ notificationOwnerState: "ready",
4956
4981
  enableNotifications: () => {},
4957
4982
  disposeGateTerminalController: () => {},
4958
4983
  disposeAckRecoveryParticipant: () => {},
@@ -5549,48 +5574,46 @@ export function createNotificationsExtension(
5549
5574
  await cleanupAbandonedStartup();
5550
5575
  return { status: "failed" };
5551
5576
  }
5577
+ // Record canonical identity before transport startup so lifecycle events emitted
5578
+ // by an early server-start callback cannot overtake it in replay. The direct
5579
+ // socket publication remains below, after the server is listening.
5580
+ const identityHeader = {
5581
+ type: "identity_header",
5582
+ sessionId: id,
5583
+ ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
5584
+ };
5585
+ host.emitEvent({ kind: identityHeader.type, payload: identityHeader });
5586
+ // Core SDK authority is published before optional notification adapters acquire
5587
+ // daemon ownership. A blocked adapter must not make an interactive session
5588
+ // undiscoverable or uncontrollable.
5589
+ const endpoint = await sdkRuntime.startTransport();
5590
+ initializedRuntime.notificationOwnerState = "ready";
5552
5591
  if (notificationsEnabledForSession && settingsAvailable && settings) {
5592
+ initializedRuntime.notificationOwnerState = "retry";
5553
5593
  try {
5554
- let registrationToken: string | undefined;
5555
5594
  const ownership = await ensureConfiguredDaemonOwners(settings, cfg, ctx.cwd, id, token => {
5556
- registrationToken = token;
5595
+ retainNotificationRootRegistration(initializedRuntime, settings, ctx.cwd, id, token);
5557
5596
  });
5597
+ initializedRuntime.notificationOwnerState =
5598
+ ownership === "ready" || (ownership === "blocked_identity_with_sibling" && isolateChatEndpoint)
5599
+ ? "ready"
5600
+ : "blocked";
5558
5601
  if (ownership === "blocked_identity") {
5559
- const result = failLifecycleStartup("failed", "Telegram daemon ownership is blocked.");
5560
- finishStartup(result);
5561
- await cleanupAbandonedStartup();
5562
- return result;
5602
+ logger.warn("notifications: Telegram daemon ownership is blocked; core SDK remains available.");
5563
5603
  }
5564
5604
  if (ownership === "blocked_identity_with_sibling" && !isolateChatEndpoint) {
5565
- await cleanupAbandonedStartup();
5566
- if (sessionStartPromises.get(id) === startSettled.promise) sessionStartPromises.delete(id);
5567
- forceIsolatedChatSessions.add(id);
5568
- const result = await startSession(ctx);
5569
- finishStartup(result);
5570
- return result;
5571
- }
5572
- if (registrationToken !== undefined) {
5573
- runtime.notificationRootRegistration = { settings, cwd: ctx.cwd, registrationToken };
5605
+ logger.warn(
5606
+ "notifications: Telegram ownership changed after core publication; preserving the canonical endpoint and withholding adapters.",
5607
+ );
5574
5608
  }
5575
5609
  } catch (error) {
5576
- const result = failLifecycleStartup("failed", error);
5577
- finishStartup(result);
5578
- await cleanupAbandonedStartup();
5579
- return result;
5610
+ initializedRuntime.notificationOwnerState = "retry";
5611
+ logger.warn(
5612
+ `notifications: provider daemon ownership unavailable; core SDK remains available: ${String(error)}`,
5613
+ );
5580
5614
  }
5581
5615
  }
5582
5616
 
5583
- // Startup contract: configured notification daemon ownership must be ready
5584
- // before identity or endpoint publication. Native frames are ephemeral, so
5585
- // publish identity only after readiness; late SDK consumers recover it from
5586
- // event_replay.
5587
- const identityHeader = {
5588
- type: "identity_header",
5589
- sessionId: id,
5590
- ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
5591
- };
5592
- host.emitEvent({ kind: identityHeader.type, payload: identityHeader });
5593
- const endpoint = await sdkRuntime.startTransport();
5594
5617
  // The native server owns the only authoritative view of this host's live
5595
5618
  // SDK client sockets; publish it so a detached session host can bound its
5596
5619
  // own lifetime without probing the OS (#4010). The handle is this
@@ -5849,7 +5872,10 @@ export function createNotificationsExtension(
5849
5872
  }
5850
5873
 
5851
5874
  const sessionRuntime: NotificationSessionRuntime<ExtensionContext> = {
5852
- isRunning: binding => runtimes.get(binding.sessionId)?.notificationsActive === true,
5875
+ isRunning: binding => {
5876
+ const runtime = runtimes.get(binding.sessionId);
5877
+ return runtime?.notificationsActive === true && runtime.notificationOwnerState === "ready";
5878
+ },
5853
5879
  start: async binding => {
5854
5880
  if (sessionStartPromises.has(binding.sessionId)) {
5855
5881
  const result = await startSession(binding.context);
@@ -5862,6 +5888,28 @@ export function createNotificationsExtension(
5862
5888
  }
5863
5889
  const runtime = runtimes.get(binding.sessionId);
5864
5890
  if (runtime) {
5891
+ if (runtime.notificationOwnerState === "retry") {
5892
+ const { cfg, settings, settingsAvailable } = resolveSettings(options.settings);
5893
+ if (!settingsAvailable || !settings) return "failed";
5894
+ try {
5895
+ const ownership = await ensureConfiguredDaemonOwners(
5896
+ settings,
5897
+ cfg,
5898
+ binding.cwd,
5899
+ binding.sessionId,
5900
+ token => {
5901
+ retainNotificationRootRegistration(runtime, settings, binding.cwd, binding.sessionId, token);
5902
+ },
5903
+ );
5904
+ runtime.notificationOwnerState =
5905
+ ownership === "ready" ||
5906
+ (ownership === "blocked_identity_with_sibling" && runtime.endpointScope === "chat")
5907
+ ? "ready"
5908
+ : "blocked";
5909
+ } catch {
5910
+ return "failed";
5911
+ }
5912
+ }
5865
5913
  return "started";
5866
5914
  }
5867
5915
  const result = await startSession(binding.context);
@@ -5871,8 +5919,11 @@ export function createNotificationsExtension(
5871
5919
  isolateTelegram: async binding => {
5872
5920
  const runtime = runtimes.get(binding.sessionId);
5873
5921
  if (runtime) {
5874
- const stopped = await stopSession(binding.sessionId, "session", runtime);
5875
- if (!stopped || runtimes.has(binding.sessionId) || cleanupRetries.has(binding.sessionId)) return "failed";
5922
+ if (runtime.endpointScope === "default") {
5923
+ runtime.notificationOwnerState = "blocked";
5924
+ return "failed";
5925
+ }
5926
+ return "started";
5876
5927
  }
5877
5928
  forceIsolatedChatSessions.add(binding.sessionId);
5878
5929
  const result = await startSession(binding.context);
@@ -5916,6 +5967,7 @@ export function createNotificationsExtension(
5916
5967
  // Activation is only valid after the controller commits a stable policy;
5917
5968
  // never expose deferred presentations while provisional policy is held.
5918
5969
  if (runtime.policySuspended) return;
5970
+ if (runtime.notificationOwnerState !== "ready") return;
5919
5971
  runtime.enableNotifications();
5920
5972
  runtime.gatePresentations?.setPublicationSuspended(false);
5921
5973
  runtime.gatePresentations?.activateDeferred(runtime.workflowGatePublicationEpoch);
@@ -5942,6 +5994,8 @@ export function createNotificationsExtension(
5942
5994
  registrationToken,
5943
5995
  });
5944
5996
  }
5997
+ if (runtime?.notificationOwnerState === "blocked")
5998
+ runtime.notificationOwnerState = result === "ready" ? "ready" : "blocked";
5945
5999
  return result;
5946
6000
  } catch {
5947
6001
  return "failed";