@gajae-code/coding-agent 0.16.0 → 0.16.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 (163) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/dist/types/async/job-manager.d.ts +32 -1
  3. package/dist/types/browser-backend.d.ts +12 -0
  4. package/dist/types/cli/update-cli.d.ts +1 -0
  5. package/dist/types/commands/sdk.d.ts +2 -0
  6. package/dist/types/config/billing-path.d.ts +25 -0
  7. package/dist/types/config/config-schema-version.d.ts +13 -1
  8. package/dist/types/config/model-registry.d.ts +16 -0
  9. package/dist/types/config/settings-schema.d.ts +17 -7
  10. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  11. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  12. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  13. package/dist/types/main.d.ts +1 -0
  14. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  15. package/dist/types/modes/components/model-selector.d.ts +4 -0
  16. package/dist/types/modes/components/session-selector.d.ts +1 -1
  17. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  18. package/dist/types/modes/interactive-mode.d.ts +16 -2
  19. package/dist/types/modes/theme/theme.d.ts +2 -1
  20. package/dist/types/modes/types.d.ts +2 -0
  21. package/dist/types/sdk/broker/broker.d.ts +2 -1
  22. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  23. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +14 -0
  25. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  26. package/dist/types/sdk/broker/state-version.d.ts +17 -0
  27. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  28. package/dist/types/sdk/bus/index.d.ts +3 -1
  29. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  30. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  31. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  32. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  33. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  34. package/dist/types/sdk/cli/rows.d.ts +8 -2
  35. package/dist/types/sdk/cli/session-cli.d.ts +2 -1
  36. package/dist/types/sdk/host/events.d.ts +1 -0
  37. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  38. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  39. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +18 -17
  41. package/dist/types/sdk/lifecycle-session.d.ts +1 -1
  42. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  43. package/dist/types/sdk/receipt-state.d.ts +2 -0
  44. package/dist/types/sdk/router/session-router.d.ts +4 -0
  45. package/dist/types/sdk/turn-result.d.ts +6 -0
  46. package/dist/types/session/agent-session.d.ts +47 -7
  47. package/dist/types/session/fold-coordinator.d.ts +29 -2
  48. package/dist/types/session/session-manager.d.ts +14 -2
  49. package/dist/types/tools/ask.d.ts +1 -0
  50. package/dist/types/tools/bash.d.ts +12 -0
  51. package/dist/types/tools/browser/launch.d.ts +10 -0
  52. package/dist/types/tools/browser/registry.d.ts +8 -0
  53. package/dist/types/tools/index.d.ts +6 -10
  54. package/dist/types/tools/job.d.ts +3 -1
  55. package/dist/types/utils/external-editor.d.ts +7 -0
  56. package/examples/extensions/README.md +1 -1
  57. package/examples/extensions/pirate.ts +7 -4
  58. package/package.json +7 -7
  59. package/scripts/generate-sdk-operation-inventory.ts +7 -1
  60. package/src/async/job-manager.ts +107 -9
  61. package/src/browser-backend.ts +29 -0
  62. package/src/cli/plugin-cli.ts +29 -0
  63. package/src/cli/update-cli.ts +63 -12
  64. package/src/commands/sdk.ts +22 -7
  65. package/src/config/billing-path.ts +42 -0
  66. package/src/config/config-schema-version.ts +18 -1
  67. package/src/config/model-preset-registry.ts +177 -18
  68. package/src/config/model-registry.ts +268 -42
  69. package/src/config/settings-schema.ts +19 -7
  70. package/src/config/settings.ts +46 -15
  71. package/src/coordinator-mcp/projection-scan.ts +6 -6
  72. package/src/coordinator-mcp/server.ts +21 -1
  73. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  74. package/src/extensibility/extensions/runner.ts +27 -0
  75. package/src/extensibility/extensions/types.ts +8 -3
  76. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  77. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  78. package/src/extensibility/skills.ts +22 -12
  79. package/src/gjc-runtime/launch-worktree.ts +285 -4
  80. package/src/gjc-runtime/session-state-lock.ts +154 -28
  81. package/src/gjc-runtime/session-state-sidecar.ts +10 -17
  82. package/src/gjc-runtime/tmux-owner-isolation.ts +16 -1
  83. package/src/internal-urls/docs-index.generated.ts +10 -10
  84. package/src/main.ts +4 -1
  85. package/src/modes/acp/acp-agent.ts +5 -5
  86. package/src/modes/components/model-selector.ts +76 -8
  87. package/src/modes/components/session-selector.ts +23 -15
  88. package/src/modes/components/sessions-dashboard.ts +8 -5
  89. package/src/modes/controllers/command-controller.ts +1 -1
  90. package/src/modes/controllers/event-controller.ts +30 -4
  91. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  92. package/src/modes/controllers/input-controller.ts +2 -2
  93. package/src/modes/controllers/selector-controller.ts +10 -3
  94. package/src/modes/interactive-mode.ts +36 -12
  95. package/src/modes/prompt-action-autocomplete.ts +30 -4
  96. package/src/modes/runtime-init.ts +12 -9
  97. package/src/modes/theme/theme.ts +5 -0
  98. package/src/modes/types.ts +2 -0
  99. package/src/modes/utils/ui-helpers.ts +22 -3
  100. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  101. package/src/prompts/tools/ask.md +1 -1
  102. package/src/prompts/tools/bash.md +1 -0
  103. package/src/prompts/tools/job.md +1 -1
  104. package/src/sdk/acp/adapter.ts +5 -0
  105. package/src/sdk/broker/broker.ts +10 -1
  106. package/src/sdk/broker/ensure.ts +76 -3
  107. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  108. package/src/sdk/broker/lifecycle.ts +254 -34
  109. package/src/sdk/broker/spawn-substrate.ts +45 -2
  110. package/src/sdk/broker/startup-budget.ts +144 -2
  111. package/src/sdk/broker/state-version.ts +17 -0
  112. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  113. package/src/sdk/bus/chat-daemon-runtime.ts +111 -24
  114. package/src/sdk/bus/discord-daemon.ts +50 -10
  115. package/src/sdk/bus/index.ts +33 -3
  116. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  117. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  118. package/src/sdk/bus/notification-service.ts +22 -5
  119. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  120. package/src/sdk/bus/reconciliation-store.ts +55 -31
  121. package/src/sdk/bus/slack-conversation.ts +1 -1
  122. package/src/sdk/bus/slack-daemon.ts +72 -38
  123. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  124. package/src/sdk/cli/rows.ts +30 -7
  125. package/src/sdk/cli/session-cli.ts +33 -21
  126. package/src/sdk/host/control/dispatch.ts +2 -2
  127. package/src/sdk/host/events.ts +1 -0
  128. package/src/sdk/host/host.ts +2 -0
  129. package/src/sdk/host/query/cursor.ts +52 -7
  130. package/src/sdk/host/query/handlers.ts +33 -12
  131. package/src/sdk/host/query/revision-store.ts +12 -0
  132. package/src/sdk/host/session-runtime.test.ts +463 -8
  133. package/src/sdk/host/session-runtime.ts +383 -54
  134. package/src/sdk/host/surface-policy.ts +3 -3
  135. package/src/sdk/lifecycle-session.ts +7 -2
  136. package/src/sdk/prompt-deadline-manager.ts +2 -1
  137. package/src/sdk/protocol/operation-inventory.generated.json +54 -3
  138. package/src/sdk/protocol/operation-registry.ts +1 -1
  139. package/src/sdk/receipt-state.ts +10 -0
  140. package/src/sdk/router/session-router.ts +152 -59
  141. package/src/sdk/session.ts +60 -41
  142. package/src/sdk/turn-result.ts +22 -0
  143. package/src/session/agent-session.ts +1033 -260
  144. package/src/session/fold-coordinator.ts +40 -3
  145. package/src/session/session-manager.ts +517 -77
  146. package/src/slash-commands/builtin-registry.ts +41 -0
  147. package/src/task/executor.ts +9 -4
  148. package/src/tools/ask-contract.test.ts +64 -0
  149. package/src/tools/ask-contract.ts +36 -1
  150. package/src/tools/ask.ts +10 -8
  151. package/src/tools/bash-interactive.ts +11 -2
  152. package/src/tools/bash.ts +156 -33
  153. package/src/tools/browser/launch.ts +37 -0
  154. package/src/tools/browser/registry.ts +99 -9
  155. package/src/tools/cron.ts +6 -4
  156. package/src/tools/descriptors.test.ts +15 -0
  157. package/src/tools/descriptors.ts +5 -1
  158. package/src/tools/index.ts +6 -6
  159. package/src/tools/job.ts +4 -1
  160. package/src/tools/monitor.ts +12 -9
  161. package/src/tools/resolve.ts +30 -10
  162. package/src/tools/tool-catalog.generated.ts +2 -2
  163. package/src/utils/external-editor.ts +72 -1
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { logger } from "@gajae-code/utils";
2
3
  import { SdkClientError } from "../client/client";
