@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.
- package/CHANGELOG.md +9 -0
- package/README.md +106 -26
- package/docs/install.md +51 -13
- package/docs/providers.md +11 -0
- package/package.json +5 -4
- package/src/apps/approve.ts +3 -3
- package/src/apps/commands.ts +38 -5
- package/src/apps/completions.ts +73 -19
- package/src/apps/image-draft.ts +46 -0
- package/src/apps/project-trust.ts +2 -42
- package/src/apps/provider-connect.ts +346 -0
- package/src/apps/render.ts +22 -47
- package/src/apps/resources.ts +5 -2
- package/src/apps/shell.ts +298 -1526
- package/src/apps/startup-input.ts +45 -9
- package/src/apps/startup.ts +128 -86
- package/src/apps/ui/app.ts +1383 -0
- package/src/apps/ui/extension-host.ts +194 -0
- package/src/apps/ui/image-cache.ts +78 -0
- package/src/apps/ui/palette.ts +88 -0
- package/src/apps/ui/project.ts +127 -0
- package/src/apps/ui/standalone.ts +146 -0
- package/src/apps/ui/status-model.ts +68 -0
- package/src/apps/ui/surface.ts +84 -0
- package/src/apps/ui/theme.ts +86 -0
- package/src/core/acceptance.ts +107 -0
- package/src/core/action-defaults.ts +47 -295
- package/src/core/benchmark.ts +2 -2
- package/src/core/branch-receipt.ts +24 -0
- package/src/core/checkpoints.ts +64 -0
- package/src/core/churn.ts +2 -0
- package/src/core/command-artifacts.ts +94 -0
- package/src/core/command-contract.ts +35 -0
- package/src/core/completion-review.ts +21 -5
- package/src/core/context-detail.ts +37 -0
- package/src/core/context-policy.ts +74 -1
- package/src/core/continuation.ts +17 -2
- package/src/core/derive.ts +29 -3
- package/src/core/draft-images.ts +56 -0
- package/src/core/environment.ts +145 -0
- package/src/core/evaluation-coverage.ts +74 -0
- package/src/core/event-validation.ts +80 -5
- package/src/core/events.ts +97 -3
- package/src/core/evidence-links.ts +7 -0
- package/src/core/evidence.ts +13 -3
- package/src/core/execution.ts +8 -2
- package/src/core/experiment.ts +133 -0
- package/src/core/export.ts +9 -0
- package/src/core/failure-recovery.ts +173 -0
- package/src/core/file-precondition.ts +72 -0
- package/src/core/handoff.ts +65 -4
- package/src/core/history-query.ts +38 -2
- package/src/core/history.ts +38 -1
- package/src/core/integration-health.ts +34 -0
- package/src/core/keymap.ts +8 -20
- package/src/core/model-picker.ts +64 -0
- package/src/core/native-eval.ts +25 -0
- package/src/core/note-guidance.ts +34 -0
- package/src/core/observation-view.ts +49 -8
- package/src/core/permissions.ts +24 -2
- package/src/core/process-receipt.ts +105 -0
- package/src/core/process-state.ts +37 -0
- package/src/core/project.ts +45 -18
- package/src/core/prompt-policy.ts +37 -0
- package/src/core/query-eval-contract.ts +14 -0
- package/src/core/read-observation.ts +82 -0
- package/src/core/recovery-corpus.ts +17 -0
- package/src/core/recovery-evaluation.ts +60 -0
- package/src/core/recovery-sources.ts +74 -0
- package/src/core/runtime-target.ts +94 -0
- package/src/core/search-observation.ts +97 -0
- package/src/core/session-tree.ts +147 -0
- package/src/core/settings.ts +26 -10
- package/src/core/skills.ts +44 -0
- package/src/core/sql-query.ts +211 -0
- package/src/core/theme.ts +36 -58
- package/src/core/tool-contract.ts +51 -2
- package/src/core/tool-discovery.ts +87 -0
- package/src/core/tool-failure.ts +60 -0
- package/src/core/tool-mode-protocol.ts +25 -0
- package/src/core/transcript.ts +19 -4
- package/src/core/trial-lifecycle.ts +14 -0
- package/src/core/tui-mode.ts +29 -0
- package/src/core/update-check.ts +97 -0
- package/src/core/version.ts +55 -0
- package/src/core/work-state.ts +49 -3
- package/src/core/working-set.ts +91 -0
- package/src/runtime/auth.ts +2 -2
- package/src/runtime/branch-jobs.ts +36 -0
- package/src/runtime/clipboard.ts +15 -4
- package/src/runtime/command-artifacts.ts +64 -0
- package/src/runtime/environment-tools.ts +60 -0
- package/src/runtime/evidence-notes.ts +4 -2
- package/src/runtime/exec-sessions.ts +206 -61
- package/src/runtime/execution-control.ts +1 -0
- package/src/runtime/extension-ui.ts +22 -30
- package/src/runtime/extensions.ts +95 -16
- package/src/runtime/harness-comparison.ts +27 -7
- package/src/runtime/harness-eval.ts +460 -0
- package/src/runtime/history-cache.ts +57 -0
- package/src/runtime/history-tools.ts +62 -12
- package/src/runtime/integrations.ts +210 -0
- package/src/runtime/loop.ts +1021 -268
- package/src/runtime/model-discovery.ts +208 -0
- package/src/runtime/native-eval-fixtures.ts +33 -0
- package/src/runtime/native-eval.ts +143 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/provider-connect.ts +109 -0
- package/src/runtime/provider-presets.ts +87 -0
- package/src/runtime/query-eval-verifier.ts +28 -0
- package/src/runtime/recovery-coverage.ts +71 -0
- package/src/runtime/recovery-evidence.ts +76 -0
- package/src/runtime/recovery-fixtures.ts +116 -0
- package/src/runtime/runtime-target.ts +149 -0
- package/src/runtime/tool-broker.ts +137 -0
- package/src/runtime/tool-discovery.ts +57 -0
- package/src/runtime/tool-mode-eval.ts +146 -0
- package/src/runtime/tool-queue.ts +77 -0
- package/src/runtime/tools.ts +628 -183
- package/src/runtime/trial-control.ts +45 -0
- package/src/runtime/update-check.ts +47 -0
- package/src/runtime/window-trial.ts +2 -1
- package/src/runtime/working-notes.ts +33 -3
- package/src/runtime/workspace-lifecycle.ts +97 -0
- package/src/store/acceptance.ts +15 -0
- package/src/store/checkpoint-history.ts +43 -0
- package/src/store/command-artifacts.ts +96 -0
- package/src/store/command.ts +65 -37
- package/src/store/file-checkpoints.ts +104 -0
- package/src/store/file-index.ts +13 -1
- package/src/store/git-checkpoints.ts +67 -0
- package/src/store/observations.ts +61 -20
- package/src/store/process-receipts.ts +34 -0
- package/src/store/query-source.ts +13 -8
- package/src/store/recovery.ts +2 -3
- package/src/store/replay.ts +16 -4
- package/src/store/runtime-target.ts +64 -0
- package/src/store/search.ts +171 -132
- package/src/store/session-archive.ts +4 -0
- package/src/store/source-cancellation.ts +6 -0
- package/src/store/update-cache.ts +36 -0
- package/src/store/workspace-observation.ts +113 -0
- package/src/tui/ansi-spans.ts +126 -0
- package/src/tui/style.ts +19 -16
- package/src/tui/ui/atomic-draft.ts +121 -0
- package/src/tui/ui/canvas.ts +96 -0
- package/src/tui/ui/colors.ts +70 -0
- package/src/tui/ui/composer.ts +199 -0
- package/src/tui/ui/dialog.ts +379 -0
- package/src/tui/ui/draft.ts +23 -0
- package/src/tui/ui/edge.ts +186 -0
- package/src/tui/ui/image-size.ts +40 -0
- package/src/tui/ui/ink.ts +210 -0
- package/src/tui/ui/items.ts +40 -0
- package/src/tui/ui/keys.ts +40 -0
- package/src/tui/ui/layout.ts +74 -0
- package/src/tui/ui/list-model.ts +86 -0
- package/src/tui/ui/list-view.ts +172 -0
- package/src/tui/ui/mascot.ts +67 -0
- package/src/tui/ui/messages.ts +518 -0
- package/src/tui/ui/motion.ts +28 -0
- package/src/tui/ui/paths.ts +10 -0
- package/src/tui/ui/pictures.ts +78 -0
- package/src/tui/ui/screen.ts +421 -0
- package/src/tui/ui/status.ts +271 -0
- package/src/tui/ui/styled.ts +85 -0
- package/src/tui/ui/testing.ts +5 -0
- package/src/tui/ui/tool-card.ts +150 -0
- package/src/apps/extension-ui.ts +0 -330
- package/src/apps/resource-browser.ts +0 -75
- package/src/apps/session-browser.ts +0 -113
- package/src/apps/session-picker.ts +0 -55
- package/src/tui/ansi.ts +0 -67
- package/src/tui/autocomplete.ts +0 -29
- package/src/tui/chat-viewport.ts +0 -47
- package/src/tui/complete.ts +0 -87
- package/src/tui/editor.ts +0 -940
- package/src/tui/host.ts +0 -264
- package/src/tui/input.ts +0 -75
- package/src/tui/keys.ts +0 -378
- package/src/tui/markdown.ts +0 -17
- package/src/tui/render.ts +0 -153
- package/src/tui/select.ts +0 -258
- package/src/tui/settings.ts +0 -44
- package/src/tui/statusline.ts +0 -162
- package/src/tui/system-theme.ts +0 -700
- package/src/tui/terminal.ts +0 -30
- package/src/tui/text.ts +0 -52
- package/src/tui/toolview.ts +0 -386
- package/src/tui/transcript.ts +0 -123
- package/src/tui/tree.ts +0 -471
- package/src/vendor/pi-tui/LICENSE.upstream +0 -21
- package/src/vendor/pi-tui/UPSTREAM.json +0 -50
- package/src/vendor/pi-tui/VENDOR.md +0 -25
- package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
- package/src/vendor/pi-tui/autocomplete.ts +0 -860
- package/src/vendor/pi-tui/colors.ts +0 -367
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
- package/src/vendor/pi-tui/components/box.ts +0 -166
- package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
- package/src/vendor/pi-tui/components/editor.ts +0 -2472
- package/src/vendor/pi-tui/components/h-stack.ts +0 -44
- package/src/vendor/pi-tui/components/image.ts +0 -127
- package/src/vendor/pi-tui/components/input.ts +0 -494
- package/src/vendor/pi-tui/components/loader.ts +0 -101
- package/src/vendor/pi-tui/components/markdown.ts +0 -1021
- package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
- package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
- package/src/vendor/pi-tui/components/select-list.ts +0 -273
- package/src/vendor/pi-tui/components/settings-list.ts +0 -328
- package/src/vendor/pi-tui/components/spacer.ts +0 -28
- package/src/vendor/pi-tui/components/stack.ts +0 -154
- package/src/vendor/pi-tui/components/text.ts +0 -107
- package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
- package/src/vendor/pi-tui/components/v-stack.ts +0 -33
- package/src/vendor/pi-tui/editor-component.ts +0 -74
- package/src/vendor/pi-tui/fuzzy.ts +0 -138
- package/src/vendor/pi-tui/index.ts +0 -186
- package/src/vendor/pi-tui/keybindings.ts +0 -320
- package/src/vendor/pi-tui/keys.ts +0 -1401
- package/src/vendor/pi-tui/kill-ring.ts +0 -46
- package/src/vendor/pi-tui/latex.ts +0 -1506
- package/src/vendor/pi-tui/layout-node.ts +0 -51
- package/src/vendor/pi-tui/layout.ts +0 -449
- package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
- package/src/vendor/pi-tui/native/clipboard.h +0 -208
- package/src/vendor/pi-tui/native/darwin/README.md +0 -22
- package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
- package/src/vendor/pi-tui/native/linux/README.md +0 -23
- package/src/vendor/pi-tui/native/linux/build.sh +0 -65
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
- package/src/vendor/pi-tui/native/napi.h +0 -91
- package/src/vendor/pi-tui/native/win32/README.md +0 -31
- package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
- package/src/vendor/pi-tui/native-modifiers.ts +0 -13
- package/src/vendor/pi-tui/native-module-path.ts +0 -31
- package/src/vendor/pi-tui/native-platform.ts +0 -65
- package/src/vendor/pi-tui/oklab.ts +0 -233
- package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
- package/src/vendor/pi-tui/terminal-colors.ts +0 -91
- package/src/vendor/pi-tui/terminal-image.ts +0 -731
- package/src/vendor/pi-tui/terminal.ts +0 -554
- package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
- package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
- package/src/vendor/pi-tui/tui.ts +0 -1493
- package/src/vendor/pi-tui/undo-stack.ts +0 -28
- package/src/vendor/pi-tui/utils.ts +0 -1385
- package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
- 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
|
|
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),
|
|
18
|
-
*
|
|
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 "../
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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) =>
|
|
105
|
-
return { error: `refused: ${MAX_SESSIONS}
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
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([
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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",
|
|
275
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
177
276
|
});
|
|
178
|
-
await Promise.race([session.exited, deadline, aborted]);
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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 <=
|
|
201
|
-
for await (const chunk of createReadStream(path, { start
|
|
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
|
|
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
|
-
|
|
209
|
-
|
|
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
|
|
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
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
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;
|