@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
@@ -11,18 +11,22 @@
11
11
  * Every call still ends in one tool_result, so the log records each
12
12
  * observation in order (log-as-truth). What the log cannot hold is the
13
13
  * process itself: sessions belong to this OS process and die with it, and a
14
- * restarted xeno reports an unknown session instead of pretending to resume.
14
+ * restarted xeno recovers observations from the log without reattaching a PID.
15
15
  *
16
16
  * Output is spooled to a file as it arrives. Each call archives exactly the
17
- * bytes since the previous call as an observation blob (D8), so memory stays
18
- * bounded by the preview and nothing the process printed is lost.
17
+ * bytes since the previous call as an observation blob (D8), without keeping
18
+ * cumulative output strings. Capture errors are explicit partial observations.
19
19
  */
20
20
  import { open, mkdir, rm, type FileHandle } from "node:fs/promises";
21
21
  import { createReadStream } from "node:fs";
22
22
  import { join } from "node:path";
23
23
  import { randomUUID } from "node:crypto";
24
- import { commandEnvironment } from "../store/command.ts";
24
+ import { commandArgv, commandEnvironment, COMMAND_TERMINATION_GRACE_MS } from "../core/command-contract.ts";
25
25
  import { archiveObservation, type ArchivedObservation } from "../store/observations.ts";
26
+ import { ToolFault, NOT_STARTED } from "../core/tool-failure.ts";
27
+ import { processReceipt, retainProcessReceipts, type ProcessReceipt, type ProcessSource } from "../core/process-receipt.ts";
28
+ import type { CommandArtifactObserver } from "./command-artifacts.ts";
29
+ import type { CommandArtifactReceipt } from "../core/command-artifacts.ts";
26
30
 
27
31
  /** Codex's default: long enough for most commands to finish in one call. */
28
32
  export const DEFAULT_YIELD_MS = 10_000;
@@ -35,34 +39,55 @@ export const MAX_YIELD_MS = 30_000;
35
39
  * well before it exhausts the machine.
36
40
  */
37
41
  export const MAX_SESSIONS = 16;
38
- const TERMINATION_GRACE_MS = 200;
39
- /** After exit, how long silence must last before the output is final. */
40
- const EXIT_IDLE_MS = 100;
42
+ /** Assumed drain bound for descendants which escaped the owned group; never claim EOF on expiry. */
43
+ const EXIT_DRAIN_MS = 1_000;
44
+ /** Disposable acceleration only. Original log receipts outlive these assumed memory bounds. */
45
+ export const CLOSED_RECEIPT_LIMIT = 64;
46
+ export const CLOSED_RECEIPT_AGE_MS = 10 * 60_000;
47
+
48
+ type TakenOutput = {
49
+ stored: ArchivedObservation; receipt: ProcessReceipt; wallMs: number;
50
+ exit: number | null; stopped?: "timeout" | "cancelled";
51
+ };
52
+ type ClosedSession = { owner?: string; id: number; endedAt: number; receipt: ProcessReceipt };
41
53
 
42
54
  interface Session {
55
+ owner?: string;
43
56
  id: number;
44
57
  command: string;
45
58
  cwd: string;
46
59
  tty: boolean;
60
+ source: ProcessSource;
61
+ artifactObserver?: CommandArtifactObserver;
62
+ artifacts?: Promise<CommandArtifactReceipt>;
47
63
  started: number;
48
64
  pid: number;
49
65
  spool: FileHandle;
50
66
  spoolPath: string;
51
67
  queue: Promise<unknown>;
68
+ operations: Promise<unknown>;
69
+ final?: TakenOutput;
70
+ captureError?: string;
52
71
  written: number;
53
72
  read: number;
54
- lastData: number;
55
73
  exit: number | null;
56
- stopped?: "timeout";
74
+ stopped?: "timeout" | "cancelled";
75
+ escalation?: ReturnType<typeof setTimeout>;
57
76
  timeoutMs: number;
58
77
  exited: Promise<void>;
78
+ drained: Promise<void>;
79
+ finishing?: Promise<void>;
80
+ closeCapture(): Promise<void>;
81
+ alive(): boolean;
59
82
  write(chars: string): void;
60
83
  kill(signal: NodeJS.Signals): void;
61
84
  timer: ReturnType<typeof setTimeout>;
62
85
  }
