@visual-z/xeno 0.8.1 → 0.8.3
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 +66 -0
- package/docs/install.md +25 -0
- package/package.json +1 -1
- package/src/apps/cli.ts +4 -1
- package/src/apps/shell.ts +13 -3
- package/src/apps/startup.ts +31 -6
- package/src/apps/ui/app.ts +32 -14
- package/src/apps/ui/project.ts +1 -1
- package/src/apps/ui/standalone.ts +4 -2
- package/src/apps/ui/surface.ts +2 -0
- package/src/core/activity-status.ts +27 -3
- package/src/core/command-contract.ts +121 -5
- package/src/core/completion-review.ts +1 -1
- package/src/core/derive.ts +9 -1
- package/src/core/environment.ts +7 -0
- package/src/core/event-validation.ts +19 -3
- package/src/core/events.ts +38 -2
- package/src/core/evidence.ts +149 -7
- package/src/core/export.ts +2 -0
- package/src/core/external-effects.ts +488 -0
- package/src/core/handoff.ts +24 -3
- package/src/core/history-query.ts +1 -1
- package/src/core/history.ts +131 -21
- package/src/core/observation-view.ts +24 -0
- package/src/core/path-suggestions.ts +59 -0
- package/src/core/permissions.ts +64 -3
- package/src/core/prompt-policy.ts +11 -2
- package/src/core/regex-dialect.ts +25 -0
- package/src/core/ripgrep-release.ts +64 -0
- package/src/core/secrets.ts +188 -0
- package/src/core/stall.ts +63 -18
- package/src/core/stop-report.ts +46 -4
- package/src/core/tool-contract.ts +2 -0
- package/src/core/tool-failure.ts +25 -4
- package/src/core/transcript.ts +3 -0
- package/src/core/update-check.ts +2 -1
- package/src/core/work-state.ts +7 -2
- package/src/core/working-set.ts +34 -6
- package/src/runtime/connection-reuse.ts +40 -0
- package/src/runtime/exec-sessions.ts +69 -19
- package/src/runtime/history-tools.ts +12 -2
- package/src/runtime/loop.ts +196 -26
- package/src/runtime/open-image.ts +42 -0
- package/src/runtime/openai-responses.ts +15 -7
- package/src/runtime/openai.ts +15 -7
- package/src/runtime/permission-resources.ts +11 -4
- package/src/runtime/pi-provider.ts +34 -9
- package/src/runtime/provider.ts +19 -8
- package/src/runtime/retry.ts +152 -9
- package/src/runtime/secrets.ts +79 -0
- package/src/runtime/session-control.ts +9 -1
- package/src/runtime/stream-idle.ts +80 -0
- package/src/runtime/tools.ts +139 -31
- package/src/runtime/turn-effects.ts +31 -0
- package/src/store/command.ts +3 -1
- package/src/store/context-files.ts +5 -1
- package/src/store/file-checkpoints.ts +6 -3
- package/src/store/log.ts +20 -0
- package/src/store/replay.ts +4 -3
- package/src/store/search.ts +9 -5
- package/src/store/secrets.ts +44 -0
- package/src/store/sessions.ts +49 -6
- package/src/store/tool-binaries.ts +90 -0
- package/src/store/worktree.ts +55 -10
- package/src/tui/ui/image-size.ts +6 -2
- package/src/tui/ui/items.ts +3 -1
- package/src/tui/ui/pictures.ts +14 -1
- package/src/tui/ui/screen.ts +6 -3
- package/src/tui/ui/status.ts +16 -0
|
@@ -23,6 +23,8 @@ import { join } from "node:path";
|
|
|
23
23
|
import { randomUUID } from "node:crypto";
|
|
24
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 { configDir } from "../store/config.ts";
|
|
27
|
+
import { xenoBinDir } from "../store/tool-binaries.ts";
|
|
26
28
|
import { ToolFault, NOT_STARTED } from "../core/tool-failure.ts";
|
|
27
29
|
import { processReceipt, retainProcessReceipts, type ProcessReceipt, type ProcessSource } from "../core/process-receipt.ts";
|
|
28
30
|
import type { CommandArtifactObserver } from "./command-artifacts.ts";
|
|
@@ -44,6 +46,19 @@ export function yieldWait(value: unknown): { ms: number; note: string } {
|
|
|
44
46
|
const ms = Math.min(MAX_YIELD_MS, Math.max(MIN_YIELD_MS, requested));
|
|
45
47
|
return { ms, note: ms === requested ? "" : ` · yield_time_ms ${requested} clamped to ${ms}` };
|
|
46
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Bounds on one owned empty wait's window, copied from Codex's empty
|
|
51
|
+
* `write_stdin` (MIN_EMPTY_YIELD_TIME_MS, DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS),
|
|
52
|
+
* not measured. The floor is what stops a chatty process from waking the
|
|
53
|
+
* model every line: below it, a 100 ms request brings the per-line wakeups back.
|
|
54
|
+
*/
|
|
55
|
+
export const MIN_EMPTY_YIELD_MS = 5_000;
|
|
56
|
+
export const MAX_EMPTY_YIELD_MS = 300_000;
|
|
57
|
+
export function emptyYieldWait(value: unknown): { ms: number; note: string } {
|
|
58
|
+
const requested = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_YIELD_MS;
|
|
59
|
+
const ms = Math.min(MAX_EMPTY_YIELD_MS, Math.max(MIN_EMPTY_YIELD_MS, requested));
|
|
60
|
+
return { ms, note: ms === requested ? "" : ` · yield_time_ms ${requested} clamped to ${ms}` };
|
|
61
|
+
}
|
|
47
62
|
/**
|
|
48
63
|
* Concurrent sessions. An assumption, not a measurement: comfortably above a
|
|
49
64
|
* server + watcher + REPL, low enough that a runaway loop of starts is refused
|
|
@@ -59,6 +74,12 @@ export const CLOSED_RECEIPT_AGE_MS = 10 * 60_000;
|
|
|
59
74
|
type TakenOutput = {
|
|
60
75
|
stored: ArchivedObservation; receipt: ProcessReceipt; wallMs: number;
|
|
61
76
|
exit: number | null; stopped?: "timeout" | "cancelled";
|
|
77
|
+
/**
|
|
78
|
+
* Input that arrived after the process exited and was not written:
|
|
79
|
+
* "control" for interrupt/EOF, whose intent the exit already satisfied;
|
|
80
|
+
* "refused" for real input, which the caller must report as not sent.
|
|
81
|
+
*/
|
|
82
|
+
unsent?: "control" | "refused";
|
|
62
83
|
};
|
|
63
84
|
type ClosedSession = { owner?: string; id: number; endedAt: number; receipt: ProcessReceipt };
|
|
64
85
|
|
|
@@ -77,7 +98,6 @@ interface Session {
|
|
|
77
98
|
spoolPath: string;
|
|
78
99
|
queue: Promise<unknown>;
|
|
79
100
|
operations: Promise<unknown>;
|
|
80
|
-
outputListeners: Set<() => void>;
|
|
81
101
|
final?: TakenOutput;
|
|
82
102
|
captureError?: string;
|
|
83
103
|
written: number;
|
|
@@ -149,7 +169,6 @@ function append(session: Session, chunk: Uint8Array): void {
|
|
|
149
169
|
if (bytesWritten < 1) throw new Error("process output spool made no write progress");
|
|
150
170
|
offset += bytesWritten; session.written += bytesWritten;
|
|
151
171
|
}
|
|
152
|
-
for (const notify of session.outputListeners) notify();
|
|
153
172
|
}).catch(error => {
|
|
154
173
|
session.captureError = `process output capture failed: ${String(error)}`;
|
|
155
174
|
stopSession(session, "cancelled");
|
|
@@ -170,12 +189,12 @@ export async function startSession(input: {
|
|
|
170
189
|
await mkdir(input.spoolDir, { recursive: true });
|
|
171
190
|
const spoolPath = join(input.spoolDir, `.${randomUUID()}.session`);
|
|
172
191
|
const spool = await open(spoolPath, "w+", 0o600);
|
|
173
|
-
const env = commandEnvironment(process.env);
|
|
192
|
+
const env = commandEnvironment(process.env, xenoBinDir(configDir()));
|
|
174
193
|
let resolveExit!: () => void;
|
|
175
194
|
const exited = new Promise<void>((resolve) => { resolveExit = resolve; });
|
|
176
195
|
const session = {
|
|
177
196
|
id: nextId++, ...(input.owner === undefined ? {} : { owner: input.owner }), command: input.command, cwd: input.cwd, tty: input.tty, source: input.source, started: performance.now(),
|
|
178
|
-
spool, spoolPath, queue: Promise.resolve(), operations: Promise.resolve(),
|
|
197
|
+
spool, spoolPath, queue: Promise.resolve(), operations: Promise.resolve(), written: 0, read: 0,
|
|
179
198
|
exit: null, timeoutMs: input.timeoutMs, exited, drained: Promise.resolve(),
|
|
180
199
|
...(input.artifactObserver ? { artifactObserver: input.artifactObserver } : {}),
|
|
181
200
|
} as Omit<Session, "pid" | "write" | "kill" | "timer"> as Session;
|
|
@@ -300,26 +319,42 @@ export interface ObservationWait {
|
|
|
300
319
|
/** Steering releases this observation wait without cancelling the owned command. */
|
|
301
320
|
wake?: AbortSignal;
|
|
302
321
|
onWaiting?: (active: boolean) => void;
|
|
322
|
+
/** One window; output is delivered at a window's end, never as it arrives. */
|
|
323
|
+
windowMs: number;
|
|
303
324
|
}
|
|
304
325
|
|
|
305
|
-
/**
|
|
326
|
+
/**
|
|
327
|
+
* OMP's job owner pattern: no paid model turn merely to observe elapsed time.
|
|
328
|
+
* Exit, timeout, cancellation and steering end the wait at once. Output does
|
|
329
|
+
* not: it is collected until the window ends, and a window with nothing new
|
|
330
|
+
* rolls into the next. Waking per byte made a process that prints a warning
|
|
331
|
+
* every few seconds cost one model request per line (session mv4wyiyg-lebu,
|
|
332
|
+
* fix-owned-wait-coalesce-output); Codex collects to the deadline the same way.
|
|
333
|
+
*/
|
|
306
334
|
async function waitForObservation(session: Session, signal: AbortSignal | undefined, wait: ObservationWait): Promise<void> {
|
|
307
335
|
await session.queue;
|
|
308
|
-
let
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
wait.wake?.
|
|
313
|
-
const pending =
|
|
336
|
+
let interrupt!: () => void;
|
|
337
|
+
const interrupted = new Promise<void>(resolve => { interrupt = resolve; });
|
|
338
|
+
signal?.addEventListener("abort", interrupt, { once: true });
|
|
339
|
+
wait.wake?.addEventListener("abort", interrupt, { once: true });
|
|
340
|
+
const ended = () => session.exit !== null || signal?.aborted === true || wait.wake?.aborted === true;
|
|
341
|
+
const pending = !ended();
|
|
342
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
314
343
|
try {
|
|
315
344
|
if (pending) {
|
|
316
345
|
wait.onWaiting?.(true);
|
|
317
|
-
|
|
346
|
+
while (!ended()) {
|
|
347
|
+
const deadline = new Promise<void>(resolve => { timer = setTimeout(resolve, wait.windowMs); });
|
|
348
|
+
await Promise.race([session.exited, interrupted, deadline]);
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
await session.queue;
|
|
351
|
+
if (session.written > session.read) break;
|
|
352
|
+
}
|
|
318
353
|
}
|
|
319
354
|
} finally {
|
|
320
|
-
|
|
321
|
-
signal?.removeEventListener("abort",
|
|
322
|
-
wait.wake?.removeEventListener("abort",
|
|
355
|
+
clearTimeout(timer);
|
|
356
|
+
signal?.removeEventListener("abort", interrupt);
|
|
357
|
+
wait.wake?.removeEventListener("abort", interrupt);
|
|
323
358
|
if (pending) wait.onWaiting?.(false);
|
|
324
359
|
}
|
|
325
360
|
if (signal?.aborted) stopSession(session, "cancelled");
|
|
@@ -369,6 +404,17 @@ async function takeOutput(session: Session, outputStore: string, previewBytes: n
|
|
|
369
404
|
return taken;
|
|
370
405
|
}
|
|
371
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Input made only of Ctrl-C / Ctrl-D. Sent to a process that has already
|
|
409
|
+
* exited, it asks for something the exit already did (stop, end of input), so
|
|
410
|
+
* it is answered as a poll rather than refused -- otherwise the exit and the
|
|
411
|
+
* unread tail wait for a second call the model may never make (ticket session
|
|
412
|
+
* mv4ugd29, write-stdin-exited card).
|
|
413
|
+
*/
|
|
414
|
+
export function isControlInput(chars: string): boolean {
|
|
415
|
+
return chars.length > 0 && /^[\u0003\u0004]+$/.test(chars);
|
|
416
|
+
}
|
|
417
|
+
|
|
372
418
|
/** One lease covers stdin, waiting, archival and cursor commit; cancellation can interrupt a queued wait. */
|
|
373
419
|
export async function pollSession(session: Session, outputStore: string, previewBytes: number, yieldMs: number,
|
|
374
420
|
chars = "", signal?: AbortSignal, wait?: ObservationWait): Promise<TakenOutput> {
|
|
@@ -376,16 +422,20 @@ export async function pollSession(session: Session, outputStore: string, preview
|
|
|
376
422
|
if (signal?.aborted) cancel(); else signal?.addEventListener("abort", cancel, { once: true });
|
|
377
423
|
const result = session.operations.then(async () => {
|
|
378
424
|
if (session.final) {
|
|
379
|
-
if (chars.length > 0) throw new ToolFault("not_found", "process is closed; input was not sent", NOT_STARTED);
|
|
425
|
+
if (chars.length > 0 && !isControlInput(chars)) throw new ToolFault("not_found", "process is closed; input was not sent", NOT_STARTED);
|
|
380
426
|
return { ...session.final, receipt: { ...session.final.receipt, replayed: true } };
|
|
381
427
|
}
|
|
428
|
+
// An exit not yet reported is still delivered with any late input: the
|
|
429
|
+
// input is never written, but the exit and unread output are not withheld.
|
|
430
|
+
let unsent: TakenOutput["unsent"];
|
|
382
431
|
if (chars.length > 0) {
|
|
383
|
-
if (session.exit !== null)
|
|
384
|
-
session.write(chars);
|
|
432
|
+
if (session.exit !== null) unsent = isControlInput(chars) ? "control" : "refused";
|
|
433
|
+
else session.write(chars);
|
|
385
434
|
}
|
|
386
435
|
if (chars.length === 0 && wait) await waitForObservation(session, signal, wait);
|
|
387
436
|
else await waitSession(session, yieldMs, signal);
|
|
388
|
-
|
|
437
|
+
const taken = await takeOutput(session, outputStore, previewBytes);
|
|
438
|
+
return unsent === undefined ? taken : { ...taken, unsent };
|
|
389
439
|
});
|
|
390
440
|
session.operations = result.catch(() => {});
|
|
391
441
|
try { return await result; }
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { deriveHandoff } from "../core/derive.ts";
|
|
4
4
|
import type { Handoff } from "../core/handoff.ts";
|
|
5
|
-
import { HISTORY_SECTIONS, historyPage, type HistorySection } from "../core/history.ts";
|
|
5
|
+
import { HISTORY_SECTIONS, historyPage, userTurnImages, type HistorySection } from "../core/history.ts";
|
|
6
6
|
import { historyEntries, historyHits, historyIndex, queryHistory, type HistoryEntry } from "../core/history-query.ts";
|
|
7
7
|
import { resourceKey } from "../core/evidence-links.ts";
|
|
8
8
|
import { canonicalValue } from "../core/continuation.ts";
|
|
@@ -87,7 +87,12 @@ export async function runHistoryTool(logDir: string, currentNode: number, ctx: T
|
|
|
87
87
|
if (typeof args["event"] === "string") {
|
|
88
88
|
const found = indexed.byEvent.get(args["event"]);
|
|
89
89
|
if (!found) throw new ToolFault("not_found", "event is not readable in the active ancestry", READ_ATTEMPTED);
|
|
90
|
-
|
|
90
|
+
// A carried user turn names its images by digest only (D8). Reading the
|
|
91
|
+
// original turn reattaches them as a tool result, so their image tokens are
|
|
92
|
+
// paid once, when asked for, instead of in every later handoff.
|
|
93
|
+
const original = found.kind === "user" ? history.nodes.flatMap(part => part.events).find(event => event.id === found.event) : undefined;
|
|
94
|
+
const images = original?.t === "user" ? original.images ?? [] : [];
|
|
95
|
+
return { ...await historyValue(ctx, found), ...(images.length ? { images } : {}) };
|
|
91
96
|
}
|
|
92
97
|
if (typeof args["query"] === "string" || args["recent"] === true || typeof args["resource"] === "string" || typeof args["work_id"] === "string") {
|
|
93
98
|
const query = typeof args["query"] === "string" ? args["query"] : "";
|
|
@@ -144,6 +149,11 @@ export async function runHistoryTool(logDir: string, currentNode: number, ctx: T
|
|
|
144
149
|
if (part.node === node) break;
|
|
145
150
|
}
|
|
146
151
|
if (full === null) throw new ToolFault("not_found", "empty history", READ_ATTEMPTED);
|
|
152
|
+
// Pages match a v3 carried suffix: user turns name their images by digest (D8).
|
|
153
|
+
if (args["section"] === "user_turns") {
|
|
154
|
+
const at = history.nodes.findIndex(part => part.node === node);
|
|
155
|
+
full = { ...full, user_turns: userTurnImages(full.user_turns, history.nodes.slice(0, at + 1)) };
|
|
156
|
+
}
|
|
147
157
|
const page = historyPage(full, args["section"] as HistorySection,
|
|
148
158
|
typeof args["offset"] === "number" ? args["offset"] : 0,
|
|
149
159
|
typeof args["limit"] === "number" ? Math.min(args["limit"], 100) : 20);
|