@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/docs/install.md +25 -0
  3. package/package.json +1 -1
  4. package/src/apps/cli.ts +4 -1
  5. package/src/apps/shell.ts +13 -3
  6. package/src/apps/startup.ts +31 -6
  7. package/src/apps/ui/app.ts +32 -14
  8. package/src/apps/ui/project.ts +1 -1
  9. package/src/apps/ui/standalone.ts +4 -2
  10. package/src/apps/ui/surface.ts +2 -0
  11. package/src/core/activity-status.ts +27 -3
  12. package/src/core/command-contract.ts +121 -5
  13. package/src/core/completion-review.ts +1 -1
  14. package/src/core/derive.ts +9 -1
  15. package/src/core/environment.ts +7 -0
  16. package/src/core/event-validation.ts +19 -3
  17. package/src/core/events.ts +38 -2
  18. package/src/core/evidence.ts +149 -7
  19. package/src/core/export.ts +2 -0
  20. package/src/core/external-effects.ts +488 -0
  21. package/src/core/handoff.ts +24 -3
  22. package/src/core/history-query.ts +1 -1
  23. package/src/core/history.ts +131 -21
  24. package/src/core/observation-view.ts +24 -0
  25. package/src/core/path-suggestions.ts +59 -0
  26. package/src/core/permissions.ts +64 -3
  27. package/src/core/prompt-policy.ts +11 -2
  28. package/src/core/regex-dialect.ts +25 -0
  29. package/src/core/ripgrep-release.ts +64 -0
  30. package/src/core/secrets.ts +188 -0
  31. package/src/core/stall.ts +63 -18
  32. package/src/core/stop-report.ts +46 -4
  33. package/src/core/tool-contract.ts +2 -0
  34. package/src/core/tool-failure.ts +25 -4
  35. package/src/core/transcript.ts +3 -0
  36. package/src/core/update-check.ts +2 -1
  37. package/src/core/work-state.ts +7 -2
  38. package/src/core/working-set.ts +34 -6
  39. package/src/runtime/connection-reuse.ts +40 -0
  40. package/src/runtime/exec-sessions.ts +69 -19
  41. package/src/runtime/history-tools.ts +12 -2
  42. package/src/runtime/loop.ts +196 -26
  43. package/src/runtime/open-image.ts +42 -0
  44. package/src/runtime/openai-responses.ts +15 -7
  45. package/src/runtime/openai.ts +15 -7
  46. package/src/runtime/permission-resources.ts +11 -4
  47. package/src/runtime/pi-provider.ts +34 -9
  48. package/src/runtime/provider.ts +19 -8
  49. package/src/runtime/retry.ts +152 -9
  50. package/src/runtime/secrets.ts +79 -0
  51. package/src/runtime/session-control.ts +9 -1
  52. package/src/runtime/stream-idle.ts +80 -0
  53. package/src/runtime/tools.ts +139 -31
  54. package/src/runtime/turn-effects.ts +31 -0
  55. package/src/store/command.ts +3 -1
  56. package/src/store/context-files.ts +5 -1
  57. package/src/store/file-checkpoints.ts +6 -3
  58. package/src/store/log.ts +20 -0
  59. package/src/store/replay.ts +4 -3
  60. package/src/store/search.ts +9 -5
  61. package/src/store/secrets.ts +44 -0
  62. package/src/store/sessions.ts +49 -6
  63. package/src/store/tool-binaries.ts +90 -0
  64. package/src/store/worktree.ts +55 -10
  65. package/src/tui/ui/image-size.ts +6 -2
  66. package/src/tui/ui/items.ts +3 -1
  67. package/src/tui/ui/pictures.ts +14 -1
  68. package/src/tui/ui/screen.ts +6 -3
  69. package/src/tui/ui/status.ts +16 -0
@@ -1,7 +1,8 @@
1
1
  /** D20: bounded carried state, with deterministic pages rebuilt from the log. */
2
- import { emitHandoff, type Handoff } from "./handoff.ts";
2
+ import { emitHandoff, type Handoff, type UserTurnRecord } from "./handoff.ts";
3
3
  import { estimateTokens } from "./tokens.ts";
