@visual-z/xeno 0.8.2 → 0.8.4

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 (60) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/package.json +1 -1
  3. package/src/apps/review.ts +59 -59
  4. package/src/apps/shell.ts +20 -5
  5. package/src/apps/startup.ts +4 -4
  6. package/src/apps/ui/app.ts +143 -20
  7. package/src/apps/ui/project.ts +1 -1
  8. package/src/apps/ui/standalone.ts +4 -2
  9. package/src/apps/ui/surface.ts +2 -0
  10. package/src/core/activity-status.ts +17 -2
  11. package/src/core/command-contract.ts +58 -16
  12. package/src/core/completion-review.ts +1 -1
  13. package/src/core/derive.ts +9 -1
  14. package/src/core/event-validation.ts +19 -3
  15. package/src/core/events.ts +38 -2
  16. package/src/core/evidence.ts +149 -7
  17. package/src/core/export.ts +2 -0
  18. package/src/core/external-effects.ts +488 -0
  19. package/src/core/handoff.ts +24 -3
  20. package/src/core/history-query.ts +1 -1
  21. package/src/core/history.ts +124 -20
  22. package/src/core/observation-view.ts +24 -0
  23. package/src/core/path-suggestions.ts +59 -0
  24. package/src/core/permissions.ts +60 -0
  25. package/src/core/prompt-policy.ts +4 -2
  26. package/src/core/secrets.ts +188 -0
  27. package/src/core/stop-report.ts +25 -3
  28. package/src/core/tool-failure.ts +11 -3
  29. package/src/core/transcript.ts +3 -0
  30. package/src/core/work-state.ts +7 -2
  31. package/src/core/working-set.ts +34 -6
  32. package/src/runtime/connection-reuse.ts +40 -0
  33. package/src/runtime/exec-sessions.ts +66 -18
  34. package/src/runtime/history-tools.ts +12 -2
  35. package/src/runtime/loop.ts +264 -73
  36. package/src/runtime/open-image.ts +42 -0
  37. package/src/runtime/openai-responses.ts +15 -7
  38. package/src/runtime/openai.ts +15 -7
  39. package/src/runtime/permission-resources.ts +11 -4
  40. package/src/runtime/pi-provider.ts +34 -9
  41. package/src/runtime/provider.ts +19 -8
  42. package/src/runtime/retry.ts +152 -9
  43. package/src/runtime/secrets.ts +79 -0
  44. package/src/runtime/session-control.ts +9 -1
  45. package/src/runtime/stream-idle.ts +80 -0
  46. package/src/runtime/tools.ts +97 -27
  47. package/src/runtime/turn-effects.ts +31 -0
  48. package/src/store/context-files.ts +5 -1
  49. package/src/store/file-checkpoints.ts +1 -1
  50. package/src/store/log.ts +20 -0
  51. package/src/store/replay.ts +4 -3
  52. package/src/store/secrets.ts +44 -0
  53. package/src/store/sessions.ts +49 -6
  54. package/src/tui/ui/branch-panel.ts +47 -0
  55. package/src/tui/ui/composer.ts +27 -28
  56. package/src/tui/ui/image-size.ts +6 -2
  57. package/src/tui/ui/items.ts +3 -1
  58. package/src/tui/ui/pictures.ts +14 -1
  59. package/src/tui/ui/prompt-history.ts +60 -0
  60. package/src/tui/ui/screen.ts +34 -3
@@ -50,6 +50,10 @@ export function scopedWorkId(namespace: string, requested: string, items: readon
50
50
  ? requested : `${namespace}:${requested}`;
51
51
  }
52
52
 
53
+ function unknownWork(id: string): string {
54
+ return `unknown work_id ${id}; create it with a next note (kind=next) first`;
55
+ }
56
+
53
57
  export function workChangeError(items: readonly WorkItem[], event: LogEvent): string | null {
54
58
  if (!("work_id" in event) || event.work_id === undefined) return null;
55
59
  const item = items.find(item => item.id === event.work_id);
@@ -57,9 +61,10 @@ export function workChangeError(items: readonly WorkItem[], event: LogEvent): st
57
61
  const error = dependencyError(items, event.work_id, event.depends_on);
58
62
  if (error) return error;
59
63
  }
