@gajae-code/coding-agent 0.15.6 → 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 (270) hide show
  1. package/CHANGELOG.md +99 -0
  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/args.d.ts +2 -0
  5. package/dist/types/cli/root-flags.d.ts +14 -0
  6. package/dist/types/cli/update-cli.d.ts +14 -0
  7. package/dist/types/cli.d.ts +7 -0
  8. package/dist/types/commands/launch.d.ts +10 -0
  9. package/dist/types/commands/sdk.d.ts +2 -0
  10. package/dist/types/config/autorouting-tier-map.d.ts +24 -0
  11. package/dist/types/config/billing-path.d.ts +25 -0
  12. package/dist/types/config/config-schema-version.d.ts +13 -1
  13. package/dist/types/config/file-lock.d.ts +5 -0
  14. package/dist/types/config/model-registry.d.ts +22 -0
  15. package/dist/types/config/settings-schema.d.ts +27 -8
  16. package/dist/types/config/settings.d.ts +2 -0
  17. package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
  18. package/dist/types/crash/record-loader.d.ts +15 -0
  19. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  20. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  21. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  22. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  23. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  24. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +66 -1
  25. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  26. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  27. package/dist/types/main.d.ts +1 -0
  28. package/dist/types/master-mode/context.d.ts +21 -0
  29. package/dist/types/master-mode/first-request.d.ts +34 -0
  30. package/dist/types/master-mode/snapshot.d.ts +32 -0
  31. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  32. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  33. package/dist/types/modes/components/model-selector.d.ts +4 -0
  34. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  35. package/dist/types/modes/components/session-selector.d.ts +1 -1
  36. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  37. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  38. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  39. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  40. package/dist/types/modes/interactive-mode.d.ts +16 -2
  41. package/dist/types/modes/print-mode.d.ts +17 -0
  42. package/dist/types/modes/theme/theme.d.ts +2 -1
  43. package/dist/types/modes/types.d.ts +2 -0
  44. package/dist/types/modes/ui-language.d.ts +7 -1
  45. package/dist/types/sdk/broker/broker.d.ts +79 -2
  46. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  47. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  48. package/dist/types/sdk/broker/index.d.ts +3 -0
  49. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  50. package/dist/types/sdk/broker/lifecycle.d.ts +40 -0
  51. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  52. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  53. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  54. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  55. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  56. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  57. package/dist/types/sdk/broker/state-version.d.ts +21 -1
  58. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  59. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  60. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  61. package/dist/types/sdk/bus/index.d.ts +9 -1
  62. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  63. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  64. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  65. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  66. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  67. package/dist/types/sdk/cli/index.d.ts +1 -0
  68. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  69. package/dist/types/sdk/cli/rows.d.ts +12 -5
  70. package/dist/types/sdk/cli/session-cli.d.ts +20 -3
  71. package/dist/types/sdk/client/client.d.ts +3 -2
  72. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  73. package/dist/types/sdk/host/control/index.d.ts +1 -1
  74. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  75. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  76. package/dist/types/sdk/host/events.d.ts +1 -0
  77. package/dist/types/sdk/host/host.d.ts +10 -0
  78. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  79. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  80. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  81. package/dist/types/sdk/host/session-runtime.d.ts +70 -17
  82. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  83. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  84. package/dist/types/sdk/lifecycle-session.d.ts +3 -1
  85. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  86. package/dist/types/sdk/receipt-state.d.ts +2 -0
  87. package/dist/types/sdk/router/session-router.d.ts +6 -0
  88. package/dist/types/sdk/session.d.ts +16 -0
  89. package/dist/types/sdk/startup-capability.d.ts +2 -1
  90. package/dist/types/sdk/turn-result.d.ts +6 -0
  91. package/dist/types/session/agent-session.d.ts +52 -7
  92. package/dist/types/session/credential-store-errors.d.ts +1 -0
  93. package/dist/types/session/fold-coordinator.d.ts +29 -2
  94. package/dist/types/session/session-manager.d.ts +79 -2
  95. package/dist/types/session/yield-queue.d.ts +5 -4
  96. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  97. package/dist/types/tools/ask.d.ts +1 -0
  98. package/dist/types/tools/bash.d.ts +16 -0
  99. package/dist/types/tools/browser/launch.d.ts +10 -0
  100. package/dist/types/tools/browser/registry.d.ts +8 -0
  101. package/dist/types/tools/gh.d.ts +8 -0
  102. package/dist/types/tools/index.d.ts +10 -10
  103. package/dist/types/tools/job.d.ts +3 -1
  104. package/dist/types/utils/external-editor.d.ts +7 -0
  105. package/examples/extensions/README.md +1 -1
  106. package/examples/extensions/pirate.ts +7 -4
  107. package/package.json +7 -7
  108. package/scripts/generate-sdk-operation-inventory.ts +13 -1
  109. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  110. package/src/async/job-manager.ts +107 -9
  111. package/src/browser-backend.ts +29 -0
  112. package/src/cli/accounts-cli.ts +9 -5
  113. package/src/cli/args.ts +17 -0
  114. package/src/cli/fast-help.ts +1 -0
  115. package/src/cli/plugin-cli.ts +29 -0
  116. package/src/cli/root-flags.ts +5 -0
  117. package/src/cli/update-cli.ts +140 -23
  118. package/src/commands/launch.ts +86 -18
  119. package/src/commands/sdk.ts +135 -28
  120. package/src/config/autorouting-tier-map.ts +12 -0
  121. package/src/config/billing-path.ts +42 -0
  122. package/src/config/config-schema-version.ts +18 -1
  123. package/src/config/file-lock.ts +158 -83
  124. package/src/config/model-preset-registry.ts +178 -23
  125. package/src/config/model-profiles.ts +5 -5
  126. package/src/config/model-registry.ts +291 -42
  127. package/src/config/model-resolver.ts +5 -0
  128. package/src/config/settings-schema.ts +27 -8
  129. package/src/config/settings.ts +51 -15
  130. package/src/coordinator-mcp/projection-scan.ts +27 -6
  131. package/src/coordinator-mcp/question-state.ts +9 -11
  132. package/src/coordinator-mcp/server.ts +115 -21
  133. package/src/crash/index-store.ts +2 -0
  134. package/src/crash/record-loader.ts +10 -0
  135. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  136. package/src/extensibility/extensions/runner.ts +27 -0
  137. package/src/extensibility/extensions/types.ts +8 -3
  138. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  139. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  140. package/src/extensibility/skills.ts +22 -12
  141. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  142. package/src/gjc-runtime/launch-worktree.ts +285 -4
  143. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  144. package/src/gjc-runtime/session-state-lock.ts +213 -14
  145. package/src/gjc-runtime/session-state-sidecar.ts +938 -39
  146. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  147. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -3
  148. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  149. package/src/hooks/skill-state.ts +6 -0
  150. package/src/internal-urls/docs-index.generated.ts +15 -15
  151. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  152. package/src/main.ts +61 -3
  153. package/src/master-mode/context.ts +50 -0
  154. package/src/master-mode/first-request.ts +65 -0
  155. package/src/master-mode/snapshot.ts +205 -0
  156. package/src/modes/acp/acp-agent.ts +610 -161
  157. package/src/modes/components/footer.ts +4 -2
  158. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  159. package/src/modes/components/model-selector.ts +76 -8
  160. package/src/modes/components/oauth-selector.ts +6 -1
  161. package/src/modes/components/session-selector.ts +23 -15
  162. package/src/modes/components/sessions-dashboard.ts +8 -5
  163. package/src/modes/components/settings-selector.ts +1 -0
  164. package/src/modes/components/status-line/model-name.ts +53 -0
  165. package/src/modes/components/status-line/priority-row.ts +107 -0
  166. package/src/modes/components/status-line/segments.ts +23 -9
  167. package/src/modes/components/tool-status-header.ts +151 -15
  168. package/src/modes/controllers/command-controller.ts +1 -1
  169. package/src/modes/controllers/event-controller.ts +30 -4
  170. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  171. package/src/modes/controllers/input-controller.ts +30 -2
  172. package/src/modes/controllers/selector-controller.ts +200 -98
  173. package/src/modes/interactive-mode.ts +36 -12
  174. package/src/modes/print-mode.ts +64 -0
  175. package/src/modes/prompt-action-autocomplete.ts +30 -4
  176. package/src/modes/runtime-init.ts +12 -9
  177. package/src/modes/theme/theme.ts +5 -0
  178. package/src/modes/types.ts +2 -0
  179. package/src/modes/ui-language.ts +49 -2
  180. package/src/modes/utils/ui-helpers.ts +22 -3
  181. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  182. package/src/prompts/system/master-mode.md +28 -0
  183. package/src/prompts/tools/ask.md +1 -1
  184. package/src/prompts/tools/bash.md +1 -0
  185. package/src/prompts/tools/job.md +1 -1
  186. package/src/sdk/acp/adapter.ts +5 -0
  187. package/src/sdk/broker/broker.ts +1646 -27
  188. package/src/sdk/broker/endpoint-authority.ts +86 -0
  189. package/src/sdk/broker/ensure.ts +81 -3
  190. package/src/sdk/broker/index.ts +3 -0
  191. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  192. package/src/sdk/broker/lifecycle.ts +440 -75
  193. package/src/sdk/broker/master-capability.ts +232 -0
  194. package/src/sdk/broker/session-index.ts +121 -17
  195. package/src/sdk/broker/session-scope.ts +272 -0
  196. package/src/sdk/broker/spawn-authority.ts +1031 -0
  197. package/src/sdk/broker/spawn-substrate.ts +591 -0
  198. package/src/sdk/broker/startup-budget.ts +144 -2
  199. package/src/sdk/broker/state-version.ts +43 -14
  200. package/src/sdk/broker/transport.ts +1 -0
  201. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  202. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  203. package/src/sdk/bus/chat-daemon-runtime.ts +136 -18
  204. package/src/sdk/bus/discord-daemon.ts +50 -10
  205. package/src/sdk/bus/index.ts +84 -7
  206. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  207. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  208. package/src/sdk/bus/notification-service.ts +22 -5
  209. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  210. package/src/sdk/bus/reconciliation-store.ts +55 -31
  211. package/src/sdk/bus/slack-conversation.ts +1 -1
  212. package/src/sdk/bus/slack-daemon.ts +72 -38
  213. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  214. package/src/sdk/cli/index.ts +1 -0
  215. package/src/sdk/cli/master-cli.ts +259 -0
  216. package/src/sdk/cli/rows.ts +41 -15
  217. package/src/sdk/cli/session-cli.ts +297 -26
  218. package/src/sdk/client/client.ts +14 -8
  219. package/src/sdk/host/control/dispatch.ts +101 -40
  220. package/src/sdk/host/control/index.ts +2 -0
  221. package/src/sdk/host/control/operations.ts +2 -0
  222. package/src/sdk/host/control/runtime-gate.ts +203 -11
  223. package/src/sdk/host/events.ts +1 -0
  224. package/src/sdk/host/host.ts +29 -2
  225. package/src/sdk/host/query/cursor.ts +52 -7
  226. package/src/sdk/host/query/handlers.ts +33 -12
  227. package/src/sdk/host/query/revision-store.ts +12 -0
  228. package/src/sdk/host/session-runtime.test.ts +673 -14
  229. package/src/sdk/host/session-runtime.ts +674 -103
  230. package/src/sdk/host/surface-policy.ts +3 -3
  231. package/src/sdk/lifecycle/broker-client.ts +28 -0
  232. package/src/sdk/lifecycle/service.ts +279 -20
  233. package/src/sdk/lifecycle-session.ts +17 -4
  234. package/src/sdk/prompt-deadline-manager.ts +2 -1
  235. package/src/sdk/protocol/operation-inventory.generated.json +105 -3
  236. package/src/sdk/protocol/operation-registry.ts +14 -4
  237. package/src/sdk/receipt-state.ts +10 -0
  238. package/src/sdk/router/session-router.ts +219 -118
  239. package/src/sdk/session.ts +416 -195
  240. package/src/sdk/startup-capability.ts +1 -0
  241. package/src/sdk/turn-result.ts +22 -0
  242. package/src/session/agent-session.ts +1628 -318
  243. package/src/session/blob-store.ts +15 -5
  244. package/src/session/credential-store-errors.ts +2 -0
  245. package/src/session/fold-coordinator.ts +40 -3
  246. package/src/session/internal/managed-session-storage.ts +153 -78
  247. package/src/session/session-manager.ts +850 -97
  248. package/src/session/yield-queue.ts +24 -10
  249. package/src/setup/frictionless-onboarding.ts +2 -0
  250. package/src/slash-commands/builtin-registry.ts +41 -0
  251. package/src/task/executor.ts +9 -4
  252. package/src/telemetry/events.ts +1138 -12
  253. package/src/tools/ask-contract.test.ts +64 -0
  254. package/src/tools/ask-contract.ts +36 -1
  255. package/src/tools/ask.ts +10 -8
  256. package/src/tools/bash-interactive.ts +11 -2
  257. package/src/tools/bash.ts +412 -75
  258. package/src/tools/browser/launch.ts +37 -0
  259. package/src/tools/browser/registry.ts +99 -9
  260. package/src/tools/cron.ts +6 -4
  261. package/src/tools/descriptors.test.ts +15 -0
  262. package/src/tools/descriptors.ts +5 -1
  263. package/src/tools/gh.ts +104 -48
  264. package/src/tools/github-cache.ts +2 -2
  265. package/src/tools/index.ts +10 -6
  266. package/src/tools/job.ts +4 -1
  267. package/src/tools/monitor.ts +12 -9
  268. package/src/tools/resolve.ts +30 -10
  269. package/src/tools/tool-catalog.generated.ts +2 -2
  270. package/src/utils/external-editor.ts +72 -1
