@gajae-code/coding-agent 0.16.7 → 0.17.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 (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -69,7 +69,7 @@ import {
69
69
  sanitizeSdkStartupMessage,
70
70
  } from "../startup-capability";
71
71
  import type { Broker, BrokerCleanupEvidence, BrokerCleanupIdentity, BrokerResponse } from "./broker";
72
- import { matchesIndexedEndpointFile, readEndpointFile } from "./endpoint-authority";
72
+ import { endpointIncarnation, matchesIndexedEndpointFile, readEndpointFile } from "./endpoint-authority";
73
73
  import { decodeLifecycleUtf8, parseLifecycleJson } from "./lifecycle-codec";
74
74
  import type {
75
75
  LifecycleCleanupProof,
@@ -79,6 +79,7 @@ import type {
79
79
  LifecycleWorktreeIntent,
80
80
  } from "./lifecycle-ledger";
81
81
  import {
82
+ observeProcessIncarnation,
82
83
  type ProcessIncarnationCommandRunner,
83
84
  type ProcessIncarnationOptions,
84
85
  parseDarwinProcessIncarnation,
@@ -2404,6 +2405,7 @@ async function executeUncertainRetirement(
2404
2405
  ...(record.processIncarnation === undefined ? {} : { processIncarnation: record.processIncarnation }),
2405
2406
  ...(record.hostIncarnation === undefined ? {} : { hostIncarnation: record.hostIncarnation }),
2406
2407
  ...(record.endpointMtimeMs === undefined ? {} : { endpointMtimeMs: record.endpointMtimeMs }),
2408
+ ...(record.endpointFileId === undefined ? {} : { endpointFileId: record.endpointFileId }),
2407
2409
  ...(record.lifecycleRequestId === undefined ? {} : { lifecycleRequestId: record.lifecycleRequestId }),
2408
2410
  });
2409
2411
  const verifiedIndexSeq = broker.index.findSessionClosedEvidence(record);
@@ -2537,6 +2539,13 @@ function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerRespon
2537
2539
  return fail("terminal_uncertain", "Lifecycle metadata candidate could not be safely inspected.");
2538
2540
  }
2539
2541
  if (!current) continue;
2542
+ if (
2543
+ file.completed &&
2544
+ path.resolve(candidate) === path.resolve(file.plannedPath) &&
2545
+ current.identity.size === 0n &&
2546
+ current.identity.nlink === 1n
2547
+ )
2548
+ continue;
2540
2549
  if (!sameLifecycleCleanupIdentity(current.identity, file.identity))
2541
2550
  return fail("terminal_uncertain", "Lifecycle metadata candidate lacks exact replay authority.");
2542
2551
  // A completed file's recorded retained quarantine is receipt-bound durable
@@ -2876,14 +2885,11 @@ async function reconcileLifecycleCleanup(
2876
2885
  // A completed file's recorded retained quarantine is durable evidence,
2877
2886
  // not a survivor — accept it only at its receipt-bound path and identity.
2878
2887
  if (
2879
- file.detachedPath &&
2880
- path.resolve(candidate) === path.resolve(file.detachedPath) &&
2881
- stat.isFile() &&
2882
- !stat.isSymbolicLink() &&
2883
- stat.nlink === 1 &&
2884
- stat.size === 0
2885
- )
2886
- continue;
2888
+ (file.detachedPath && path.resolve(candidate) === path.resolve(file.detachedPath)) ||
2889
+ path.resolve(candidate) === path.resolve(file.plannedPath)
2890
+ ) {
2891
+ if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1 && stat.size === 0) continue;
2892
+ }
2887
2893
  return fail(
2888
2894
  "terminal_uncertain",
2889
2895
  "Lifecycle cleanup receipt marks a target complete while an authorized candidate remains.",
@@ -3411,6 +3417,22 @@ async function removeOwnedLifecycleArtifacts(
3411
3417
  { dev: BigInt(endpointParent.dev), ino: BigInt(endpointParent.ino) },
3412
3418
  );
3413
3419
  if (!lifecycleProofWithinDeadline(proofBudget)) return false;
3420
+ if (endpointRemoval.ok) {
3421
+ // POSIX exact-unlink scrubs the detached exchange payload and may retain
3422
+ // its zero-byte quarantine name as durable internal evidence even when
3423
+ // the canonical removal itself succeeded. Carry that known placeholder
3424
+ // into the enclosing close reconciliation so it is not mistaken for a
3425
+ // live endpoint payload.
3426
+ for (const candidate of [plannedEndpointPath, retryEndpointPath, finalEndpointPath]) {
3427
+ try {
3428
+ const metadata = fsSync.lstatSync(candidate);
3429
+ if (metadata.isFile() && metadata.nlink === 1 && metadata.size === 0)
3430
+ onDurablePlaceholder?.(path.resolve(candidate));
3431
+ } catch {
3432
+ // A missing quarantine alias carries no retained authority.
3433
+ }
3434
+ }
3435
+ }
3414
3436
  if (!endpointRemoval.ok) {
3415
3437
  if (endpointRemoval.retainedUnknownPath) {
3416
3438
  onRetainedUnknown?.();
@@ -3499,6 +3521,14 @@ async function recordTerminalUncertain(
3499
3521
  ...(registered.processIncarnation === undefined ? {} : { processIncarnation: registered.processIncarnation }),
3500
3522
  ...(registered.hostIncarnation === undefined ? {} : { hostIncarnation: registered.hostIncarnation }),
3501
3523
  ...(registered.endpointMtimeMs === undefined ? {} : { endpointMtimeMs: registered.endpointMtimeMs }),
3524
+ ...(registered.endpointFileId !== undefined &&
3525
+ registered.pid === pid &&
3526
+ path.resolve(registered.locator.stateRoot) === path.resolve(root) &&
3527
+ expected !== undefined &&
3528
+ registered.lifecycleRequestId === expected.effectMarker &&
3529
+ (registered.hostIncarnation ?? registered.processIncarnation) === expected.incarnation
3530
+ ? { endpointFileId: registered.endpointFileId }
3531
+ : {}),
3502
3532
  ...(registered.lifecycleRequestId === undefined
3503
3533
  ? expected?.effectMarker === undefined
3504
3534
  ? {}
@@ -3517,6 +3547,70 @@ async function recordTerminalUncertain(
3517
3547
  });
3518
3548
  }
3519
3549
 
3550
+ /**
3551
+ * Release the worktree held by a forced stop of a stale-endpoint session, bound to
3552
+ * the exact stale identity the caller already validated against the requested close
3553
+ * authority. A successor incarnation can re-register under this session id in the
3554
+ * window between that authority check and this claim, so refresh once and append a
3555
+ * terminal-uncertain marker ONLY when the current row is still that captured
3556
+ * identity and its owning process is not observably alive. A rotated successor no
3557
+ * longer matches, so it is left untouched and nothing is released — the strict
3558
+ * "uncertain counts as occupied" rule keeps protecting it.
3559
+ *
3560
+ * Unlike `recordTerminalUncertain`, the compare and the append share one refresh
3561
+ * boundary and the marker is keyed to the captured generation/pid/incarnation
3562
+ * rather than to whatever row a second refresh happens to read. A successor that
3563
+ * rotates in while the append is in flight therefore cannot be marked terminal:
3564
+ * the claim targets the old generation, whose row never outranks the successor's
3565
+ * higher generation in the projection (#5581).
3566
+ */
3567
+ async function releaseForcedStaleWorktree(broker: Broker, id: string, expected: IndexedSession): Promise<void> {
3568
+ await broker.index.refresh();
3569
+ const current = broker.index.listSessions().sessions.find(session => session.sessionId === id);
3570
+ if (
3571
+ !current ||
3572
+ current.endpointGeneration !== expected.endpointGeneration ||
3573
+ current.pid !== expected.pid ||
3574
+ (current.hostIncarnation ?? current.processIncarnation) !==
3575
+ (expected.hostIncarnation ?? expected.processIncarnation)
3576
+ )
3577
+ return;
3578
+ // For a force-stopped stale-endpoint session, release the worktree unless the
3579
+ // owning process is provably alive. An `alive` process still owns its checkout,
3580
+ // so it stays occupied even under force. Both `exited` (proven gone) and
3581
+ // `uncertain` (the pid-reuse case, which can never be proven exited) are treated
3582
+ // as terminal here: otherwise the row stays occupied forever and follow-up
3583
+ // delegate launches into the same checkout are refused with worktree_in_use
3584
+ // indefinitely. The identity guards above (generation/PID/incarnation) already
3585
+ // ensure a live successor that rotated in under the same id is never released.
3586
+ const observation = observeProcess(current.pid, current.hostIncarnation ?? current.processIncarnation, value =>
3587
+ processIncarnationForBroker(broker, value),
3588
+ );
3589
+ if (observation === "alive") return;
3590
+ if (observation === "uncertain")
3591
+ logger.warn("sdk broker recording forced stale-worktree release under uncertain process state", {
3592
+ sessionId: id,
3593
+ pid: current.pid,
3594
+ endpointGeneration: current.endpointGeneration,
3595
+ });
3596
+ await broker.index.append({
3597
+ type: "lifecycle_terminal",
3598
+ sessionId: id,
3599
+ locator: current.locator,
3600
+ endpointGeneration: current.endpointGeneration,
3601
+ pid: current.pid,
3602
+ ...(current.processIncarnation === undefined ? {} : { processIncarnation: current.processIncarnation }),
3603
+ ...(current.hostIncarnation === undefined ? {} : { hostIncarnation: current.hostIncarnation }),
3604
+ ...(current.endpointMtimeMs === undefined ? {} : { endpointMtimeMs: current.endpointMtimeMs }),
3605
+ ...(current.lifecycleRequestId === undefined ? {} : { lifecycleRequestId: current.lifecycleRequestId }),
3606
+ terminalUncertain: true,
3607
+ // Marks this claim as the forced release, which is the only terminal-uncertain
3608
+ // claim that frees the worktree. The fail-closed teardown tail writes
3609
+ // `terminalUncertain` without it and keeps its checkout (see `worktreeOccupant`).
3610
+ forcedStaleRelease: true,
3611
+ });
3612
+ }
3613
+
3520
3614
  async function waitUntil(timing: LifecycleTiming, deadline: number): Promise<void> {
3521
3615
  while (timing.now() < deadline) await timing.sleep(Math.max(0, Math.min(POLL_MS, deadline - timing.now())));
3522
3616
  }
@@ -3734,13 +3828,20 @@ async function signalVerifiedSession(
3734
3828
  signal: NodeJS.Signals,
3735
3829
  expected?: EffectMarker,
3736
3830
  ): Promise<boolean> {
3737
- if (!(await hasDurableProcessIdentity(record, id, expected))) return false;
3831
+ // An exited process may remain a zombie until its parent reaps it. Its
3832
+ // incarnation is intentionally unavailable at that point, but native
3833
+ // observation positively classifies the pid as absent. Treat that as a
3834
+ // successful no-op so close can continue with endpoint/index cleanup rather
3835
+ // than escalating to an unverifiable signal and reporting terminal uncertainty.
3836
+ if (!(await hasDurableProcessIdentity(record, id, expected)))
3837
+ return observeProcessIncarnation(record.pid).status === "absent";
3738
3838
  try {
3739
- if (!(await hasDurableProcessIdentity(record, id, expected))) return false;
3839
+ if (!(await hasDurableProcessIdentity(record, id, expected)))
3840
+ return observeProcessIncarnation(record.pid).status === "absent";
3740
3841
  process.kill(record.pid, signal);
3741
3842
  return true;
3742
3843
  } catch {
3743
- return false;
3844
+ return observeProcessIncarnation(record.pid).status === "absent";
3744
3845
  }
3745
3846
  }
3746
3847
 
@@ -3812,7 +3913,17 @@ async function removeExactDeadSessionEndpoint(
3812
3913
  ? authorizedDetachedSource
3813
3914
  : candidates.find(candidate => {
3814
3915
  try {
3815
- return fsSync.lstatSync(candidate).isFile();
3916
+ const metadata = fsSync.lstatSync(candidate);
3917
+ // A native-proven scrubbed placeholder is no longer endpoint JSON.
3918
+ // Unknown empty files and nonempty replacements still require validation.
3919
+ if (
3920
+ durablePlaceholders?.has(path.resolve(candidate)) &&
3921
+ metadata.isFile() &&
3922
+ metadata.nlink === 1 &&
3923
+ metadata.size === 0
3924
+ )
3925
+ return false;
3926
+ return metadata.isFile();
3816
3927
  } catch {
3817
3928
  return false;
3818
3929
  }
@@ -3902,6 +4013,12 @@ async function hasOwnedEndpointPayload(
3902
4013
  ): Promise<boolean> {
3903
4014
  const directory = path.join(root, "sdk");
3904
4015
  const endpointName = `${id}.json`;
4016
+ const knownScrubbedPlaceholders = new Set([
4017
+ `.gjc-delete-endpoint-${effectMarker}-${endpointName}`,
4018
+ `.gjc-delete-endpoint-retry-${effectMarker}-${endpointName}`,
4019
+ `.gjc-delete-endpoint-final-${effectMarker}-${endpointName}`,
4020
+ `.gjc-delete-endpoint-detached-${effectMarker}-${endpointName}`,
4021
+ ]);
3905
4022
  let names: string[];
3906
4023
  try {
3907
4024
  names = await fs.readdir(directory);
@@ -3917,7 +4034,9 @@ async function hasOwnedEndpointPayload(
3917
4034
  if (
3918
4035
  !metadata.isFile() ||
3919
4036
  metadata.size > 0 ||
3920
- (metadata.size === 0 && !durablePlaceholders.has(path.resolve(candidate)))
4037
+ (metadata.size === 0 &&
4038
+ !durablePlaceholders.has(path.resolve(candidate)) &&
4039
+ !knownScrubbedPlaceholders.has(name))
3921
4040
  )
3922
4041
  return true;
3923
4042
  } catch (error) {
@@ -4210,6 +4329,15 @@ export function worktreeOccupantForTest(
4210
4329
  return worktreeOccupant(sessions, worktreePath, observe);
4211
4330
  }
4212
4331
 
4332
+ /** Test seam for the forced stale-worktree release boundary. */
4333
+ export async function releaseForcedStaleWorktreeForTest(
4334
+ broker: Broker,
4335
+ id: string,
4336
+ expected: IndexedSession,
4337
+ ): Promise<void> {
4338
+ await releaseForcedStaleWorktree(broker, id, expected);
4339
+ }
4340
+
4213
4341
  async function preparePlannedWorktree(
4214
4342
  plan: GjcLaunchWorktreePlan,
4215
4343
  opts: { signal: AbortSignal; deadlineAt: number; now: () => number },
@@ -4832,32 +4960,6 @@ type CloseRecord = {
4832
4960
  processIncarnation?: string;
4833
4961
  };
4834
4962
 
4835
- function endpointIncarnation(
4836
- record: Pick<CloseRecord, "endpointGeneration" | "endpointMtimeMs" | "pid">,
4837
- sessionId: string,
4838
- ): string | undefined {
4839
- if (
4840
- !Number.isSafeInteger(record.endpointGeneration) ||
4841
- record.endpointGeneration <= 0 ||
4842
- !Number.isSafeInteger(record.pid) ||
4843
- record.pid <= 0 ||
4844
- typeof record.endpointMtimeMs !== "number" ||
4845
- !Number.isFinite(record.endpointMtimeMs) ||
4846
- record.endpointMtimeMs <= 0
4847
- )
4848
- return undefined;
4849
- return createHash("sha256")
4850
- .update(
4851
- JSON.stringify({
4852
- endpointGeneration: record.endpointGeneration,
4853
- endpointMtimeMs: record.endpointMtimeMs,
4854
- pid: record.pid,
4855
- sessionId,
4856
- }),
4857
- )
4858
- .digest("hex");
4859
- }
4860
-
4861
4963
  function requestedCloseAuthority(input: Input): { authority: CloseAuthority | undefined } | { error: BrokerResponse } {
4862
4964
  const endpointGeneration = input.endpointGeneration;
4863
4965
  const endpointIncarnation = input.endpointIncarnation;
@@ -5028,13 +5130,15 @@ async function executeLifecycleResponse(
5028
5130
  return fail("endpoint_stale", "Live session changed while its resume authority was being verified.");
5029
5131
  if (current.endpointMtimeMs === undefined)
5030
5132
  return fail("endpoint_stale", "Live session endpoint authority is incomplete.");
5133
+ const finalIncarnation = endpointIncarnation(current, requestedSessionId!);
5134
+ if (!finalIncarnation) return fail("endpoint_stale", "Live session endpoint incarnation is unavailable.");
5031
5135
  return {
5032
5136
  ok: true,
5033
5137
  result: {
5034
5138
  sessionId: requestedSessionId,
5035
5139
  cwd: finalScope.cwd,
5036
5140
  endpointGeneration: current.endpointGeneration,
5037
- endpointIncarnation: initialIncarnation,
5141
+ endpointIncarnation: finalIncarnation,
5038
5142
  pid: current.pid,
5039
5143
  endpointMtimeMs: current.endpointMtimeMs,
5040
5144
  endpoint: endpoint.result,
@@ -5515,6 +5619,7 @@ async function executeLifecycleResponse(
5515
5619
  endpointGeneration: verified.endpointGeneration,
5516
5620
  endpointMtimeMs: verified.endpointMtimeMs,
5517
5621
  pid: spawnedAuthority.pid,
5622
+ ...(verified.endpointFileId === undefined ? {} : { endpointFileId: verified.endpointFileId }),
5518
5623
  },
5519
5624
  launch.id,
5520
5625
  );
@@ -5545,6 +5650,14 @@ async function executeLifecycleResponse(
5545
5650
  let record = broker.index.listSessions().sessions.find(session => session.sessionId === id);
5546
5651
  if (operation === "session.close") {
5547
5652
  if (!record) return fail("not_found", "session is not indexed");
5653
+ // A forced stop of a session whose endpoint is already stale (its coordinator
5654
+ // service went away mid-run) cannot reach the runtime to prove teardown. The
5655
+ // caller has explicitly requested force, so when the endpoint proves stale
5656
+ // below and the owning process is no longer observably alive, record a
5657
+ // terminal-uncertain claim before surfacing endpoint_stale — otherwise the
5658
+ // row keeps holding its worktree forever, since an OS probe of a reused pid
5659
+ // returns `uncertain` and never `exited` (#5581).
5660
+ const forceReleaseStaleWorktree = input.forceReleaseStaleWorktree === true;
5548
5661
  if (record.terminalUncertain)
5549
5662
  return fail("terminal_uncertain", "Session ownership is uncertain and cannot be closed safely.");
5550
5663
  if (!isSessionAuthorityEligible(record))
@@ -5589,7 +5702,26 @@ async function executeLifecycleResponse(
5589
5702
  }
5590
5703
  }
5591
5704
  if (!endpointResult.ok) {
5592
- if (endpointResult.error.code === "endpoint_stale") return endpointResult;
5705
+ if (endpointResult.error.code === "endpoint_stale") {
5706
+ // `record` matched the requested close authority above, so it is the exact
5707
+ // stale identity the forced stop targeted — never a live successor, which
5708
+ // would have failed that authority check. Release its worktree bound to
5709
+ // that identity so a rotated successor is left untouched (#5581).
5710
+ // Best-effort: the endpoint_stale outcome is already proven and must be
5711
+ // returned. A release failure (index/broker write error) must not mask it;
5712
+ // the stale-endpoint recovery paths retry the release later.
5713
+ if (forceReleaseStaleWorktree) {
5714
+ try {
5715
+ await releaseForcedStaleWorktree(broker, id, record);
5716
+ } catch (error) {
5717
+ logger.warn("sdk broker forced stale-worktree release failed; returning endpoint_stale", {
5718
+ sessionId: id,
5719
+ error: String(error),
5720
+ });
5721
+ }
5722
+ }
5723
+ return endpointResult;
5724
+ }
5593
5725
  if (endpointResult.error.code !== "resource_gone") return endpointResult;
5594
5726
  usedSignalFallback = true;
5595
5727
  } else {
@@ -121,6 +121,90 @@ export function isProcessIncarnation(value: unknown): value is string {
121
121
  );
122
122
  }
123
123
 
124
+ /**
125
+ * Read-only, non-mutating observation of whether a pid currently names a
126
+ * verifiable process incarnation.
127
+ *
128
+ * - `status: "present"` carries the exact kernel-reported identity evidence
129
+ * (the same value `processIncarnation` returns).
130
+ * - `status: "absent"` is positive OS-confirmed death proof (POSIX
131
+ * `kill(pid, 0)` returning `ESRCH`, or Windows `OpenProcess` failing with
132
+ * the exact code the kernel reports for an unresolved client id) — never a
133
+ * permission failure or an ambiguous disappearance.
134
+ * - `status: "unknown"` covers every inconclusive case (invalid pid, missing
135
+ * or unavailable native addon, permission denial, platform limitation).
136
+ * Callers MUST NOT treat this as death proof.
137
+ */
138
+ export type ProcessIncarnationObservation =
139
+ | { status: "present"; incarnation: string }
140
+ | { status: "absent" }
141
+ | { status: "unknown"; reasonCode: string };
142
+
143
+ /** Raw shape returned by the native `Process.observe` binding. */
144
+ export type NativeProcessObserveResult = { status?: unknown; incarnation?: unknown; reasonCode?: unknown };
145
+
146
+ export interface ObserveProcessIncarnationOptions {
147
+ /**
148
+ * Injectable native observe function, used by tests to exercise error
149
+ * classification (malformed shapes, unavailable addon) without a global
150
+ * mock. Defaults to the real `Process.observe` binding.
151
+ */
152
+ observe?: (pid: number) => NativeProcessObserveResult;
153
+ }
154
+
155
+ /**
156
+ * Read-only process-incarnation observation for `pid`, distinguishing a
157
+ * proved-live incarnation from positive OS-confirmed absence from every
158
+ * inconclusive outcome. Never signals, kills, reaps, waits on, or spawns any
159
+ * process — it only reads existing native process-table state via
160
+ * `Process.observe`.
161
+ *
162
+ * `pid` must be a positive safe integer; any other value (including `0`,
163
+ * negative, `NaN`, or non-integer) is reported as `unknown` rather than
164
+ * risking an ambiguous platform-specific interpretation (`0`/negative PIDs
165
+ * address process groups on POSIX, not a single process).
166
+ */
167
+ export function observeProcessIncarnation(
168
+ pid: number,
169
+ options: ObserveProcessIncarnationOptions = {},
170
+ ): ProcessIncarnationObservation {
171
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0x7fff_ffff) {
172
+ return { status: "unknown", reasonCode: "invalid_pid" };
173
+ }
174
+ try {
175
+ const observe =
176
+ options.observe ?? (nativeProcessBindings().Process as { observe?: (pid: number) => unknown }).observe;
177
+ if (typeof observe !== "function") {
178
+ // Older addon build predating Process.observe: liveness cannot be
179
+ // determined through this path, and that is not death proof.
180
+ return { status: "unknown", reasonCode: "observe_unavailable" };
181
+ }
182
+ const result = observe(pid) as NativeProcessObserveResult;
183
+ if (result?.status === "present" && isProcessIncarnation(result.incarnation) && result.reasonCode === undefined) {
184
+ return { status: "present", incarnation: result.incarnation };
185
+ }
186
+ if (result?.status === "absent" && result.incarnation === undefined && result.reasonCode === undefined) {
187
+ return { status: "absent" };
188
+ }
189
+ if (
190
+ result?.status === "unknown" &&
191
+ result.incarnation === undefined &&
192
+ typeof result.reasonCode === "string" &&
193
+ /^[a-z][a-z0-9_]{0,63}$/.test(result.reasonCode)
194
+ ) {
195
+ return { status: "unknown", reasonCode: result.reasonCode };
196
+ }
197
+ // A native result shape that matches none of the above (e.g. "present"
198
+ // without a canonical incarnation) is a contract mismatch, not evidence of
199
+ // anything — report unknown rather than guessing.
200
+ return { status: "unknown", reasonCode: "malformed_observation" };
201
+ } catch {
202
+ // The native addon is missing/unloadable on this host. Not proof of
203
+ // death: the caller has no OS confirmation either way.
204
+ return { status: "unknown", reasonCode: "native_addon_unavailable" };
205
+ }
206
+ }
207
+
124
208
  /** A PID is reusable; bind it to the strongest OS-provided process start incarnation available. */
125
209
  export function processIncarnation(pid: number, options: ProcessIncarnationOptions = {}): string | undefined {
126
210
  if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
@@ -128,8 +212,8 @@ export function processIncarnation(pid: number, options: ProcessIncarnationOptio
128
212
  if (platform === process.platform && options.runCommand === undefined) {
129
213
  try {
130
214
  const nativeProcess = nativeProcessBindings().Process.fromPid(pid) as { incarnation?: unknown } | null;
131
- // null is the native binding's authoritative absent-process result: the
132
- // process is dead or its PID was never opened. Returning undefined here
215
+ // A null native reference cannot provide an incarnation: the process
216
+ // may be absent or inaccessible. Returning undefined here
133
217
  // avoids repeatedly spawning powershell.exe (whose Get-Process uses the same
134
218
  // OpenProcess path and therefore cannot recover a valid incarnation either)
135
219
  // during the broker's ~5 s liveness polling, which on Windows 11 produces a
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
 
4
5
  import { currentExecutablePath } from "@gajae-code/natives";
@@ -123,17 +124,92 @@ function expectedPackageName(packageDirectory: string): void {
123
124
  }
124
125
  }
125
126
 
127
+ type SdkInternalRuntimeEvidence = { kind: "bun-source" | "compiled"; markerPath: string };
128
+
129
+ /**
130
+ * Classify this process's runtime from exact marker and embedded-file evidence.
131
+ * Both the spawn descriptor and the published runtime image derive from this one
132
+ * authority; neither may fall back to argv or PATH.
133
+ */
134
+ function classifySdkInternalRuntime(options: SdkInternalRuntimeDescriptorTestOptions): SdkInternalRuntimeEvidence {
135
+ const markerPath = options.markerPath ?? internalSourceMarker;
136
+ const embeddedFiles = options.embeddedFiles ?? (typeof Bun === "undefined" ? undefined : Bun.embeddedFiles);
137
+ if (!embeddedFiles) throw new Error("SDK internal launch refused: Bun runtime evidence is unavailable.");
138
+ const markerName = path.basename(markerPath.replaceAll("\\", "/"));
139
+ const markerEntries = embeddedFiles.filter(file => embeddedFileName(file) === markerName);
140
+ const compiledMarkerPath = isCompiledMarkerPath(markerPath);
141
+ const exactCompiledArtifact = COMPILED_MARKER_NAME.test(markerName) && markerEntries.length === 1;
142
+ const isSourceMarker = path.isAbsolute(markerPath) && !compiledMarkerPath;
143
+ if (embeddedFiles.length === 0 && isSourceMarker) return { kind: "bun-source", markerPath };
144
+ if (exactCompiledArtifact && compiledMarkerPath) return { kind: "compiled", markerPath };
145
+ throw new Error("SDK internal launch refused: compiled-runtime marker evidence is inconsistent.");
146
+ }
147
+
148
+ /**
149
+ * The image a source-runtime internal spawn executes: this process's own Bun.
150
+ * It is the spawn command file, so it must be executable, not merely readable.
151
+ */
152
+ function sourceRuntimeImage(options: SdkInternalRuntimeDescriptorTestOptions): string {
153
+ if (options.bunAvailable === false || typeof Bun === "undefined")
154
+ throw new Error("SDK internal launch refused: Bun source runtime is unavailable.");
155
+ return regularExecutablePath(path.resolve(options.execPath ?? process.execPath), "runtime executable");
156
+ }
157
+
158
+ /** The runtime image alone, without the source-tree assets a full spawn also proves. */
159
+ function provenRuntimeImage(options: SdkInternalRuntimeDescriptorTestOptions): string {
160
+ const evidence = classifySdkInternalRuntime(options);
161
+ return evidence.kind === "bun-source" ? sourceRuntimeImage(options) : compiledExecutable(options);
162
+ }
163
+
164
+ /**
165
+ * The image the same classification names, without the on-disk proof a spawn
166
+ * requires. A proof-first publication is exactly backwards for the broker that
167
+ * matters here: a runtime deleted between this process's exec and its
168
+ * publication makes `regularExecutablePath` throw, so the one broker that can
169
+ * never spawn would publish no evidence and read as reusable forever.
170
+ *
171
+ * Publishing the classified path instead moves the verdict to the reader, which
172
+ * already retires only on proven absence. It is evidence, never a spawn source.
173
+ * A Bun virtual bundle path is not publishable: nothing on disk backs it, so a
174
+ * reader would probe `ENOENT` and retire a healthy compiled broker.
175
+ */
176
+ function classifiedRuntimeImage(options: SdkInternalRuntimeDescriptorTestOptions): string | undefined {
177
+ const evidence = classifySdkInternalRuntime(options);
178
+ const execPath = options.execPath ?? process.execPath;
179
+ if (evidence.kind === "bun-source") {
180
+ // Without Bun there is no source runtime to name; `execPath` would be some
181
+ // other interpreter, which is worse evidence than none.
182
+ if (options.bunAvailable === false || typeof Bun === "undefined") return undefined;
183
+ return isBunVirtualExecutablePath(execPath) ? undefined : path.resolve(execPath);
184
+ }
185
+ if (!isBunVirtualExecutablePath(execPath)) return path.resolve(execPath);
186
+ const currentExecutable = currentExecutablePath();
187
+ if (!currentExecutable || isBunVirtualExecutablePath(currentExecutable)) return undefined;
188
+ return path.resolve(currentExecutable);
189
+ }
190
+
191
+ /** Publication evidence: the proven image, else the classified path, else nothing. */
192
+ function publishedRuntimeImage(options: SdkInternalRuntimeDescriptorTestOptions): string | undefined {
193
+ try {
194
+ return provenRuntimeImage(options);
195
+ } catch {
196
+ try {
197
+ return classifiedRuntimeImage(options);
198
+ } catch {
199
+ return undefined;
200
+ }
201
+ }
202
+ }
203
+
126
204
  function sourceDescriptor(
127
205
  action: SdkInternalAction,
128
206
  options: SdkInternalRuntimeDescriptorTestOptions,
129
207
  markerPath: string,
130
208
  ): SdkInternalSpawnCommand {
131
- if (options.bunAvailable === false || typeof Bun === "undefined")
132
- throw new Error("SDK internal launch refused: Bun source runtime is unavailable.");
209
+ const runtime = sourceRuntimeImage(options);
133
210
  const brokerDirectory = path.resolve(options.brokerDirectory ?? import.meta.dir);
134
211
  const packageDirectory = path.resolve(brokerDirectory, "../../..");
135
212
  const sourceDirectory = path.resolve(brokerDirectory, "../..");
136
- const runtime = regularReadablePath(path.resolve(options.execPath ?? process.execPath), "runtime executable");
137
213
  const cli = regularReadablePath(
138
214
  path.resolve(options.cliPath ?? path.join(sourceDirectory, "cli.ts")),
139
215
  "CLI entrypoint",
@@ -168,25 +244,68 @@ function resolveSdkInternalSpawnCommandWithEvidence(
168
244
  action: SdkInternalAction,
169
245
  options: SdkInternalRuntimeDescriptorTestOptions,
170
246
  ): SdkInternalSpawnCommand {
171
- const markerPath = options.markerPath ?? internalSourceMarker;
172
- const embeddedFiles = options.embeddedFiles ?? (typeof Bun === "undefined" ? undefined : Bun.embeddedFiles);
173
- if (!embeddedFiles) throw new Error("SDK internal launch refused: Bun runtime evidence is unavailable.");
174
- const markerName = path.basename(markerPath.replaceAll("\\", "/"));
175
- const markerEntries = embeddedFiles.filter(file => embeddedFileName(file) === markerName);
176
- const compiledMarkerPath = isCompiledMarkerPath(markerPath);
177
- const exactCompiledArtifact = COMPILED_MARKER_NAME.test(markerName) && markerEntries.length === 1;
178
- const isSourceMarker = path.isAbsolute(markerPath) && !compiledMarkerPath;
179
- if (embeddedFiles.length === 0 && isSourceMarker) return sourceDescriptor(action, options, markerPath);
180
- if (exactCompiledArtifact && compiledMarkerPath) {
181
- const executable = compiledExecutable(options);
182
- return {
183
- kind: "compiled",
184
- file: executable,
185
- args: ["sdk", action],
186
- env: internalEnvironment(options.environment ?? process.env, false),
187
- };
247
+ const evidence = classifySdkInternalRuntime(options);
248
+ if (evidence.kind === "bun-source") return sourceDescriptor(action, options, evidence.markerPath);
249
+ return {
250
+ kind: "compiled",
251
+ file: compiledExecutable(options),
252
+ args: ["sdk", action],
253
+ env: internalEnvironment(options.environment ?? process.env, false),
254
+ };
255
+ }
256
+
257
+ /**
258
+ * The on-disk image internal SDK spawns from this process execute, or
259
+ * `undefined` only when runtime evidence cannot be classified at all. It comes
260
+ * from the same marker authority a spawn uses, but deliberately not from the
261
+ * source-tree assets a spawn also proves (CLI entrypoint, isolated Bun config,
262
+ * package identity): a failure to read one of those says nothing about the
263
+ * runtime image, and publishing `undefined` for it would blind every reader.
264
+ *
265
+ * An image the classification names but this process can no longer prove is
266
+ * still published. `undefined` means only "no evidence" -- readers keep such a
267
+ * broker reusable, so a broker whose own image vanished before it published
268
+ * must not hide behind it.
269
+ */
270
+ export function sdkInternalRuntimeImage(): string | undefined {
271
+ return publishedRuntimeImage({});
272
+ }
273
+
274
+ /** A stat that has not answered by here is inconclusive, never proof of absence. */
275
+ const RUNTIME_IMAGE_PROBE_TIMEOUT_MS = 1_000;
276
+
277
+ /**
278
+ * Only these prove the image is gone. Every other failure (`EACCES`, `EPERM`,
279
+ * `ELOOP`, `EIO`, a sandbox that hides the path from this caller, anything
280
+ * unrecognized) describes the caller's view, not the image: the broker may still
281
+ * execute a file this process cannot even inspect.
282
+ */
283
+ function isProvenRuntimeImageAbsence(error: unknown): boolean {
284
+ const code = (error as NodeJS.ErrnoException | null)?.code;
285
+ return code === "ENOENT" || code === "ENOTDIR";
286
+ }
287
+
288
+ /**
289
+ * Whether a published runtime image is still a regular file on disk. A resident broker keeps
290
+ * answering requests after the executable it was started from is deleted (a
291
+ * Homebrew Bun upgrade removing the Cellar path, an install directory swapped
292
+ * underneath it), yet every internal spawn it then attempts is refused. Callers
293
+ * use this to retire such an incumbent before a session launch reaches it, so it
294
+ * answers `true` on every inconclusive outcome, including a probe that outlives
295
+ * its bound: a false retirement kills a healthy broker.
296
+ */
297
+ export async function isSdkInternalRuntimeImagePresent(file: string): Promise<boolean> {
298
+ const probe = fsp.stat(path.resolve(file)).then(
299
+ stats => stats.isFile(),
300
+ (error: unknown) => !isProvenRuntimeImageAbsence(error),
301
+ );
302
+ const inconclusive = Promise.withResolvers<boolean>();
303
+ const timer: NodeJS.Timeout = setTimeout(() => inconclusive.resolve(true), RUNTIME_IMAGE_PROBE_TIMEOUT_MS);
304
+ try {
305
+ return await Promise.race([probe, inconclusive.promise]);
306
+ } finally {
307
+ clearTimeout(timer);
188
308
  }
189
- throw new Error("SDK internal launch refused: compiled-runtime marker evidence is inconsistent.");
190
309
  }
191
310
 
192
311
  /** Resolve the production descriptor from the statically imported marker and current Bun runtime evidence. */
@@ -201,3 +320,8 @@ export function resolveSdkInternalSpawnCommandForTest(
201
320
  ): SdkInternalSpawnCommand {
202
321
  return resolveSdkInternalSpawnCommandWithEvidence(action, options);
203
322
  }
323
+
324
+ /** Test hook: the publication decision under injected evidence, same authority as production. */
325
+ export function sdkInternalRuntimeImageForTest(options: SdkInternalRuntimeDescriptorTestOptions): string | undefined {
326
+ return publishedRuntimeImage(options);
327
+ }