@gajae-code/coding-agent 0.16.3 → 0.16.6

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 (277) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli/args.d.ts +2 -2
  4. package/dist/types/cli/customize-doctor.d.ts +22 -0
  5. package/dist/types/cli/root-flags.d.ts +4 -2
  6. package/dist/types/cli.d.ts +1 -1
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  9. package/dist/types/config/file-lock.d.ts +31 -3
  10. package/dist/types/config/model-registry.d.ts +4 -0
  11. package/dist/types/config/model-resolver.d.ts +9 -0
  12. package/dist/types/config/settings-schema.d.ts +29 -1
  13. package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
  14. package/dist/types/coordinator-mcp/server.d.ts +7 -0
  15. package/dist/types/discovery/claude-plugins.d.ts +3 -1
  16. package/dist/types/discovery/helpers.d.ts +35 -0
  17. package/dist/types/exec/bash-executor.d.ts +17 -1
  18. package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
  19. package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
  20. package/dist/types/exec/bash-shell-signals.d.ts +2 -0
  21. package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
  22. package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
  23. package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
  24. package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
  25. package/dist/types/exec/bash-shell-worker.d.ts +1 -0
  26. package/dist/types/exec/isolated-shell.d.ts +19 -0
  27. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
  28. package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
  29. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  30. package/dist/types/extensibility/extensions/runner.d.ts +46 -6
  31. package/dist/types/extensibility/extensions/types.d.ts +16 -0
  32. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
  33. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
  34. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
  35. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
  36. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  37. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  38. package/dist/types/extensibility/runtime-skill-discovery.d.ts +5 -2
  39. package/dist/types/extensibility/shared-events.d.ts +1 -1
  40. package/dist/types/extensibility/skill-management.d.ts +9 -4
  41. package/dist/types/extensibility/skills.d.ts +2 -0
  42. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
  43. package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
  44. package/dist/types/gjc-runtime/session-state-lock.d.ts +14 -0
  45. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
  46. package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
  47. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  48. package/dist/types/modes/components/bash-execution.d.ts +1 -0
  49. package/dist/types/modes/components/execution-shared.d.ts +1 -0
  50. package/dist/types/modes/components/settings-selector.d.ts +6 -1
  51. package/dist/types/modes/components/status-line/command.d.ts +35 -0
  52. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  53. package/dist/types/modes/components/status-line/types.d.ts +5 -0
  54. package/dist/types/modes/components/tool-status-header.d.ts +14 -0
  55. package/dist/types/modes/controllers/event-controller.d.ts +6 -0
  56. package/dist/types/modes/interactive-mode.d.ts +3 -0
  57. package/dist/types/modes/types.d.ts +7 -0
  58. package/dist/types/runtime-mcp/client.d.ts +9 -1
  59. package/dist/types/runtime-mcp/manager.d.ts +7 -1
  60. package/dist/types/runtime-mcp/pool.d.ts +22 -3
  61. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  62. package/dist/types/runtime-mcp/types.d.ts +22 -0
  63. package/dist/types/sdk/broker/ensure.d.ts +32 -0
  64. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
  65. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  66. package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
  67. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
  68. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
  69. package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
  70. package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
  71. package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
  72. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  73. package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
  74. package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
  75. package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
  76. package/dist/types/sdk/client/client.d.ts +6 -0
  77. package/dist/types/sdk/host/host.d.ts +3 -1
  78. package/dist/types/sdk/host/session-runtime.d.ts +26 -1
  79. package/dist/types/sdk/lifecycle/service.d.ts +1 -0
  80. package/dist/types/sdk/router/session-router.d.ts +10 -2
  81. package/dist/types/sdk/session-api-key.d.ts +26 -0
  82. package/dist/types/sdk/session.d.ts +5 -1
  83. package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
  84. package/dist/types/session/agent-session.d.ts +11 -0
  85. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  86. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  87. package/dist/types/session/messages.d.ts +1 -0
  88. package/dist/types/session/session-manager.d.ts +3 -1
  89. package/dist/types/setup/paseo/announce.d.ts +112 -0
  90. package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
  91. package/dist/types/skill-state/canonical-skills.d.ts +4 -0
  92. package/dist/types/system-prompt.d.ts +10 -0
  93. package/dist/types/task/executor.d.ts +5 -1
  94. package/dist/types/tools/bash.d.ts +1 -0
  95. package/dist/types/tools/fetch.d.ts +7 -0
  96. package/dist/types/tools/github-cache.d.ts +2 -1
  97. package/dist/types/tools/index.d.ts +10 -0
  98. package/dist/types/tools/path-utils.d.ts +2 -0
  99. package/dist/types/tools/search.d.ts +6 -0
  100. package/package.json +9 -7
  101. package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
  102. package/scripts/generate-sdk-operation-inventory.ts +11 -0
  103. package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
  104. package/src/capability/index.ts +14 -2
  105. package/src/capability/types.ts +4 -0
  106. package/src/cli/args.ts +20 -9
  107. package/src/cli/customize-doctor.ts +155 -12
  108. package/src/cli/root-flags.ts +5 -3
  109. package/src/cli.ts +22 -0
  110. package/src/commands/sdk.ts +74 -31
  111. package/src/config/autorouting-tier-map.ts +1 -0
  112. package/src/config/file-lock-gc.ts +40 -2
  113. package/src/config/file-lock.ts +379 -31
  114. package/src/config/model-preset-registry.ts +120 -19
  115. package/src/config/model-profiles.ts +40 -0
  116. package/src/config/model-registry.ts +73 -23
  117. package/src/config/model-resolver.ts +48 -0
  118. package/src/config/settings-schema.ts +33 -1
  119. package/src/config/settings.ts +1 -0
  120. package/src/coordinator-mcp/question-state.ts +33 -2
  121. package/src/coordinator-mcp/server.ts +477 -101
  122. package/src/crash/sanitize.ts +1 -1
  123. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
  124. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
  125. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
  126. package/src/discovery/builtin.ts +52 -55
  127. package/src/discovery/claude-plugins.ts +12 -4
  128. package/src/discovery/helpers.ts +307 -34
  129. package/src/exec/bash-executor.ts +330 -50
  130. package/src/exec/bash-shell-guardian.ts +388 -0
  131. package/src/exec/bash-shell-runtime-entry.ts +5 -0
  132. package/src/exec/bash-shell-signals.ts +29 -0
  133. package/src/exec/bash-shell-supervisor-entry.ts +3 -0
  134. package/src/exec/bash-shell-supervisor.ts +359 -0
  135. package/src/exec/bash-shell-worker-entry.ts +5 -0
  136. package/src/exec/bash-shell-worker-protocol.ts +36 -0
  137. package/src/exec/bash-shell-worker.ts +116 -0
  138. package/src/exec/isolated-shell.ts +393 -0
  139. package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
  140. package/src/extensibility/extensions/function-hooks.ts +959 -0
  141. package/src/extensibility/extensions/index.ts +22 -0
  142. package/src/extensibility/extensions/loader.ts +53 -2
  143. package/src/extensibility/extensions/runner.ts +895 -264
  144. package/src/extensibility/extensions/types.ts +42 -0
  145. package/src/extensibility/extensions/wrapper.ts +56 -15
  146. package/src/extensibility/gjc-plugins/compiler.ts +228 -60
  147. package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
  148. package/src/extensibility/gjc-plugins/installer.ts +13 -12
  149. package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
  150. package/src/extensibility/gjc-plugins/paths.ts +13 -0
  151. package/src/extensibility/gjc-plugins/registry.ts +113 -1
  152. package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
  153. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
  154. package/src/extensibility/gjc-plugins/schema.ts +121 -4
  155. package/src/extensibility/gjc-plugins/types.ts +25 -0
  156. package/src/extensibility/hooks/loader.ts +133 -16
  157. package/src/extensibility/hooks/types.ts +28 -0
  158. package/src/extensibility/runtime-skill-discovery.ts +149 -190
  159. package/src/extensibility/shared-events.ts +1 -1
  160. package/src/extensibility/skill-management.ts +200 -52
  161. package/src/extensibility/skills.ts +73 -25
  162. package/src/gjc-runtime/empty-delete-gc.ts +94 -4
  163. package/src/gjc-runtime/gc-runtime.ts +29 -0
  164. package/src/gjc-runtime/linux-proc.ts +14 -0
  165. package/src/gjc-runtime/repository-binding.ts +1 -1
  166. package/src/gjc-runtime/session-state-lock.ts +143 -10
  167. package/src/gjc-runtime/session-state-sidecar.ts +27 -11
  168. package/src/gjc-runtime/state-writer.ts +30 -29
  169. package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
  170. package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
  171. package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
  172. package/src/gjc-runtime/workflow-manifest.ts +16 -0
  173. package/src/harness-control-plane/session-lease.ts +2 -2
  174. package/src/internal-urls/docs-index.generated.ts +12 -12
  175. package/src/lsp/config.ts +10 -3
  176. package/src/lsp/index.ts +1 -1
  177. package/src/lsp/path-trust.ts +69 -14
  178. package/src/main.ts +78 -35
  179. package/src/modes/DESIGN.md +54 -0
  180. package/src/modes/acp/acp-agent.ts +16 -3
  181. package/src/modes/components/assistant-message.ts +22 -9
  182. package/src/modes/components/bash-execution.ts +4 -1
  183. package/src/modes/components/execution-shared.ts +4 -1
  184. package/src/modes/components/model-selector.ts +166 -33
  185. package/src/modes/components/session-observer-overlay.ts +1 -1
  186. package/src/modes/components/settings-selector.ts +1075 -235
  187. package/src/modes/components/status-line/command.ts +190 -0
  188. package/src/modes/components/status-line/index.ts +1 -0
  189. package/src/modes/components/status-line/segments.ts +32 -5
  190. package/src/modes/components/status-line/types.ts +5 -0
  191. package/src/modes/components/tool-status-header.ts +396 -22
  192. package/src/modes/controllers/command-controller.ts +3 -0
  193. package/src/modes/controllers/event-controller.ts +151 -7
  194. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  195. package/src/modes/controllers/input-controller.ts +6 -3
  196. package/src/modes/controllers/selector-controller.ts +21 -0
  197. package/src/modes/interactive-mode.ts +53 -6
  198. package/src/modes/print-mode.ts +5 -1
  199. package/src/modes/prompt-action-autocomplete.ts +54 -27
  200. package/src/modes/runtime-init.ts +2 -0
  201. package/src/modes/types.ts +17 -3
  202. package/src/modes/utils/ui-helpers.ts +2 -0
  203. package/src/prompts/system/system-prompt.md +8 -0
  204. package/src/prompts/tools/search.md +2 -0
  205. package/src/prompts/tools/subagent.md +1 -0
  206. package/src/runtime-mcp/client.ts +60 -17
  207. package/src/runtime-mcp/manager.ts +189 -14
  208. package/src/runtime-mcp/pool.test.ts +218 -0
  209. package/src/runtime-mcp/pool.ts +224 -23
  210. package/src/runtime-mcp/transports/stdio.ts +158 -24
  211. package/src/runtime-mcp/types.ts +24 -0
  212. package/src/sdk/broker/broker.ts +124 -32
  213. package/src/sdk/broker/ensure.ts +248 -41
  214. package/src/sdk/broker/lifecycle-ledger.ts +7 -17
  215. package/src/sdk/broker/lifecycle.ts +56 -6
  216. package/src/sdk/broker/startup-failure.ts +15 -6
  217. package/src/sdk/bus/chat-daemon-control.ts +3 -1
  218. package/src/sdk/bus/chat-daemon-runtime.ts +72 -6
  219. package/src/sdk/bus/chat-effect-journal.ts +132 -7
  220. package/src/sdk/bus/conversation-store.ts +29 -0
  221. package/src/sdk/bus/lifecycle-commands.ts +17 -11
  222. package/src/sdk/bus/slack-conversation.ts +1 -0
  223. package/src/sdk/bus/slack-daemon.ts +478 -49
  224. package/src/sdk/bus/slack-live-provider.ts +12 -0
  225. package/src/sdk/bus/slack-provider.ts +5 -0
  226. package/src/sdk/bus/telegram-daemon.ts +16 -2
  227. package/src/sdk/bus/topic-registry.ts +19 -0
  228. package/src/sdk/cli/master-cli.ts +1 -1
  229. package/src/sdk/cli/session-cli.ts +11 -8
  230. package/src/sdk/client/client.ts +66 -17
  231. package/src/sdk/host/host.ts +29 -9
  232. package/src/sdk/host/query/revision-store.ts +19 -13
  233. package/src/sdk/host/reverse-leases.ts +7 -4
  234. package/src/sdk/host/session-runtime.test.ts +52 -10
  235. package/src/sdk/host/session-runtime.ts +383 -44
  236. package/src/sdk/lifecycle/service.ts +4 -0
  237. package/src/sdk/protocol/operation-inventory.generated.json +77 -0
  238. package/src/sdk/router/session-router.ts +89 -38
  239. package/src/sdk/session-api-key.ts +94 -0
  240. package/src/sdk/session.ts +228 -55
  241. package/src/sdk/transport/relay.ts +35 -13
  242. package/src/sdk/transport/serve-cli.ts +3 -3
  243. package/src/session/agent-session.ts +240 -90
  244. package/src/session/contribution-prep.ts +214 -21
  245. package/src/session/fallback-chain-controller.ts +38 -0
  246. package/src/session/internal/managed-session-storage.ts +19 -5
  247. package/src/session/messages.ts +5 -0
  248. package/src/session/session-manager.ts +29 -5
  249. package/src/session-import/redact.ts +5 -1
  250. package/src/setup/paseo/announce.ts +456 -0
  251. package/src/setup/paseo/setup-deps.ts +15 -3
  252. package/src/skill-state/canonical-skills.ts +12 -0
  253. package/src/skills/skills.test.ts +41 -21
  254. package/src/slash-commands/builtin-registry.ts +1 -0
  255. package/src/system-prompt.ts +29 -3
  256. package/src/task/executor.ts +7 -1
  257. package/src/task/index.ts +21 -4
  258. package/src/tools/bash.ts +63 -19
  259. package/src/tools/cron.ts +1 -1
  260. package/src/tools/fetch.ts +29 -1
  261. package/src/tools/gh.ts +26 -3
  262. package/src/tools/github-cache.ts +68 -16
  263. package/src/tools/index.ts +10 -0
  264. package/src/tools/job.ts +21 -4
  265. package/src/tools/monitor.ts +8 -2
  266. package/src/tools/path-utils.ts +7 -0
  267. package/src/tools/read.ts +14 -2
  268. package/src/tools/render-utils.ts +1 -3
  269. package/src/tools/resource-gc.ts +28 -8
  270. package/src/tools/search.ts +116 -58
  271. package/src/tools/skill-discovery.ts +5 -0
  272. package/src/tools/skill.ts +37 -2
  273. package/src/tools/subagent.ts +6 -3
  274. package/src/tools/tool-catalog.generated.ts +10 -3
  275. package/src/tools/ultragoal-ask-guard.ts +2 -2
  276. package/src/web/insane/bridge.ts +113 -23
  277. package/src/web/search/providers/perplexity.ts +102 -41