@@ -3,6 +3,14 @@ export const DEFAULT_READINESS_TIMEOUT_MS = 10_000;
3
3
  export const MIN_READINESS_TIMEOUT_MS = 4_000;
4
4
  export const MAX_READINESS_TIMEOUT_MS = 60_000;
5
5
 
6
+ /** Bounded git worktree add/reuse window; independent of child semantic readiness. */
7
+ export const DEFAULT_WORKTREE_PREPARATION_TIMEOUT_MS = 30_000;
8
+ export const MIN_PREPARATION_TIMEOUT_MS = 1_000;
9
+ export const MAX_PREPARATION_TIMEOUT_MS = 120_000;
10
+
11
+ /** Bounded workspace install window; independent of child semantic readiness. */
12
+ export const DEFAULT_DEPENDENCY_PREPARATION_TIMEOUT_MS = 30_000;
13
+
6
14
  export function isValidReadinessTimeoutMs(value: unknown): value is number {
7
15
  return (
8
16
  typeof value === "number" &&
@@ -12,7 +20,17 @@ export function isValidReadinessTimeoutMs(value: unknown): value is number {
12
20
  );
13
21
  }
14
22
 
23
+ export function isValidPreparationTimeoutMs(value: unknown): value is number {
24
+ return (
25
+ typeof value === "number" &&
26
+ Number.isSafeInteger(value) &&
27
+ value >= MIN_PREPARATION_TIMEOUT_MS &&
28
+ value <= MAX_PREPARATION_TIMEOUT_MS
29
+ );
30
+ }
31
+
15
32
  export const READINESS_TIMEOUT_INVALID_MESSAGE = `readinessTimeoutMs must be an integer between ${MIN_READINESS_TIMEOUT_MS} and ${MAX_READINESS_TIMEOUT_MS}.`;
33
+ export const PREPARATION_TIMEOUT_INVALID_MESSAGE = `worktreePreparationTimeoutMs and dependencyPreparationTimeoutMs must be integers between ${MIN_PREPARATION_TIMEOUT_MS} and ${MAX_PREPARATION_TIMEOUT_MS}.`;
16
34
  /**
17
35
  * Sleep until the duration elapses or the caller cancels the wait. Queue admission
18
36
  * uses this so a granted or refused waiter does not retain its cutoff timer for the
@@ -77,6 +95,10 @@ export function lifecycleStartupBudgetMs(requestedReadinessTimeoutMs: number): n
77
95
  * rather than left unextended: the broker queues it for exactly as long as one that
78
96
  * asked, so the common path would otherwise fail client-side while the broker runs
79
97
  * the startup to a durably persisted terminal result.
98
+ *
99
+ * Worktree launches add independent preparation budgets so git add / install cannot
100
+ * cut the caller while the broker still owns the request. One-arg
101
+ * {@link lifecycleStartupBudgetMs} stays queue+readiness for no-worktree callers.
80
102
  */
81
103
  export function lifecycleRequestTimeoutMs(operation: string, input: Record<string, unknown>): number | undefined {
82
104
  const deadlineFields = [
@@ -97,7 +119,127 @@ export function lifecycleRequestTimeoutMs(operation: string, input: Record<strin
97
119
  if (requested !== undefined && !isValidReadinessTimeoutMs(requested)) return undefined;
98
120
  supplied = requested as number | undefined;
99
121
  }
100
- if (isStartupLifecycleOperation(operation))
101
- return lifecycleStartupBudgetMs(supplied ?? DEFAULT_READINESS_TIMEOUT_MS) + CALLER_DEADLINE_SLACK_MS;
122
+ if (isStartupLifecycleOperation(operation)) {
123
+ const readiness = supplied ?? DEFAULT_READINESS_TIMEOUT_MS;
124
+ const preparation = preparationBudgetMs(input);
125
+ if (preparation === undefined) return undefined;
126
+ return lifecycleStartupBudgetMs(readiness) + preparation + CALLER_DEADLINE_SLACK_MS;
127
+ }
102
128
  return supplied === undefined ? undefined : supplied + CALLER_DEADLINE_SLACK_MS;
103
129
  }
130
+
131
+ function recordEnabled(value: unknown): boolean {
132
+ return (
133
+ typeof value === "object" &&
134
+ value !== null &&
135
+ !Array.isArray(value) &&
136
+ (value as Record<string, unknown>).enabled === true
137
+ );
138
+ }
139
+
140
+ function worktreeNamePresent(value: unknown): boolean {
141
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
142
+ const name = (value as Record<string, unknown>).name;
143
+ return typeof name === "string" && name.length > 0;
144
+ }
145
+
146
+ /**
147
+ * True when this request will run named or detached worktree preparation.
148
+ * Coordinator nests the selector at `target.worktree`; Direct/lifecycle service
149
+ * put `worktree` on the mutation target itself; broker serialization also
150
+ * accepts `{ worktree: { name } }` without `enabled`.
151
+ */
152
+ export function lifecycleRequestHasWorktree(input: Record<string, unknown>): boolean {
153
+ const nested = input.target;
154
+ if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) {
155
+ const nestedWorktree = (nested as Record<string, unknown>).worktree;
156
+ if (recordEnabled(nestedWorktree) || worktreeNamePresent(nestedWorktree)) return true;
157
+ }
158
+ const worktree = input.worktree;
159
+ if (worktree === true) return true;
160
+ return recordEnabled(worktree) || worktreeNamePresent(worktree);
161
+ }
162
+
163
+ function readPreparationTimeout(
164
+ input: Record<string, unknown>,
165
+ key: "worktreePreparationTimeoutMs" | "dependencyPreparationTimeoutMs",
166
+ ): number | undefined | "invalid" {
167
+ const value = input[key];
168
+ if (value === undefined) return undefined;
169
+ return isValidPreparationTimeoutMs(value) ? value : "invalid";
170
+ }
171
+
172
+ /**
173
+ * Sum of worktree and dependency preparation budgets, or 0 when the request
174
+ * has no worktree. Returns `undefined` when an explicit prep field is present
175
+ * but invalid so callers fail closed instead of silently dropping prep time.
176
+ */
177
+ export function preparationBudgetMs(input: Record<string, unknown>): number | undefined {
178
+ const worktreeTimeout = readPreparationTimeout(input, "worktreePreparationTimeoutMs");
179
+ const dependencyTimeout = readPreparationTimeout(input, "dependencyPreparationTimeoutMs");
180
+ if (worktreeTimeout === "invalid" || dependencyTimeout === "invalid") return undefined;
181
+ if (!lifecycleRequestHasWorktree(input)) return 0;
182
+ return (
183
+ (worktreeTimeout ?? DEFAULT_WORKTREE_PREPARATION_TIMEOUT_MS) +
184
+ (dependencyTimeout ?? DEFAULT_DEPENDENCY_PREPARATION_TIMEOUT_MS)
185
+ );
186
+ }
187
+
188
+ export interface LifecycleOuterDeadlines {
189
+ admittedAt: number;
190
+ worktreePrepTimeoutMs: number;
191
+ dependencyPrepTimeoutMs: number;
192
+ requestedReadinessTimeoutMs: number;
193
+ worktreePreparationDeadlineAt: number;
194
+ lifecycleCleanupDeadlineAt: number;
195
+ }
196
+
197
+ /**
198
+ * Whole-request outer deadlines for a worktree launch. Child semantic readiness
199
+ * is still derived later at prepSucceededAt. Dependency start is sequential:
200
+ * callers clip remaining dep budget against `lifecycleCleanupDeadlineAt`
201
+ * after worktreeDoneAt.
202
+ */
203
+ export function deriveLifecycleOuterDeadlines(input: {
204
+ admittedAt: number;
205
+ worktreePrepTimeoutMs: number;
206
+ dependencyPrepTimeoutMs: number;
207
+ requestedReadinessTimeoutMs: number;
208
+ }): LifecycleOuterDeadlines {
209
+ const { admittedAt, worktreePrepTimeoutMs, dependencyPrepTimeoutMs, requestedReadinessTimeoutMs } = input;
210
+ if (
211
+ !Number.isSafeInteger(admittedAt) ||
212
+ !isValidPreparationTimeoutMs(worktreePrepTimeoutMs) ||
213
+ !isValidPreparationTimeoutMs(dependencyPrepTimeoutMs) ||
214
+ !isValidReadinessTimeoutMs(requestedReadinessTimeoutMs)
215
+ ) {
216
+ throw new Error("Lifecycle outer timing values must be safe integers in the approved ranges.");
217
+ }
218
+ const worktreePreparationDeadlineAt = admittedAt + worktreePrepTimeoutMs;
219
+ const lifecycleCleanupDeadlineAt =
220
+ admittedAt + worktreePrepTimeoutMs + dependencyPrepTimeoutMs + requestedReadinessTimeoutMs;
221
+ if (!Number.isSafeInteger(worktreePreparationDeadlineAt) || !Number.isSafeInteger(lifecycleCleanupDeadlineAt)) {
222
+ throw new Error("Lifecycle outer timing values overflow the safe integer range.");
223
+ }
224
+ return {
225
+ admittedAt,
226
+ worktreePrepTimeoutMs,
227
+ dependencyPrepTimeoutMs,
228
+ requestedReadinessTimeoutMs,
229
+ worktreePreparationDeadlineAt,
230
+ lifecycleCleanupDeadlineAt,
231
+ };
232
+ }
233
+
234
+ export function readPreparationTimeouts(
235
+ input: Record<string, unknown>,
236
+ ): { ok: true; worktreePrepTimeoutMs: number; dependencyPrepTimeoutMs: number } | { ok: false } {
237
+ const worktreeTimeout = readPreparationTimeout(input, "worktreePreparationTimeoutMs");
238
+ const dependencyTimeout = readPreparationTimeout(input, "dependencyPreparationTimeoutMs");
239
+ if (worktreeTimeout === "invalid" || dependencyTimeout === "invalid") return { ok: false };
240
+ return {
241
+ ok: true,
242
+ worktreePrepTimeoutMs: worktreeTimeout ?? DEFAULT_WORKTREE_PREPARATION_TIMEOUT_MS,
243
+ dependencyPrepTimeoutMs: dependencyTimeout ?? DEFAULT_DEPENDENCY_PREPARATION_TIMEOUT_MS,
244
+ };
245
+ }
@@ -1,15 +1,23 @@
1
1
  export const SDK_STATE_VERSION = 1;
2
2
 
3
- // The session-index snapshot carries its own format version, independent of the shared
4
- // SDK_STATE_VERSION used by discovery, the lifecycle ledger, and per-event records.
5
- // Version 2 snapshots relax the on-disk schema to allow gaps in indexSeq (compaction
6
- // drops terminal+dead sessions and superseded heartbeats), so an older broker must
7
- // hard-fail rather than silently fall back to a truncated log. Version 3 snapshots may
8
- // additionally carry composite-identity events (hostIncarnation, activity checkpoints,
9
- // session_deleted tombstones) and retention-compacted sessions; v2 (and v1) snapshots
10
- // remain readable. Bumping only this constant keeps the broker protocol untouched.
11
- export const SESSION_INDEX_SNAPSHOT_VERSION = 3;
3
+ // The session-index snapshot and event log share one format version. Version 4
4
+ // requires SessionLocatorV2 (`cwd`, `worktreeRoot`, `stateRoot`) on every
5
+ // retained row. New v4 events force older brokers to fail closed before they
6
+ // misread `cwd` as the former `repo`; pre-v4 snapshots remain readable only to
7
+ // quarantine legacy rows.
8
+ export const SESSION_INDEX_SNAPSHOT_VERSION = 4;
9
+ export const SESSION_INDEX_EVENT_VERSION = SESSION_INDEX_SNAPSHOT_VERSION;
12
10
 
11
+ /**
12
+ * Thrown when a persisted SDK state file carries a version this build does not
13
+ * support. `assertSupportedStateVersion` fences only generic SDK state (broker
14
+ * discovery, lifecycle-ledger rows, guide cache meta) whose current format is
15
+ * exactly `SDK_STATE_VERSION`. Session-index files carry their own versions
16
+ * (see `assertSupportedSessionIndexEventVersion` and
17
+ * `assertSupportedSnapshotVersion`) and MUST NOT be validated with the generic
18
+ * guard — a supported v4 index row would crash with "maximum supported version
19
+ * is 1" (#5181).
20
+ */
13
21
  export class UnsupportedStateVersionError extends Error {
14
22
  readonly code = "unsupported_state_version";
15
23
 
@@ -25,6 +33,13 @@ export class UnsupportedStateVersionError extends Error {
25
33
  }
26
34
  }
27
35
 
36
+ /**
37
+ * Generic SDK-state fence: rejects any version above `SDK_STATE_VERSION`.
38
+ * ONLY for generic state whose current format is exactly `SDK_STATE_VERSION`
39
+ * (broker discovery `broker.json`, lifecycle-ledger rows, guide cache meta).
40
+ * Never validate session-index rows or snapshots with this guard — they have
41
+ * dedicated version fences and currently accept v4 (#5181).
42
+ */
28
43
  export function assertSupportedStateVersion(file: string, value: unknown): void {
29
44
  if (!value || typeof value !== "object") return;
30
45
  const record = value as { version?: unknown; stateVersion?: unknown };
@@ -35,11 +50,25 @@ export function assertSupportedStateVersion(file: string, value: unknown): void
35
50
  }
36
51
  }