4
- import { recoveryWorkingSet } from "./working-set.ts";
4
+ import { recoveryWorkingSet, recoveryWorkingSetV3 } from "./working-set.ts";
5
+ import { recoveryInstructions } from "./prompt-policy.ts";
5
6
  import type { RecoveryNodes } from "./recovery-sources.ts";
6
7
 
7
8
  // A carried handoff must leave room for another segment's work. This budget is
@@ -64,20 +65,19 @@ function suffixes(h: Handoff, offsets: Record<HistorySection, number>): Handoff
64
65
  };
65
66
  }
66
67
 
67
- export function boundHandoff(full: Handoff, budget = DEFAULT_HANDOFF_TOKENS): Handoff {
68
- if (!Number.isFinite(budget) || budget <= 0) throw new RangeError("handoff budget must be positive");
69
- const size = (h: Handoff): number => estimateTokens.count([], emitHandoff(h));
70
- if (size(full) <= budget) return full;
71
- if (full.predecessor === null) throw new Error("history requires an immutable source");
72
- // An absent new section must not change legacy projections or frozen replay.
73
- const sections = HISTORY_SECTIONS.filter(section => (section !== "state.work_items" || full.state.work_items !== undefined) &&
68
+ /** Sections a handoff actually has; an absent new section must not change legacy projections or frozen replay. */
69
+ function pageableSections(full: Handoff): HistorySection[] {
70
+ return HISTORY_SECTIONS.filter(section => (section !== "state.work_items" || full.state.work_items !== undefined) &&
74
71
  (section !== "evidence_links" || full.evidence_links !== undefined) &&
75
72
  (section !== "acceptance.criteria" || full.acceptance !== undefined) &&
76
73
  (section !== "acceptance.targets" || full.acceptance?.targets !== undefined) &&
77
74
  (section !== "loaded_skills" || full.loaded_skills !== undefined) &&
78
75
  (section !== "processes" || full.processes !== undefined));
79
- const offsets = Object.fromEntries(sections.map((s) => [s, historyItems(full, s).length])) as Record<HistorySection, number>;
80
- const build = (): Handoff => ({
76
+ }
77
+
78
+ /** The v2 shape carrying `items.length - offset` newest items of every pageable section. */
79
+ function carriedAt(full: Handoff, sections: readonly HistorySection[], offsets: Record<HistorySection, number>): Handoff {
80
+ return {
81
81
  ...suffixes(full, offsets),
82
82
  handoff_version: 2,
83
83
  history: {
@@ -86,22 +86,42 @@ export function boundHandoff(full: Handoff, budget = DEFAULT_HANDOFF_TOKENS): Ha
86
86
  total: historyItems(full, s).length, offset: offsets[s],
87
87
  }])),
88
88
  },
89
- });
89
+ };
90
+ }
91
+
92
+ const handoffSize = (h: Handoff): number => estimateTokens.count([], emitHandoff(h));
93
+
94
+ /**
95
+ * `priority` sections grow to completion, newest first, before any other
96
+ * section gets an item; the rest then share what is left round-robin. The
97
+ * default (none) is the original D20 order, which recorded cuts replay.
98
+ */
99
+ export function boundHandoff(full: Handoff, budget = DEFAULT_HANDOFF_TOKENS, priority: readonly HistorySection[] = []): Handoff {
100
+ if (!Number.isFinite(budget) || budget <= 0) throw new RangeError("handoff budget must be positive");
101
+ const size = handoffSize;
102
+ if (size(full) <= budget) return full;
103
+ if (full.predecessor === null) throw new Error("history requires an immutable source");
104
+ const sections = pageableSections(full);
105
+ const offsets = Object.fromEntries(sections.map((s) => [s, historyItems(full, s).length])) as Record<HistorySection, number>;
106
+ const build = (): Handoff => carriedAt(full, sections, offsets);
90
107
  let carried = build();
91
108
  // The objective stays verbatim (D2). An oversized objective is an explicit
92
109
  // input problem; repeatedly cutting it would create an infinite cut loop.
93
110
  if (size(carried) > budget) throw new RangeError("handoff scalar fields exceed budget; shorten the objective or frozen input");
94
- const active = new Set<HistorySection>(sections);
111
+ const grow = (section: HistorySection): boolean => {
112
+ if (offsets[section] === 0) return false;
113
+ offsets[section]--;
114
+ const candidate = build();
115
+ if (size(candidate) <= budget) { carried = candidate; return true; }
116
+ offsets[section]++;
117
+ return false;
118
+ };
119
+ for (const section of priority) if (sections.includes(section)) while (grow(section)) { /* newest first until full or out of room */ }
120
+ const active = new Set<HistorySection>(sections.filter(section => !priority.includes(section)));
95
121
  // Round-robin suffix growth prevents one long section starving every other
96
122
  // category. Each retained range is contiguous and independently pageable.
97
123
  while (active.size > 0) {
98
- for (const section of active) {
99
- if (offsets[section] === 0) { active.delete(section); continue; }
100
- offsets[section]--;
101
- const candidate = build();
102
- if (size(candidate) <= budget) carried = candidate;
103
- else { offsets[section]++; active.delete(section); }
104
- }
124
+ for (const section of active) if (!grow(section)) active.delete(section);
105
125
  }
106
126
  return carried;
107
127
  }
