@visual-z/xeno 0.2.1 → 0.3.0

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 (258) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +106 -26
  3. package/docs/install.md +51 -13
  4. package/docs/providers.md +11 -0
  5. package/package.json +5 -4
  6. package/src/apps/approve.ts +3 -3
  7. package/src/apps/commands.ts +38 -5
  8. package/src/apps/completions.ts +73 -19
  9. package/src/apps/image-draft.ts +46 -0
  10. package/src/apps/project-trust.ts +2 -42
  11. package/src/apps/provider-connect.ts +346 -0
  12. package/src/apps/render.ts +22 -47
  13. package/src/apps/resources.ts +5 -2
  14. package/src/apps/shell.ts +298 -1526
  15. package/src/apps/startup-input.ts +45 -9
  16. package/src/apps/startup.ts +128 -86
  17. package/src/apps/ui/app.ts +1383 -0
  18. package/src/apps/ui/extension-host.ts +194 -0
  19. package/src/apps/ui/image-cache.ts +78 -0
  20. package/src/apps/ui/palette.ts +88 -0
  21. package/src/apps/ui/project.ts +127 -0
  22. package/src/apps/ui/standalone.ts +146 -0
  23. package/src/apps/ui/status-model.ts +68 -0
  24. package/src/apps/ui/surface.ts +84 -0
  25. package/src/apps/ui/theme.ts +86 -0
  26. package/src/core/acceptance.ts +107 -0
  27. package/src/core/action-defaults.ts +47 -295
  28. package/src/core/benchmark.ts +2 -2
  29. package/src/core/branch-receipt.ts +24 -0
  30. package/src/core/checkpoints.ts +64 -0
  31. package/src/core/churn.ts +2 -0
  32. package/src/core/command-artifacts.ts +94 -0
  33. package/src/core/command-contract.ts +35 -0
  34. package/src/core/completion-review.ts +21 -5
  35. package/src/core/context-detail.ts +37 -0
  36. package/src/core/context-policy.ts +74 -1
  37. package/src/core/continuation.ts +17 -2
  38. package/src/core/derive.ts +29 -3
  39. package/src/core/draft-images.ts +56 -0
  40. package/src/core/environment.ts +145 -0
  41. package/src/core/evaluation-coverage.ts +74 -0
  42. package/src/core/event-validation.ts +80 -5
  43. package/src/core/events.ts +97 -3
  44. package/src/core/evidence-links.ts +7 -0
  45. package/src/core/evidence.ts +13 -3
  46. package/src/core/execution.ts +8 -2
  47. package/src/core/experiment.ts +133 -0
  48. package/src/core/export.ts +9 -0
  49. package/src/core/failure-recovery.ts +173 -0
  50. package/src/core/file-precondition.ts +72 -0
  51. package/src/core/handoff.ts +65 -4
  52. package/src/core/history-query.ts +38 -2
  53. package/src/core/history.ts +38 -1
  54. package/src/core/integration-health.ts +34 -0
  55. package/src/core/keymap.ts +8 -20
  56. package/src/core/model-picker.ts +64 -0
  57. package/src/core/native-eval.ts +25 -0
  58. package/src/core/note-guidance.ts +34 -0
  59. package/src/core/observation-view.ts +49 -8
  60. package/src/core/permissions.ts +24 -2
  61. package/src/core/process-receipt.ts +105 -0
  62. package/src/core/process-state.ts +37 -0
  63. package/src/core/project.ts +45 -18
  64. package/src/core/prompt-policy.ts +37 -0
  65. package/src/core/query-eval-contract.ts +14 -0
  66. package/src/core/read-observation.ts +82 -0
  67. package/src/core/recovery-corpus.ts +17 -0
  68. package/src/core/recovery-evaluation.ts +60 -0
  69. package/src/core/recovery-sources.ts +74 -0
  70. package/src/core/runtime-target.ts +94 -0
  71. package/src/core/search-observation.ts +97 -0
  72. package/src/core/session-tree.ts +147 -0
  73. package/src/core/settings.ts +26 -10
  74. package/src/core/skills.ts +44 -0
  75. package/src/core/sql-query.ts +211 -0
  76. package/src/core/theme.ts +36 -58
  77. package/src/core/tool-contract.ts +51 -2
  78. package/src/core/tool-discovery.ts +87 -0
  79. package/src/core/tool-failure.ts +60 -0
  80. package/src/core/tool-mode-protocol.ts +25 -0
  81. package/src/core/transcript.ts +19 -4
  82. package/src/core/trial-lifecycle.ts +14 -0
  83. package/src/core/tui-mode.ts +29 -0
  84. package/src/core/update-check.ts +97 -0
  85. package/src/core/version.ts +55 -0
  86. package/src/core/work-state.ts +49 -3
  87. package/src/core/working-set.ts +91 -0
  88. package/src/runtime/auth.ts +2 -2
  89. package/src/runtime/branch-jobs.ts +36 -0
  90. package/src/runtime/clipboard.ts +15 -4
  91. package/src/runtime/command-artifacts.ts +64 -0
  92. package/src/runtime/environment-tools.ts +60 -0
  93. package/src/runtime/evidence-notes.ts +4 -2
  94. package/src/runtime/exec-sessions.ts +206 -61
  95. package/src/runtime/execution-control.ts +1 -0
  96. package/src/runtime/extension-ui.ts +22 -30
  97. package/src/runtime/extensions.ts +95 -16
  98. package/src/runtime/harness-comparison.ts +27 -7
  99. package/src/runtime/harness-eval.ts +460 -0
  100. package/src/runtime/history-cache.ts +57 -0
  101. package/src/runtime/history-tools.ts +62 -12
  102. package/src/runtime/integrations.ts +210 -0
  103. package/src/runtime/loop.ts +1021 -268
  104. package/src/runtime/model-discovery.ts +208 -0
  105. package/src/runtime/native-eval-fixtures.ts +33 -0
  106. package/src/runtime/native-eval.ts +143 -0
  107. package/src/runtime/oauth.ts +1 -1
  108. package/src/runtime/provider-connect.ts +109 -0
  109. package/src/runtime/provider-presets.ts +87 -0
  110. package/src/runtime/query-eval-verifier.ts +28 -0
  111. package/src/runtime/recovery-coverage.ts +71 -0
  112. package/src/runtime/recovery-evidence.ts +76 -0
  113. package/src/runtime/recovery-fixtures.ts +116 -0
  114. package/src/runtime/runtime-target.ts +149 -0
  115. package/src/runtime/tool-broker.ts +137 -0
  116. package/src/runtime/tool-discovery.ts +57 -0
  117. package/src/runtime/tool-mode-eval.ts +146 -0
  118. package/src/runtime/tool-queue.ts +77 -0
  119. package/src/runtime/tools.ts +628 -183
  120. package/src/runtime/trial-control.ts +45 -0
  121. package/src/runtime/update-check.ts +47 -0
  122. package/src/runtime/window-trial.ts +2 -1
  123. package/src/runtime/working-notes.ts +33 -3
  124. package/src/runtime/workspace-lifecycle.ts +97 -0
  125. package/src/store/acceptance.ts +15 -0
  126. package/src/store/checkpoint-history.ts +43 -0
  127. package/src/store/command-artifacts.ts +96 -0
  128. package/src/store/command.ts +65 -37
  129. package/src/store/file-checkpoints.ts +104 -0
  130. package/src/store/file-index.ts +13 -1
  131. package/src/store/git-checkpoints.ts +67 -0
  132. package/src/store/observations.ts +61 -20
  133. package/src/store/process-receipts.ts +34 -0
  134. package/src/store/query-source.ts +13 -8
  135. package/src/store/recovery.ts +2 -3
  136. package/src/store/replay.ts +16 -4
  137. package/src/store/runtime-target.ts +64 -0
  138. package/src/store/search.ts +171 -132
  139. package/src/store/session-archive.ts +4 -0
  140. package/src/store/source-cancellation.ts +6 -0
  141. package/src/store/update-cache.ts +36 -0
  142. package/src/store/workspace-observation.ts +113 -0
  143. package/src/tui/ansi-spans.ts +126 -0
  144. package/src/tui/style.ts +19 -16
  145. package/src/tui/ui/atomic-draft.ts +121 -0
  146. package/src/tui/ui/canvas.ts +96 -0
  147. package/src/tui/ui/colors.ts +70 -0
  148. package/src/tui/ui/composer.ts +199 -0
  149. package/src/tui/ui/dialog.ts +379 -0
  150. package/src/tui/ui/draft.ts +23 -0
  151. package/src/tui/ui/edge.ts +186 -0
  152. package/src/tui/ui/image-size.ts +40 -0
  153. package/src/tui/ui/ink.ts +210 -0
  154. package/src/tui/ui/items.ts +40 -0
  155. package/src/tui/ui/keys.ts +40 -0
  156. package/src/tui/ui/layout.ts +74 -0
  157. package/src/tui/ui/list-model.ts +86 -0
  158. package/src/tui/ui/list-view.ts +172 -0
  159. package/src/tui/ui/mascot.ts +67 -0
  160. package/src/tui/ui/messages.ts +518 -0
  161. package/src/tui/ui/motion.ts +28 -0
  162. package/src/tui/ui/paths.ts +10 -0
  163. package/src/tui/ui/pictures.ts +78 -0
  164. package/src/tui/ui/screen.ts +421 -0
  165. package/src/tui/ui/status.ts +271 -0
  166. package/src/tui/ui/styled.ts +85 -0
  167. package/src/tui/ui/testing.ts +5 -0
  168. package/src/tui/ui/tool-card.ts +150 -0
  169. package/src/apps/extension-ui.ts +0 -330
  170. package/src/apps/resource-browser.ts +0 -75
  171. package/src/apps/session-browser.ts +0 -113
  172. package/src/apps/session-picker.ts +0 -55
  173. package/src/tui/ansi.ts +0 -67
  174. package/src/tui/autocomplete.ts +0 -29
  175. package/src/tui/chat-viewport.ts +0 -47
  176. package/src/tui/complete.ts +0 -87
  177. package/src/tui/editor.ts +0 -940
  178. package/src/tui/host.ts +0 -264
  179. package/src/tui/input.ts +0 -75
  180. package/src/tui/keys.ts +0 -378
  181. package/src/tui/markdown.ts +0 -17
  182. package/src/tui/render.ts +0 -153
  183. package/src/tui/select.ts +0 -258
  184. package/src/tui/settings.ts +0 -44
  185. package/src/tui/statusline.ts +0 -162
  186. package/src/tui/system-theme.ts +0 -700
  187. package/src/tui/terminal.ts +0 -30
  188. package/src/tui/text.ts +0 -52
  189. package/src/tui/toolview.ts +0 -386
  190. package/src/tui/transcript.ts +0 -123
  191. package/src/tui/tree.ts +0 -471
  192. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  193. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  194. package/src/vendor/pi-tui/VENDOR.md +0 -25
  195. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  196. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  197. package/src/vendor/pi-tui/colors.ts +0 -367
  198. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  199. package/src/vendor/pi-tui/components/box.ts +0 -166
  200. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  201. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  202. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  203. package/src/vendor/pi-tui/components/image.ts +0 -127
  204. package/src/vendor/pi-tui/components/input.ts +0 -494
  205. package/src/vendor/pi-tui/components/loader.ts +0 -101
  206. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  207. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  208. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  209. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  210. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  211. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  212. package/src/vendor/pi-tui/components/stack.ts +0 -154
  213. package/src/vendor/pi-tui/components/text.ts +0 -107
  214. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  215. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  216. package/src/vendor/pi-tui/editor-component.ts +0 -74
  217. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  218. package/src/vendor/pi-tui/index.ts +0 -186
  219. package/src/vendor/pi-tui/keybindings.ts +0 -320
  220. package/src/vendor/pi-tui/keys.ts +0 -1401
  221. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  222. package/src/vendor/pi-tui/latex.ts +0 -1506
  223. package/src/vendor/pi-tui/layout-node.ts +0 -51
  224. package/src/vendor/pi-tui/layout.ts +0 -449
  225. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  226. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  227. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  228. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  229. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  230. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  231. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  232. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  233. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  234. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  235. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  236. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  237. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  238. package/src/vendor/pi-tui/native/napi.h +0 -91
  239. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  240. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  241. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  242. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  243. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  244. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  245. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  246. package/src/vendor/pi-tui/native-platform.ts +0 -65
  247. package/src/vendor/pi-tui/oklab.ts +0 -233
  248. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  249. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  250. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  251. package/src/vendor/pi-tui/terminal.ts +0 -554
  252. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  253. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  254. package/src/vendor/pi-tui/tui.ts +0 -1493
  255. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  256. package/src/vendor/pi-tui/utils.ts +0 -1385
  257. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  258. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -0,0 +1,45 @@