37
52
 
38
- // Fences the session-index snapshot format independently of SDK_STATE_VERSION. An older
39
- // broker (whose SESSION_INDEX_SNAPSHOT_VERSION is below 3) trips assertSupportedStateVersion
40
- // on a version-3 snapshot and refuses to start; a current broker accepts the legacy
41
- // contiguous format (version 1), the gapped version-2 format, and the version-3 format
42
- // with composite-identity events.
53
+ /** Rejects session-index events outside the known legacy and current formats. */
54
+ export function assertSupportedSessionIndexEventVersion(file: string, value: unknown): void {
55
+ if (!value || typeof value !== "object") return;
56
+ const record = value as { version?: unknown; stateVersion?: unknown };
57
+ for (const version of [record.version, record.stateVersion]) {
58
+ if (
59
+ typeof version === "number" &&
60
+ Number.isFinite(version) &&
61
+ version !== SDK_STATE_VERSION &&
62
+ version !== SESSION_INDEX_EVENT_VERSION
63
+ ) {
64
+ throw new UnsupportedStateVersionError(file, version, SESSION_INDEX_EVENT_VERSION);
65
+ }
66
+ }
67
+ }
68
+
69
+ // Fences future session-index snapshot formats. Locator-v2 row validation below
70
+ // rejects legacy `repo` rows without translating them, while snapshots predating
71
+ // v4 remain readable solely to quarantine those rows with a re-register diagnostic.
43
72
  export function assertSupportedSnapshotVersion(file: string, value: unknown): void {
44
73
  if (!value || typeof value !== "object") return;
45
74
  const record = value as { version?: unknown; stateVersion?: unknown };
@@ -19,6 +19,7 @@ const BROKER_OPERATIONS = new Set([
19
19
  "session.control",
20
20
  "broker.lookup_lifecycle",
21
21
  "model.resolve",
22
+ "session.spawn",
22
23
  ]);
23
24
  type RequestInput = Record<string, unknown>;
24
25
  type BrokerRequest = {
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Which session currently holds a GJC-managed worktree.
3
+ *
4
+ * Both launch paths need this answer and must not disagree about it: the broker
5
+ * refuses a lifecycle launch into an occupied worktree, and the interactive
6
+ * `gjc --worktree` launch refuses for the same reason. Keeping one predicate —
7
+ * and one definition of "still running" — here prevents the two from drifting
8
+ * into different notions of liveness.
9
+ */
10
+
11
+ import type * as NativeBindings from "@gajae-code/natives";
12
+ import { resolveEquivalentPath } from "@gajae-code/utils";
13
+ import { probeLinuxProcPidSync } from "../../gjc-runtime/linux-proc";
14
+ import { processIncarnation } from "./process-incarnation";
15
+ import type { IndexedSession } from "./session-index";
16
+
17
+ function nativeLifecycle(): typeof NativeBindings {
18
+ return require("@gajae-code/natives") as typeof NativeBindings;
19
+ }
20
+
21
+ export type ProcessObservation = "alive" | "exited" | "uncertain";
22
+
23
+ /** Only ESRCH or a changed, readable incarnation proves the owned process exited. */
24
+ export function observeProcess(
25
+ pid: number,
26
+ expectedIncarnation: string | undefined,
27
+ readIncarnation: (pid: number) => string | undefined = processIncarnation,
28
+ ): ProcessObservation {
29
+ if (process.platform === "win32") {
30
+ try {
31
+ const reference = nativeLifecycle().Process.fromPid(pid);
32
+ if (reference?.status() !== "running") return "exited";
33
+ } catch {
34
+ return "uncertain";
35
+ }
36
+ if (!expectedIncarnation) return "uncertain";
37
+ const actualIncarnation = readIncarnation(pid);
38
+ if (!actualIncarnation) return "uncertain";
39
+ return actualIncarnation === expectedIncarnation ? "alive" : "exited";
40
+ }
41
+ try {
42
+ process.kill(pid, 0);
43
+ } catch (error) {
44
+ return (error as NodeJS.ErrnoException).code === "ESRCH" ? "exited" : "uncertain";
45
+ }
46
+ if (process.platform === "linux") {
47
+ const probe = probeLinuxProcPidSync(pid);
48
+ if (probe.kind === "live" && probe.state === "Z" && expectedIncarnation === `linux:${probe.startTime}`)
49
+ return "exited";
50
+ }
51
+ if (!expectedIncarnation) return "uncertain";
52
+ const actualIncarnation = readIncarnation(pid);
53
+ if (!actualIncarnation) return "uncertain";
54
+ return actualIncarnation === expectedIncarnation ? "alive" : "exited";
55
+ }
56
+
57
+ /**
58
+ * The id of a session still occupying `worktreePath`, or null when it is free.
59
+ *
60
+ * `ensureLaunchWorktree` reuses an existing worktree without asking whether
61
+ * anyone is in it, and an unnamed launch derives its directory deterministically
62
+ * from the repository's current branch. Two concurrent sessions in one
63
+ * repository therefore land in the same checkout and overwrite each other's
64
+ * files with no error.
65
+ *
66
+ * Only a process observed as definitively exited releases the worktree.
67
+ * `uncertain` counts as occupied: refusing a launch is recoverable by picking
68
+ * another worktree name, whereas two live sessions sharing a checkout corrupts
69
+ * work already done.
70
+ */
71
+ export function worktreeOccupant(
72
+ sessions: readonly IndexedSession[],
73
+ worktreePath: string,
74
+ observe: (pid: number, expectedIncarnation: string | undefined) => ProcessObservation = observeProcess,
75
+ ): string | null {
76
+ // Session locators retain the canonical Git worktree root separately from the
77
+ // session cwd. A host may start below that root, and a launch plan may arrive
78
+ // through a symlink, so compare the root identity rather than the cwd spelling.
79
+ const target = resolveEquivalentPath(worktreePath);
80
+ for (const session of sessions) {
81
+ const sessionWorktreeRoot = session.locator.worktreeRoot;
82
+ if (session.terminal || typeof sessionWorktreeRoot !== "string" || sessionWorktreeRoot.length === 0) continue;
83
+ if (resolveEquivalentPath(sessionWorktreeRoot) !== target) continue;
84
+ // `live` is heartbeat-derived and can be stale. Only positive process-exit
85
+ // evidence releases a matching retained session's worktree.
86
+ if (observe(session.pid, session.hostIncarnation ?? session.processIncarnation) === "exited") continue;
87
+ return session.sessionId;
88
+ }
89
+ return null;
90
+ }
@@ -130,10 +130,13 @@ export type ChatDaemonAction = "stop" | "reload";
130
130
  * session-index lock every 2s; staleness retirement moved to a 30s sweep and
131
131
  * lease heartbeats no longer force an authority reconcile. A pre-upgrade owner
132
132
  * would retain the old hot polling loop, so replacement is required.
133
+ * Discord generation 72 / Slack generation 75 serialize same-generation
134
+ * successor attachment admission behind predecessor provider retirement, so a
135
+ * pre-upgrade owner cannot retain the queue race fixed by #5120.
133
136
  */
134
137
  export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
135
- discord: 71,
136
- slack: 74,
138
+ discord: 73,
139
+ slack: 77,
137
140
  };
138
141
 
139
142
  export function chatDaemonGeneration(kind: ChatDaemonKind): number {
@@ -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
 
@@ -258,7 +259,11 @@ export class ChatDaemonRuntime {
258
259
  readonly #router: SessionRouter;
259
260
  readonly #attachments = new Map<string, SessionAttachment>();
260
261
  readonly #cleanupWork = new Map<string, Promise<void>>();
262
+ readonly #retirementWork = new Map<string, Promise<void>>();
261
263
  readonly #attachmentBarriers = new Map<string, Promise<void>>();
264
+ readonly #cleanupFailures = new Map<string, unknown>();
265
+ #replayReady: Promise<void> = Promise.resolve();
266
+ #stopping = false;
262
267
  #discord: DiscordNotificationDaemon | undefined;
263
268
  #slack: SlackNotificationDaemon | undefined;
264
269
  #presentation: NotificationPresentationEngine | undefined;
@@ -275,18 +280,40 @@ export class ChatDaemonRuntime {
275
280
  ...deps.routerDeps,
276
281
  onFrame: async (attachment, frame) => await this.#handleFrame(attachment, frame),
277
282
  onAttachment: async attachment => this.#onAttachment(attachment),
283
+ onReplayReady: async () => await this.#replayReady,
278
284
  onSessionRemoved: async (attachment, reason) => await this.#onSessionRemoved(attachment, reason),
279
285
  },
280
286
  });
281
287
  }