@@ -122,6 +142,96 @@ export function boundRecoveryHandoff(full: Handoff, nodes: RecoveryNodes, budget
122
142
  const legacy = boundHandoff(full, budget);
123
143
  const view = recoveryWorkingSet(full, nodes, budget * .4, tools);
124
144
  if (!view) return legacy;
125
- try { return boundHandoff({ ...full, working_set: view }, budget); }
145
+ let carried: Handoff;
146
+ try { carried = boundHandoff({ ...full, working_set: view }, budget); }
126
147
  catch (error) { if (error instanceof RangeError) return legacy; throw error; }
148
+ // Whether the user turns survived bounding is known only now. The inline
149
+ // wording is shorter, so swapping it in cannot push the handoff over budget.
150
+ const inline = tools?.includes("history") && carried.user_turns_truncated === undefined && (carried.history?.sections["user_turns"]?.offset ?? 0) === 0;
151
+ return inline && carried.working_set ? { ...carried, working_set: { ...carried.working_set, recovery: recoveryInstructions(tools!, true) } } : carried;
152
+ }
153
+
154
+ /**
155
+ * D8: join carried turns to their original user events to name attached images
156
+ * by digest. Turns and events are both the active ancestry in log order; any
157
+ * disagreement leaves the turns undecorated rather than guessing a pairing.
158
+ */
159
+ export function userTurnImages(turns: readonly UserTurnRecord[], nodes: RecoveryNodes): readonly UserTurnRecord[] {
160
+ const users = nodes.flatMap(part => part.events.flatMap(event => event.t === "user" ? [event] : []));
161
+ if (users.length !== turns.length || users.some((event, i) => event.at !== turns[i]!.at || event.text !== turns[i]!.text)) return turns;
162
+ return turns.map((turn, i) => {
163
+ const event = users[i]!;
164
+ return event.images?.length ? { ...turn, event: event.id,
165
+ images: event.images.map(({ sha256, name, width, height }) => ({ sha256, name, width, height })) } : turn;
166
+ });
167
+ }
168
+
169
+ /**
170
+ * v3 carries one copy of each work item's prose. `state.in_flight` and
171
+ * `state.blocked` are compatible projections of identified work (O.2), so a
172
+ * leading run of items the carried `state.work_items` or working set already
173
+ * hold moves into the existing page range, which `validateHandoff` accepts for
174
+ * continuation; D20 arrays stay contiguous suffixes. A working-set entry whose
175
+ * prose the carried `state.work_items` holds keeps only its id and status, since
176
+ * `state.work_items` is the canonical identified work. Every change only
177
+ * removes bytes, so the budget still holds.
178
+ */
179
+ function oneCopyOfWork(h: Handoff): Handoff {
180
+ const items = new Map((h.state.work_items ?? []).map(item => [item.id, item]));
181
+ const viewed = new Map((h.working_set?.work ?? []).map(entry => [entry.id, entry]));
182
+ const history = h.history && { ...h.history, sections: { ...h.history.sections } };
183
+ type Prose = { status: string; what?: string; next_action?: string; reason?: string };
184
+ const holders = (id: string | undefined): Prose[] => {
185
+ const found: Prose[] = [], item = id === undefined ? undefined : items.get(id), entry = id === undefined ? undefined : viewed.get(id);
186
+ if (item) found.push(item);
187
+ if (entry) found.push(entry);
188
+ return found;
189
+ };
190
+ const page = <T extends { work_id?: string }>(section: "state.in_flight" | "state.blocked", list: readonly T[], same: (item: T, holder: Prose) => boolean): readonly T[] => {
191
+ const range = history?.sections[section];
192
+ let skip = 0;
193
+ while (range && skip < list.length && holders(list[skip]!.work_id).some(holder => same(list[skip]!, holder))) skip++;
194
+ if (skip === 0) return list;
195
+ history!.sections[section] = { total: range!.total, offset: range!.offset + skip };
196
+ return list.slice(skip);
197
+ };
198
+ const in_flight = page("state.in_flight", h.state.in_flight, (item, holder) => holder.status === "active" && holder.what === item.what && holder.next_action === item.next_action);
199
+ const blocked = page("state.blocked", h.state.blocked, (item, holder) => holder.status === "blocked" && holder.what === item.what && holder.reason === item.reason);
200
+ const view = h.working_set && { ...h.working_set, work: h.working_set.work.map(entry => {
201
+ const item = items.get(entry.id);
202
+ if (!item || item.what !== entry.what || item.next_action !== entry.next_action || item.reason !== entry.reason) return entry;
203
+ const { what: _what, next_action: _next, reason: _reason, ...rest } = entry;
204
+ return rest;
205
+ }) };
206
+ return { ...h, ...(view ? { working_set: view } : {}), ...(history ? { history } : {}), state: { ...h.state, in_flight, blocked } };
207
+ }
208
+
209
+ const USERS_FIRST: readonly HistorySection[] = ["user_turns"];
210
+
211
+ /**
212
+ * Working set v3. User turns are the smallest, highest-value carried content
213
+ * (Codex compaction likewise keeps user messages newest first, verbatim, under
214
+ * its own cap), so they are reserved before the working set and before every
215
+ * other page; only turns that alone exceed the budget are cut, newest kept.
216
+ * While every turn is inline, user references would only point at text already
217
+ * present, so the view leaves them out. The view keeps v2's 40% allocation
218
+ * assumption, measured inside the handoff it is carried in.
219
+ */
220
+ export function boundRecoveryHandoffV3(full: Handoff, nodes: RecoveryNodes, budget = DEFAULT_HANDOFF_TOKENS, tools?: readonly string[]): Handoff {
221
+ if (!Number.isFinite(budget) || budget <= 0) throw new RangeError("handoff budget must be positive");
222
+ const base: Handoff = { ...full, user_turns: userTurnImages(full.user_turns, nodes) };
223
+ const plain = (): Handoff => oneCopyOfWork(boundHandoff(base, budget, USERS_FIRST));
224
+ if (base.predecessor === null) return plain();
225
+ const sections = pageableSections(base);
226
+ const empty = Object.fromEntries(sections.map((s) => [s, historyItems(base, s).length])) as Record<HistorySection, number>;
227
+ const withUsers = carriedAt(base, sections, { ...empty, user_turns: 0 });
228
+ const usersInline = base.user_turns_truncated === undefined && handoffSize(withUsers) <= budget;
229
+ const skeleton = usersInline ? withUsers : carriedAt(base, sections, empty);
230
+ const cap = Math.min(budget, handoffSize(skeleton) + budget * .4);
231
+ const recovery = recoveryInstructions(tools ?? ["history"], usersInline, base.user_turns.some(turn => turn.images !== undefined));
232
+ const view = recoveryWorkingSetV3(base, nodes, { users: !usersInline, recovery,
233
+ fits: candidate => handoffSize({ ...skeleton, working_set: candidate }) <= cap });
234
+ if (!view) return plain();
235
+ try { return oneCopyOfWork(boundHandoff({ ...base, working_set: view }, budget, USERS_FIRST)); }
236
+ catch (error) { if (error instanceof RangeError) return plain(); throw error; }
127
237
  }
