@visual-z/xeno 0.8.2 → 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.
Files changed (55) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/package.json +1 -1
  3. package/src/apps/shell.ts +13 -3
  4. package/src/apps/startup.ts +4 -4
  5. package/src/apps/ui/app.ts +32 -14
  6. package/src/apps/ui/project.ts +1 -1
  7. package/src/apps/ui/standalone.ts +4 -2
  8. package/src/apps/ui/surface.ts +2 -0
  9. package/src/core/activity-status.ts +17 -2
  10. package/src/core/command-contract.ts +58 -16
  11. package/src/core/completion-review.ts +1 -1
  12. package/src/core/derive.ts +9 -1
  13. package/src/core/event-validation.ts +19 -3
  14. package/src/core/events.ts +38 -2
  15. package/src/core/evidence.ts +149 -7
  16. package/src/core/export.ts +2 -0
  17. package/src/core/external-effects.ts +488 -0
  18. package/src/core/handoff.ts +24 -3
  19. package/src/core/history-query.ts +1 -1
  20. package/src/core/history.ts +124 -20
  21. package/src/core/observation-view.ts +24 -0
  22. package/src/core/path-suggestions.ts +59 -0
  23. package/src/core/permissions.ts +60 -0
  24. package/src/core/prompt-policy.ts +4 -2
  25. package/src/core/secrets.ts +188 -0
  26. package/src/core/stop-report.ts +25 -3
  27. package/src/core/tool-failure.ts +11 -3
  28. package/src/core/transcript.ts +3 -0
  29. package/src/core/work-state.ts +7 -2
  30. package/src/core/working-set.ts +34 -6
  31. package/src/runtime/connection-reuse.ts +40 -0
  32. package/src/runtime/exec-sessions.ts +66 -18
  33. package/src/runtime/history-tools.ts +12 -2
  34. package/src/runtime/loop.ts +98 -14
  35. package/src/runtime/open-image.ts +42 -0
  36. package/src/runtime/openai-responses.ts +15 -7
  37. package/src/runtime/openai.ts +15 -7
  38. package/src/runtime/permission-resources.ts +11 -4
  39. package/src/runtime/pi-provider.ts +34 -9
  40. package/src/runtime/provider.ts +19 -8
  41. package/src/runtime/retry.ts +152 -9
  42. package/src/runtime/secrets.ts +79 -0
  43. package/src/runtime/session-control.ts +9 -1
  44. package/src/runtime/stream-idle.ts +80 -0
  45. package/src/runtime/tools.ts +97 -27
  46. package/src/runtime/turn-effects.ts +31 -0
  47. package/src/store/context-files.ts +5 -1
  48. package/src/store/file-checkpoints.ts +1 -1
  49. package/src/store/log.ts +20 -0
  50. package/src/store/replay.ts +4 -3
  51. package/src/store/secrets.ts +44 -0
  52. package/src/store/sessions.ts +49 -6
  53. package/src/tui/ui/image-size.ts +6 -2
  54. package/src/tui/ui/items.ts +3 -1
  55. package/src/tui/ui/pictures.ts +14 -1
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The per-session vault behind `[[xeno-secret-N]]` placeholders (S.9).
3
+ *
4
+ * A separate append-only file, never a log event: projections, handoffs,
5
+ * exports and history pages read the log, so a value kept here cannot reach a
6
+ * model request or a shared transcript. 0600 like every session file. It is
7
+ * read back at start so a placeholder written by an earlier run still expands
8
+ * after a restart.
9
+ */
10
+ import { copyFile, readFile } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { durableAppend } from "./atomic.ts";
13
+ import type { SecretEntry } from "../core/secrets.ts";
14
+
15
+ export const SECRETS_FILE = "secrets.jsonl";
16
+
17
+ export async function readVault(dir: string): Promise<SecretEntry[]> {
18
+ let text: string;
19
+ try { text = await readFile(join(dir, SECRETS_FILE), "utf8"); }
20
+ catch (error) { if ((error as { code?: string }).code === "ENOENT") return []; throw error; }
21
+ const entries: SecretEntry[] = [];
22
+ for (const line of text.split("\n")) {
23
+ if (!line) continue;
24
+ // A torn final line from a crash loses that one entry; its placeholder then
25
+ // stays literal rather than expanding to something else.
26
+ try {
27
+ const value = JSON.parse(line) as Partial<SecretEntry>;
28
+ if (Number.isSafeInteger(value.n) && typeof value.value === "string" && typeof value.rule === "string" &&
29
+ !entries.some(entry => entry.n === value.n)) entries.push({ n: value.n!, value: value.value, rule: value.rule });
30
+ } catch { /* see above */ }
31
+ }
32
+ return entries;
33
+ }
34
+
35
+ export async function appendVault(dir: string, entries: readonly SecretEntry[]): Promise<void> {
36
+ if (entries.length === 0) return;
37
+ await durableAppend(join(dir, SECRETS_FILE), entries.map(entry => JSON.stringify(entry) + "\n").join(""));
38
+ }
39
+
40
+ /** A fork's log carries the parent's placeholders, so it needs their values. */
41
+ export async function copyVault(from: string, to: string): Promise<void> {
42
+ try { await copyFile(join(from, SECRETS_FILE), join(to, SECRETS_FILE)); }
43
+ catch (error) { if ((error as { code?: string }).code !== "ENOENT") throw error; }
44
+ }
@@ -35,6 +35,7 @@ import {
35
35
  readCursor,
36
36
  readCursorAt,
37
37
  readNode,
38
+ forkedAt,
38
39
  writeCursor,
39
40
  setParentSession,
40
41
  type Manifest,
@@ -44,6 +45,7 @@ import { replayContext } from "./replay.ts";
44
45
  import { recoverCut } from "./recovery.ts";
45
46
  import { planSessionEntry, type SessionEntryFlags } from "../core/session-entry.ts";
46
47
  import { assertNever } from "../core/exhaustive.ts";
48
+ import type { LogEvent } from "../core/events.ts";
47
49
  import { resolveHistoryPosition } from "./session-position.ts";
48
50
  import { importSessionArchive } from "./session-archive.ts";
49
51
 
@@ -86,6 +88,10 @@ export type ListedSession =
86
88
  workspace?: string;
87
89
  parent_session?: string;
88
90
  text?: string;
91
+ /** Details only: whether any node holds a user event (fix-skip-empty-sessions). */
92
+ conversation?: boolean;
93
+ /** Details only, forks only: when the copy was made (fix-fork-start-time). */
94
+ forked_at?: string;
89
95
  }