@@ -294,6 +294,18 @@ export class SlackLiveProvider implements SlackProviderClient, SlackDiagnosticPr
294
294
  return message;
295
295
  }
296
296
 
297
+ async addReaction(input: { channel: string; timestamp: string; name: string; signal?: AbortSignal }): Promise<void> {
298
+ await this.#api(
299
+ "reactions.add",
300
+ {
301
+ channel: input.channel,
302
+ timestamp: input.timestamp,
303
+ name: input.name,
304
+ },
305
+ input.signal,
306
+ );
307
+ }
308
+
297
309
  async findMessageByClientMsgId(input: {
298
310
  channel: string;
299
311
  clientMsgId: string;
@@ -57,6 +57,7 @@ export interface SlackProviderClient {
57
57
  threadTs?: string;
58
58
  signal?: AbortSignal;
59
59
  }): Promise<SlackMessageSearchResult | null>;
60
+ addReaction?(input: { channel: string; timestamp: string; name: string; signal?: AbortSignal }): Promise<void>;
60
61
  /**
61
62
  * Prove that an operator-supplied timestamp addresses a real message in the
62
63
  * requested channel. Adoption of an existing root is refused when a client
@@ -108,6 +109,10 @@ export class SlackProvider {
108
109
  return (await this.client.findMessageByClientMsgId?.(input)) ?? null;
109
110
  }
110
111
 
112
+ async addReaction(input: { channel: string; timestamp: string; name: string; signal?: AbortSignal }): Promise<void> {
113
+ await this.client.addReaction?.(input);
114
+ }
115
+
111
116
  /** Fail-closed root verification: a client without this capability can never confirm a root. */
112
117
  async findMessageByTimestamp(input: { channel: string; ts: string }): Promise<SlackMessageSearchResult | null> {
113
118
  return (await this.client.findMessageByTimestamp?.(input)) ?? null;
@@ -4821,7 +4821,11 @@ export class TelegramNotificationDaemon {
4821
4821
  .catch(() => undefined);
4822
4822
  }
4823
4823
  };
4824
- const parsed = parseLifecycleCommand(text, commandCtx);
4824
+ const threadSessionId =
4825
+ threadId === undefined
4826
+ ? undefined
4827
+ : this.topics.sessionForLifecycleTopic(String(threadId), String(this.opts.chatId));
4828
+ const parsed = parseLifecycleCommand(text, { ...commandCtx, threadSessionId });
4825
4829
  if (parsed.kind === "none") return false;
4826
4830
  if (updateId !== undefined && this.dispatchState.seenUpdateIds.has(updateId)) return true;
4827
4831
  if (updateId !== undefined) await this.rememberSeenUpdateId(updateId);
@@ -4841,7 +4845,17 @@ export class TelegramNotificationDaemon {
4841
4845
  ? `Recent sessions could not be verified: ${recent.message}`
4842
4846
  : recent.entries.length
4843
4847
  ? recent.entries
4844
- .map(entry => `• ${code(entry.sessionId)}${entry.path ? ` (${code(entry.path)})` : ""}`)
4848
+ .map(entry => {
4849
+ const connected = this.#logicalSessionOwners.has(entry.sessionId);
4850
+ return [
4851
+ `${connected ? "🟢 connected" : "⚪ saved"} — ${code(entry.sessionId)}`,
4852
+ entry.title ? ` ${code(entry.title)}` : undefined,
4853
+ entry.path ? ` ${code(entry.path)}` : undefined,
4854
+ connected ? undefined : ` Resume: ${code(`/session_resume ${entry.sessionId}`)}`,
4855
+ ]
4856
+ .filter((line): line is string => line !== undefined)
4857
+ .join("\n");
4858
+ })
4845
4859
  .join("\n")
4846
4860
  : "No recent sessions.";
4847
4861
  await replyHtml(
@@ -775,6 +775,25 @@ export class TopicRegistry {
775
775
  return this.byTopic.get(topicId);
776
776
  }
777
777
 
778
+ /**
779
+ * Resolve a durable topic association for explicit lifecycle controls.
780
+ *
781
+ * Unlike inbound message routing, this may return an inactive session so a
782
+ * user can resume it from its old topic. Ambiguous, malformed, cross-chat,
783
+ * and quarantined records fail closed.
784
+ */
785
+ sessionForLifecycleTopic(topicId: string, chatId: string): string | undefined {
786
+ if (!isValidTopicId(topicId)) return undefined;
787
+ const matches = [...this.topics].filter(
788
+ ([, record]) =>
789
+ record.topicId === topicId &&
790
+ record.chatId === chatId &&
791
+ !record.bindingMalformed &&
792
+ record.authorityState !== "legacy_quarantined",
793
+ );
794
+ return matches.length === 1 ? matches[0]![0] : undefined;
795
+ }
796
+
778
797
  /** All session ids with a persisted topic record. */
779
798
  sessionIds(): string[] {
780
799
  return [...this.topics.keys()];
@@ -15,7 +15,7 @@ import { SdkClientError } from "../client";
15
15
  import { dispatchSpawnGlobal } from "../lifecycle/broker-client";
16
16
 
17
17
  const MASTER_CAPABILITY_ENV = "GJC_MASTER_CAPABILITY";
18
- const MASTER_SESSION_ENV = "GJC_SESSION_ID";
18
+ const MASTER_SESSION_ENV = "GJC_MASTER_OWNER_SESSION_ID";
19
19
  const SPAWN_TIMEOUT_MS = 120_000;
20
20
 
21
21
  export class SdkMasterCliError extends Error {
@@ -334,13 +334,14 @@ function throwResponseFailure(response: unknown): void {
334
334
  async function paginatedSessionList(
335
335
  router: SessionRouter,
336
336
  input: JsonRecord = {},
337
- requestKey = `${SDK_SESSION_CLI_LIFECYCLE_ACTOR.namespace}:session.list`,
337
+ requestKey = `${SDK_SESSION_CLI_LIFECYCLE_ACTOR.namespace}:session.list:${randomBytes(12).toString("hex")}`,
338
338
  ): Promise<unknown> {
339
339
  try {
340
340
  const pages = await traverseSessionList(
341
341
  input,
342
342
  async pageInput => {
343
- const response = object(await router.listBrokerSessions(pageInput, requestKey));
343
+ const cursor = typeof pageInput.cursor === "string" ? pageInput.cursor : "initial";
344
+ const response = object(await router.listBrokerSessions(pageInput, `${requestKey}:${cursor}`));
344
345
  if (response?.ok === false) {
345
346
  const failure = object(response.error);
346
347
  throw new SdkClientError(
@@ -375,10 +376,10 @@ type SessionRows = {
375
376
  sessions: SdkSessionRowV1[];
376
377
  };
377
378
 
378
- async function sessionRows(agentDir: string): Promise<SessionRows> {
379
+ async function sessionRows(agentDir: string, input: JsonRecord = {}): Promise<SessionRows> {
379
380
  await ensureBroker({ agentDir });
380
381
  return await withRouter(agentDir, async router => {
381
- const response = await paginatedSessionList(router);
382
+ const response = await paginatedSessionList(router, input);
382
383
  const result = resultObject(response) ?? {};
383
384
  let sessions: SdkSessionRowV1[];
384
385
  try {
@@ -454,7 +455,7 @@ export async function runSdkSearch(
454
455
  createService: (agentDir: string) => SessionLifecycleService = createBrokerSessionLifecycleService,
455
456
  probe: (agentDir: string, result: SdkSearchResultV1) => Promise<SdkSearchResultV1> = probeSearchRows,
456
457
  ): Promise<{ result: SdkSearchResultV1; exitCode: 0 | 1 }> {
457
- const agentDir = args.agentDir ?? getAgentDir();
458
+ const agentDir = path.resolve(args.agentDir ?? getAgentDir());
458
459
  const locator = await resolveSessionLocator(args.repo ?? process.cwd(), agentDir);
459
460
  const scope: ScopeNameV1 =
460
461
  args.scope === undefined || args.scope === "repo" || args.scope === "pwd" || args.scope === "global"
@@ -689,7 +690,7 @@ async function runList(agentDir: string, args: SdkSessionCliArgs): Promise<unkno
689
690
  }
690
691
 
691
692
  async function runInspect(agentDir: string, sessionId: string): Promise<unknown> {
692
- const listing = await sessionRows(agentDir);
693
+ const listing = await sessionRows(agentDir, { resolveSessionId: sessionId });
693
694
  const session = listing.sessions.find(candidate => candidate.sessionId === sessionId);
694
695
  if (!session)
695
696
  throw new SdkSessionCliError("session_unavailable", `Session ${sessionId} is not indexed by the broker.`, 1);
@@ -1671,7 +1672,9 @@ export async function runTail(
1671
1672
  sessionId: string,
1672
1673
  args: SdkSessionCliArgs,
1673
1674
  ): Promise<unknown> {
1674
- const row = (await sessionRows(agentDir)).sessions.find(candidate => candidate.sessionId === sessionId);
1675
+ const row = (await sessionRows(agentDir, { resolveSessionId: sessionId })).sessions.find(
1676
+ candidate => candidate.sessionId === sessionId,
1677
+ );
1675
1678
  if (!row)
1676
1679
  throw new SdkSessionCliError("session_unavailable", `Session ${sessionId} is not indexed by the broker.`, 1);
1677
1680
  if (row.deleted)
@@ -1801,7 +1804,7 @@ export async function runSdkSessionCli(
1801
1804
  "Expected one of: list, inspect, send, status, tail, retire, raw (control|query|global).",
1802
1805
  2,
1803
1806
  );
1804
- const agentDir = args.agentDir ?? getAgentDir();
1807
+ const agentDir = path.resolve(args.agentDir ?? getAgentDir());
1805
1808
  if (action === "list") {
1806
1809
  writeOutput(stripSecretFields(await runList(agentDir, args)));
1807
1810
  return;
@@ -31,6 +31,14 @@ export class SdkClientError extends Error {
31
31
  }
32
32
  }
33
33
 
34
+ /** A prepared transport operation failed before any wire handoff. */
35
+ export class SdkPreparedDispatchError extends SdkClientError {
36
+ constructor(error: SdkClientError) {
37
+ super(error.code, error.message, error.details, error.reconnect);
38
+ this.name = "SdkPreparedDispatchError";
39
+ }
40
+ }
41
+
34
42
  export type SdkReconnectTerminationReason = "attempts_exhausted" | "deadline" | "cancelled";
35
43
 
36
44
  /**
@@ -90,6 +98,8 @@ export interface SdkRequestOptions {
90
98
  * alter that settlement.
91
99
  */
92
100
  onDispatch?: SdkDispatchHandler;
101
+ /** Internal prepared-dispatch mode: fail pre-send instead of reconnecting. */
102
+ connectedOnly?: boolean;
93
103
  }
94
104
  export type SdkFrame = Record<string, unknown>;
95
105
  /**
@@ -157,6 +167,7 @@ type Incarnation = {
157
167
  };
158
168
  type Pending = {
159
169
  readonly incarnation: Incarnation;
170
+ readonly connectedOnly: boolean;
160
171
  resolve: (value: unknown) => void;
161
172
  reject: (error: Error) => void;
162
173
  timer: NodeJS.Timeout;
@@ -453,11 +464,30 @@ export class SdkClient {
453
464
  }
454
465
 
455
466
  async #request(frame: Frame, options: SdkRequestOptions, onResponse?: () => void): Promise<unknown> {
456
- if (this.#closed) throw new SdkClientError("connection_closed", "SDK client closed");
457
- this.#throwIfDeadlineElapsed();
458
- const incarnation = await this.#connect();
467
+ if (this.#closed) {
468
+ const error = new SdkClientError("connection_closed", "SDK client closed");
469
+ throw options.connectedOnly ? new SdkPreparedDispatchError(error) : error;
470
+ }
471
+ try {
472
+ this.#throwIfDeadlineElapsed();
473
+ } catch (error) {
474
+ if (options.connectedOnly && error instanceof SdkClientError) throw new SdkPreparedDispatchError(error);
475
+ throw error;
476
+ }
477
+ let incarnation: Incarnation;
478
+ if (options.connectedOnly) {
479
+ try {
480
+ incarnation = this.#requireConnectedIncarnation();
481
+ } catch (error) {
482
+ if (error instanceof SdkClientError) throw new SdkPreparedDispatchError(error);
483
+ throw error;
484
+ }
485
+ } else incarnation = await this.#connect();
459
486
  const timeoutMs = this.#remainingTimeout(options.timeoutMs ?? this.#timeoutMs);
460
- if (timeoutMs <= 0) throw this.#deadlineError();
487
+ if (timeoutMs <= 0) {
488
+ const error = this.#deadlineError();
489
+ throw options.connectedOnly ? new SdkPreparedDispatchError(error) : error;
490
+ }
461
491
  const id = randomUUID();
462
492
  const requestFrame = {
463
493
  ...frame,
@@ -482,6 +512,7 @@ export class SdkClient {
482
512
  const deferred = Promise.withResolvers<unknown>();
483
513
  const pending: Pending = {
484
514
  incarnation,
515
+ connectedOnly: options.connectedOnly === true,
485
516
  resolve: deferred.resolve,
486
517
  reject: deferred.reject,
487
518
  sent: false,
@@ -502,7 +533,8 @@ export class SdkClient {
502
533
  };
503
534
  this.#pending.set(id, pending);
504
535
  if (!this.#isActive(incarnation) || incarnation.socket.readyState !== WebSocket.OPEN) {
505
- this.#settlePending(id, pending, new SdkClientError("unavailable", "SDK WebSocket is not connected"));
536
+ const error = new SdkClientError("unavailable", "SDK WebSocket is not connected");
537
+ this.#settlePending(id, pending, options.connectedOnly ? new SdkPreparedDispatchError(error) : error);
506
538
  return await deferred.promise;
507
539
  }
508
540
  if (options.beforeDispatch) {
@@ -553,16 +585,22 @@ export class SdkClient {
553
585
  this.#settlePending(
554
586
  id,
555
587
  pending,
556
- new SdkClientError("connection_closed", "SDK client closed during dispatch"),
588
+ options.connectedOnly
589
+ ? new SdkPreparedDispatchError(
590
+ new SdkClientError("connection_closed", "SDK client closed during dispatch"),
591
+ )
592
+ : new SdkClientError("connection_closed", "SDK client closed during dispatch"),
557
593
  );
558
594
  return await deferred.promise;
559
595
  }
560
596
  if (this.#deadline !== undefined && Date.now() >= this.#deadline) {
561
- this.#settlePending(id, pending, this.#deadlineError());
597
+ const error = this.#deadlineError();
598
+ this.#settlePending(id, pending, options.connectedOnly ? new SdkPreparedDispatchError(error) : error);
562
599
  return await deferred.promise;
563
600
  }
564
601
  if (incarnation.socket.readyState !== WebSocket.OPEN) {
565
- this.#settlePending(id, pending, new SdkClientError("unavailable", "SDK WebSocket is not connected"));
602
+ const error = new SdkClientError("unavailable", "SDK WebSocket is not connected");
603
+ this.#settlePending(id, pending, options.connectedOnly ? new SdkPreparedDispatchError(error) : error);
566
604
  return await deferred.promise;
567
605
  }
568
606
  // Handoff bookkeeping is reentrancy-safe: `sent` flips BEFORE the wire
@@ -593,12 +631,14 @@ export class SdkClient {
593
631
  // in which case that settlement stands and must not be displaced.
594
632
  pending.sent = false;
595
633
  this.#sentRecords.delete(id);
634
+ const sendError =
635
+ error instanceof SdkClientError
636
+ ? error
637
+ : new SdkClientError("unavailable", "SDK WebSocket send failed", error);
596
638
  this.#settlePending(
597
639
  id,
598
640
  pending,
599
- error instanceof SdkClientError
600
- ? error
601
- : new SdkClientError("unavailable", "SDK WebSocket send failed", error),
641
+ options.connectedOnly ? new SdkPreparedDispatchError(sendError) : sendError,
602
642
  );
603
643
  }
604
644
  return await deferred.promise;
@@ -649,6 +689,13 @@ export class SdkClient {
649
689
  return await cycle.promise!;
650
690
  }
651
691
 
692
+ #requireConnectedIncarnation(): Incarnation {
693
+ const current = this.#currentSocketRecord;
694
+ if (!current || !this.#isActive(current) || current.socket.readyState !== WebSocket.OPEN)
695
+ throw new SdkClientError("connection_closed", "SDK WebSocket disconnected before prepared dispatch.");
696
+ return current;
697
+ }
698
+
652
699
  async #openWithRetry(cycle: Cycle): Promise<Incarnation> {
653
700
  const startedAt = Date.now();
654
701
  let attemptsConsumed = 0;
@@ -983,12 +1030,14 @@ export class SdkClient {
983
1030
  clearTimeout(pending.timer);
984
1031
  if (responseReceived) pending.onResponse?.();
985
1032
  if (result instanceof Error) {
986
- if (
987
- transportFailure &&
988
- pending.sent &&
1033
+ const errorResult =
1034
+ !pending.sent &&
1035
+ pending.connectedOnly &&
989
1036
  result instanceof SdkClientError &&
990
- (result.code === "timeout" || result.code === "connection_closed")
991
- )
1037
+ !(result instanceof SdkPreparedDispatchError)
1038
+ ? new SdkPreparedDispatchError(result)
1039
+ : result;
1040
+ if (transportFailure && pending.sent && errorResult instanceof SdkClientError)
992
1041
  pending.reject(
993
1042
  new SdkClientError(
994
1043
  "uncertain_after_send",
@@ -998,7 +1047,7 @@ export class SdkClient {
998
1047
  );
999
1048
  else {
1000
1049
  this.#sentRecords.delete(id);
1001
- pending.reject(result);
1050
+ pending.reject(errorResult);
1002
1051
  }
1003
1052
  return;
1004
1053
  }
@@ -1,5 +1,6 @@
1
1
  import { logger } from "@gajae-code/utils";
2
2
  import { AUTOROUTING_INACTIVE_WARNING } from "../../config/autorouting-contract";
3
+ import { FileLockAcquireError } from "../../config/file-lock";
3
4
  import { redactBrokerRuntimeCapabilities, redactObservedRequestContent } from "./control/runtime-gate";
4
5
  import { type EventFrame, SessionEventStream } from "./events";
5
6
  import { isAutoroutingInactive } from "./internal-autorouting-state";
@@ -363,10 +364,10 @@ export class SessionSdkHost {
363
364
  return "activated";
364
365
  }
365
366
 
366
- async stop(): Promise<"stopped" | "already"> {
367
+ async stop(options: { allowLockContention?: boolean } = {}): Promise<"stopped" | "already"> {
367
368
  if (this.#stopPromise) return this.#stopPromise;
368
369
  if (!this.#started) return "already";
369
- const stopPromise = this.#stopStartedHost();
370
+ const stopPromise = this.#stopStartedHost(options);
370
371
  this.#stopPromise = stopPromise;
371
372
  try {
372
373
  return await stopPromise;
@@ -375,18 +376,37 @@ export class SessionSdkHost {
375
376
  }
376
377
  }
377
378
 
378
- async #stopStartedHost(): Promise<"stopped"> {
379
+ async #stopStartedHost(options: { allowLockContention?: boolean }): Promise<"stopped"> {
379
380
  // Fence before the first await: everything after this point is teardown,
380
381
  // and no in-flight activation may publish readiness across it.
381
382
  this.#stopping = true;
382
383
  this.#unsubscribe?.();
383
384
  this.#unsubscribe = undefined;
384
- if (this.#registration?.writer.unregister)
385
- await this.#registration.writer.unregister({
386
- sessionId: this.#options.sessionId,
387
- stateRoot: this.#options.stateRoot,
388
- endpointGeneration: this.events.generation,
389
- });
385
+ if (this.#registration?.writer.unregister) {
386
+ try {
387
+ await this.#registration.writer.unregister({
388
+ sessionId: this.#options.sessionId,
389
+ stateRoot: this.#options.stateRoot,
390
+ endpointGeneration: this.events.generation,
391
+ });
392
+ } catch (error) {
393
+ // A live broker may hold the shared session-index lock beyond the
394
+ // bounded acquisition budget. Unregistration is best effort: leaving
395
+ // the durable row in place is fail-closed because peers still fence
396
+ // authority on process liveness and incarnation, while propagating the
397
+ // contention error turns ordinary shutdown into an uncaught failure.
398
+ if (
399
+ !options.allowLockContention ||
400
+ !(error instanceof FileLockAcquireError) ||
401
+ error.code !== "acquire_timeout"
402
+ )
403
+ throw error;
404
+ logger.warn("sdk broker unregister deferred because the session index is busy", {
405
+ holder: error.holder,
406
+ lockPath: error.lockPath,
407
+ });
408
+ }
409
+ }
390
410
  this.#started = false;
391
411
  this.#stopping = false;
392
412
  return "stopped";
@@ -397,20 +397,29 @@ export class RevisionStore {
397
397
  if (!revision) return undefined;
398
398
  revision.lastAccessed = this.now();
399
399
  if (!revision.index?.items) return undefined;
400
- const items: unknown[] = [];
400
+ const ranges = revision.index.items.slice(offset);
401
+ let count = 0;
401
402
  let itemsBytes = 2; // []
402
- for (const range of revision.index.items.slice(offset)) {
403
- // The manifest records the canonical item length, so reject an oversized
404
- // item before reading or parsing its complete range.
405
- if (range.end - range.start > targetBytes) break;
406
- const value = JSON.parse(await this.#readRange(revision, range));
407
- const itemBytes = Buffer.byteLength(JSON.stringify(value));
408
- const candidateBytes = itemsBytes + itemBytes + (items.length ? 1 : 0);
409
- if (candidateBytes > targetBytes && items.length) break;
403
+ for (const range of ranges) {
404
+ // Indexed ranges contain canonical JSON, so their byte lengths select
405
+ // the page without repeatedly loading and hashing the same chunk.
406
+ const itemBytes = range.end - range.start;
407
+ if (itemBytes > targetBytes) break;
408
+ const candidateBytes = itemsBytes + itemBytes + (count ? 1 : 0);
409
+ if (candidateBytes > targetBytes && count) break;
410
410
  if (candidateBytes > 1024 * 1024) break;
411
- items.push(value);
411
+ count++;
412
412
  itemsBytes = candidateBytes;
413
413
  }
414
+ const items: unknown[] = [];
415
+ if (count) {
416
+ const start = ranges[0]!.start;
417
+ const bytes = await this.#readBytes(revision, start, ranges[count - 1]!.end);
418
+ for (let index = 0; index < count; index++) {
419
+ const range = ranges[index]!;
420
+ items.push(JSON.parse(bytes.subarray(range.start - start, range.end - start).toString("utf8")));
421
+ }
422
+ }
414
423
  return { items, complete: offset + items.length >= revision.index.items.length };
415
424
  }
416
425
  /** Returns canonical root JSON in a UTF-8-safe bounded range without parsing it. */
@@ -1095,9 +1104,6 @@ export class RevisionStore {
1095
1104
  }
1096
1105
  return Buffer.concat(values);
1097
1106
  }
1098
- async #readRange(revision: Revision, range: { start: number; end: number }): Promise<string> {
1099
- return (await this.#readBytes(revision, range.start, range.end)).toString("utf8");
1100
- }
1101
1107
 
1102
1108
  async #readChunks(chunks: string[], lengths: number[]): Promise<Buffer | undefined> {
1103
1109
  if (!this.#directory || chunks.length === 0) return undefined;
@@ -89,7 +89,7 @@ export class ReverseLeaseRuntime {
89
89
  readonly #onDefinitionsRemoved?: ReverseLeaseOptions["onDefinitionsRemoved"];
90
90
  readonly #onCancel?: ReverseLeaseOptions["onCancel"];
91
91
  readonly #leases = new Map<string, ProviderLease>();
92
- readonly #idempotency = new Map<string, { fingerprint: string; lease: ProviderLease }>();
92
+ readonly #idempotency = new Map<string, { connectionId: string; fingerprint: string; lease: ProviderLease }>();
93
93
  readonly #outstanding = new Map<string, Outstanding>();
94
94
  readonly #installedCapabilities = new Set<string>();
95
95
  readonly #sweepTimer: ReturnType<typeof setInterval>;
@@ -139,7 +139,7 @@ export class ReverseLeaseRuntime {
139
139
  active: true,
140
140
  };
141
141
  this.#leases.set(capability, lease);
142
- if (idempotencyKey) this.#idempotency.set(key, { fingerprint, lease });
142
+ if (idempotencyKey) this.#idempotency.set(key, { connectionId, fingerprint, lease });
143
143
  return { ...lease };
144
144
  }
145
145
  const reclaiming =
@@ -152,7 +152,7 @@ export class ReverseLeaseRuntime {
152
152
  existing!.expiresAt = now + this.#leaseTtlMs;
153
153
  existing!.graceUntil = undefined;
154
154
  existing!.active = true;
155
- if (idempotencyKey) this.#idempotency.set(key, { fingerprint, lease: existing! });
155
+ if (idempotencyKey) this.#idempotency.set(key, { connectionId, fingerprint, lease: existing! });
156
156
  return { ...existing! };
157
157
  }
158
158
  this.#installDefinitionsFor(capability, definitions);
@@ -165,7 +165,7 @@ export class ReverseLeaseRuntime {
165
165
  active: true,
166
166
  };
167
167
  this.#leases.set(capability, lease);
168
- if (idempotencyKey) this.#idempotency.set(key, { fingerprint, lease });
168
+ if (idempotencyKey) this.#idempotency.set(key, { connectionId, fingerprint, lease });
169
169
  return { ...lease };
170
170
  }
171
171
 
@@ -197,6 +197,9 @@ export class ReverseLeaseRuntime {
197
197
 
198
198
  disconnect(connectionId: string): void {
199
199
  const now = this.#now();
200
+ for (const [key, entry] of this.#idempotency) {
201
+ if (entry.connectionId === connectionId) this.#idempotency.delete(key);
202
+ }
200
203
  for (const lease of this.#leases.values())
201
204
  if (lease.connectionId === connectionId) {
202
205
  lease.expiresAt = now;
@@ -2125,7 +2125,7 @@ describe("SessionSdkSessionRuntime", () => {
2125
2125
  while (!transport.sent.some(frame => frame.id === "predecessor-prompt")) await Bun.sleep(10);
2126
2126
  await handlers.get("agent_start")?.({}, ctx); // lifecycle epoch 1
2127
2127
  await handlers.get("agent_end")?.(
2128
- { type: "agent_end", stopReason: "maintenance", maintenanceOutcome: "completed" },
2128
+ { type: "agent_end", stopReason: "maintenance", maintenanceOutcome: "compacted" },
2129
2129
  ctx,
2130
2130
  );
2131
2131
  transport.feed("client", {
@@ -2148,7 +2148,7 @@ describe("SessionSdkSessionRuntime", () => {
2148
2148
  while (abortCalls < 1) await Bun.sleep(10);
2149
2149
  await handlers.get("agent_end")?.({ type: "agent_end" }, ctx); // delayed predecessor
2150
2150
  await handlers.get("agent_end")?.(
2151
- { type: "agent_end", stopReason: "maintenance", maintenanceOutcome: "completed" },
2151
+ { type: "agent_end", stopReason: "maintenance", maintenanceOutcome: "compacted" },
2152
2152
  ctx,
2153
2153
  );
2154
2154
  abortRelease.resolve();
@@ -5261,7 +5261,7 @@ describe("post-acceptance invocation terminalization", () => {
5261
5261
  }
5262
5262
  });
5263
5263
 
5264
- test("a later provider error enriches but never re-opens an already terminal prompt", async () => {
5264
+ test("a later provider error never changes or re-opens an already terminal prompt", async () => {
5265
5265
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-once-"));
5266
5266
  try {
5267
5267
  const inflight = Promise.withResolvers<void>();
@@ -5282,13 +5282,8 @@ describe("post-acceptance invocation terminalization", () => {
5282
5282
  // A lifecycle frame arriving after the terminal must not resurrect the record either.
5283
5283
  await harness.emit("agent_start");
5284
5284
  const settled = await harness.query("turn.prompt_status", { commandId, turnId });
5285
- // The late reason attaches to the settled record, so `error` is the only field that may
5286
- // appear; status, terminalAt, and identity stay exactly as claimed.
5287
- const { error: _claimedReason, ...claimedTerminal } = claimed;
5288
- const { error: _lateReason, ...settledTerminal } = settled.result ?? {};
5289
- expect(settledTerminal).toEqual(claimedTerminal);
5290
- // The recorded reason is the sanitized late failure: never fabricated, never raw.
5291
- expect(settled.result?.error).toEqual({ code: "upstream_error", message: "Prompt submission failed." });
5285
+ // A rejected submission after terminal publication is only a cleanup diagnostic.
5286
+ expect(settled.result).toEqual(claimed);
5292
5287
  await harness.stop();
5293
5288
  } finally {
5294
5289
  await Bun.sleep(50);
@@ -7145,3 +7140,50 @@ test("SDK-only host rebinds the steering snapshot when the requester's turn wins
7145
7140
  await rm(cwd, { recursive: true, force: true });
7146
7141
  }
7147
7142
  });
7143
+
7144
+ describe("rejection terminalization idempotency", () => {
7145
+ test("cleanup rejection after success preserves the sole correlated wire terminal and durable result", async () => {
7146
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-cleanup-rejection-"));
7147
+ const cleanup = Promise.withResolvers<void>();
7148
+ const harness = await invocationHarness("cleanup-rejection", cwd, {
7149
+ sendUserMessage: async (_content, options) => {
7150
+ await options?.onPreflightAcceptCommit?.();
7151
+ await cleanup.promise;
7152
+ },
7153
+ });
7154
+ const warning = spyOn(logger, "warn").mockImplementation(() => {});
7155
+ try {
7156
+ const accepted = await harness.control("turn.prompt", { text: "complete before cleanup" });
7157
+ expect(accepted.ok).toBe(true);
7158
+ const ids = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
7159
+ await harness.emit("agent_start");
7160
+ await harness.emit("agent_end", {
7161
+ messages: [{ role: "assistant", content: [{ type: "text", text: "Completed." }], stopReason: "stop" }],
7162
+ });
7163
+ const before = await settledStatus(harness, "turn.result", { kind: "prompt", ...ids });
7164
+ expect(before.status).toBe("terminal_ok");
7165
+ cleanup.reject(new Error("post-turn cleanup failed"));
7166
+ await Bun.sleep(50);
7167
+ const after = await harness.query("turn.result", { kind: "prompt", ...ids });
7168
+ expect(after.result).toEqual(before);
7169
+ expect(warning).toHaveBeenCalledWith("SDK submission cleanup failed after terminal publication", {
7170
+ kind: "prompt",
7171
+ ...ids,
7172
+ error: { code: "internal", message: "Prompt submission failed." },
7173
+ });
7174
+ const correlated = harness.broadcasts.filter(
7175
+ frame => (frame.payload as { commandId?: string } | undefined)?.commandId === ids.commandId,
7176
+ );
7177
+ expect(correlated.filter(frame => frame.kind === "agent_failed")).toHaveLength(0);
7178
+ const terminals = correlated.filter(frame => frame.kind === "agent_end");
7179
+ expect(terminals).toHaveLength(1);
7180
+ expect(terminals[0]).toMatchObject({
7181
+ payload: { ...ids, outcome: (before as { outcome?: unknown }).outcome },
7182
+ });
7183
+ } finally {
7184
+ warning.mockRestore();
7185
+ await harness.stop();
7186
+ await rm(cwd, { recursive: true, force: true });
7187
+ }
7188
+ });
7189
+ });