282
288
 
283
289
  async start(): Promise<void> {
284
- if (this.#cleanupWork.size > 0) {
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);
295
+ if (this.#cleanupWork.size > 0 || this.#retirementWork.size > 0) {
296
+ const retirements = [...this.#retirementWork.entries()];
297
+ const cleanup = [...this.#cleanupWork.values()];
298
+ const retirement = retirements.map(([, work]) => work);
285
299
  const settled = await Promise.race([
286
- Promise.allSettled([...this.#cleanupWork.values()]).then(() => true),
300
+ Promise.all([Promise.allSettled(cleanup), Promise.allSettled(retirement)]).then(() => true),
287
301
  Bun.sleep(5_000).then(() => false),
288
302
  ]);
289
303
  if (!settled) throw new Error("Prior provider cleanup did not settle before chat daemon restart.");
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);
290
317
  }
291
318
  this.#attachments.clear();
292
319
  const retainedProvider =
@@ -301,8 +328,10 @@ export class ChatDaemonRuntime {
301
328
  try {
302
329
  await this.#router.start();
303
330
  await retainedProvider.start();
331
+ replayReady.resolve();
304
332
  return;
305
333
  } catch (error) {
334
+ replayReady.reject(error);
306
335
  await this.#router.stop().catch(() => undefined);
307
336
  throw error;
308
337
  }
@@ -363,7 +392,9 @@ export class ChatDaemonRuntime {
363
392
  await this.#router.start();
364
393
  if (this.#discord) await this.#discord.start();
365
394
  if (this.#slack) await this.#slack.start();
395
+ replayReady.resolve();
366
396
  } catch (error) {
397
+ replayReady.reject(error);
367
398
  await this.stop();
368
399
  throw error;
369
400
  }
@@ -379,6 +410,20 @@ export class ChatDaemonRuntime {
379
410
  }
380
411
 
381
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.");
382
427
  const providerResults = await Promise.allSettled([this.#discord?.stop(), this.#slack?.stop()]);
383
428
  const discordRestartBlocked = this.#discord?.restartBlocked() ?? false;
384
429
  const slackRestartBlocked = this.#slack?.restartBlocked() ?? false;
@@ -388,10 +433,12 @@ export class ChatDaemonRuntime {
388
433
  this.#presentation = undefined;
389
434
  this.#transportHealthy = undefined;
390
435
  }
391
- await this.#router.stop();
392
- const failures = providerResults.filter(
393
- (result): result is PromiseRejectedResult => result.status === "rejected",
394
- );
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");
395
442
  if (failures.length > 0)
396
443
  throw new AggregateError(
397
444
  failures.map(result => result.reason),
@@ -428,9 +475,34 @@ export class ChatDaemonRuntime {
428
475
  const discord = this.#discord;
429
476
  const slack = this.#slack;
430
477
  const barrier = (async () => {
431
- await this.#cleanupWork.get(attachment.sessionId)?.catch(() => undefined);
432
- await discord?.recoverCleanup(attachment.sessionId, attachment.generation);
433
- await slack?.recoverCleanup(attachment.sessionId, attachment.generation);
478
+ const retirement = this.#retirementWork.get(attachment.sessionId);
479
+ let retirementFailure: unknown;
480
+ if (retirement) {
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);
499
+ }
500
+ if (
501
+ retirementFailure !== undefined &&
502
+ retirement &&
503
+ this.#retirementWork.get(attachment.sessionId) === retirement
504
+ )
505
+ this.#retirementWork.delete(attachment.sessionId);
434
506
  })();
435
507
  this.#attachmentBarriers.set(attachment.sessionId, barrier);
436
508
  try {
@@ -451,11 +523,41 @@ export class ChatDaemonRuntime {
451
523
  ): Promise<void> {
452
524
  if (this.#attachments.get(attachment.sessionId) !== attachment) return;
453
525
  if (reason === "replaced_same_generation") {
454
- await this.#discord?.retireAttachment(attachment.sessionId, attachment.generation);
455
- await this.#slack?.retireAttachment(attachment.sessionId, attachment.generation);
526
+ const retirement = this.#trackRetirement(attachment);
527
+ this.#attachments.delete(attachment.sessionId);
528
+ await retirement;
529
+ return;
456
530
  }
457
531
  this.#attachments.delete(attachment.sessionId);
458
- if (reason === "removed") await this.#trackCleanup(attachment);
532
+ if (reason === "removed" && !this.#stopping) await this.#trackCleanup(attachment);
533
+ }
534
+
535
+ #trackRetirement(attachment: SessionAttachment): Promise<void> {
536
+ const sessionId = attachment.sessionId;
537
+ const discord = this.#discord;
538
+ const slack = this.#slack;
539
+ const previous = this.#retirementWork.get(sessionId);
540
+ const work = (
541
+ previous
542
+ ? previous.catch(error => {
543
+ this.#cleanupFailures.set(sessionId, error);
544
+ })
545
+ : Promise.resolve()
546
+ ).then(async () => {
547
+ // Capture the predecessor providers before stop() can clear the runtime
548
+ // fields. The successor barrier owns this promise until every retirement
549
+ // waiter settles, even when takeover overlaps worker shutdown.
550
+ await discord?.retireAttachment(sessionId, attachment.generation);
551
+ await slack?.retireAttachment(sessionId, attachment.generation);
552
+ });
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
+ );
560
+ return work;
459
561
  }
460
562
 
461
563
  async #handleFrame(attachment: SessionAttachment, correlated: CorrelatedFrame): Promise<void> {
@@ -476,7 +578,10 @@ export class ChatDaemonRuntime {
476
578
  }
477
579
  if (name === SESSION_PREPARED_EVENT || bodyType === SESSION_PREPARED_EVENT) return;
478
580
  if (name === "session_ready") {
479
- 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;
480
585
  await this.#resume(
481
586
  sessionId,
482
587
  attachment.generation,
@@ -519,7 +624,7 @@ export class ChatDaemonRuntime {
519
624
  await this.#slack.notify(
520
625
  sessionId,
521
626
  content,
522
- notification.type === "action_needed" ? notification.id : undefined,
627
+ notification.type === "action_needed" && notification.kind === "ask" ? notification.id : undefined,
523
628
  attachment.generation,
524
629
  publicationId,
525
630
  );
@@ -528,13 +633,26 @@ export class ChatDaemonRuntime {
528
633
  #trackCleanup(attachment: SessionAttachment): Promise<void> {
529
634
  const sessionId = attachment.sessionId;
530
635
  const previous = this.#cleanupWork.get(sessionId);
531
- 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 () => {
532
643
  await this.#closeProviders(attachment);
533
644
  });
534
645
  this.#cleanupWork.set(sessionId, work);
535
- return work.finally(() => {
536
- if (this.#cleanupWork.get(sessionId) === work) this.#cleanupWork.delete(sessionId);
537
- });
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;
538
656
  }
539
657
  async #closeProviders(attachment: SessionAttachment): Promise<void> {
540
658
  const discord = this.#discord;