60
- if (event.t === "work_dependencies" && !item) return `unknown work_id ${event.work_id}`;
64
+ // Every unknown-id rejection names the way to make the id exist, or the model retries the same call.
65
+ if (event.t === "work_dependencies" && !item) return unknownWork(event.work_id);
61
66
  if (event.t === "work_owner") {
62
- if (!item) return `unknown work_id ${event.work_id}`;
67
+ if (!item) return unknownWork(event.work_id);
63
68
  if (event.operation === "release") return item.owner === event.owner ? null : "only the recorded owner can release work";
64
69
  const ready = workReadiness(items, item.id, event.owner);
65
70
  return ready.ready ? null : `work ${item.id} is ${ready.reason}; cannot claim it`;
@@ -7,9 +7,17 @@ import { estimateTokens } from "./tokens.ts";
7
7
  import { RECOVERY_SECTIONS, recoverySources, type RecoveryNodes, type RecoveryRanges, type RecoveryReference } from "./recovery-sources.ts";
8
8
  import { recoveryInstructions } from "./prompt-policy.ts";
9
9
 
10
+ /**
11
+ * The cut format new sessions record. Live cuts, cold replay and interrupted-cut
12
+ * repair all select through `carriedHandoff`, so this is only the default for
13
+ * cuts not yet written; a recorded version is never reinterpreted.
14
+ */
15
+ export const CURRENT_WORKING_SET_VERSION = 3;
16
+
10
17
  export type WorkingSet = {
11
- version: 1 | 2; source: NodeRef;
12
- work: (Pick<WorkItem, "id" | "what" | "status" | "next_action" | "reason" | "depends_on" | "owner"> & { readiness?: WorkReadiness })[];
18
+ version: 1 | 2 | 3; source: NodeRef;
19
+ /** v3 omits `what`, `next_action` and `reason` when the carried `state.work_items` holds the same item. */
20
+ work: (Pick<WorkItem, "id" | "status" | "next_action" | "reason" | "depends_on" | "owner"> & { what?: string; readiness?: WorkReadiness })[];
13
21
  references: ({ node?: number; event: string; kind: string; work?: string } & Partial<Omit<RecoveryReference, "node" | "event" | "kind" | "work">>)[];
14
22
  omitted: { work: number; references: number };
15
23
  recovery_sections?: RecoveryRanges;
@@ -50,20 +58,40 @@ export function workingSet(full: Handoff, budget: number): WorkingSet | null {
50
58
 
51
59
  /** R9: a new projection version keeps the original v1 ordering byte-stable. */
52
60
  export function recoveryWorkingSet(full: Handoff, nodes: RecoveryNodes, budget: number, tools?: readonly string[]): WorkingSet | null {
61
+ return sourceView(full, nodes, { version: 2, users: true,
62
+ fits: view => estimateTokens.count([], emitYaml(view)) <= budget,
63
+ recovery: tools === undefined ? "Read original user messages before continuing: history(node=source.node,recovery=users,offset=0). Other pages: failures, environment, next_actions; event=ID reads one original. Abandoned messages are cancelled; annotations are not user instructions. Observations are historical: recheck resource, host and current permissions before acting. history(work_id=ID) recovers work." : recoveryInstructions(tools) });
64
+ }
65
+
66
+ /**
67
+ * v3 shares v2's source ordering. Two things differ, both decided by the caller
68
+ * that owns the whole carried handoff: `fits` measures the view inside that
69
+ * handoff rather than standalone, and `users: false` leaves user sources out
70
+ * because the carried `user_turns` already hold every one verbatim.
71
+ */
72
+ export function recoveryWorkingSetV3(full: Handoff, nodes: RecoveryNodes, options: {
73
+ users: boolean; recovery: string; fits: (view: WorkingSet) => boolean;
74
+ }): WorkingSet | null {
75
+ return sourceView(full, nodes, { version: 3, ...options });
76
+ }
77
+
78
+ function sourceView(full: Handoff, nodes: RecoveryNodes, options: {
79
+ version: 2 | 3; users: boolean; recovery: string; fits: (view: WorkingSet) => boolean;
80
+ }): WorkingSet | null {
53
81
  const legacy = workingSet(full, Infinity);
54
82
  if (!legacy) return null;
55
83
  const sources = recoverySources(nodes);
56
- const view: WorkingSet = { version: 2, source: legacy.source, work: [], references: [],
84
+ const view: WorkingSet = { version: options.version, source: legacy.source, work: [], references: [],
57
85
  omitted: { work: legacy.work.length, references: legacy.references.length + RECOVERY_SECTIONS.reduce((sum, section) => sum + sources[section].length, 0) },
58
86
  recovery_sections: Object.fromEntries(RECOVERY_SECTIONS.map(section => [section, { total: sources[section].length, omitted: sources[section].length }])) as RecoveryRanges,
59
- recovery: tools === undefined ? "Read original user messages before continuing: history(node=source.node,recovery=users,offset=0). Other pages: failures, environment, next_actions; event=ID reads one original. Abandoned messages are cancelled; annotations are not user instructions. Observations are historical: recheck resource, host and current permissions before acting. history(work_id=ID) recovers work." : recoveryInstructions(tools) };
60
- const fits = () => estimateTokens.count([], emitYaml(view)) <= budget;
87
+ recovery: options.recovery };
88
+ const fits = () => options.fits(view);
61
89
  if (!fits()) return null;
62
90
  const queues = RECOVERY_SECTIONS.map(section => ({ section, items: [...sources[section]], at: 0 }));
63
91
  // The latest and first live user messages anchor correction and initial scope;
64
92
  // remaining user sources are newest first. Cancelled turns stay pageable.
65
93
  const live = sources.users.filter(ref => !ref.abandoned), first = live.at(-1);
66
- queues[0]!.items = [...live.slice(0, 1), ...(first && first !== live[0] ? [first] : []),
94
+ queues[0]!.items = !options.users ? [] : [...live.slice(0, 1), ...(first && first !== live[0] ? [first] : []),
67
95
  ...live.slice(1, -1), ...sources.users.filter(ref => ref.abandoned)];
68
96
  const takeReference = (reference: WorkingSet["references"][number], section?: keyof RecoveryRanges) => {
69
97
  view.references.push(reference); view.omitted.references--;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Whether a provider may keep reusing pooled connections.
3
+ *
4
+ * A socket that a proxy, NAT or server dropped while it sat in the pool fails
5
+ * the next request the same way -- Horizyn 2026-10-12 lost the first request
6
+ * after "continue" to it. Claude Code answers by turning keep-alive off for
7
+ * the rest of the process once a connection dies; this does the same per
8
+ * provider instance, so tests sharing a process cannot leak state into each
9
+ * other and a fresh session starts from the cheap path again
10
+ * (feat-fresh-connection-after-drop).
11
+ *
12
+ * Bun honours `keepalive: false` by opening a new socket per request
13
+ * (measured on 1.3.10: three default fetches share one socket, three with
14
+ * keepalive:false open three). The cost is a TLS handshake per request,
15
+ * negligible next to a model call. Google, Vertex and Bedrock go through
16
+ * pi-ai's native HTTP with no fetch hook, so this cannot reach them.
17
+ */
18
+ import { connectionFailure } from "./retry.ts";
19
+
20
+ export class ConnectionReuse {
21
+ private fresh = false;
22
+
23
+ /**
24
+ * Called with every failed attempt, retried or final. Only a connection
25
+ * that existed and died counts: one that never opened left nothing stale
26
+ * in the pool.
27
+ */
28
+ note(error: unknown): void {
29
+ if (connectionFailure(error) === "stale") this.fresh = true;
30
+ }
31
+
32
+ init(init: RequestInit | undefined): RequestInit | undefined {
33
+ return this.fresh ? { ...init, keepalive: false } : init;
34
+ }
35
+
36
+ /** A fetch that opens fresh connections once one has died. */
37
+ wrap(fetchImpl: typeof fetch): typeof fetch {
38
+ return Object.assign((input: RequestInfo | URL, init?: RequestInit) => fetchImpl(input, this.init(init)), { preconnect: fetchImpl.preconnect });
39
+ }
40
+ }
@@ -46,6 +46,19 @@ export function yieldWait(value: unknown): { ms: number; note: string } {
46
46
  const ms = Math.min(MAX_YIELD_MS, Math.max(MIN_YIELD_MS, requested));
47
47
  return { ms, note: ms === requested ? "" : ` · yield_time_ms ${requested} clamped to ${ms}` };
48
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
+ }
49
62
  /**
50
63
  * Concurrent sessions. An assumption, not a measurement: comfortably above a
51
64
  * server + watcher + REPL, low enough that a runaway loop of starts is refused
@@ -61,6 +74,12 @@ export const CLOSED_RECEIPT_AGE_MS = 10 * 60_000;
61
74
  type TakenOutput = {
62
75
  stored: ArchivedObservation; receipt: ProcessReceipt; wallMs: number;
63
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";
64
83
  };
65
84
  type ClosedSession = { owner?: string; id: number; endedAt: number; receipt: ProcessReceipt };
66
85
 
@@ -79,7 +98,6 @@ interface Session {
79
98
  spoolPath: string;
80
99
  queue: Promise<unknown>;
81
100
  operations: Promise<unknown>;
82
- outputListeners: Set<() => void>;
83
101
  final?: TakenOutput;
84
102
  captureError?: string;
85
103
  written: number;
@@ -151,7 +169,6 @@ function append(session: Session, chunk: Uint8Array): void {
151
169
  if (bytesWritten < 1) throw new Error("process output spool made no write progress");
152
170
  offset += bytesWritten; session.written += bytesWritten;
153
171
  }
154
- for (const notify of session.outputListeners) notify();
155
172
  }).catch(error => {
156
173
  session.captureError = `process output capture failed: ${String(error)}`;
157
174
  stopSession(session, "cancelled");
@@ -177,7 +194,7 @@ export async function startSession(input: {
177
194
  const exited = new Promise<void>((resolve) => { resolveExit = resolve; });
178
195
  const session = {
179
196
  id: nextId++, ...(input.owner === undefined ? {} : { owner: input.owner }), command: input.command, cwd: input.cwd, tty: input.tty, source: input.source, started: performance.now(),
180
- spool, spoolPath, queue: Promise.resolve(), operations: Promise.resolve(), outputListeners: new Set<() => void>(), written: 0, read: 0,
197
+ spool, spoolPath, queue: Promise.resolve(), operations: Promise.resolve(), written: 0, read: 0,
181
198
  exit: null, timeoutMs: input.timeoutMs, exited, drained: Promise.resolve(),
182
199
  ...(input.artifactObserver ? { artifactObserver: input.artifactObserver } : {}),
183
200
  } as Omit<Session, "pid" | "write" | "kill" | "timer"> as Session;
@@ -302,26 +319,42 @@ export interface ObservationWait {
302
319
  /** Steering releases this observation wait without cancelling the owned command. */
303
320
  wake?: AbortSignal;
304
321
  onWaiting?: (active: boolean) => void;
322
+ /** One window; output is delivered at a window's end, never as it arrives. */
323
+ windowMs: number;
305
324
  }
306
325
 
307
- /** OMP's job owner pattern: no paid model turn merely to observe elapsed time. */
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
+ */
308
334
  async function waitForObservation(session: Session, signal: AbortSignal | undefined, wait: ObservationWait): Promise<void> {
309
335
  await session.queue;
310
- let notify!: () => void;
311
- const changed = new Promise<void>(resolve => { notify = resolve; });
312
- session.outputListeners.add(notify);
313
- signal?.addEventListener("abort", notify, { once: true });
314
- wait.wake?.addEventListener("abort", notify, { once: true });
315
- const pending = session.exit === null && session.read === session.written && !signal?.aborted && !wait.wake?.aborted;
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;
316
343
  try {
317
344
  if (pending) {
318
345
  wait.onWaiting?.(true);
319
- await Promise.race([session.exited, changed]);
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
+ }
320
353
  }
321
354
  } finally {
322
- session.outputListeners.delete(notify);
323
- signal?.removeEventListener("abort", notify);
324
- wait.wake?.removeEventListener("abort", notify);
355
+ clearTimeout(timer);
356
+ signal?.removeEventListener("abort", interrupt);
357
+ wait.wake?.removeEventListener("abort", interrupt);
325
358
  if (pending) wait.onWaiting?.(false);
326
359
  }
327
360
  if (signal?.aborted) stopSession(session, "cancelled");
@@ -371,6 +404,17 @@ async function takeOutput(session: Session, outputStore: string, previewBytes: n
371
404
  return taken;
372
405
  }
373
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
+
374
418
  /** One lease covers stdin, waiting, archival and cursor commit; cancellation can interrupt a queued wait. */
375
419
  export async function pollSession(session: Session, outputStore: string, previewBytes: number, yieldMs: number,
376
420
  chars = "", signal?: AbortSignal, wait?: ObservationWait): Promise<TakenOutput> {
@@ -378,16 +422,20 @@ export async function pollSession(session: Session, outputStore: string, preview
378
422
  if (signal?.aborted) cancel(); else signal?.addEventListener("abort", cancel, { once: true });
379
423
  const result = session.operations.then(async () => {
380
424
  if (session.final) {
381
- 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);
382
426
  return { ...session.final, receipt: { ...session.final.receipt, replayed: true } };
383
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"];
384
431
  if (chars.length > 0) {
385
- if (session.exit !== null) throw new ToolFault("not_found", "process has exited; input was not sent; poll with empty chars", NOT_STARTED);
386
- session.write(chars);
432
+ if (session.exit !== null) unsent = isControlInput(chars) ? "control" : "refused";
433
+ else session.write(chars);
387
434
  }
388
435
  if (chars.length === 0 && wait) await waitForObservation(session, signal, wait);
389
436
  else await waitSession(session, yieldMs, signal);
390
- return takeOutput(session, outputStore, previewBytes);
437
+ const taken = await takeOutput(session, outputStore, previewBytes);
438
+ return unsent === undefined ? taken : { ...taken, unsent };
391
439
  });
392
440
  session.operations = result.catch(() => {});
393
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
- return historyValue(ctx, found);
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);