@@ -85,6 +85,30 @@ export function headTailSelection(
85
85
  visible: [...(headEnd ? [[0, headEnd] as [number, number]] : []), ...(kept ? [[total - kept, total] as [number, number]] : [])] };
86
86
  }
87
87
 
88
+ /**
89
+ * The text a terminal would finally show for output that redraws lines with
90
+ * `\r` (progress bars). Within each `\n`-separated line, a carriage return
91
+ * moves back to column 0 and the following characters overwrite, one code
92
+ * point each; characters not overwritten stay. `\r\n` is thus a plain line
93
+ * break. Other control sequences (`ESC[K` and the like) are left as they are.
94
+ *
95
+ * A view only: the archived bytes keep every frame (D8). Without this a 6.3KB
96
+ * brew download of 48 frames reached the model whole (carriage-return-view
97
+ * card), though a terminal showed one line.
98
+ */
99
+ export function foldCarriageReturns(text: string): string {
100
+ if (!text.includes("\r")) return text;
101
+ return text.split("\n").map(line => {
102
+ if (!line.includes("\r")) return line;
103
+ let screen: string[] = [];
104
+ for (const frame of line.split("\r")) {
105
+ const chars = Array.from(frame);
106
+ screen = chars.concat(screen.slice(chars.length));
107
+ }
108
+ return screen.join("");
109
+ }).join("\n");
110
+ }
111
+
88
112
  /** Signals a shell reports as 128+n; the common ones a model can act on. */