63
86
 
64
87
  const sessions = new Map<number, Session>();
65
- let nextId = 1;
88
+ const closedSessions = new Map<number, ClosedSession>();
89
+ // O.14: a restarted process must not accidentally reuse an old poll capability.
90
+ let nextId = Number.parseInt(randomUUID().replaceAll("-", "").slice(0, 12), 16);
66
91
  let hooked = false;
67
92
 
68
93
  function killGroup(pid: number, signal: NodeJS.Signals): void {
@@ -77,32 +102,55 @@ function killGroup(pid: number, signal: NodeJS.Signals): void {
77
102
  export function killAllExecSessions(): void {
78
103
  for (const session of sessions.values()) {
79
104
  clearTimeout(session.timer);
80
- session.kill("SIGKILL");
105
+ clearTimeout(session.escalation);
106
+ if (session.exit === null) session.kill("SIGKILL");
81
107
  void session.spool.close().catch(() => {});
82
108
  void rm(session.spoolPath, { force: true });
83
109
  }
84
110
  sessions.clear();
111
+ closedSessions.clear();
112
+ }
113
+
114
+ export function runningSessions(owner?: string): { id: number; command: string; exit: number | null }[] {
115
+ return [...sessions.values()].filter(session => session.owner === owner).map(({ id, command, exit }) => ({ id, command, exit }));
85
116
  }
86
117
 
87
- export function runningSessions(): { id: number; command: string }[] {
88
- return [...sessions.values()].map(({ id, command }) => ({ id, command }));
118
+ function pruneClosed(now: number): void {
119
+ const retained = retainProcessReceipts([...closedSessions.values()], now, CLOSED_RECEIPT_AGE_MS, CLOSED_RECEIPT_LIMIT);
120
+ closedSessions.clear();
121
+ for (const item of retained) closedSessions.set(item.id, item);
122
+ }
123
+
124
+ export function closedSession(id: number, owner?: string): ClosedSession | undefined {
125
+ pruneClosed(performance.now());
126
+ const item = closedSessions.get(id);
127
+ return item?.owner === owner ? item : undefined;
89
128
  }
90
129
 
91
130
  function append(session: Session, chunk: Uint8Array): void {
92
- session.lastData = performance.now();
93
131
  const bytes = chunk.slice();
94
132
  session.queue = session.queue.then(async () => {
95
- await session.spool.write(bytes, 0, bytes.length, session.written);
96
- session.written += bytes.length;
133
+ let offset = 0;
134
+ while (offset < bytes.length) {
135
+ const { bytesWritten } = await session.spool.write(bytes, offset, bytes.length - offset, session.written);
136
+ if (bytesWritten < 1) throw new Error("process output spool made no write progress");
137
+ offset += bytesWritten; session.written += bytesWritten;
138
+ }
139
+ }).catch(error => {
140
+ session.captureError = `process output capture failed: ${String(error)}`;
141
+ stopSession(session, "cancelled");
97
142
  });
98
143
  }
99
144
 
100
145
  export async function startSession(input: {
101
- command: string; cwd: string; tty: boolean; timeoutMs: number; spoolDir: string;
146
+ command: string; cwd: string; tty: boolean; timeoutMs: number; spoolDir: string; source: ProcessSource; owner?: string;
147
+ artifactObserver?: CommandArtifactObserver;
148
+ /** Internal literal launch capability; model exec_command remains a Bash contract. */
149
+ argv?: readonly string[];
102
150
  }): Promise<Session | { error: string }> {
103
151
  if (sessions.size >= MAX_SESSIONS) {
104
- const list = runningSessions().map((s) => `${s.id}: ${s.command}`).join("; ");
105
- return { error: `refused: ${MAX_SESSIONS} sessions are already running (${list}); stop one with write_stdin chars="\\u0003" or kill it with bash` };
152
+ const list = runningSessions(input.owner).map((s) => s.id).join(", ");
153
+ return { error: `refused: managed process capacity ${MAX_SESSIONS} reached; your handles: ${list || "none"}; finish or cancel an owned process before starting another` };
106
154
  }
107
155
  if (!hooked) { hooked = true; process.once("exit", killAllExecSessions); }
108
156
  await mkdir(input.spoolDir, { recursive: true });
@@ -112,27 +160,35 @@ export async function startSession(input: {
112
160
  let resolveExit!: () => void;
113
161
  const exited = new Promise<void>((resolve) => { resolveExit = resolve; });
114
162
  const session = {
115
- id: nextId++, command: input.command, cwd: input.cwd, tty: input.tty, started: performance.now(),
116
- spool, spoolPath, queue: Promise.resolve(), written: 0, read: 0, lastData: performance.now(),
117
- exit: null, timeoutMs: input.timeoutMs, exited,
163
+ id: nextId++, ...(input.owner === undefined ? {} : { owner: input.owner }), command: input.command, cwd: input.cwd, tty: input.tty, source: input.source, started: performance.now(),
164
+ spool, spoolPath, queue: Promise.resolve(), operations: Promise.resolve(), written: 0, read: 0,
165
+ exit: null, timeoutMs: input.timeoutMs, exited, drained: Promise.resolve(),
166
+ ...(input.artifactObserver ? { artifactObserver: input.artifactObserver } : {}),
118
167
  } as Omit<Session, "pid" | "write" | "kill" | "timer"> as Session;
119
168
 
120
- if (input.tty) {
169
+ try { if (input.tty) {
121
170
  // A PTY: the line discipline turns ^C into SIGINT for the foreground job,
122
171
  // which is what an interactive program expects. Bun ships the PTY.
123
- const proc = Bun.spawn(["bash", "-lc", input.command], {
172
+ let drained!: () => void;
173
+ session.drained = new Promise<void>(resolve => { drained = resolve; });
174
+ const proc = Bun.spawn(input.argv ? [...input.argv] : commandArgv(input.command), {
124
175
  cwd: input.cwd, env,
125
- terminal: { cols: 120, rows: 40, data: (_terminal, data) => append(session, data) },
176
+ terminal: { cols: 120, rows: 40, data: (_terminal, data) => append(session, data),
177
+ // This callback closes the stream; only proc.exited supplies the command exit.
178
+ exit: (_terminal, code) => { if (code !== 0) session.captureError = "PTY output stream closed with a read error"; drained(); } },
126
179
  });
127
180
  session.pid = proc.pid;
181
+ session.alive = () => proc.exitCode === null;
128
182
  session.write = (chars) => { proc.terminal?.write(chars); };
129
183
  session.kill = (signal) => killGroup(proc.pid, signal);
130
- void proc.exited.then((code) => { session.exit = code; resolveExit(); });
184
+ session.closeCapture = async () => { proc.terminal?.close(); drained(); };
185
+ void proc.exited.then((code) => { session.exit = code; resolveExit(); void drainExitedSession(session); });
131
186
  } else {
132
- const proc = Bun.spawn(["bash", "-lc", input.command], {
187
+ const proc = Bun.spawn(input.argv ? [...input.argv] : commandArgv(input.command), {
133
188
  cwd: input.cwd, env, stdin: "pipe", stdout: "pipe", stderr: "pipe", detached: true,
134
189
  });
135
190
  session.pid = proc.pid;
191
+ session.alive = () => proc.exitCode === null;
136
192
  // Without a terminal there is no line discipline, so the two control
137
193
  // characters a model reaches for are mapped by hand: ^C interrupts the
138
194
  // group, ^D closes stdin.
@@ -144,44 +200,85 @@ export async function startSession(input: {
144
200
  }
145
201
  };
146
202
  session.kill = (signal) => killGroup(proc.pid, signal);
203
+ const readers: ReadableStreamDefaultReader<Uint8Array>[] = [];
147
204
  const pump = async (stream: ReadableStream<Uint8Array>) => {
148
205
  const reader = stream.getReader();
149
- try { for (;;) { const { done, value } = await reader.read(); if (done) return; append(session, value); } }
150
- catch { /* the stream closed under us; the exit code is what matters */ }
206
+ readers.push(reader);
207
+ try { for (;;) { const { done, value } = await reader.read(); if (done) return; append(session, value); await session.queue; } }
208
+ catch (error) { session.captureError = `process output stream failed: ${String(error)}`; }
151
209
  finally { reader.releaseLock(); }
152
210
  };
153
- void pump(proc.stdout);
154
- void pump(proc.stderr);
155
- void proc.exited.then((code) => { session.exit = code; resolveExit(); });
211
+ session.closeCapture = async () => { await Promise.allSettled(readers.map(reader => reader.cancel())); };
212
+ session.drained = Promise.all([pump(proc.stdout), pump(proc.stderr)]).then(() => {});
213
+ void proc.exited.then((code) => { session.exit = code; resolveExit(); void drainExitedSession(session); });
214
+ } } catch (error) {
215
+ await spool.close(); await rm(spoolPath, { force: true });
216
+ const os = (error as { code?: string }).code;
217
+ throw new ToolFault(os === "ENOENT" || os === "ENOTDIR" ? "environment_missing" : os === "EACCES" || os === "EPERM" ? "permission_denied" : "execution_failed", String(error), NOT_STARTED);
156
218
  }
157
- session.timer = setTimeout(() => {
158
- session.stopped = "timeout";
159
- session.kill("SIGTERM");
160
- setTimeout(() => session.kill("SIGKILL"), TERMINATION_GRACE_MS);
161
- }, input.timeoutMs);
219
+ session.timer = setTimeout(() => stopSession(session, "timeout"), input.timeoutMs);
162
220
  sessions.set(session.id, session);
163
221
  return session;
164
222
  }
165
223
 
166
- export function findSession(id: number): Session | undefined {
167
- return sessions.get(id);
224
+ export function findSession(id: number, owner?: string): Session | undefined {
225
+ const session = sessions.get(id);
226
+ return session?.owner === owner ? session : undefined;
227
+ }
228
+
229
+ function stopSession(session: Session, reason: "timeout" | "cancelled"): void {
230
+ if (session.stopped !== undefined || session.exit !== null) return;
231
+ session.stopped = reason;
232
+ clearTimeout(session.timer);
233
+ session.kill("SIGTERM");
234
+ session.escalation = setTimeout(() => session.kill("SIGKILL"), COMMAND_TERMINATION_GRACE_MS);
235
+ }
236
+
237
+ /** A managed group is owned for its foreground command lifetime, never silently detached. */
238
+ function drainExitedSession(session: Session): Promise<void> {
239
+ // Once only: a later artifact retry must not signal a potentially reused PID/group.
240
+ session.finishing ??= (async () => {
241
+ try { await finishCapture(session); } catch (error) { session.captureError = `output drain failed: ${String(error)}`; }
242
+ // The exit owns this observation even if no model polls until files change again.
243
+ if (session.artifactObserver && session.exit !== null) {
244
+ session.artifacts = session.artifactObserver.finish({ exit: session.exit,
245
+ ...(session.stopped ? { stopped: session.stopped } : {}), capture: session.captureError ? "partial" : "complete" });
246
+ await session.artifacts;
247
+ }
248
+ })();
249
+ return session.finishing;
250
+ }
251
+ async function finishCapture(session: Session): Promise<void> {
252
+ session.kill("SIGKILL");
253
+ clearTimeout(session.escalation);
254
+ clearTimeout(session.timer);
255
+ let timer: ReturnType<typeof setTimeout> | undefined;
256
+ const deadline = new Promise<false>(resolve => { timer = setTimeout(() => resolve(false), EXIT_DRAIN_MS); });
257
+ const complete = await Promise.race([session.drained.then(() => true), deadline]);
258
+ clearTimeout(timer);
259
+ if (!complete) {
260
+ session.captureError = "output drain deadline reached; a descendant may have escaped the owned group";
261
+ await session.closeCapture();
262
+ }
263
+ await session.queue;
168
264
  }
169
265
 
170
266
  /** Wait for exit, the yield deadline or cancellation, whichever is first. */
171
- export async function waitSession(session: Session, yieldMs: number, signal?: AbortSignal): Promise<void> {
172
- const deadline = new Promise<void>((resolve) => setTimeout(resolve, yieldMs));
267
+ async function waitSession(session: Session, yieldMs: number, signal?: AbortSignal): Promise<void> {
268
+ let timer: ReturnType<typeof setTimeout> | undefined;
269
+ let onAbort: (() => void) | undefined;
270
+ const deadline = new Promise<void>((resolve) => { timer = setTimeout(resolve, yieldMs); });
173
271
  const aborted = new Promise<void>((resolve) => {
174
272
  if (signal === undefined) return;
273
+ onAbort = resolve;
175
274
  if (signal.aborted) resolve();
176
- signal.addEventListener("abort", () => resolve(), { once: true });
275
+ else signal.addEventListener("abort", onAbort, { once: true });
177
276
  });
178
- await Promise.race([session.exited, deadline, aborted]);
179
- if (session.exit !== null) {
180
- // Output written just before exit can still be in flight; wait until it goes quiet.
181
- const until = performance.now() + 1_000;
182
- while (performance.now() - session.lastData < EXIT_IDLE_MS && performance.now() < until) await Bun.sleep(EXIT_IDLE_MS / 2);
183
- await Bun.sleep(20);
184
- }
277
+ try { await Promise.race([session.exited, deadline, aborted]); }
278
+ finally { clearTimeout(timer); if (onAbort) signal?.removeEventListener("abort", onAbort); }
279
+ if (signal?.aborted) stopSession(session, "cancelled");
280
+ if (session.stopped !== undefined) await session.exited;
281
+ if (session.exit !== null) await drainExitedSession(session);
185
282
  await session.queue;
186
283
  }
187
284
 
@@ -189,26 +286,74 @@ export async function waitSession(session: Session, yieldMs: number, signal?: Ab
189
286
  * Bytes printed since the previous call, archived in full. A session whose
190
287
  * exit has now been reported is closed.
191
288
  */
192
- export async function takeOutput(session: Session, outputStore: string, previewBytes: number): Promise<{
193
- stored: ArchivedObservation; exit: number | null; stopped?: "timeout"; wallMs: number;
194
- }> {
289
+ async function takeOutput(session: Session, outputStore: string, previewBytes: number): Promise<TakenOutput> {
290
+ if (session.exit !== null) await drainExitedSession(session);
195
291
  await session.queue;
292
+ if (session.exit !== null) await drainExitedSession(session);
196
293
  const from = session.read, to = session.written;
197
- session.read = to;
294
+ // Snapshot exit before async archival: a later exit belongs to the next observation.
295
+ const exit = session.exit;
198
296
  const path = session.spoolPath;
199
- async function* range(): AsyncGenerator<Uint8Array> {
200
- if (to <= from) return;
201
- for await (const chunk of createReadStream(path, { start: from, end: to - 1 })) yield chunk as Uint8Array;
297
+ async function* range(start: number): AsyncGenerator<Uint8Array> {
298
+ if (to <= start) return;
299
+ for await (const chunk of createReadStream(path, { start, end: to - 1 })) yield chunk as Uint8Array;
202
300
  }
203
- const stored = await archiveObservation(outputStore, range(), previewBytes);
204
- const exit = session.exit;
301
+ const stored = await archiveObservation(outputStore, range(from), previewBytes);
302
+ const full = exit === null ? undefined : from === 0 ? stored : await archiveObservation(outputStore, range(0), previewBytes);
303
+ const artifacts = exit === null ? session.artifactObserver?.pending : await session.artifacts;
304
+ // Neither the cursor nor the handle advances until every referenced artifact exists.
305
+ session.read = to;
306
+ const taken: TakenOutput = { stored, exit, ...(session.stopped ? { stopped: session.stopped } : {}), wallMs: performance.now() - session.started,
307
+ receipt: processReceipt({ session: session.id, source: session.source, exit, output: stored.ref, start: from, end: to,
308
+ ...(session.stopped ? { stopped: session.stopped } : {}), ...(full ? { fullOutput: full.ref } : {}),
309
+ ...(artifacts ? { artifacts } : {}),
310
+ ...(session.captureError ? { capture: "partial" } : exit !== null ? { capture: "complete" } : {}) }) };
205
311
  if (exit !== null) {
206
312
  clearTimeout(session.timer);
313
+ clearTimeout(session.escalation);
207
314
  sessions.delete(session.id);
208
- // A background child the command started deliberately (`nohup server &`)
209
- // is left running, as bash leaves it: only a timeout kills the group.
315
+ session.final = taken;
316
+ closedSessions.set(session.id, { ...(session.owner === undefined ? {} : { owner: session.owner }), id: session.id,
317
+ endedAt: performance.now(), receipt: taken.receipt });
318
+ pruneClosed(performance.now());
319
+ await session.closeCapture();
210
320
  await session.spool.close().catch(() => {});
211
321
  await rm(path, { force: true });
212
322
  }
213
- return { stored, exit, ...(session.stopped ? { stopped: session.stopped } : {}), wallMs: performance.now() - session.started };
323
+ return taken;
324
+ }
325
+
326
+ /** One lease covers stdin, waiting, archival and cursor commit; cancellation can interrupt a queued wait. */
327
+ export async function pollSession(session: Session, outputStore: string, previewBytes: number, yieldMs: number,
328
+ chars = "", signal?: AbortSignal): Promise<TakenOutput> {
329
+ const cancel = () => stopSession(session, "cancelled");
330
+ if (signal?.aborted) cancel(); else signal?.addEventListener("abort", cancel, { once: true });
331
+ const result = session.operations.then(async () => {
332
+ if (session.final) {
333
+ if (chars.length > 0) throw new ToolFault("not_found", "process is closed; input was not sent", NOT_STARTED);
334
+ return { ...session.final, receipt: { ...session.final.receipt, replayed: true } };
335
+ }
336
+ if (chars.length > 0) {
337
+ if (session.exit !== null) throw new ToolFault("not_found", "process has exited; input was not sent; poll with empty chars", NOT_STARTED);
338
+ session.write(chars);
339
+ }
340
+ await waitSession(session, yieldMs, signal);
341
+ return takeOutput(session, outputStore, previewBytes);
342
+ });
343
+ session.operations = result.catch(() => {});
344
+ try { return await result; }
345
+ finally { signal?.removeEventListener("abort", cancel); }
346
+ }
347
+
348
+ /** Stop only this owner, drain its actual exit and preserve every unread byte (D8). */
349
+ export async function disposeExecSessions(owner: string, outputStore: string, previewBytes: number) {
350
+ const owned = [...sessions.values()].filter(session => session.owner === owner);
351
+ const results = await Promise.all(owned.map(async session => {
352
+ stopSession(session, "cancelled");
353
+ const output = await pollSession(session, outputStore, previewBytes, 0);
354
+ if (output.exit === null) throw new Error("disposed process has no exit observation");
355
+ if (output.receipt.replayed) return null;
356
+ return { session: session.id, command: session.command, exit: output.exit, output: output.stored.ref, receipt: output.receipt };
357
+ }));
358
+ return results.filter(item => item !== null);
214
359
  }
@@ -25,6 +25,7 @@ export class ExecutionControl {
25
25
  cancel(): void { this.active?.controller.abort(); }
26
26
  whenIdle(): Promise<void> { return this.active?.done ?? Promise.resolve(); }
27
27
  get running(): boolean { return this.active !== null; }
28
+ get signal(): AbortSignal | undefined { return this.active?.controller.signal; }
28
29
  }
29
30
 
30
31
  /** Only for admission waits: an unstarted action has no effect to drain. */
@@ -1,52 +1,44 @@
1
- /** Structural Pi UI contract; the runtime never constructs or owns a terminal (D19). */
2
- import type { TUI, OverlayHandle, OverlayOptions } from "../vendor/pi-tui/tui.ts";
3
- import type { EditorComponent } from "../vendor/pi-tui/editor-component.ts";
4
- import type { EditorTheme } from "../vendor/pi-tui/components/editor.ts";
5
- import type { AutocompleteProvider } from "../vendor/pi-tui/autocomplete.ts";
6
- import type { KeybindingsManager } from "../vendor/pi-tui/keybindings.ts";
1
+ /**
2
+ * What an extension may do to the interactive UI (D20). The runtime never
3
+ * constructs a terminal; the app supplies an implementation.
4
+ *
5
+ * Deliberately small. Extensions describe content (`render(width): string[]`
6
+ * ANSI lines) and ask for decisions (select / confirm / input); they do not
7
+ * own focus, overlays or the composer. That keeps a misbehaving extension
8
+ * from wedging input, and keeps the UI framework replaceable without
9
+ * breaking extensions again.
10
+ */
7
11
  import type { Theme } from "../core/theme.ts";
8
12
 
9
- export interface ExtensionComponent { render(width: number): string[]; invalidate?(): void; handleInput?(data: string): void; dispose?(): void; focused?: boolean }
13
+ /** A block of ANSI lines that may depend on width. */
14
+ export interface ExtensionComponent { render(width: number): string[]; dispose?(): void }
15
+
10
16
  export interface ExtensionTheme extends Theme {
11
17
  fg(color: string, text: string): string; bg(color: string, text: string): string;
12
18
  bold(text: string): string; italic(text: string): string; underline(text: string): string; dim(text: string): string; inverse(text: string): string; strikethrough(text: string): string;
13
- getFgAnsi(color: string): string; getBgAnsi(color: string): string;
14
- getColorMode(): "truecolor"; getThinkingBorderColor(level: string): (text: string) => string; getBashModeBorderColor(): (text: string) => string;
15
19
  }
16
- export type WidgetFactory = (tui: TUI, theme: ExtensionTheme) => ExtensionComponent;
17
- export type EditorFactory = (tui: TUI, theme: EditorTheme, bindings: KeybindingsManager) => EditorComponent & { dispose?(): void; focused?: boolean };
18
- export type AutocompleteFactory = (base: AutocompleteProvider) => AutocompleteProvider;
19
- export interface FooterData { getGitBranch(): string | undefined; getExtensionStatuses(): ReadonlyMap<string, string>; onBranchChange(callback: () => void): () => void }
20
- export type FooterFactory = (tui: TUI, theme: ExtensionTheme, data: FooterData) => ExtensionComponent;
20
+
21
21
  export interface DialogOptions { signal?: AbortSignal; timeout?: number }
22
- export interface CustomOptions extends DialogOptions { overlay?: boolean; overlayOptions?: OverlayOptions | (() => OverlayOptions); onHandle?: (handle: OverlayHandle) => void }
23
- export type InputHandler = (data: string) => { consume?: boolean; data?: string } | undefined;
22
+
24
23
  export interface ExtensionUI {
25
24
  select(title: string, options: string[], opts?: DialogOptions): Promise<string | undefined>;
26
25
  confirm(title: string, message: string, opts?: DialogOptions): Promise<boolean>;
27
26
  input(title: string, placeholder?: string, opts?: DialogOptions): Promise<string | undefined>;
28
- editor(title: string, prefill?: string): Promise<string | undefined>;
29
- custom<T>(factory: (tui: TUI, theme: ExtensionTheme, bindings: KeybindingsManager, done: (result: T) => void) => ExtensionComponent | Promise<ExtensionComponent>, options?: CustomOptions): Promise<T>;
27
+ /** Read-only block in a dialog; resolves when the user closes it. */
28
+ show(title: string, component: ExtensionComponent): Promise<void>;
30
29
  notify(message: string, type?: "info" | "warning" | "error"): void;
31
- onTerminalInput(handler: InputHandler): () => void;
32
30
  setStatus(key: string, text: string | undefined): void;
33
- setWorkingMessage(message?: string): void; setWorkingVisible(visible: boolean): void;
34
- setWorkingIndicator(options?: { frames?: string[]; intervalMs?: number }): void;
31
+ /** Lines shown above the composer, keyed so an extension can replace or clear its own. */
32
+ setWidget(key: string, lines: string[] | undefined): void;
33
+ setWorkingMessage(message?: string): void;
35
34
  setHiddenThinkingLabel(label?: string): void;
36
- setWidget(key: string, content: string[] | WidgetFactory | undefined, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
37
- setFooter(factory: FooterFactory | undefined): void; setHeader(factory: WidgetFactory | undefined): void;
38
35
  setTitle(title: string): void;
39
- pasteToEditor(text: string): void; setEditorText(text: string): void; getEditorText(): string;
40
- addAutocompleteProvider(factory: AutocompleteFactory): void;
41
- setEditorComponent(factory: EditorFactory | undefined): void; getEditorComponent(): EditorFactory | undefined;
36
+ getEditorText(): string; setEditorText(text: string): void; pasteToEditor(text: string): void;
42
37
  readonly theme: ExtensionTheme;
43
- getAllThemes(): { name: string; path: string | undefined }[];
44
- getTheme(name: string): ExtensionTheme | undefined;
45
- setTheme(theme: string | ExtensionTheme): { success: boolean; error?: string };
46
38
  getToolsExpanded(): boolean; setToolsExpanded(expanded: boolean): void;
47
39
  }
48
40
 
49
- /** Headless extensions can notify and inspect capability; interactive requests fail explicitly. */
41
+ /** Headless extensions can notify; interactive requests fail explicitly. */
50
42
  export function headlessUI(notify: ExtensionUI["notify"]): ExtensionUI {
51
43
  return new Proxy({ notify }, { get(target, key) {
52
44
  if (key === "notify") return target.notify;