90
96
  | {
91
97
  ok: false;
@@ -370,8 +376,7 @@ export async function resolveLogDir(
370
376
  return { logDir: created.path, created: true };
371
377
  }
372
378
  case "continue": {
373
- const { sessions } = await listSessions(workspace, opts.sessionDir, Infinity);
374
- const newest = sessions.find((s) => s.ok);
379
+ const newest = await newestConversation(workspace, opts.sessionDir);
375
380
  if (newest !== undefined) return { logDir: newest.path, created: false };
376
381
  break;
377
382
  }
@@ -399,7 +404,8 @@ export async function readListed(id: string, path: string, details?: { workspace
399
404
  if ((err as { code?: string }).code === "ENOENT") {
400
405
  return {
401
406
  ok: true, id, path,
402
- name: null, model: null, node: null, events: 0, updated_at: null, ...(details ? { workspace: details.workspace } : {}),
407
+ name: null, model: null, node: null, events: 0, updated_at: null,
408
+ ...(details ? { workspace: details.workspace, conversation: await hasConversation(path) } : {}),
403
409
  };
404
410
  }
405
411
  return { ok: false, id, path, error: `${path} could not be read: ${(err as Error).message}` };
@@ -414,7 +420,8 @@ export async function readListed(id: string, path: string, details?: { workspace
414
420
  if (cursor === undefined) {
415
421
  return {
416
422
  ok: true, id, path,
417
- name: null, model: null, node: null, events: 0, updated_at: null, ...(details ? { workspace: details.workspace } : {}),
423
+ name: null, model: null, node: null, events: 0, updated_at: null,
424
+ ...(details ? { workspace: details.workspace, conversation: await hasConversation(path) } : {}),
418
425
  };
419
426
  }
420
427
 
@@ -423,15 +430,18 @@ export async function readListed(id: string, path: string, details?: { workspace
423
430
  // bad tree is the same shape as a list that throws for one bad file.
424
431
  const events = (await readNode(path, cursor.node)).events.length;
425
432
  const text: string[] = [];
433
+ let conversation = false;
426
434
  if (details) for (const node of await listNodes(path)) {
427
435
  if (details.signal?.aborted) break;
428
436
  const read = await readNode(path, node);
429
437
  if (read.corrupt.length) throw new Error(`${path} has corrupt events`);
430
438
  for (const event of read.events) {
439
+ if (userActivity(event)) conversation = true;
431
440
  if (event.t === "user") text.push(event.text);
432
441
  else if (event.t === "assistant") for (const block of event.blocks) if (block.type === "text") text.push(block.text);
433
442
  }
434
443
  }
444
+ const forked = details ? await forkedAt(path) : null;
435
445
  return {
436
446
  ok: true,
437
447
  id,
@@ -441,11 +451,44 @@ export async function readListed(id: string, path: string, details?: { workspace
441
451
  node: cursor.node,
442
452
  events,
443
453
  updated_at: cursor.updated_at,
444
- ...(details ? { workspace: cursor.workspace ?? details.workspace, text: text.join("\n"),
445
- ...(manifest.parent_session ? { parent_session: manifest.parent_session } : {}) } : {}),
454
+ ...(details ? { workspace: cursor.workspace ?? details.workspace, text: text.join("\n"), conversation,
455
+ ...(manifest.parent_session ? { parent_session: manifest.parent_session } : {}),
456
+ ...(forked === null ? {} : { forked_at: forked }) } : {}),
446
457
  };
447
458
  }
448
459
 
460
+ /**
461
+ * A session the user opened and left without a word still has a header,
462
+ * cursor and manifest: Session.start writes them before the first message.
463
+ * Pi avoids such files by not persisting until a message exists; xeno keeps
464
+ * its write order and filters on read instead, like Codex's resume picker,
465
+ * so -c and the pickers land on a conversation (fix-skip-empty-sessions).
466
+ * A shell command the user ran counts: it is their activity, not setup.
467
+ */
468
+ function userActivity(event: LogEvent): boolean {
469
+ return event.t === "user" || event.t === "user_bash";
470
+ }
471
+
472
+ /**
473
+ * Stops at the first node holding user activity, so a real session costs one
474
+ * node read. An unreadable node counts as activity: hiding a session we
475
+ * cannot judge would silently skip it, while opening it reports the error.
476
+ */
477
+ export async function hasConversation(path: string): Promise<boolean> {
478
+ for (const node of await listNodes(path)) {
479
+ try { if ((await readNode(path, node)).events.some(userActivity)) return true; }
480
+ catch { return true; }
481
+ }
482
+ return false;
483
+ }
484
+
485
+ /** What -c continues: the newest readable session the user actually spoke in. */
486
+ export async function newestConversation(workspace: string, sessionDir?: string): Promise<Extract<ListedSession, { ok: true }> | undefined> {
487
+ const { sessions } = await listSessions(workspace, sessionDir, Infinity);
488
+ for (const session of sessions) if (session.ok && await hasConversation(session.path)) return session;
489
+ return undefined;
490
+ }
491
+
449
492
  function byRecordedTime(a: ListedSession, b: ListedSession): number {
450
493
  const ta = a.ok ? a.updated_at : null;
451
494
  const tb = b.ok ? b.updated_at : null;
@@ -26,9 +26,13 @@ export function imageCells(pixels: { readonly width: number; readonly height: nu
26
26
  return { cols: Math.max(1, Math.min(capCols, cols)), rows: Math.max(1, Math.min(capRows, rows)) };
27
27
  }
28
28
 
29
- /** Rows for pictures in the transcript: a quarter of the terminal, 4–12. Assumed, between OpenCode (≤8) and Grok (≤20). */
29
+ /**
30
+ * Rows for pictures in the transcript: half the terminal, 4–20 (feat-tui-image-full-size).
31
+ * A quarter left a screenshot's text unreadable; 20 is Grok's ceiling, past which one picture
32
+ * pushes the conversation off screen. Assumed, not measured. Full size is a click away.
33
+ */
30
34
  export function transcriptImageRows(terminalRows: number): number {
31
- return Math.max(4, Math.min(12, Math.floor(terminalRows / 4)));
35
+ return Math.max(4, Math.min(20, Math.floor(terminalRows / 2)));
32
36
  }
33
37
 
34
38
  /** Rows for the attachment strip above the input box: a sixth of the terminal, 3–6. Assumed. */
@@ -8,7 +8,9 @@
8
8
  export type ToolStatus = "pending" | "ok" | "error";
9
9
 
10
10
  /** A picture the view may draw: its blob address, name and pixel size (from the log's ImageRef). */
11
- export interface ImageView { readonly sha256: string; readonly name: string; readonly width: number; readonly height: number }
11
+ export interface ImageView { readonly sha256: string; readonly name: string; readonly width: number; readonly height: number;
12
+ /** The blob a click opens when it differs from `sha256`: what was attached, before any reduction for the model. */
13
+ readonly full?: string }
12
14
 
13
15
  export type ViewItem =
14
16
  | { readonly kind: "user"; readonly id: string; readonly rev: string; readonly text: string; readonly images?: readonly ImageView[]; readonly interrupted?: boolean }
@@ -8,6 +8,10 @@
8
8
  * pictures are off, or the terminal can only draw block characters, the
9
9
  * row is a single muted line: `▣ shot.png · 1280×720 +2 more`.
10
10
  *
11
+ * A click on a drawn picture asks the source to open its full-size original
12
+ * (docs/tasks/feat-tui-image-full-size.md): a thumbnail cannot show a
13
+ * screenshot's text, the system viewer can.
14
+ *
11
15
  * The view never reads a file: the app's source loads and verifies bytes.
12
16
  */
13
17
  import { BoxRenderable, ImageRenderable, type CliRenderer, type ImageRenderProtocol, type NativeImage } from "@opentui/core";
@@ -28,6 +32,8 @@ export interface PictureSource {
28
32
  readonly reason?: string;
29
33
  /** The decoded picture for a blob, shared by every node that shows it, or its preparation failure. */
30
34
  load(sha256: string): Promise<PictureLoad>;
35
+ /** Show a blob at full size outside the terminal; absent where nothing can (tests, print mode). */
36
+ open?(sha256: string): void;
31
37
  }
32
38
 
33
39
  export type PictureLoad = { readonly kind: "ready"; readonly image: NativeImage } |
@@ -72,7 +78,7 @@ export class Pictures extends BoxRenderable {
72
78
  const size = imageCells(image, Math.min(each, source.widthCells), options.maxRows, source.cell);
73
79
  const picture = new ImageRenderable(ctx, { id: `${id}-${image.sha256.slice(0, 8)}-${serial++}`, width: size.cols, height: size.rows,
74
80
  fit: "fit", protocol: source.protocol, flexShrink: 0,
75
- onLoad: () => { states[index] = "decoded"; if (!caption.isDestroyed) paintCaption(); },
81
+ onLoad: () => { states[index] = source.open ? "click to open" : "decoded"; if (!caption.isDestroyed) paintCaption(); },
76
82
  onError: () => { states[index] = "preview failed: decode"; picture.visible = false; if (!caption.isDestroyed) paintCaption(); } });
77
83
  void source.load(image.sha256).then(result => {
78
84
  if (picture.isDestroyed) return;
@@ -82,6 +88,13 @@ export class Pictures extends BoxRenderable {
82
88
  if (picture.isDestroyed) return;
83
89
  picture.visible = false; states[index] = "preview failed: decode"; paintCaption();
84
90
  });
91
+ const open = source.open;
92
+ if (open) {
93
+ // Same rule as a tool card's head: press and release on one cell is a click; anything else is a selection.
94
+ let down: { x: number; y: number } | undefined;
95
+ picture.onMouseDown = event => { down = { x: event.x, y: event.y }; };
96
+ picture.onMouseUp = event => { if (down && down.x === event.x && down.y === event.y && picture.image) open(image.full ?? image.sha256); down = undefined; };
97
+ }
85
98
  pictures.push(picture);
86
99
  row.add(picture);
87
100
  }