1
+ /** Q.14: an explicit signal owner records the first interruption independent of exception paths. */
2
+ import type { TrialInterruption, TrialLifecycle } from "../core/trial-lifecycle.ts";
3
+
4
+ /** A bad deadline must not strand the gateway acquired immediately before its clock starts. */
5
+ export async function openTrialControl(timeoutMs:number,caller:AbortSignal|undefined,disposeOnFailure:()=>Promise<void>):Promise<TrialControl> {
6
+ try {return new TrialControl(timeoutMs,caller);}
7
+ catch(error){await disposeOnFailure();throw error;}
8
+ }
9
+
10
+ export class TrialControl {
11
+ private readonly controller=new AbortController();
12
+ readonly signal=this.controller.signal;
13
+ private readonly deadline:AbortSignal;
14
+ private readonly caller:AbortSignal|undefined;
15
+ private phase:TrialInterruption["phase"]="initialization";
16
+ private requests=0;
17
+ private interrupted:TrialInterruption|null=null;
18
+ private active=true;
19
+ private readonly onDeadline=()=>this.stop("deadline");
20
+ private readonly onCaller=()=>this.stop("caller");
21
+ constructor(timeoutMs:number,caller?:AbortSignal) {
22
+ this.deadline=AbortSignal.timeout(timeoutMs);this.caller=caller;
23
+ this.deadline.addEventListener("abort",this.onDeadline,{once:true});
24
+ caller?.addEventListener("abort",this.onCaller,{once:true});
25
+ // An already-cancelled caller precedes the newly-created local deadline.
26
+ if(caller?.aborted)this.onCaller();else if(this.deadline.aborted)this.onDeadline();
27
+ }
28
+ private stop(source:TrialInterruption["source"]):void {
29
+ if(!this.active||this.interrupted)return;
30
+ this.interrupted={source,phase:this.phase,backendRequests:this.requests};
31
+ this.controller.abort();
32
+ }
33
+ enterExecution():void {this.phase="execution";}
34
+ beginBackend():void {
35
+ if(!this.active||this.signal.aborted)throw Error("trial no longer accepts backend requests");
36
+ this.requests++;
37
+ }
38
+ finishWork():void {
39
+ this.active=false;
40
+ this.deadline.removeEventListener("abort",this.onDeadline);this.caller?.removeEventListener("abort",this.onCaller);
41
+ }
42
+ observe(cleanup:TrialLifecycle["cleanup"]):TrialLifecycle {
43
+ return {version:1,backendRequests:this.requests,interruption:this.interrupted?{...this.interrupted}:null,cleanup};
44
+ }
45
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Ask the npm registry for the newest published xeno (docs/tasks/feat-update-check.md).
3
+ *
4
+ * One small GET (the dist-tags document is about 20 bytes) with a 3-second
5
+ * timeout, falling back to the package's `latest` document. It carries
6
+ * Bun's default User-Agent and nothing that identifies the person: the
7
+ * registry sees an IP address, the time and the package name. Failure of
8
+ * any kind is "no answer"; it never reaches the screen as an error.
9
+ */
10
+ import { distTagsUrl } from "../core/update-check.ts";
11
+
12
+ /** How long to wait for the registry. Codex and pi wait longer; startup never waits on it anyway. Assumed. */
13
+ export const UPDATE_TIMEOUT_MS = 3000;
14
+
15
+ type Fetch = (input: string, init?: RequestInit) => Promise<Response>;
16
+
17
+ export interface LatestVersionOptions {
18
+ /**
19
+ * Ends the request early. The interactive app aborts it on close: an open
20
+ * socket keeps the process alive, so quitting would otherwise wait out the
21
+ * timeout (feat-update-check DoD 3).
22
+ */
23
+ readonly signal?: AbortSignal;
24
+ readonly fetcher?: Fetch;
25
+ readonly timeoutMs?: number;
26
+ }
27
+
28
+ export async function fetchLatestVersion(registry: string | undefined, options: LatestVersionOptions = {}): Promise<string | null> {
29
+ const fetcher = options.fetcher ?? ((input, init) => fetch(input, init));
30
+ const timeout = AbortSignal.timeout(options.timeoutMs ?? UPDATE_TIMEOUT_MS);
31
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
32
+ const tags = distTagsUrl(registry);
33
+ const attempts: [string, (body: Record<string, unknown>) => unknown][] = [
34
+ [tags, body => body["latest"]],
35
+ // Mirrors that do not serve dist-tags still serve the package's latest document.
36
+ [tags.replace(/\/-\/package\/([^/]+)\/dist-tags$/, "/$1/latest"), body => body["version"]],
37
+ ];
38
+ for (const [url, pick] of attempts) {
39
+ try {
40
+ const response = await fetcher(url, { signal, headers: { accept: "application/json" } });
41
+ if (!response.ok) continue;
42
+ const value = pick(await response.json() as Record<string, unknown>);
43
+ if (typeof value === "string" && value.length <= 64) return value;
44
+ } catch { if (signal.aborted) return null; }
45
+ }
46
+ return null;
47
+ }
@@ -111,7 +111,8 @@ export async function runWindowTrial(options: {
111
111
  row.lastEvent = events.at(-1)?.id ?? null;
112
112
  row.fullPageRead = events.some(event => event.t === "tool_result" && event.ok && event.effect?.kind === "read" &&
113
113
  event.effect.path === page.path && event.effect.sha256 === sha(page.body) && event.effect.ranges?.some(range => range[0] === 1 && range[1] === page.body.split("\n").length) &&
114
- event.content_ref === undefined && event.content.includes(page.body.split("\n").at(-1)!));
114
+ // Archival is independent of truncation. Verify what the model actually received, including the middle.
115
+ event.content.includes(page.body.split("\n").map((line, index) => `${index + 1}\t${line}`).join("\n")));
115
116
  for (const prior of options.fixture.pages.slice(0, stage + 1)) {
116
117
  const content = await readFile(join(workspace, prior.path), "utf8").catch(() => null);
117
118
  row.inputs[prior.path] = content === null ? null : sha(content);
@@ -2,14 +2,28 @@
2
2
  import type { LogEvent } from "../core/events.ts";
3
3
  import { deriveHandoff } from "../core/derive.ts";
4
4
  import { scopedWorkId, workChangeError } from "../core/work-state.ts";
5
+ import { resolveEvidence, type EvidenceResolution } from "../core/evidence.ts";
6
+ import { unknownWorkClaimDiagnostic, unverifiedNoteDiagnostic } from "../core/note-guidance.ts";
7
+ import { failureDiagnostic, NOT_STARTED } from "../core/tool-failure.ts";
8
+ import type { ToolDiagnostic } from "../core/tool-contract.ts";
5
9
  import { replayContext } from "../store/replay.ts";
6
10
  import { digestNode, nodePath } from "../store/log.ts";
7
11
  import { resolveEvidenceNote } from "./evidence-notes.ts";
8
12
  import type { ToolResult } from "./tools.ts";
9
13
 
14
+ function rejected(message: string, diagnostic = failureDiagnostic("invalid_input", message, NOT_STARTED)): { out: ToolResult } {
15
+ return { out: { ok: false, content: message, data: { recorded: false }, diagnostic } };
16
+ }
17
+ function claimResult(annotation: Extract<LogEvent, { t: "claim" }>, proof: EvidenceResolution, openedAt?: string): ToolResult {
18
+ const diagnostic: ToolDiagnostic | undefined = proof.ok ? undefined : unverifiedNoteDiagnostic(proof, annotation.id, openedAt);
19
+ return { ok: proof.ok, content: JSON.stringify({ recorded: true, claim_event: annotation.id,
20
+ verification: proof.ok ? "verified" : "unverified", ...(proof.ok ? { evidence: proof.evidence } : { reason: proof.reason }) }),
21
+ ...(diagnostic ? { diagnostic } : {}) };
22
+ }
23
+
10
24
  export async function prepareWorkingNote(logDir: string, node: number, namespace: string, annotation: LogEvent | undefined, out: ToolResult): Promise<{ out: ToolResult; annotation?: LogEvent }> {
11
25
  if (!annotation) return { out };
12
- if (annotation.t !== "evidence_link" && !("work_id" in annotation && annotation.work_id !== undefined)) return { out, annotation };
26
+ if (annotation.t !== "claim" && annotation.t !== "evidence_link" && !("work_id" in annotation && annotation.work_id !== undefined)) return { out, annotation };
13
27
  const replayed = await replayContext(logDir, node);
14
28
  const input = { prev: replayed.fullHandoff, events: replayed.events, depth: replayed.depth,
15
29
  self: { node, log_ref: nodePath(logDir, node), sha256: await digestNode(logDir, node) } };
@@ -18,13 +32,29 @@ export async function prepareWorkingNote(logDir: string, node: number, namespace
18
32
  const resolved = await resolveEvidenceNote(logDir, node, namespace, current, annotation);
19
33
  return { out: resolved.result, ...(resolved.event ? { annotation: resolved.event } : {}) };
20
34
  }
35
+ if (annotation.t === "claim" && annotation.work_id === undefined) {
36
+ return { annotation, out: claimResult(annotation, resolveEvidence(input.events, annotation.evidence)) };
37
+ }
21
38
  if (!("work_id" in annotation) || annotation.work_id === undefined) throw new Error("missing identified work annotation");
22
39
  const workId = scopedWorkId(namespace, annotation.work_id, current.state.work_items ?? []);
23
40
  annotation = { ...annotation, work_id: workId };
41
+ const owned = current.state.work_items?.find(item => item.id === workId);
42
+ if (owned?.owner && owned.owner !== namespace) return rejected(`work ${workId} is owned by ${owned.owner}`);
43
+ if ((annotation.t === "next_action" || annotation.t === "work_dependencies") && annotation.depends_on !== undefined) annotation = {
44
+ ...annotation, depends_on: annotation.depends_on.map(id => scopedWorkId(namespace, id, current.state.work_items ?? [])),
45
+ };
46
+ if ((annotation.t === "decision" || annotation.t === "pitfall") && !current.state.work_items?.some(item => item.id === workId)) {
47
+ return rejected(`unknown work_id ${workId}; record a next note first`);
48
+ }
49
+ if (!owned && annotation.t === "claim") {
50
+ const diagnostic = unknownWorkClaimDiagnostic(input.events, annotation);
51
+ return rejected(diagnostic.message, diagnostic);
52
+ }
24
53
  const error = workChangeError(current.state.work_items ?? [], annotation);
25
- if (error) return { out: { ok: false, content: error } };
54
+ if (error) return rejected(error);
26
55
  const proposed = deriveHandoff({ ...input, events: [...input.events, annotation] });
27
56
  const item = proposed.state.work_items?.find(item => item.id === workId);
28
- return { annotation, out: { ok: annotation.t !== "claim" || item?.verification === "verified",
57
+ const claim = annotation.t === "claim" ? claimResult(annotation, resolveEvidence(input.events, annotation.evidence, owned?.opened_at), owned?.opened_at) : undefined;
58
+ return { annotation, out: { ...claim, ok: annotation.t !== "claim" || item?.verification === "verified",
29
59
  content: JSON.stringify({ recorded: true, work_item: item }) } };
30
60
  }
@@ -0,0 +1,97 @@
1
+ /** P.3: execution owns checkpoints; session opening owns no filesystem baseline. */
2
+ import { randomUUID } from "node:crypto";
3
+ import type { CheckpointEvent, FileVersion, WorkspaceObservationEvent } from "../core/checkpoints.ts";
4
+ import { sameVersion } from "../core/checkpoints.ts";
5
+ import { blobAddress } from "../store/blobs.ts";
6
+ import { archiveEdit, replaceFile, readEditableFile } from "../store/file-checkpoints.ts";
7
+ import { GitCheckpoints, type GitCheckpoint } from "../store/git-checkpoints.ts";
8
+ import { observeWorkspace } from "../store/workspace-observation.ts";
9
+ import type { ToolResult } from "./tools.ts";
10
+ import type { JsonValue } from "../core/tool-contract.ts";
11
+
12
+ type Record = Omit<CheckpointEvent, "id" | "at"> | Omit<WorkspaceObservationEvent, "id" | "at">;
13
+ interface CommandCheckpoint { operation: string; snapshot?: GitCheckpoint; unavailable?: string; }
14
+ export class WorkspaceLifecycle {
15
+ readonly root: string;
16
+ readonly blobs: string;
17
+ readonly shellCheckpoints: boolean;
18
+ private readonly record: (event: Record) => Promise<void>;
19
+ private readonly ownedPaths: readonly string[];
20
+ private readonly git: GitCheckpoints;
21
+ private readonly pending = new Map<number, CommandCheckpoint>();
22
+ private fingerprint: string | undefined;
23
+ constructor(root: string, blobs: string, shellCheckpoints: boolean, record: (event: Record) => Promise<void>, checkpointStorage = blobs, ownedPaths: readonly string[] = []) {
24
+ this.root = root; this.blobs = blobs; this.shellCheckpoints = shellCheckpoints; this.record = record;
25
+ this.ownedPaths = ownedPaths;
26
+ this.git = new GitCheckpoints(root, checkpointStorage, ownedPaths);
27
+ }
28
+ private interfere(): void {
29
+ for (const checkpoint of this.pending.values()) checkpoint.unavailable = "overlapping mutations prevent attributing a command checkpoint";
30
+ }
31
+ async edit(path: string, before: { bytes: Uint8Array | null; version: FileVersion }, after: Uint8Array, signal?: AbortSignal): Promise<void> {
32
+ this.interfere();
33
+ await archiveEdit(this.blobs, before.bytes, after);
34
+ const operation = randomUUID();
35
+ const next = { sha256: blobAddress(after), mode: before.version?.mode ?? (0o666 & ~process.umask()) };
36
+ const changes = { [path]: { before: before.version, after: next } };
37
+ await this.record({ t: "checkpoint", operation, source: "edit", phase: "prepared", changes });
38
+ try {
39
+ await replaceFile(this.root, path, before.version, after, next.mode, signal);
40
+ const actual = (await readEditableFile(this.root, path)).version;
41
+ if (!sameVersion(actual, next)) throw new Error(`file changed concurrently after editing: ${path}`);
42
+ await this.record({ t: "checkpoint", operation, source: "edit", phase: "applied", changes });
43
+ } catch (error) {
44
+ await this.record({ t: "checkpoint", operation, source: "edit", phase: "unavailable", changes,
45
+ reason: "edit did not establish the expected file version; inspect before retrying" });
46
+ throw error;
47
+ }
48
+ }
49
+ async command(name: string, input: unknown, execute: () => Promise<ToolResult>, signal?: AbortSignal): Promise<ToolResult> {
50
+ const args = input as { session_id?: number };
51
+ let checkpoint: CommandCheckpoint | undefined;
52
+ if (name === "write_stdin") checkpoint = args.session_id === undefined ? undefined : this.pending.get(args.session_id);
53
+ else {
54
+ this.interfere();
55
+ if (this.shellCheckpoints) {
56
+ checkpoint = { operation: randomUUID() };
57
+ try {
58
+ if (this.pending.size) throw new Error("another command is still running");
59
+ checkpoint.snapshot = await this.git.capture(signal);
60
+ await this.record({ t: "checkpoint", operation: checkpoint.operation, source: "git", phase: "prepared", changes: {}, tree: checkpoint.snapshot.tree });
61
+ } catch (error) { checkpoint.unavailable = error instanceof Error ? error.message : "Git checkpoint unavailable"; }
62
+ }
63
+ }
64
+ const result = await execute();
65
+ if (!checkpoint) return result;
66
+ const data = (result.data && typeof result.data === "object" && !Array.isArray(result.data) ? result.data : {}) as { readonly [key: string]: JsonValue };
67
+ const session = data["session_id"];
68
+ if (data["running"] === true && typeof session === "number") {
69
+ this.pending.set(session, checkpoint);
70
+ return { ...result, data: { ...data, checkpoint: "pending" } };
71
+ }
72
+ if (typeof args.session_id === "number") this.pending.delete(args.session_id);
73
+ try {
74
+ if (checkpoint.unavailable || !checkpoint.snapshot) throw new Error(checkpoint.unavailable ?? "missing command baseline");
75
+ const snapshot = await this.git.capture(signal);
76
+ const changes = await this.git.changes(checkpoint.snapshot, snapshot, this.blobs, signal);
77
+ await this.record({ t: "checkpoint", operation: checkpoint.operation, source: "git", phase: "applied", changes, tree: snapshot.tree });
78
+ return { ...result, data: { ...data, checkpoint: "captured" } };
79
+ } catch (error) {
80
+ const reason = error instanceof Error ? error.message : "Git checkpoint unavailable";
81
+ await this.record({ t: "checkpoint", operation: checkpoint.operation, source: "git", phase: "unavailable", changes: {}, reason });
82
+ return { ...result, content: `${result.content}\nFile recovery unavailable: ${reason}. The command result is unchanged.`, data: { ...data, checkpoint: "unavailable" } };
83
+ }
84
+ }
85
+ async abandonCommands(): Promise<void> {
86
+ for (const checkpoint of this.pending.values()) await this.record({ t: "checkpoint", operation: checkpoint.operation,
87
+ source: "git", phase: "unavailable", changes: {}, reason: "process ended without an observed checkpoint boundary" });
88
+ this.pending.clear();
89
+ }
90
+ async observe(previous?: string, signal?: AbortSignal): Promise<void> {
91
+ const observation = await observeWorkspace(this.root, signal, this.ownedPaths);
92
+ const fingerprint = observation.ok ? observation.fingerprint : undefined;
93
+ await this.record({ t: "workspace_observation", scope: "source-v1", changed: !observation.ok || (previous ?? this.fingerprint) !== undefined && fingerprint !== (previous ?? this.fingerprint),
94
+ ...(observation.ok ? { fingerprint: observation.fingerprint } : { reason: observation.reason }) });
95
+ this.fingerprint = fingerprint;
96
+ }
97
+ }
@@ -0,0 +1,15 @@
1
+ /** Explicit files or trusted project declarations; never infer natural-language criteria. */
2
+ import { join, resolve } from "node:path";
3
+ import { parseAcceptanceContract, type AcceptanceContract } from "../core/acceptance.ts";
4
+ import { blobAddress } from "./blobs.ts";
5
+
6
+ export async function loadAcceptanceContract(workspace: string, trusted: boolean, explicit?: string): Promise<AcceptanceContract | undefined> {
7
+ if (!explicit && !trusted) return undefined;
8
+ const path = explicit ? resolve(workspace, explicit) : join(workspace, ".xeno", "acceptance.json");
9
+ const file = Bun.file(path);
10
+ if (!await file.exists()) { if (explicit) throw new Error(`missing acceptance file: ${path}`); return undefined; }
11
+ if (file.size > 64000) throw new Error("acceptance file exceeds 64 KiB");
12
+ const text = await file.text();
13
+ const contract = parseAcceptanceContract(JSON.parse(text));
14
+ return { ...contract, scope: contract.scope ?? (explicit ? "request" : "changes"), source: { path, sha256: blobAddress(text) } };
15
+ }
@@ -0,0 +1,43 @@
1
+ /** File ancestry is independent of model-context ancestry (P.3). */
2
+ import { readNodeHistory, type RestoreTarget } from "./restore.ts";
3
+ import { planFileTransition, type CheckpointEvent, type FileVersion, type FileChanges } from "../core/checkpoints.ts";
4
+
5
+ export async function checkpointHistory(dir: string, target: RestoreTarget, visited = new Set<string>()): Promise<CheckpointEvent[]> {
6
+ const position = typeof target === "number" ? { node: target } : target;
7
+ const key = `${position.node}:${position.atEvent ?? "end"}`;
8
+ if (visited.has(key) || visited.size >= 256) throw new Error("cyclic or excessively deep file history");
9
+ const next = new Set(visited).add(key);
10
+ const history = await readNodeHistory(dir, position.node);
11
+ if (!history.ok) throw new Error(history.error);
12
+ let checkpoints: CheckpointEvent[] = [];
13
+ const legacy = new Map<string, FileVersion>();
14
+ for (const node of history.nodes) {
15
+ let events = node.events;
16
+ if (node.node === position.node && position.atEvent !== undefined) {
17
+ const at = events.findIndex(event => event.id === position.atEvent);
18
+ if (at < 0) throw new Error(`NO_SUCH_EVENT: ${position.atEvent}`);
19
+ events = events.slice(0, at + 1);
20
+ }
21
+ for (const event of events) {
22
+ if (event.t === "rewind" && event.files_from) {
23
+ checkpoints = await checkpointHistory(dir, { node: event.files_from.node,
24
+ ...(event.files_from.at_event ? { atEvent: event.files_from.at_event } : {}) }, next);
25
+ } else if (event.t === "checkpoint") checkpoints.push(event);
26
+ else if (event.t === "snapshot") {
27
+ // Interpret historical full snapshots without rewriting them or scanning today's disk.
28
+ const changes: FileChanges = Object.create(null) as FileChanges;
29
+ for (const [path, sha256] of Object.entries({ ...event.added, ...event.changed })) {
30
+ changes[path] = { before: legacy.get(path) ?? null, after: { sha256, mode: 0o644 } };
31
+ legacy.set(path, changes[path]!.after);
32
+ }
33
+ for (const path of event.removed) { changes[path] = { before: legacy.get(path) ?? null, after: null }; legacy.delete(path); }
34
+ checkpoints.push({ t: "checkpoint", id: event.id, at: event.at, operation: event.id, source: "git", phase: "applied", changes });
35
+ }
36
+ }
37
+ }
38
+ return checkpoints;
39
+ }
40
+
41
+ export async function planCheckpointRestore(dir: string, current: RestoreTarget, target: RestoreTarget): Promise<FileChanges> {
42
+ return planFileTransition(await checkpointHistory(dir, current), await checkpointHistory(dir, target));
43
+ }
@@ -0,0 +1,96 @@
1
+ /** R8: bounded observations over file descriptors; binaries themselves are not copied into the log. */
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, realpath } from "node:fs/promises";
4
+ import { resolve, join, relative, sep } from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ import { MAX_COMMAND_ARTIFACT_BYTES, COMMAND_ARTIFACT_SCOPE, sameArtifactIdentity, validArtifactPath,
7
+ type ArtifactFile, type ArtifactFileIdentity, type CommandArtifactPlan } from "../core/command-artifacts.ts";
8
+ import { archiveObservation } from "./observations.ts";
9
+ import { observeWorkspace, SOURCE_IGNORE } from "./workspace-observation.ts";
10
+ import { blobAddress } from "./blobs.ts";
11
+
12
+ // Assumed per-phase bounds, not performance measurements; exhaustion yields unknown.
13
+ const OBSERVATION_MS = 10_000;
14
+ const CHUNK_BYTES = 1024 * 1024;
15
+ type IdentityStat = { dev: bigint; ino: bigint; mtimeNs: bigint; ctimeNs: bigint; mode: bigint };
16
+ const identity = (info: IdentityStat): ArtifactFileIdentity => ({ device: String(info.dev), inode: String(info.ino),
17
+ mtime_ns: String(info.mtimeNs), ctime_ns: String(info.ctimeNs), mode: Number(info.mode) & 0o777 });
18
+
19
+ /** Both walks reject symlink components. This is a local observation, not an OS sandbox against hostile concurrent renames. */
20
+ async function ordinaryPath(root: string, path: string): Promise<string> {
21
+ let current = root;
22
+ const parts = path.split("/");
23
+ for (let i = 0; i < parts.length; i++) {
24
+ current = join(current, parts[i]!);
25
+ const info = await lstat(current);
26
+ if (info.isSymbolicLink() || (i === parts.length - 1 ? !info.isFile() : !info.isDirectory())) throw Object.assign(new Error("not an ordinary confined file"), { code: "EUNSAFE" });
27
+ }
28
+ if (await realpath(current) !== current) throw Object.assign(new Error("file path changed or follows a symlink"), { code: "EUNSAFE" });
29
+ return current;
30
+ }
31
+
32
+ export function artifactExclusions(root: string, plan: CommandArtifactPlan, ownedPaths: readonly string[] = []): string[] {
33
+ const owned = ownedPaths.map(path => relative(resolve(root), resolve(path)))
34
+ .filter(path => path && path !== ".." && !path.startsWith(`..${sep}`) && !path.startsWith(sep) && !path.split(sep).some(part => SOURCE_IGNORE.includes(part)));
35
+ return [...new Set([...plan.output_roots, ...owned])].sort();
36
+ }
37
+ export async function observeArtifactInputs(root: string, exclusions: readonly string[], signal?: AbortSignal): Promise<{ sha256: string | null; reason?: string }> {
38
+ const bounded = AbortSignal.any([AbortSignal.timeout(OBSERVATION_MS), ...(signal ? [signal] : [])]);
39
+ const result = await observeWorkspace(root, bounded, exclusions.map(path => resolve(root, path)));
40
+ // A digest binds the declared finite scope as well as its bytes; it is never source-v1 evidence.
41
+ return result.ok && !bounded.aborted ? { sha256: blobAddress(JSON.stringify([COMMAND_ARTIFACT_SCOPE, SOURCE_IGNORE, exclusions, result.fingerprint])) }
42
+ : { sha256: null, reason: result.ok ? "input observation cancelled or timed out" : result.reason };
43
+ }
44
+
45
+ export async function observeArtifactFiles(root: string, paths: readonly string[], signal?: AbortSignal): Promise<ArtifactFile[]> {
46
+ const bounded = AbortSignal.any([AbortSignal.timeout(OBSERVATION_MS), ...(signal ? [signal] : [])]);
47
+ let remaining = MAX_COMMAND_ARTIFACT_BYTES;
48
+ const results: ArtifactFile[] = [];
49
+ let base: string;
50
+ try { base = await realpath(root); } catch { return paths.map(() => ({ state: "unknown", reason: "workspace unavailable" })); }
51
+ for (const path of paths) {
52
+ if (!validArtifactPath(path)) { results.push({ state: "unsafe", reason: "invalid workspace artifact path" }); continue; }
53
+ if (bounded.aborted) { results.push({ state: "unknown", reason: "artifact observation cancelled or timed out" }); continue; }
54
+ try {
55
+ const absolute = await ordinaryPath(base, path);
56
+ // O_NONBLOCK prevents a concurrent FIFO replacement from hanging before fstat rejects it.
57
+ const file = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
58
+ try {
59
+ const before = await file.stat({ bigint: true });
60
+ if (!before.isFile()) { results.push({ state: "unsafe", reason: "opened object is not an ordinary file" }); continue; }
61
+ if (before.size > BigInt(remaining)) { results.push({ state: "unknown", reason: "artifact byte budget exceeded" }); continue; }
62
+ const limit = Number(before.size), buffer = Buffer.alloc(Math.min(CHUNK_BYTES, Math.max(1, limit + 1))), hash = createHash("sha256");
63
+ let bytes = 0, failed = false;
64
+ for (;;) {
65
+ if (bounded.aborted) { results.push({ state: "unknown", reason: "artifact observation cancelled or timed out" }); failed = true; break; }
66
+ const read = await file.read(buffer, 0, Math.min(buffer.length, limit + 1 - bytes), bytes);
67
+ bytes += read.bytesRead;
68
+ remaining -= read.bytesRead;
69
+ if (bytes > limit) { results.push({ state: "changed", reason: "file grew during observation" }); failed = true; break; }
70
+ if (read.bytesRead === 0) break;
71
+ hash.update(buffer.subarray(0, read.bytesRead));
72
+ }
73
+ if (failed) continue;
74
+ const after = await file.stat({ bigint: true });
75
+ await ordinaryPath(base, path);
76
+ const named = await lstat(absolute, { bigint: true });
77
+ if (bytes !== Number(before.size) || after.size !== before.size || named.size !== before.size ||
78
+ !sameArtifactIdentity(identity(before), identity(after)) || !sameArtifactIdentity(identity(after), identity(named))) {
79
+ results.push({ state: "changed", reason: "file contents or identity changed during observation" }); continue;
80
+ }
81
+ results.push({ state: "observed", sha256: hash.digest("hex"), bytes, identity: identity(after) });
82
+ } finally { await file.close(); }
83
+ } catch (error) {
84
+ const code = (error as { code?: string }).code;
85
+ results.push(code === "ENOENT" || code === "ENOTDIR" ? { state: "missing", reason: "artifact path was not found" }
86
+ : code === "EUNSAFE" || code === "ELOOP" ? { state: "unsafe", reason: "artifact path is not an ordinary confined file" }
87
+ : { state: "unknown", reason: "artifact file could not be observed" });
88
+ }
89
+ }
90
+ return results;
91
+ }
92
+
93
+ export async function archiveCommandArtifacts(outputStore: string, observation: unknown) {
94
+ async function* bytes() { yield new TextEncoder().encode(JSON.stringify(observation)); }
95
+ return (await archiveObservation(outputStore, bytes(), 0)).ref;
96
+ }
@@ -1,49 +1,60 @@
1
1
  /** O.1: own the process group, preserve partial observations, and drain before returning. */
2
2
  import { captureCommandObservation } from "./observations.ts";
3
+ import { commandArgv, commandEnvironment, COMMAND_TERMINATION_GRACE_MS } from "../core/command-contract.ts";
4
+ import { ToolFault, NOT_STARTED, failureDiagnostic } from "../core/tool-failure.ts";
3
5
 
4
- /** Operational grace, not a measured optimum: do not let ignored SIGTERM defeat cancellation. */
5
- const TERMINATION_GRACE_MS = 200;
6
-
7
- /**
8
- * Variables that make a command behave as if no one is at the keyboard.
9
- *
10
- * There never is: a pager waits on a tty that does not exist, colour codes
11
- * spend budget on escape bytes, and a git credential prompt or an apt
12
- * question hangs the command until the timeout. Codex sets the same core set
13
- * for its exec sessions (`unified_exec/process_manager.rs`). LANG is filled in
14
- * only when absent -- a user's own locale is not ours to override.
15
- */
16
- export function commandEnvironment(base: Readonly<Record<string, string | undefined>>): Record<string, string> {
17
- const env: Record<string, string> = {};
18
- for (const [key, value] of Object.entries(base)) if (value !== undefined) env[key] = value;
19
- Object.assign(env, {
20
- TERM: "dumb", PAGER: "cat", GIT_PAGER: "cat", NO_COLOR: "1",
21
- GIT_TERMINAL_PROMPT: "0", DEBIAN_FRONTEND: "noninteractive",
22
- });
23
- if (env["LANG"] === undefined || env["LANG"] === "") env["LANG"] = "C.UTF-8";
24
- return env;
25
- }
26
-
27
- export async function observeCommand(input: {
28
- command: string;
6
+ interface CommandCaptureInput {
29
7
  cwd: string;
30
8
  outputStore: string;
31
9
  maxOutputBytes: number;
32
10
  timeoutMs: number;
33
11
  signal?: AbortSignal;
34
12
  onOutput?: (chunk: Uint8Array) => void;
35
- }) {
13
+ /** Bounded probes never transfer background children to the caller. */
14
+ cleanupOnExit?: boolean;
15
+ /** Stop capture after the crossing chunk; command cancellation still owns process cleanup. */
16
+ outputLimitBytes?: number;
17
+ }
18
+
19
+ export async function observeCommand(input: CommandCaptureInput & { command: string }) {
36
20
  if (input.signal?.aborted) {
37
21
  const empty = () => new ReadableStream<Uint8Array>({ start: controller => controller.close() });
38
22
  const captured = await captureCommandObservation(input.outputStore, empty(), empty(), Promise.resolve(130), input.maxOutputBytes);
39
- return { ...captured, wallMs: 0, stopped: "cancelled" as const };
23
+ return { ...captured, wallMs: 0, stopped: "cancelled" as const, cleanup: undefined };
40
24
  }
25
+ return executeCaptured(input, commandArgv(input.command), false);
26
+ }
27
+
28
+ /** Native host API: argv is never interpreted by a shell and each pipe is recoverable. */
29
+ export async function observeArgvCommand(input: CommandCaptureInput & { argv: readonly string[] }) {
30
+ if (!Array.isArray(input.argv) || input.argv.length === 0 || !input.argv[0] ||
31
+ input.argv.some(value => typeof value !== "string" || value.includes("\0"))) {
32
+ throw new ToolFault("invalid_input", "native argv must contain a program and literal string arguments without NUL", NOT_STARTED);
33
+ }
34
+ if (input.signal?.aborted) throw new ToolFault("cancelled", "native command was cancelled before it started", NOT_STARTED);
35
+ const captured = await executeCaptured(input, [...input.argv], true);
36
+ if (captured.channels.length !== 2) throw new Error("missing native stdout/stderr artifacts");
37
+ return { ...captured, stdout: captured.channels[0]!, stderr: captured.channels[1]! };
38
+ }
39
+
40
+ async function executeCaptured(input: CommandCaptureInput, argv: string[], native: boolean) {
41
41
  const started = performance.now();
42
- const proc = Bun.spawn(["bash", "-o", "pipefail", "-lc", input.command], {
43
- cwd: input.cwd, stdout: "pipe", stderr: "pipe", detached: true, env: commandEnvironment(process.env),
44
- });
42
+ const proc = (() => {
43
+ try { return Bun.spawn(argv, {
44
+ cwd: input.cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe", detached: true,
45
+ // Preserve the trusted native API's inherited environment; shell tools
46
+ // retain their explicit noninteractive policy and login-shell startup.
47
+ env: native ? process.env : commandEnvironment(process.env),
48
+ }); } catch (error) {
49
+ const os = (error as { code?: string }).code;
50
+ throw new ToolFault(os === "ENOENT" || os === "ENOTDIR" ? "environment_missing" : os === "EACCES" || os === "EPERM" ? "permission_denied" : "execution_failed", String(error), NOT_STARTED);
51
+ }
52
+ })();
45
53
  let stopped: "cancelled" | "timeout" | undefined;
46
54
  let escalation: ReturnType<typeof setTimeout> | undefined;
55
+ let timer: ReturnType<typeof setTimeout> | undefined;
56
+ let exited = false, cleaned = false;
57
+ let cleanupError: unknown;
47
58
  const kill = (signal: "SIGTERM" | "SIGKILL") => {
48
59
  try {
49
60
  // POSIX detached children lead their own group; never signal the caller's group.
@@ -53,22 +64,39 @@ export async function observeCommand(input: {
53
64
  if ((error as { code?: string }).code !== "ESRCH") throw error;
54
65
  }
55
66
  };
67
+ const cleanup = () => {
68
+ if (cleaned) return;
69
+ cleaned = true;
70
+ try { kill("SIGKILL"); } catch (error) { cleanupError = error; }
71
+ };
56
72
  const stop = (reason: "cancelled" | "timeout") => {
57
- if (stopped !== undefined) return;
73
+ if (exited || stopped !== undefined) return;
58
74
  stopped = reason;
59
75
  kill("SIGTERM");
60
- escalation = setTimeout(() => kill("SIGKILL"), TERMINATION_GRACE_MS);
76
+ escalation = setTimeout(cleanup, COMMAND_TERMINATION_GRACE_MS);
61
77
  };
78
+ const exit = proc.exited.then(code => {
79
+ exited = true;
80
+ clearTimeout(timer); clearTimeout(escalation);
81
+ // Clean once at the observed exit, never after slow archival when a PID
82
+ // might have been reused. Legacy successful bash background starts remain.
83
+ if (native || input.cleanupOnExit || stopped !== undefined) cleanup();
84
+ return code;
85
+ });
62
86
  const abort = () => stop("cancelled");
63
87
  input.signal?.addEventListener("abort", abort, { once: true });
64
- const timer = setTimeout(() => stop("timeout"), input.timeoutMs);
88
+ timer = setTimeout(() => stop("timeout"), input.timeoutMs);
65
89
  if (input.signal?.aborted) abort();
66
90
  try {
67
91
  const captured = await captureCommandObservation(input.outputStore, proc.stdout, proc.stderr,
68
- proc.exited, input.maxOutputBytes, input.onOutput);
69
- return { ...captured, wallMs: performance.now() - started, ...(stopped ? { stopped } : {}) };
92
+ exit, input.maxOutputBytes, input.onOutput, native, input.outputLimitBytes);
93
+ // Parent exit and archived output remain facts even if group cleanup
94
+ // cannot be confirmed. This is execution uncertainty, not user denial.
95
+ const cleanupDiagnostic = cleanupError === undefined ? undefined : failureDiagnostic("unknown_effect",
96
+ `process-group cleanup was not confirmed: ${String(cleanupError)}`, { execution: "started", effects: "possible" });
97
+ return { ...captured, wallMs: performance.now() - started, ...(stopped ? { stopped } : {}), cleanup: cleanupDiagnostic };
70
98
  } catch (error) {
71
- kill("SIGKILL");
99
+ cleanup();
72
100
  await proc.exited;
73
101
  throw error;
74
102
  } finally {
@@ -76,6 +104,6 @@ export async function observeCommand(input: {
76
104
  clearTimeout(escalation);
77
105
  input.signal?.removeEventListener("abort", abort);
78
106
  // A descendant can close stdio yet ignore SIGTERM; completing capture is not proof it stopped.
79
- if (stopped !== undefined) kill("SIGKILL");
107
+ if (stopped !== undefined) cleanup();
80
108
  }
81
109
  }