@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
@@ -1,7 +1,7 @@
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
5
  import { recoveryInstructions } from "./prompt-policy.ts";
6
6
  import type { RecoveryNodes } from "./recovery-sources.ts";
7
7
 
@@ -65,20 +65,19 @@ function suffixes(h: Handoff, offsets: Record<HistorySection, number>): Handoff
65
65
  };
66
66
  }
67
67
 
68
- export function boundHandoff(full: Handoff, budget = DEFAULT_HANDOFF_TOKENS): Handoff {
69
- if (!Number.isFinite(budget) || budget <= 0) throw new RangeError("handoff budget must be positive");
70
- const size = (h: Handoff): number => estimateTokens.count([], emitHandoff(h));
71
- if (size(full) <= budget) return full;
72
- if (full.predecessor === null) throw new Error("history requires an immutable source");
73
- // An absent new section must not change legacy projections or frozen replay.
74
- 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) &&
75
71
  (section !== "evidence_links" || full.evidence_links !== undefined) &&
76
72
  (section !== "acceptance.criteria" || full.acceptance !== undefined) &&
77
73
  (section !== "acceptance.targets" || full.acceptance?.targets !== undefined) &&
78
74
  (section !== "loaded_skills" || full.loaded_skills !== undefined) &&
79
75
  (section !== "processes" || full.processes !== undefined));
80
- const offsets = Object.fromEntries(sections.map((s) => [s, historyItems(full, s).length])) as Record<HistorySection, number>;
81
- 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 {
82
81
  ...suffixes(full, offsets),
83
82
  handoff_version: 2,
84
83
  history: {
@@ -87,22 +86,42 @@ export function boundHandoff(full: Handoff, budget = DEFAULT_HANDOFF_TOKENS): Ha
87
86
  total: historyItems(full, s).length, offset: offsets[s],
88
87
  }])),
89
88
  },
90
- });
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);
91
107
  let carried = build();
92
108
  // The objective stays verbatim (D2). An oversized objective is an explicit
93
109
  // input problem; repeatedly cutting it would create an infinite cut loop.
94
110
  if (size(carried) > budget) throw new RangeError("handoff scalar fields exceed budget; shorten the objective or frozen input");
95
- 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)));
96
121
  // Round-robin suffix growth prevents one long section starving every other
97
122
  // category. Each retained range is contiguous and independently pageable.
98
123
  while (active.size > 0) {
99
- for (const section of active) {
100
- if (offsets[section] === 0) { active.delete(section); continue; }
101
- offsets[section]--;
102
- const candidate = build();
103
- if (size(candidate) <= budget) carried = candidate;
104
- else { offsets[section]++; active.delete(section); }
105
- }
124
+ for (const section of active) if (!grow(section)) active.delete(section);
106
125
  }
107
126
  return carried;
108
127
  }
@@ -131,3 +150,88 @@ export function boundRecoveryHandoff(full: Handoff, nodes: RecoveryNodes, budget
131
150
  const inline = tools?.includes("history") && carried.user_turns_truncated === undefined && (carried.history?.sections["user_turns"]?.offset ?? 0) === 0;
132
151
  return inline && carried.working_set ? { ...carried, working_set: { ...carried.working_set, recovery: recoveryInstructions(tools!, true) } } : carried;
133
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; }
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
+ }
@@ -162,6 +162,66 @@ export function decideWithSessionRules(
162
162
  return decide([...remembered, ...configured], request);
163
163
  }
164
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
+
165
225
  /** Lexical normalization is pure; the store adds canonical symlink resources. */