3
4
  import { SESSION_PREPARED_EVENT } from "../host/host";
4
5
 
@@ -260,6 +261,9 @@ export class ChatDaemonRuntime {
260
261
  readonly #cleanupWork = new Map<string, Promise<void>>();
261
262
  readonly #retirementWork = new Map<string, Promise<void>>();
262
263
  readonly #attachmentBarriers = new Map<string, Promise<void>>();
264
+ readonly #cleanupFailures = new Map<string, unknown>();
265
+ #replayReady: Promise<void> = Promise.resolve();
266
+ #stopping = false;
263
267
  #discord: DiscordNotificationDaemon | undefined;
264
268
  #slack: SlackNotificationDaemon | undefined;
265
269
  #presentation: NotificationPresentationEngine | undefined;
@@ -276,24 +280,40 @@ export class ChatDaemonRuntime {
276
280
  ...deps.routerDeps,
277
281
  onFrame: async (attachment, frame) => await this.#handleFrame(attachment, frame),
278
282
  onAttachment: async attachment => this.#onAttachment(attachment),
283
+ onReplayReady: async () => await this.#replayReady,
279
284
  onSessionRemoved: async (attachment, reason) => await this.#onSessionRemoved(attachment, reason),
280
285
  },
281
286
  });
282
287
  }