89
113
  const SIGNALS: Readonly<Record<number, string>> = {
90
114
  1: "SIGHUP", 2: "SIGINT", 6: "SIGABRT", 9: "SIGKILL", 11: "SIGSEGV", 13: "SIGPIPE", 15: "SIGTERM",
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Existing paths that a missing one probably meant.
3
+ *
4
+ * Models guess paths, and most guesses in a reviewed session (flotil: 11 of
5
+ * 11 ENOENT) had the right file name in the wrong directory or a near
6
+ * spelling. OpenCode's read tool suggests up to three entries of the same
7
+ * directory whose name contains, or is contained in, the requested one
8
+ * (`packages/opencode/src/tool/read.ts`); that misses a wrong directory, so
9
+ * this ranks the whole workspace listing, file name first.
10
+ *
11
+ * Pure and deterministic: the result depends on the set of candidates, never
12
+ * their order, so the same failure always names the same paths.
13
+ */
14
+ import { editDistance } from "./text-normalize.ts";
15
+
16
+ /** OpenCode's count: enough to cover a wrong directory, short enough to read at a glance. */
17
+ export const PATH_SUGGESTION_LIMIT = 3;
18
+
19
+ /** Containment of a shorter name is too weak a signal below this length (assumed: "a" is in everything). */
20
+ const MIN_CONTAINED = 3;
21
+
22
+ const baseName = (path: string) => path.slice(path.lastIndexOf("/") + 1);
23
+ const stem = (name: string) => { const dot = name.lastIndexOf("."); return dot > 0 ? name.slice(0, dot) : name; };
24
+
25
+ /**
26
+ * Rank tier of `name` against the wanted file name, or null when unrelated.
27
+ * 0: same name; 1: same name without extension; 2: one name contains the
28
+ * other; 3: a near spelling (edit distance within a quarter of the length).
29
+ */
30
+ function tier(name: string, wanted: string, distance: number): number | null {
31
+ if (name === wanted) return 0;
32
+ if (stem(name) === stem(wanted) && stem(name).length > 0) return 1;
33
+ const shorter = name.length < wanted.length ? name : wanted, longer = shorter === name ? wanted : name;
34
+ if (shorter.length >= MIN_CONTAINED && longer.includes(shorter)) return 2;
35
+ if (distance <= Math.max(1, Math.floor(wanted.length / 4))) return 3;
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * Up to `limit` workspace-relative paths from `candidates` closest to the
41
+ * missing `target`, best first. Paths use "/" separators; comparison ignores
42
+ * case. The target itself is never suggested.
43
+ */
44
+ export function nearestPaths(target: string, candidates: readonly string[], limit = PATH_SUGGESTION_LIMIT): string[] {
45
+ const wantedPath = target.replace(/^\.\//, "").toLowerCase();
46
+ const wanted = baseName(wantedPath);
47
+ if (wanted.length === 0 || limit <= 0) return [];
48
+ const ranked: { path: string; tier: number; name: number; whole: number }[] = [];
49
+ for (const path of new Set(candidates)) {
50
+ const lower = path.toLowerCase();
51
+ if (lower === wantedPath) continue;
52
+ const name = baseName(lower), distance = editDistance(name, wanted);
53
+ const rank = tier(name, wanted, distance);
54
+ if (rank === null) continue;
55
+ ranked.push({ path, tier: rank, name: distance, whole: editDistance(lower, wantedPath) });
56
+ }
57
+ ranked.sort((a, b) => a.tier - b.tier || a.name - b.name || a.whole - b.whole || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
58
+ return ranked.slice(0, limit).map(item => item.path);
59
+ }
@@ -45,9 +45,10 @@ export type ToolSource = "builtin" | "extension";
45
45
 
46
46
  /** Task capabilities are frozen once at session creation (O.7); not permissions. */
47
47
  export const TOOL_PROFILES = {
48
- coding: ["read", "grep", "find", "write", "edit", "environment", "exec_command", "write_stdin", "history", "note"],
49
- // An explicit preset preserves the measured legacy ten-tool coding contract.
50
- foundation: ["read", "grep", "find", "write", "edit", "environment", "exec_command", "write_stdin", "history", "note", "question", "apply_patch", "diff", "webfetch", "websearch", "format", "lsp"],
48
+ // worktree joined the measured ten (2026-10-11, feat-worktree-tool): without
49
+ // it a model needing isolation had no checkout it was allowed to edit.
50
+ coding: ["read", "grep", "find", "write", "edit", "environment", "exec_command", "write_stdin", "history", "note", "worktree"],
51
+ foundation: ["read", "grep", "find", "write", "edit", "environment", "exec_command", "write_stdin", "history", "note", "worktree", "question", "apply_patch", "diff", "webfetch", "websearch", "format", "lsp"],
51
52
  readonly: ["read", "grep", "find", "history", "note"],
52
53
  research: ["read", "grep", "find", "history", "note", "branch", "resolve"],
53
54
  data: ["read", "grep", "find", "history", "note", "query"],
@@ -161,6 +162,66 @@ export function decideWithSessionRules(
161
162
  return decide([...remembered, ...configured], request);
162
163
  }
163
164
 
165
+ /**
166
+ * Where a gate outcome came from, for the durable approval record
167
+ * (feat-approval-events). `auto` is an allow by a rule with no selector --
168
+ * what `--yolo` compiles to -- so "no approval was required" is derived from
169
+ * the rule list itself rather than from a side flag. `none` means no rule
170
+ * matched and the empty-list default allowed: no policy was consulted.
171
+ */
172
+ export type DecisionSource = "session_rule" | "config" | "auto" | "none";
173
+ export interface DecisionExplanation {
174
+ readonly action: PermissionAction;
175
+ readonly source: DecisionSource;
176
+ readonly rule?: PermissionRule;
177
+ }
178
+
179
+ /** Mirrors decide() exactly, but keeps the index of the rule that produced the action. */
180
+ function explainList(rules: readonly PermissionRule[], request: PermissionRequest): { action: PermissionAction; index?: number } {
181
+ if (request.paths && request.paths.length > 1) {
182
+ const each = request.paths.map(path => explainList(rules, { ...request, paths: [path] }));
183
+ const actions = each.map(item => item.action);
184
+ const action: PermissionAction = actions.includes("deny") ? "deny" : actions.includes("ask") ? "ask" : "allow";
185
+ return each.find(item => item.action === action)!;
186
+ }
187
+ const index = rules.findIndex(rule => ruleMatches(rule, request));
188
+ return index < 0 ? { action: "allow" } : { action: rules[index]!.action, index };
189
+ }
190
+
191
+ /** decideWithSessionRules plus the rule that decided; the action is always identical (property-tested). */
192
+ export function explainDecision(
193
+ configured: readonly PermissionRule[], remembered: readonly PermissionRule[], request: PermissionRequest,
194
+ ): DecisionExplanation {
195
+ const ceiling = explainList(configured, request);
196
+ if (ceiling.action === "deny") return { action: "deny", source: "config", rule: configured[ceiling.index!]! };
197
+ const combined = [...remembered, ...configured];
198
+ const found = explainList(combined, request);
199
+ if (found.index === undefined) return { action: found.action, source: "none" };
200
+ const rule = combined[found.index]!;
201
+ const unconditional = rule.tool === undefined && rule.command === undefined && rule.commandPrefix === undefined &&
202
+ rule.path === undefined && rule.pathGlob === undefined;
203
+ return { action: found.action, rule,
204
+ source: found.index < remembered.length ? "session_rule" : unconditional && rule.action === "allow" ? "auto" : "config" };
205
+ }
206
+
207
+ /**
208
+ * A call needs a decision when the product default would not silently allow
209
+ * it, or when the effective policy did not. Calls outside both -- confined
210
+ * reads and edits under the default -- had nobody's judgement to record.
211
+ */
212
+ export function approvalNeeded(request: PermissionRequest, effective: PermissionAction): boolean {
213
+ return effective !== "allow" || decide(defaultRules(), request) !== "allow";
214
+ }
215
+
216
+ /** Stable one-line rule text for the log and the export; field order is fixed. */
217
+ export function describeRule(rule: PermissionRule): string {
218
+ return [rule.action, rule.tool ?? "*",
219
+ ...(rule.command !== undefined ? [`command=${JSON.stringify(rule.command)}`] : []),
220
+ ...(rule.commandPrefix !== undefined ? [`commandPrefix=${JSON.stringify(rule.commandPrefix.join(" "))}`] : []),
221
+ ...(rule.path !== undefined ? [`path=${JSON.stringify(rule.path)}`] : []),
222
+ ...(rule.pathGlob !== undefined ? [`pathGlob=${JSON.stringify(rule.pathGlob)}`] : [])].join(" ");
223
+ }
224
+
164
225
  /** Lexical normalization is pure; the store adds canonical symlink resources. */
165
226
  export function normalizePermissionPath(path: string): string {
166
227
  const absolute = path.startsWith("/");
@@ -23,9 +23,18 @@ export function toolDescription(description: string, guidance: readonly ToolGuid
23
23
  return [description, ...guidance.filter(item => item.requires.every(name => tools.includes(name))).map(item => item.text)].join(" ");
24
24
  }
25
25
 
26
- export function recoveryInstructions(tools: readonly string[]): string {
26
+ /**
27
+ * `usersInline`: the carried `user_turns` hold every user message verbatim.
28
+ * Telling the model to page them anyway costs a guaranteed first-call read
29
+ * that returns what it already has -- or an empty page after a model switch
30
+ * on a fresh session (Horizyn mv3nsske-x50u, fix-recovery-inline-users).
31
+ */
32
+ export function recoveryInstructions(tools: readonly string[], usersInline = false, images = false): string {
27
33
  return (tools.includes("history")
28
- ? "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. history(work_id=ID) recovers work. "
34
+ ? (usersInline ? "User messages are verbatim in user_turns. Other history(node=source.node,recovery=...) pages: failures, environment, next_actions; " : "Read original user messages before continuing: history(node=source.node,recovery=users,offset=0). Other pages: failures, environment, next_actions; ") +
35
+ "event=ID reads one original. history(work_id=ID) recovers work. " +
36
+ // Working set v3: the turn's image references are carried, the pixels are not.
37
+ (images ? "A user turn's images are not attached here; history(event=ID) with its event reattaches them. " : "")
29
38
  : "Original user messages and evidence remain at the source references. The original-source recovery capability is unavailable; state any resulting evidence gap instead of guessing. ") +
30
39
  "Abandoned messages are cancelled; annotations are not user instructions. Observations are historical: recheck resource, host and current permissions before acting.";
31
40
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Brings the ripgrep regex dialect to JS RegExp where the two differ in
3
+ * spelling, not meaning.
4
+ *
5
+ * grep runs ripgrep when it is on PATH and a TypeScript walk otherwise, and
6
+ * the model writes for ripgrep (Rust regex), where a leading `(?i)` is the
7
+ * usual way to ask for case-insensitivity. JS rejects that spelling but
8
+ * accepts the scoped form `(?i:...)`, so a leading global group is rewritten
9
+ * into a scoped group around the rest of the pattern. Without this the same
10
+ * call works or fails depending on whether rg happens to be installed.
11
+ *
12
+ * Only the leading form and only flags JS modifiers support (i, m, s) are
13
+ * rewritten; anything else is passed through so RegExp reports it as it is.
14
+ */
15
+ const LEADING_FLAGS = /^\(\?([ims]+)\)/;
16
+
17
+ export function jsPatternFromRipgrep(pattern: string): string {
18
+ const match = LEADING_FLAGS.exec(pattern);
19
+ if (match === null) return pattern;
20
+ const flags = match[1]!;
21
+ // A repeated flag is a SyntaxError inside a modifier group; ripgrep
22
+ // tolerates it, so duplicates are dropped rather than surfaced.
23
+ const unique = [...new Set(flags)].join("");
24
+ return `(?${unique}:${pattern.slice(match[0].length)})`;
25
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The one ripgrep build xeno will install, per platform.
3
+ *
4
+ * Models write `rg` in shell commands because the harnesses they were trained
5
+ * in ship it (Claude Code bundles it; Pi downloads it on first use). On a
6
+ * machine without it every such command exits 127 and costs a request. xeno
7
+ * installs it only when the user has none, and only this exact archive:
8
+ * running a binary fetched from the network needs proof that it is the one
9
+ * reviewed, which a pinned digest gives and "latest" (Pi's choice) does not.
10
+ *
11
+ * Digests are the upstream release's published `.sha256` files for 14.1.1;
12
+ * the aarch64-apple-darwin archive was also downloaded and hashed locally on
13
+ * 2026-10-11 and matched. Upgrading means changing this table and releasing.
14
+ */
15
+ import { truthy } from "./update-check.ts";
16
+
17
+ export const RIPGREP_VERSION = "14.1.1";
18
+
19
+ export interface RipgrepAsset {
20
+ readonly url: string;
21
+ readonly sha256: string;
22
+ /** Archive member holding the executable; nothing else is extracted. */
23
+ readonly member: string;
24
+ }
25
+
26
+ const DIGESTS: Readonly<Record<string, string>> = {
27
+ "aarch64-apple-darwin": "24ad76777745fbff131c8fbc466742b011f925bfa4fffa2ded6def23b5b937be",
28
+ "x86_64-apple-darwin": "fc87e78f7cb3fea12d69072e7ef3b21509754717b746368fd40d88963630e2b3",
29
+ // musl is static, so it runs on glibc and musl distributions alike.
30
+ "x86_64-unknown-linux-musl": "4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e",
31
+ // 14.1.1 publishes no aarch64 musl build; this one needs glibc.
32
+ "aarch64-unknown-linux-gnu": "c827481c4ff4ea10c9dc7a4022c8de5db34a5737cb74484d62eb94a95841ab2f",
33
+ };
34
+
35
+ function target(platform: string, arch: string): string | null {
36
+ if (platform === "darwin") return arch === "arm64" ? "aarch64-apple-darwin" : arch === "x64" ? "x86_64-apple-darwin" : null;
37
+ if (platform === "linux") return arch === "arm64" ? "aarch64-unknown-linux-gnu" : arch === "x64" ? "x86_64-unknown-linux-musl" : null;
38
+ return null;
39
+ }
40
+
41
+ /** Null for platforms without a pinned build: those keep the TypeScript search. */
42
+ export function ripgrepAsset(platform: string, arch: string): RipgrepAsset | null {
43
+ const triple = target(platform, arch);
44
+ if (triple === null) return null;
45
+ const name = `ripgrep-${RIPGREP_VERSION}-${triple}`;
46
+ return {
47
+ url: `https://github.com/BurntSushi/ripgrep/releases/download/${RIPGREP_VERSION}/${name}.tar.gz`,
48
+ sha256: DIGESTS[triple]!,
49
+ member: `${name}/rg`,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Whether xeno may fetch the binary on its own, in the background.
55
+ *
56
+ * The same occasions the update check stays off the network (core/update-check.ts):
57
+ * offline, CI, and a source checkout -- the last is also what keeps test
58
+ * subprocesses, which run the CLI from this repository, from downloading.
59
+ * XENO_NO_TOOL_INSTALL opts out explicitly; the user's own rg makes it moot.
60
+ */
61
+ export function toolInstallAllowed(input: { env: Readonly<Record<string, string | undefined>>; offline: boolean; install: string }): boolean {
62
+ const on = (name: string) => truthy(input.env[name]);
63
+ return !input.offline && input.install !== "checkout" && !on("XENO_OFFLINE") && !on("XENO_NO_TOOL_INSTALL") && !on("CI") && !on("CONTINUOUS_INTEGRATION");
64
+ }