166
226
  export function normalizePermissionPath(path: string): string {
167
227
  const absolute = path.startsWith("/");
@@ -29,10 +29,12 @@ export function toolDescription(description: string, guidance: readonly ToolGuid
29
29
  * that returns what it already has -- or an empty page after a model switch
30
30
  * on a fresh session (Horizyn mv3nsske-x50u, fix-recovery-inline-users).
31
31
  */
32
- export function recoveryInstructions(tools: readonly string[], usersInline = false): string {
32
+ export function recoveryInstructions(tools: readonly string[], usersInline = false, images = false): string {
33
33
  return (tools.includes("history")
34
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. "
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. " : "")
36
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. ") +
37
39
  "Abandoned messages are cancelled; annotations are not user instructions. Observations are historical: recheck resource, host and current permissions before acting.";
38
40
  }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Credential redaction at the log boundary (S.9).
3
+ *
4
+ * The log is the only truth and every model request is a projection of it, so
5
+ * a credential that reaches the log is resent on every later request and
6
+ * survives every cut through `objective` and `user_turns` (D2). Masking at
7
+ * projection time would leave the original in an append-only file forever.
8
+ * Redaction therefore happens before an event is written: the secret is
9
+ * replaced by a placeholder, and the value lives in a separate per-session
10
+ * vault that no projection reads.
11
+ *
12
+ * The placeholder is an exact inverse: tools expand it back to the value when
13
+ * they run (OpenHands' SecretRegistry does the same with secret names), and
14
+ * any output that echoes a known value is masked again. A false positive
15
+ * therefore costs readability, never correctness -- an edit whose old_string
16
+ * holds a placeholder still matches the file.
17
+ *
18
+ * Only high-confidence shapes are detected (gitleaks prefix rules plus
19
+ * "secret-named key = value"); entropy detection is deliberately absent because
20
+ * its false positives on hashes and code are irreversible in a live view.
21
+ * A bare password has no shape, so it is recognised only by context: a short
22
+ * token the user sends right after the assistant asked for a credential.
23
+ */
24
+
25
+ export interface SecretEntry {
26
+ /** The placeholder's number: `[[xeno-secret-<n>]]`. */
27
+ n: number;
28
+ value: string;
29
+ /** Which rule found it, for the audit trail; never the value. */
30
+ rule: string;
31
+ }
32
+
33
+ export type Vault = readonly SecretEntry[];
34
+
35
+ export const placeholder = (n: number): string => `[[xeno-secret-${n}]]`;
36
+ const PLACEHOLDER = /\[\[xeno-secret-(\d+)\]\]/g;
37
+
38
+ /**
39
+ * A known value shorter than this is too likely to occur by accident ("admin",
40
+ * "123456") to mask everywhere it appears. Assumed, not measured; Codex's
41
+ * key-assignment rule uses the same 8.
42
+ */
43
+ export const MIN_SECRET_LENGTH = 8;
44
+
45
+ interface Rule { id: string; pattern: RegExp; /** Capture group holding the secret; 0 is the whole match. */ group: number }
46
+
47
+ // Prefix rules follow gitleaks' default config (b58d3f10) and Gemini CLI's
48
+ // environmentSanitization.ts; tskey- is added for Tailscale, which gitleaks lacks.
49
+ const RULES: readonly Rule[] = [
50
+ { id: "pem-private-key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----/g, group: 0 },
51
+ { id: "anthropic-key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g, group: 0 },
52
+ { id: "openai-key", pattern: /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}/g, group: 0 },
53
+ { id: "github-token", pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/g, group: 0 },
54
+ { id: "gitlab-token", pattern: /\bglpat-[A-Za-z0-9_-]{20,}/g, group: 0 },
55
+ { id: "slack-token", pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g, group: 0 },
56
+ { id: "aws-access-key", pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, group: 0 },
57
+ { id: "google-api-key", pattern: /\bAIza[0-9A-Za-z_-]{35}/g, group: 0 },
58
+ { id: "stripe-key", pattern: /\b[sr]k_live_[0-9A-Za-z]{24,}/g, group: 0 },
59
+ // tskey-<kind>-<id with a digit>-<secret>; documentation like "tskey-auth-reusable-" has no digit in the id.
60
+ { id: "tailscale-key", pattern: /\btskey-[a-z]+-[A-Za-z0-9]*\d[A-Za-z0-9]*-[A-Za-z0-9]{10,}/g, group: 0 },
61
+ { id: "npm-token", pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, group: 0 },
62
+ { id: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, group: 0 },
63
+ { id: "bearer-token", pattern: /\bBearer\s+([A-Za-z0-9._~+/=-]{20,})/g, group: 1 },
64
+ { id: "url-password", pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:([^\s/@]{3,})@/gi, group: 1 },
65
+ // A secret-named key assigned a literal. The value class excludes quotes,
66
+ // brackets and parentheses so `token = getToken()` and `password: string`
67
+ // stay code; requiring a digit or 16+ characters keeps `secret: undefined`.
68
+ { id: "secret-assignment", group: 1,
69
+ // Up to three prefix words, so access_token, HUILIANYI_REFRESH_TOKEN and db-password count.
70
+ pattern: /\b(?:[a-z0-9]+[_-]){0,3}(?:api[_-]?key|access[_-]?key|secret(?:[_-]?key)?|client[_-]?secret|token|password|passwd|pwd)["']?\s*[:=]\s*["']?([^\s"'`(){}[\]<>;,]{8,})/gi },
71
+ { id: "secret-assignment-zh", group: 1, pattern: /(?:密码|口令|密钥|令牌)\s*(?:是|为|[::=])\s*["'“]?([^\s"'”,。,;;]{4,})/g },
72
+ ];
73
+
74
+ const plausibleAssigned = (value: string): boolean =>
75
+ !/^(?:\$|process\.|os\.|env\.|self\.|this\.)/.test(value) && !value.startsWith("[[xeno-secret-") &&
76
+ // A pure number after a token-named key is a count (input_tokens=12000000), not a credential.
77
+ !/^\d+$/.test(value) &&
78
+ // Credentials carry digits; a digit-free value is a path, a member access or a placeholder word
79
+ // (replayed 2026-10-12 sessions: "internal/server/…", "auth.AccessToken"). A long mixed-case
80
+ // value without separators is the one digit-free exception.
81
+ (/\d/.test(value) && /[A-Za-z]/.test(value) || value.length >= 20 && /[A-Z]/.test(value) && /[a-z]/.test(value) && !/[./]/.test(value));
82
+
83
+ /** Every secret-shaped span in `text`, with the rule that found it. Overlaps resolve to the earliest, longest. */
84
+ export function findSecrets(text: string): { start: number; end: number; rule: string }[] {
85
+ const spans: { start: number; end: number; rule: string }[] = [];
86
+ for (const rule of RULES) {
87
+ for (const match of text.matchAll(rule.pattern)) {
88
+ const value = match[rule.group];
89
+ if (value === undefined || match.index === undefined) continue;
90
+ if (rule.id === "secret-assignment" && !plausibleAssigned(value)) continue;
91
+ const start = match.index + match[0].lastIndexOf(value);
92
+ spans.push({ start, end: start + value.length, rule: rule.id });
93
+ }
94
+ }
95
+ spans.sort((a, b) => a.start - b.start || b.end - a.end);
96
+ const kept: typeof spans = [];
97
+ for (const span of spans) if (kept.length === 0 || span.start >= kept.at(-1)!.end) kept.push(span);
98
+ return kept;
99
+ }
100
+
101
+ /**
102
+ * Replaces known values and newly found secrets with placeholders. Returns the
103
+ * vault entries it had to add; the caller persists them before the text is
104
+ * written anywhere, so a placeholder in the log always has a value to expand to.
105
+ */
106
+ export function redactText(text: string, vault: Vault, candidates: readonly { value: string; rule: string }[] = []): { text: string; added: SecretEntry[] } {
107
+ if (text.length === 0) return { text, added: [] };
108
+ const added: SecretEntry[] = [];
109
+ let next = vault.reduce((max, entry) => Math.max(max, entry.n), 0) + 1;
110
+ // Candidates (secret-named environment values) join the vault only when they
111
+ // actually appear, so a placeholder number is never spent on a value no event holds.
112
+ const known: SecretEntry[] = [...vault];
113
+ for (const candidate of candidates) {
114
+ if (candidate.value.length < MIN_SECRET_LENGTH || !text.includes(candidate.value) || known.some(entry => entry.value === candidate.value)) continue;
115
+ const entry = { n: next++, value: candidate.value, rule: candidate.rule };
116
+ known.push(entry); added.push(entry);
117
+ }
118
+ let out = maskKnown(text, known);
119
+ const spans = findSecrets(out);
120
+ if (spans.length === 0) return { text: out, added };
121
+ let result = "", at = 0;
122
+ for (const span of spans) {
123
+ const value = out.slice(span.start, span.end);
124
+ const existing = known.find(entry => entry.value === value);
125
+ const entry = existing ?? { n: next++, value, rule: span.rule };
126
+ if (!existing) { known.push(entry); added.push(entry); }
127
+ result += out.slice(at, span.start) + placeholder(entry.n);
128
+ at = span.end;
129
+ }
130
+ // Values found in this pass may occur again where a neighbouring match split
131
+ // them (two tokens with no delimiter); a second known-value pass closes that.
132
+ return { text: maskKnown(result + out.slice(at), known), added };
133
+ }
134
+
135
+ /** Longest first, so a value containing another is masked whole. A short value is masked only where a rule found it. */
136
+ function maskKnown(text: string, known: readonly SecretEntry[]): string {
137
+ let out = text;
138
+ for (const entry of known.filter(entry => entry.value.length >= MIN_SECRET_LENGTH).sort((a, b) => b.value.length - a.value.length)) {
139
+ if (out.includes(entry.value)) out = out.split(entry.value).join(placeholder(entry.n));
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /** The inverse of redactText for tool inputs. An unknown placeholder is left as written. */
145
+ export function expandText(text: string, vault: Vault): string {
146
+ if (!text.includes("[[xeno-secret-")) return text;
147
+ return text.replace(PLACEHOLDER, (whole, n: string) => vault.find(entry => entry.n === Number(n))?.value ?? whole);
148
+ }
149
+
150
+ const ASKED_FOR_CREDENTIAL = /password|passcode|passphrase|credential|api[ _-]?key|access[ _-]?token|\btoken\b|密码|口令|密钥|凭据|凭证|令牌|验证码/i;
151
+
152
+ /**
153
+ * A bare password has no shape; what identifies it is the exchange. A message
154
+ * that is a single token with mixed character classes, sent right after the
155
+ * assistant asked for a credential, is that credential (the 2026-10-12 ticket
156
+ * session: the assistant asked for a login password, the user replied with it).
157
+ */
158
+ export function isSolicitedSecret(userText: string, previousAssistantText: string | undefined): boolean {
159
+ const value = userText.trim();
160
+ if (previousAssistantText === undefined || !ASKED_FOR_CREDENTIAL.test(previousAssistantText)) return false;
161
+ if (!/^\S{6,128}$/.test(value) || value.startsWith("[[xeno-secret-")) return false;
162
+ // Letters with digits, or three character classes: "dsm612566" and "Pa$$word"
163
+ // qualify, an identifier like "OPENAI_API_KEY" or a branch name does not.
164
+ const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter(pattern => pattern.test(value)).length;
165
+ const lettersAndDigits = /[A-Za-z]/.test(value) && /\d/.test(value);
166
+ return (lettersAndDigits || classes >= 3) && !/^[a-z]+:\/\//i.test(value) && !/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(value);
167
+ }
168
+
169
+ /** Applies `redact` to every string in a JSON value, preserving its shape. */
170
+ export function mapStrings(value: unknown, redact: (text: string) => string, skip: (key: string) => boolean = () => false): unknown {
171
+ if (typeof value === "string") return redact(value);
172
+ if (Array.isArray(value)) return value.map(item => mapStrings(item, redact, skip));
173
+ if (value !== null && typeof value === "object") {
174
+ const out: Record<string, unknown> = {};
175
+ for (const [key, item] of Object.entries(value)) out[key] = skip(key) ? item : mapStrings(item, redact, skip);
176
+ return out;
177
+ }
178
+ return value;
179
+ }
180
+
181
+ /** Environment variables whose names say they hold a secret; their values are masked wherever they appear. */
182
+ export function secretEnvironmentValues(env: Readonly<Record<string, string | undefined>>): { value: string; rule: string }[] {
183
+ return Object.entries(env).flatMap(([name, value]) =>
184
+ value !== undefined && value.length >= MIN_SECRET_LENGTH && !value.startsWith("/") &&
185
+ /(?:^|_)(?:API_?KEY|KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIALS?|AUTH)(?:_|$)/i.test(name) &&
186
+ !/(?:^|_)(?:PATH|DIR|FILE|URL|HOST|PORT|USER|NAME|ID|MODE|TYPE)$/i.test(name)
187
+ ? [{ value, rule: `env:${name}` }] : []).sort((a, b) => a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0);
188
+ }
@@ -8,7 +8,11 @@ export function stopReport(events: readonly LogEvent[], requests: number, limit:
8
8
  const scoped = currentRequestEvents(events);
9
9
  const current = scoped.length > 0 ? scoped : events;
10
10
  const results = current.filter(event => event.t === "tool_result");
11
- const failures = results.filter(event => !event.ok);
11
+ // Q.22 keeps ok:false on a recorded-but-unverified claim so the model does
12
+ // not read "recorded" as "done"; to a person reading why work stopped it is
13
+ // an unproven statement, not a broken tool.
14
+ const unverified = results.filter(unverifiedClaim);
15
+ const failures = results.filter(event => !event.ok && !unverifiedClaim(event));
12
16
  const exits = results.filter(event => event.effect?.kind === "command" && event.effect.exit === 0).length;
13
17
  const ids = new Set<number>();
14
18
  for (const event of current) {
@@ -17,8 +21,8 @@ export function stopReport(events: readonly LogEvent[], requests: number, limit:
17
21
  }
18
22
  const running = [...ids].filter(id => latestProcessReceipt([{ node: 0, events: current }], id)?.receipt.running === true);
19
23
  return [
20
- `Stopped: ${exitLine(cause)} (${limit === Infinity ? requests : `${requests}/${limit}`} model requests).`,
21
- `Recorded evidence: ${results.length} tool receipts; ${exits} successful command exits; ${failures.length} failed tool receipts. Task completion is not verified by this stop.`,
24
+ requestLine(exitLine(cause), requests, limit),
25
+ `Recorded evidence: ${results.length} tool receipts; ${exits} successful command exits; ${failures.length} failed tool receipts${unverified.length ? `; ${unverified.length} unverified claims` : ""}. Task completion is not verified by this stop.`,
22
26
  ...(failures.length ? [`Failed tools: ${failedTools(current, failures)}.`] : []),
23
27
  ...(finalReplyGiven(current) ? ["A final reply was given before this stop; read it above — this stop does not mark it wrong."] : []),
24
28
  ...(running.length ? [`Last observed running: ${running.slice(0, 3).join(", ")}${running.length > 3 ? ` (+${running.length - 3})` : ""}; inspect the process receipt before replaying a command.`] : []),
@@ -26,6 +30,16 @@ export function stopReport(events: readonly LogEvent[], requests: number, limit:
26
30
  ].join("\n");
27
31
  }
28
32
 
33
+ /**
34
+ * S.6 removed the default cap. A bare "(80 model requests)" in the same
35
+ * brackets as "(3/7 model requests)" read as a ceiling that was hit
36
+ * (fix-stop-report-request-count), so an uncapped run says it is a count.
37
+ */
38
+ function requestLine(cause: string, requests: number, limit: number): string {
39
+ if (limit !== Infinity) return `Stopped: ${cause} (${requests}/${limit} model requests).`;
40
+ return `Stopped: ${cause}. This request sent ${requests} model request${requests === 1 ? "" : "s"}; no request cap is set.`;
41
+ }
42
+
29
43
  /**
30
44
  * The user reads this line; no command looks a receipt up by id, so ids told
31
45
  * them nothing (fix-stop-report-readable). Tool names say where it went wrong.
@@ -39,6 +53,14 @@ function failedTools(events: readonly LogEvent[], failures: readonly Extract<Log
39
53
  return ranked.slice(0, 3).map(([name, count]) => `${name} ×${count}`).join(", ") + (ranked.length > 3 ? ` (+${ranked.length - 3} more)` : "");
40
54
  }
41
55
 
56
+ function unverifiedClaim(event: Extract<LogEvent, { t: "tool_result" }>): boolean {
57
+ if (event.ok || !event.content.startsWith("{")) return false;
58
+ try {
59
+ const body = JSON.parse(event.content) as { recorded?: unknown; verification?: unknown };
60
+ return body.recorded === true && body.verification === "unverified";
61
+ } catch { return false; }
62
+ }
63
+
42
64
  /** A stop after the model already answered must not read as "the task failed". */
43
65
  function finalReplyGiven(events: readonly LogEvent[]): boolean {
44
66
  return events.some(event => event.t === "assistant" && !event.blocks.some(block => block.type === "tool_use") &&
@@ -27,10 +27,18 @@ const guidance: Record<ToolDiagnostic["code"], string> = {
27
27
  * policy change is required") would send the model to ask for a setting that
28
28
  * does not exist -- which is what a real session did before this text.
29
29
  */
30
- export const STATE_PATH_GUIDANCE = "This path is repository or xeno session state, which edit tools never change; no setting changes this. " +
30
+ /*
31
+ * The diagnostic code stays permission_denied because codes are a closed set
32
+ * validated on every logged tool_result (event-validation.ts); a new code
33
+ * would change the session format. A model still read that code as "wait for
34
+ * approval" and stopped (Horizyn mv3nsske), so the text has to say outright
35
+ * that approval is neither needed nor able to help.
36
+ */
37
+ const STATE_PATH_RULE = "This path is repository or xeno session state, which edit tools never change. " +
38
+ "This is not a permission problem and no setting changes this: no user approval allows it, so do not wait for one. ";
39
+ export const STATE_PATH_GUIDANCE = STATE_PATH_RULE +
31
40
  "To work in an isolated checkout, use the worktree tool instead of creating one under .git or .xeno.";
32
- const STATE_PATH_GUIDANCE_WITHOUT_WORKTREE = "This path is repository or xeno session state, which edit tools never change; no setting changes this. " +
33
- "Edit files outside .git and .xeno.";
41
+ const STATE_PATH_GUIDANCE_WITHOUT_WORKTREE = STATE_PATH_RULE + "Edit files in a directory outside .git and .xeno.";
34
42
 
35
43
  export function failureDiagnostic(code: ToolDiagnostic["code"], message: string, facts: FailureFacts,
36
44
  extra: Pick<ToolDiagnostic, "recover" | "example" | "next"> = {}): ToolDiagnostic {
@@ -81,6 +81,9 @@ export function projectTranscript(events: readonly LogEvent[], entryTypes: Reado
81
81
  case "goal": items.push({ ...identity, kind: "notice", text: event.action === "set"
82
82
  ? `Goal set · ${event.accepted.length} accepted${event.rejected.length ? `, ${event.rejected.length} rejected` : ""} · ${event.objective}`
83
83
  : event.reason === "met" ? "Goal met · every accepted criterion has current evidence" : "Goal cleared" }); break;
84
+ // Approvals answer "why was this allowed" in /export and history; the person
85
+ // already saw the prompt, and a notice per auto-allowed command would bury the chat.
86
+ case "approval":
84
87
  case "checkpoint": case "workspace_observation":
85
88
  case "guidance": case "claim": case "decision": case "pitfall": case "blocked": case "question": case "next_action": case "work_status": case "work_owner": case "work_dependencies": case "evidence_link":
86
89
  // These remain inspectable via /state and /export; they are not chat messages.