283
288
 
284
289
  async start(): Promise<void> {
290
+ const replayReady = Promise.withResolvers<void>();
291
+ this.#replayReady = replayReady.promise;
292
+ // Startup can fail before any attachment observes the replay barrier. Mark
293
+ // the rejection handled without changing what actual waiters observe.
294
+ void this.#replayReady.catch(() => undefined);
285
295
  if (this.#cleanupWork.size > 0 || this.#retirementWork.size > 0) {
286
296
  const retirements = [...this.#retirementWork.entries()];
297
+ const cleanup = [...this.#cleanupWork.values()];
298
+ const retirement = retirements.map(([, work]) => work);
287
299
  const settled = await Promise.race([
288
- Promise.all([
289
- Promise.allSettled([...this.#cleanupWork.values()]),
290
- Promise.all(retirements.map(([, retirement]) => retirement)),
291
- ]).then(() => true),
300
+ Promise.all([Promise.allSettled(cleanup), Promise.allSettled(retirement)]).then(() => true),
292
301
  Bun.sleep(5_000).then(() => false),
293
302
  ]);
294
303
  if (!settled) throw new Error("Prior provider cleanup did not settle before chat daemon restart.");
295
- for (const [sessionId, retirement] of retirements)
296
- if (this.#retirementWork.get(sessionId) === retirement) this.#retirementWork.delete(sessionId);
304
+ const failures = await Promise.all([Promise.allSettled(cleanup), Promise.allSettled(retirement)]);
305
+ const rejected = failures
306
+ .flat()
307
+ .filter((result): result is PromiseRejectedResult => result.status === "rejected");
308
+ if (rejected.length > 0) {
309
+ logger.warn(`SDK chat daemon retained ${rejected.length} prior cleanup failure(s) for recovery.`);
310
+ }
311
+ for (const [sessionId, work] of retirements)
312
+ if (this.#retirementWork.get(sessionId) === work && !rejected.some(result => result.status === "rejected"))
313
+ this.#retirementWork.delete(sessionId);
314
+ for (const [sessionId, work] of [...this.#cleanupWork.entries()])
315
+ if (work !== undefined && !rejected.some(result => result.status === "rejected"))
316
+ this.#cleanupWork.delete(sessionId);
297
317
  }
298
318
  this.#attachments.clear();
299
319
  const retainedProvider =
@@ -308,8 +328,10 @@ export class ChatDaemonRuntime {
308
328
  try {
309
329
  await this.#router.start();
310
330
  await retainedProvider.start();
331
+ replayReady.resolve();
311
332
  return;
312
333
  } catch (error) {
334
+ replayReady.reject(error);
313
335
  await this.#router.stop().catch(() => undefined);
314
336
  throw error;
315
337
  }
@@ -370,7 +392,9 @@ export class ChatDaemonRuntime {
370
392
  await this.#router.start();
371
393
  if (this.#discord) await this.#discord.start();
372
394
  if (this.#slack) await this.#slack.start();
395
+ replayReady.resolve();
373
396
  } catch (error) {
397
+ replayReady.reject(error);
374
398
  await this.stop();
375
399
  throw error;
376
400
  }
@@ -386,6 +410,20 @@ export class ChatDaemonRuntime {
386
410
  }
387
411
 
388
412
  async stop(): Promise<void> {
413
+ this.#stopping = true;
414
+ let routerError: unknown;
415
+ try {
416
+ await this.#router.stop();
417
+ } catch (error) {
418
+ routerError = error;
419
+ }
420
+ const cleanupDrain = Promise.allSettled([...this.#cleanupWork.values(), ...this.#retirementWork.values()]);
421
+ const cleanupSettled = await Promise.race([cleanupDrain.then(() => true), Bun.sleep(5_000).then(() => false)]);
422
+ const cleanupResults = cleanupSettled
423
+ ? await cleanupDrain
424
+ : [{ status: "rejected" as const, reason: new Error("Provider cleanup exceeded shutdown drain.") }];
425
+ if (!cleanupSettled)
426
+ logger.warn("SDK chat daemon cleanup exceeded the shutdown drain; provider stop will invalidate it.");
389
427
  const providerResults = await Promise.allSettled([this.#discord?.stop(), this.#slack?.stop()]);
390
428
  const discordRestartBlocked = this.#discord?.restartBlocked() ?? false;
391
429
  const slackRestartBlocked = this.#slack?.restartBlocked() ?? false;
@@ -395,10 +433,12 @@ export class ChatDaemonRuntime {
395
433
  this.#presentation = undefined;
396
434
  this.#transportHealthy = undefined;
397
435
  }
398
- await this.#router.stop();
399
- const failures = providerResults.filter(
400
- (result): result is PromiseRejectedResult => result.status === "rejected",
401
- );
436
+ this.#stopping = false;
437
+ const failures = [
438
+ ...(routerError === undefined ? [] : [{ status: "rejected" as const, reason: routerError }]),
439
+ ...cleanupResults,
440
+ ...providerResults,
441
+ ].filter((result): result is PromiseRejectedResult => result.status === "rejected");
402
442
  if (failures.length > 0)
403
443
  throw new AggregateError(
404
444
  failures.map(result => result.reason),
@@ -436,14 +476,33 @@ export class ChatDaemonRuntime {
436
476
  const slack = this.#slack;
437
477
  const barrier = (async () => {
438
478
  const retirement = this.#retirementWork.get(attachment.sessionId);
479
+ let retirementFailure: unknown;
439
480
  if (retirement) {
440
- await retirement;
441
- if (this.#retirementWork.get(attachment.sessionId) === retirement)
442
- this.#retirementWork.delete(attachment.sessionId);
481
+ try {
482
+ await retirement;
483
+ } catch (error) {
484
+ retirementFailure = error;
485
+ logger.warn(`SDK chat daemon retirement cleanup failed before successor attachment: ${String(error)}`);
486
+ }
487
+ }
488
+ const cleanup = this.#cleanupWork.get(attachment.sessionId);
489
+ if (cleanup) await cleanup.catch(error => logger.warn(`SDK cleanup retry pending: ${String(error)}`));
490
+ const [discordRecovered, slackRecovered] = await Promise.all([
491
+ discord?.recoverCleanup(attachment.sessionId, attachment.generation, attachment.authorityId) ?? true,
492
+ slack?.recoverCleanup(attachment.sessionId, attachment.generation, attachment.authorityId) ?? true,
493
+ ]);
494
+ if (!discordRecovered || !slackRecovered)
495
+ throw new Error("successor_attachment_blocked_until_retirement_is_verified");
496
+ if (cleanup !== undefined && this.#cleanupWork.get(attachment.sessionId) === cleanup) {
497
+ this.#cleanupWork.delete(attachment.sessionId);
498
+ this.#cleanupFailures.delete(attachment.sessionId);
443
499
  }
444
- await this.#cleanupWork.get(attachment.sessionId)?.catch(() => undefined);
445
- await discord?.recoverCleanup(attachment.sessionId, attachment.generation);
446
- await slack?.recoverCleanup(attachment.sessionId, attachment.generation);
500
+ if (
501
+ retirementFailure !== undefined &&
502
+ retirement &&
503
+ this.#retirementWork.get(attachment.sessionId) === retirement
504
+ )
505
+ this.#retirementWork.delete(attachment.sessionId);
447
506
  })();
448
507
  this.#attachmentBarriers.set(attachment.sessionId, barrier);
449
508
  try {
@@ -470,7 +529,7 @@ export class ChatDaemonRuntime {
470
529
  return;
471
530
  }
472
531
  this.#attachments.delete(attachment.sessionId);
473
- if (reason === "removed") await this.#trackCleanup(attachment);
532
+ if (reason === "removed" && !this.#stopping) await this.#trackCleanup(attachment);
474
533
  }
475
534
 
476
535
  #trackRetirement(attachment: SessionAttachment): Promise<void> {
@@ -478,7 +537,13 @@ export class ChatDaemonRuntime {
478
537
  const discord = this.#discord;
479
538
  const slack = this.#slack;
480
539
  const previous = this.#retirementWork.get(sessionId);
481
- const work = (previous ?? Promise.resolve()).then(async () => {
540
+ const work = (
541
+ previous
542
+ ? previous.catch(error => {
543
+ this.#cleanupFailures.set(sessionId, error);
544
+ })
545
+ : Promise.resolve()
546
+ ).then(async () => {
482
547
  // Capture the predecessor providers before stop() can clear the runtime
483
548
  // fields. The successor barrier owns this promise until every retirement
484
549
  // waiter settles, even when takeover overlaps worker shutdown.
@@ -486,6 +551,12 @@ export class ChatDaemonRuntime {
486
551
  await slack?.retireAttachment(sessionId, attachment.generation);
487
552
  });
488
553
  this.#retirementWork.set(sessionId, work);
554
+ void work.then(
555
+ () => {
556
+ if (this.#retirementWork.get(sessionId) === work) this.#retirementWork.delete(sessionId);
557
+ },
558
+ () => undefined,
559
+ );
489
560
  return work;
490
561
  }
491
562
 
@@ -507,7 +578,10 @@ export class ChatDaemonRuntime {
507
578
  }
508
579
  if (name === SESSION_PREPARED_EVENT || bodyType === SESSION_PREPARED_EVENT) return;
509
580
  if (name === "session_ready") {
510
- if (correlated.generation !== attachment.generation) return;
581
+ // The attachment itself is the endpoint-generation authority. An
582
+ // unpositioned session_ready frame deliberately carries no ring
583
+ // generation; reject only an explicit, conflicting Router-owned value.
584
+ if (correlated.generation !== undefined && correlated.generation !== attachment.generation) return;
511
585
  await this.#resume(
512
586
  sessionId,
513
587
  attachment.generation,
@@ -550,7 +624,7 @@ export class ChatDaemonRuntime {
550
624
  await this.#slack.notify(
551
625
  sessionId,
552
626
  content,
553
- notification.type === "action_needed" ? notification.id : undefined,
627
+ notification.type === "action_needed" && notification.kind === "ask" ? notification.id : undefined,
554
628
  attachment.generation,
555
629
  publicationId,
556
630
  );
@@ -559,13 +633,26 @@ export class ChatDaemonRuntime {
559
633
  #trackCleanup(attachment: SessionAttachment): Promise<void> {
560
634
  const sessionId = attachment.sessionId;
561
635
  const previous = this.#cleanupWork.get(sessionId);
562
- const work = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(async () => {
636
+ const work = (
637
+ previous
638
+ ? previous.catch(error => {
639
+ this.#cleanupFailures.set(sessionId, error);
640
+ })
641
+ : Promise.resolve()
642
+ ).then(async () => {
563
643
  await this.#closeProviders(attachment);
564
644
  });
565
645
  this.#cleanupWork.set(sessionId, work);
566
- return work.finally(() => {
567
- if (this.#cleanupWork.get(sessionId) === work) this.#cleanupWork.delete(sessionId);
568
- });
646
+ void work.then(
647
+ () => {
648
+ if (this.#cleanupWork.get(sessionId) === work) {
649
+ this.#cleanupWork.delete(sessionId);
650
+ this.#cleanupFailures.delete(sessionId);
651
+ }
652
+ },
653
+ () => undefined,
654
+ );
655
+ return work;
569
656
  }
570
657
  async #closeProviders(attachment: SessionAttachment): Promise<void> {
571
658
  const discord = this.#discord;
@@ -166,8 +166,9 @@ export class DiscordNotificationDaemon {
166
166
  expectedGeneration?: number,
167
167
  ) => SessionAttachment | null | Promise<SessionAttachment | null>;
168
168
  readonly #effects: ChatEffectJournal;
169
- readonly #activeWork = new Set<Promise<unknown>>();
169
+ readonly #activeWork = new Map<Promise<unknown>, string | undefined>();
170
170
  readonly #workInvalidators = new Set<() => Promise<void>>();
171
+ readonly #sessionInvalidators = new Map<string, Set<() => Promise<void>>>();
171
172
  readonly #inflightInbound = new Set<string>();
172
173
  #started = false;
173
174
  #lifecycleGeneration = 0;
@@ -264,7 +265,8 @@ export class DiscordNotificationDaemon {
264
265
  await this.options.provider.start(
265
266
  async event => {
266
267
  if (lifecycleGeneration !== this.#lifecycleGeneration || !this.#started) return;
267
- await this.#track(this.handleInbound(event));
268
+ const record = await this.#byThread(event.guildId, event.parentId, event.threadId);
269
+ await this.#track(this.handleInbound(event), record?.sessionId);
268
270
  },
269
271
  () => {},
270
272
  );
@@ -375,6 +377,9 @@ export class DiscordNotificationDaemon {
375
377
  this.#workGeneration += 1;
376
378
  await Promise.allSettled([...this.#workInvalidators].map(invalidate => invalidate()));
377
379
  }
380
+ async #invalidateSessionWork(sessionId: string): Promise<void> {
381
+ await Promise.allSettled([...(this.#sessionInvalidators.get(sessionId) ?? [])].map(invalidate => invalidate()));
382
+ }
378
383
 
379
384
  async #drainActiveWork(deadline: number): Promise<void> {
380
385
  while (this.#activeWork.size > 0) {
@@ -385,7 +390,7 @@ export class DiscordNotificationDaemon {
385
390
  return;
386
391
  }
387
392
  const settled = await Promise.race([
388
- Promise.allSettled([...this.#activeWork]).then(() => true),
393
+ Promise.allSettled([...this.#activeWork.keys()]).then(() => true),
389
394
  Bun.sleep(remaining).then(() => false),
390
395
  ]);
391
396
  if (!settled) {
@@ -395,6 +400,17 @@ export class DiscordNotificationDaemon {
395
400
  }
396
401
  }
397
402
  }
403
+ async #drainSessionWork(sessionId: string, deadline: number): Promise<void> {
404
+ while (true) {
405
+ const work = [...this.#activeWork.entries()]
406
+ .filter(([, activeSessionId]) => activeSessionId === sessionId)
407
+ .map(([promise]) => promise);
408
+ if (work.length === 0) return;
409
+ const remaining = deadline - this.#now();
410
+ if (remaining <= 0) return;
411
+ await Promise.race([Promise.allSettled(work), Bun.sleep(remaining)]);
412
+ }
413
+ }
398
414
 
399
415
  #recordProviderLifecycleError(error: unknown): void {
400
416
  this.#providerLifecycleError = error;
@@ -422,7 +438,7 @@ export class DiscordNotificationDaemon {
422
438
  * legitimate create into a spurious teardown rejection.
423
439
  */
424
440
  async notify(input: DiscordNotificationInput): Promise<DiscordConversation> {
425
- return await this.#track(this.#notify(input));
441
+ return await this.#track(this.#notify(input), input.sessionId);
426
442
  }
427
443
  async #notify(input: DiscordNotificationInput): Promise<DiscordConversation> {
428
444
  await this.#requireLiveBinding(input.sessionId, input.endpointGeneration, input.attachmentAuthorityId);
@@ -457,7 +473,7 @@ export class DiscordNotificationDaemon {
457
473
 
458
474
  /** Posts a safe command outcome to the active mapped conversation. */
459
475
  async postCommandResult(sessionId: string, content: string): Promise<boolean> {
460
- return await this.#track(this.#postCommandResult(sessionId, content));
476
+ return await this.#track(this.#postCommandResult(sessionId, content), sessionId);
461
477
  }
462
478
  async #postCommandResult(sessionId: string, content: string): Promise<boolean> {
463
479
  const record = await this.#bySession(sessionId);
@@ -471,13 +487,20 @@ export class DiscordNotificationDaemon {
471
487
  if (closing) await this.#driveClose(closing);
472
488
  }
473
489
 
474
- async recoverCleanup(sessionId: string, endpointGeneration: number): Promise<void> {
490
+ async recoverCleanup(sessionId: string, endpointGeneration: number, authorityId?: string): Promise<boolean> {
475
491
  const record = await this.#bySession(sessionId);
476
- if (record?.endpointGeneration !== endpointGeneration || !closingIntent(record)) return;
492
+ if (record?.endpointGeneration !== endpointGeneration) return true;
493
+ if (record?.state === "closed" || record?.state === "archived" || record?.state === "resuming") return true;
494
+ if (!closingIntent(record) && record?.state === "active" && record.attachmentAuthorityId === authorityId)
495
+ return true;
496
+ if (!closingIntent(record)) return false;
477
497
  await this.#driveClose(record);
498
+ return true;
478
499
  }
479
500
 
480
501
  async retireAttachment(sessionId: string, endpointGeneration: number): Promise<void> {
502
+ await this.#invalidateSessionWork(sessionId);
503
+ await this.#drainSessionWork(sessionId, this.#now() + 5_000);
481
504
  const intentKey = this.#intentKey(sessionId);
482
505
  const now = this.#now();
483
506
  await this.#store.transact(intentKey, current => {
@@ -548,7 +571,7 @@ export class DiscordNotificationDaemon {
548
571
  endpointGeneration: number,
549
572
  attachmentAuthorityId?: string,
550
573
  ): Promise<DiscordConversation | undefined> {
551
- return await this.#track(this.#resumeAdmission(sessionId, endpointGeneration, attachmentAuthorityId));
574
+ return await this.#track(this.#resumeAdmission(sessionId, endpointGeneration, attachmentAuthorityId), sessionId);
552
575
  }
553
576
  async #resumeAdmission(
554
577
  sessionId: string,
@@ -665,8 +688,8 @@ export class DiscordNotificationDaemon {
665
688
  }
666
689
  }
667
690
 
668
- #track<T>(work: Promise<T>): Promise<T> {
669
- this.#activeWork.add(work);
691
+ #track<T>(work: Promise<T>, sessionId?: string): Promise<T> {
692
+ this.#activeWork.set(work, sessionId);
670
693
  return work.finally(() => this.#activeWork.delete(work));
671
694
  }
672
695
 
@@ -1871,6 +1894,11 @@ export class DiscordNotificationDaemon {
1871
1894
  #intentKey(sessionId: string): string {
1872
1895
  return `${this.options.provider.applicationId}:${this.options.guildId}:${this.options.parentChannelId}:creating:${sessionId}`;
1873
1896
  }
1897
+ #removeSessionInvalidator(sessionId: string, set: Set<() => Promise<void>>, invalidate: () => Promise<void>): void {
1898
+ set.delete(invalidate);
1899
+ if (set.size === 0 && this.#sessionInvalidators.get(sessionId) === set)
1900
+ this.#sessionInvalidators.delete(sessionId);
1901
+ }
1874
1902
  async #withCreateIntentLease<T>(intent: DiscordConversation, work: () => Promise<T>): Promise<T> {
1875
1903
  let lost = false;
1876
1904
  let renewal: Promise<boolean> | undefined;
@@ -1886,6 +1914,9 @@ export class DiscordNotificationDaemon {
1886
1914
  await abandonCurrent();
1887
1915
  };
1888
1916
  this.#workInvalidators.add(invalidate);
1917
+ const sessionInvalidators = this.#sessionInvalidators.get(intent.sessionId!) ?? new Set<() => Promise<void>>();
1918
+ sessionInvalidators.add(invalidate);
1919
+ this.#sessionInvalidators.set(intent.sessionId!, sessionInvalidators);
1889
1920
  const renew = async (): Promise<boolean> => {
1890
1921
  if (lost || workGeneration !== this.#workGeneration) {
1891
1922
  await invalidate();
@@ -1953,6 +1984,7 @@ export class DiscordNotificationDaemon {
1953
1984
  } finally {
1954
1985
  clearInterval(timer);
1955
1986
  this.#workInvalidators.delete(invalidate);
1987
+ this.#removeSessionInvalidator(intent.sessionId!, sessionInvalidators, invalidate);
1956
1988
  }
1957
1989
  }
1958
1990
  async #clearPending(record: DiscordConversation, actionId: string, actionNonce: string): Promise<void> {
@@ -2076,6 +2108,12 @@ export class DiscordNotificationDaemon {
2076
2108
  );
2077
2109
  };
2078
2110
  this.#workInvalidators.add(invalidate);
2111
+ let sessionInvalidators: Set<() => Promise<void>> | undefined;
2112
+ if (sessionId !== undefined) {
2113
+ sessionInvalidators = this.#sessionInvalidators.get(sessionId) ?? new Set<() => Promise<void>>();
2114
+ sessionInvalidators.add(invalidate);
2115
+ this.#sessionInvalidators.set(sessionId, sessionInvalidators);
2116
+ }
2079
2117
  const renewLease = async (): Promise<boolean> => {
2080
2118
  if (renewalLost || workGeneration !== this.#workGeneration) {
2081
2119
  renewalLost = true;
@@ -2136,6 +2174,8 @@ export class DiscordNotificationDaemon {
2136
2174
  } finally {
2137
2175
  clearInterval(timer);
2138
2176
  this.#workInvalidators.delete(invalidate);
2177
+ if (sessionId !== undefined && sessionInvalidators !== undefined)
2178
+ this.#removeSessionInvalidator(sessionId, sessionInvalidators, invalidate);
2139
2179
  }
2140
2180
  }
2141
2181
 
@@ -374,6 +374,8 @@ export type LifecycleErrorReason =
374
374
  | "spawn_failed"
375
375
  | "discovery_timeout"
376
376
  | "readiness_timeout"
377
+ | "worktree_preparation_timeout"
378
+ | "dependency_preparation_timeout"
377
379
  | "close_refused"
378
380
  | "not_found"
379
381
  | "terminal_uncertain"
@@ -1223,6 +1225,8 @@ interface SessionRuntime {
1223
1225
  disposeGateTerminalController: () => void;
1224
1226
  disposeAckRecoveryParticipant: () => void;
1225
1227
  disposeGateEmitterListener: () => void;
1228
+ /** Deregisters this session's job-fold listener (`bash_folded`). */
1229
+ disposeFoldListener: () => void;
1226
1230
  /** Aborts and fences side turns while notification delivery is disabled. */
1227
1231
  disableEphemeralTurns: () => void;
1228
1232
  waitForGateResolutionQuiescence: () => Promise<void>;
@@ -2384,6 +2388,7 @@ function sdkQuerySurface(
2384
2388
  steerStatusLookup: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown = () => ({
2385
2389
  status: "unknown",
2386
2390
  }),
2391
+ getRuntimeHost: () => SessionSdkHost | undefined = () => undefined,
2387
2392
  ): SessionSurface {
2388
2393
  return createSdkSurfaceFactory({
2389
2394
  ctx,
@@ -2396,6 +2401,7 @@ function sdkQuerySurface(
2396
2401
  turnResultLookup,
2397
2402
  steerStatusLookup,
2398
2403
  hostTools: () => getInstalledDefinitions("host_tools") !== undefined,
2404
+ getRuntimeHost,
2399
2405
  }).query;
2400
2406
  }
2401
2407
 
@@ -3522,7 +3528,7 @@ function sdkControlSurface(
3522
3528
  setQueueMode: (kind, mode) => {
3523
3529
  if (!bindings.has("setQueueMode"))
3524
3530
  return unavailable(`queue.${kind}_mode.set`, "no session queue-mode seam is installed")();
3525
- if (!ctx.setQueueMode(kind as "steering" | "follow_up" | "interrupt", mode))
3531
+ if (!ctx.setQueueMode(kind as "steering" | "follow_up" | "tool_interrupt", mode))
3526
3532
  throw Object.assign(new Error("Invalid queue mode."), { code: "invalid_input" });
3527
3533
  return { changed: true };
3528
3534
  },
@@ -4378,8 +4384,14 @@ export function createNotificationsExtension(
4378
4384
  rt.workflowGate?.setRuntimeTurnProvider?.(null);
4379
4385
  } catch {}
4380
4386
  try {
4387
+ const attachedClients = rt.server.clientCount();
4381
4388
  const delivered = await pushTerminalSessionFrame(rt, { type: "session_closed", sessionId: id });
4382
- if (!delivered) logger.warn("notifications: session_closed socket delivery was not acknowledged");
4389
+ if (!delivered) {
4390
+ const fields = { sessionId: id, endpointScope: rt.endpointScope, attachedClients };
4391
+ if (attachedClients > 0)
4392
+ logger.warn("notifications: session_closed socket delivery was not acknowledged", fields);
4393
+ else logger.debug("notifications: session_closed had no attached client to acknowledge", fields);
4394
+ }
4383
4395
  } catch (e) {
4384
4396
  logger.warn(`notifications: session_closed failed: ${String(e)}`);
4385
4397
  }
@@ -4390,6 +4402,9 @@ export function createNotificationsExtension(
4390
4402
  try {
4391
4403
  rt.disposeGateEmitterListener();
4392
4404
  } catch {}
4405
+ try {
4406
+ rt.disposeFoldListener();
4407
+ } catch {}
4393
4408
  rt.gatePresentations?.dispose();
4394
4409
  try {
4395
4410
  rt.disposeGateTerminalController();
@@ -5282,7 +5297,6 @@ export function createNotificationsExtension(
5282
5297
  submission.reconciliationKind,
5283
5298
  correlation,
5284
5299
  winner,
5285
- undefined,
5286
5300
  extra?.error,
5287
5301
  extra?.finalText,
5288
5302
  );
@@ -5454,6 +5468,7 @@ export function createNotificationsExtension(
5454
5468
  settings,
5455
5469
  selector => kindReconciliation.lookupResult(selector.kind, selector),
5456
5470
  selector => kindReconciliation.lookupSteer(selector),
5471
+ () => host,
5457
5472
  ),
5458
5473
  id,
5459
5474
  revisions,
@@ -6985,6 +7000,7 @@ export function createNotificationsExtension(
6985
7000
  ]);
6986
7001
  },
6987
7002
  disposeGateEmitterListener: () => {},
7003
+ disposeFoldListener: () => {},
6988
7004
  trackGateResolution,
6989
7005
  waitForGateResolutionQuiescence: async () => {
6990
7006
  await Promise.allSettled(inFlightGateResolutions);
@@ -8009,6 +8025,20 @@ export function createNotificationsExtension(
8009
8025
  };
8010
8026
  activeRuntime.disposeGateEmitterListener = registerWorkflowGateEmitterListener(id, attachWorkflowGate);
8011
8027
  if (ctx.workflowGate) attachWorkflowGate(ctx.workflowGate);
8028
+ // Every fold (steer, chord, auto-background timer, `bash.background`)
8029
+ // publishes one replayable `bash_folded` frame. `runtime.jobs.list`
8030
+ // `foldReason` remains the source of truth clients reconcile against after
8031
+ // a replay, since a replayed frame may describe a job that has since ended.
8032
+ activeRuntime.disposeFoldListener =
8033
+ ctx.onJobFold?.(event => {
8034
+ emitSessionEvent(activeRuntime, {
8035
+ type: "bash_folded",
8036
+ sessionId: id,
8037
+ jobId: event.jobId,
8038
+ generation: event.generation,
8039
+ reason: event.reason,
8040
+ });
8041
+ }) ?? (() => {});
8012
8042
  finishStartup({ status: "started", runtime: initializedRuntime });
8013
8043
  return { status: "started", runtime: initializedRuntime };
8014
8044
  } catch (e) {