@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
@@ -109,6 +109,22 @@ export interface OutcomeEvent {
109
109
  at: IsoTime;
110
110
  reason: string;
111
111
  outcome: ExecutionOutcome;
112
+ /** Why a failed request failed; absent on success and in logs written before it existed. */
113
+ failure?: FailureCause;
114
+ }
115
+
116
+ /**
117
+ * Credential-free facts about a failure (PLAYBOOK: provider error text never
118
+ * reaches the log). `label` is a phrase from retry.ts's fixed vocabulary or an
119
+ * error class name -- both chosen by code, never copied from a response body.
120
+ */
121
+ export interface FailureCause {
122
+ /** provider: the model request failed after its retries; harness: xeno itself threw. */
123
+ source: "provider" | "harness";
124
+ label: string;
125
+ status?: number;
126
+ /** Retries the adapter actually made before giving up. */
127
+ retries?: number;
112
128
  }
113
129
 
114
130
  /** A factual harness notice, appended once rather than rewriting the prompt (N.1). */
@@ -116,7 +132,7 @@ export interface GuidanceEvent {
116
132
  t: "guidance";
117
133
  id: EventId;
118
134
  at: IsoTime;
119
- reason: "continuation" | "repetition" | "budget" | "boundary" | "stall" | "output_recovery" | "completion_review" | "churn" | "deadline" | "runtime_acceptance" | "goal";
135
+ reason: "continuation" | "repetition" | "budget" | "boundary" | "stall" | "output_recovery" | "completion_review" | "churn" | "deadline" | "runtime_acceptance" | "goal" | "secret" | "branch";
120
136
  text: string;
121
137
  sources: readonly EventId[];
122
138
  }
@@ -243,7 +259,8 @@ export interface CutEvent {
243
259
  handoff_tokens?: number;
244
260
  /** O.10: absent preserves the original D20 carried projection. */
245
261
  /** R9: present when the source-priority working set was carried across this cut. */
246
- working_set_version?: 2;
262
+ /** 3: user turns are carried before other pages, with image references and one copy of each next action. */
263
+ working_set_version?: 2 | 3;
247
264
  /** R10: declared builtin advice capabilities at this cut, not an authorization grant. Absent replays old text. */
248
265
  guidance_tools?: readonly string[];
249
266
  /** N.8: first retained event on the effective ancestry; no copied transcript. */
@@ -564,8 +581,26 @@ export interface ProcessExitEvent {
564
581
  receipt?: import("./process-receipt.ts").ProcessReceipt;
565
582
  }
566
583
 
584
+ /**
585
+ * Who let one call through the permission gate (feat-approval-events). A
586
+ * harness fact recorded between the call's invocation and its result, before
587
+ * the call starts; it never enters the model's context. `decision` is the
588
+ * person's prompt choice when `source` is `user`, otherwise the rule's own
589
+ * `allow` / `deny`. `rule` is the matched rule's text -- for `user`, the rule
590
+ * that asked. `no_prompt`: a rule asked and nobody could be asked.
591
+ */
592
+ export interface ApprovalEvent {
593
+ t: "approval"; id: EventId; at: IsoTime;
594
+ call_id: string; tool: string;
595
+ decision: "once" | "command" | "tool" | "deny" | "allow";
596
+ source: "user" | "session_rule" | "config" | "auto" | "no_prompt";
597
+ command?: string;
598
+ rule?: string;
599
+ }
600
+
567
601
  export type LogEvent =
568
602
  | ToolInvocationEvent
603
+ | ApprovalEvent
569
604
  | ProcessExitEvent
570
605
  | SkillLoadedEvent
571
606
  | AcceptanceContractEvent
@@ -606,6 +641,7 @@ export const HARNESS_EVENTS = [
606
641
  "checkpoint", "workspace_observation",
607
642
  "target_observation",
608
643
  "process_exit",
644
+ "approval",
609
645
  "skill_loaded",
610
646
  "acceptance_contract", "acceptance_decision", "goal", "counter_check", "blind_check",
611
647
  "user", "user_bash", "outcome", "guidance", "interrupt", "assistant", "tool_result", "cut", "rewind", "extension",
@@ -74,15 +74,109 @@ export function exchangeProgressChecks(events: readonly LogEvent[]): Set<string>
74
74
  return eligibleProgressChecks(events, claimed, new Set([...gates].filter(([, exit]) => exit !== 0).map(([gate]) => gate)));
75
75
  }
76
76
 
77
- /** D11: classify an actual verifier invocation, never a runner word in data. */
77
+ /**
78
+ * D11: classify an actual verifier invocation, never a runner word in data.
79
+ *
80
+ * Only command shapes whose exit 0 proves every gate in them exited 0 are
81
+ * read: simple commands joined by `&&` (the chain stops at the first failure),
82
+ * each optionally ending in output redirects to fixed paths (a redirect that
83
+ * cannot open fails the command, it never masks one). Anything that can turn
84
+ * a failure into exit 0 or run text the log does not show -- `;`, `||`,
85
+ * pipes, background jobs, subshells, expansions, input redirects -- rejects
86
+ * the whole command, as before. A chain's gates join with `+` in order, so a
87
+ * failed chain is recorded as that chain failing, not as one of its parts.
88
+ */
78
89
  export function classifyGate(gates: Readonly<Record<string, string>>, command: string): string | undefined {
79
- if (/[\n\r;&|<>`$(){}'"\\]/.test(command)) return undefined;
80
- const tokens = command.trim().split(/\s+/);
81
- const informational = ["--version", "-V", "--help", "-h", "--collect-only", "--listTests", "--showConfig", "--init", "--fixtures", "--markers"];
82
- if (tokens.some((t) => informational.includes(t))) return undefined;
90
+ const segments = gateSegments(command);
91
+ if (!segments) return undefined;
92
+ const names = [...new Set(segments.flatMap((words) => segmentGates(gates, words)))];
93
+ return names.length === 0 ? undefined : names.join("+");
94
+ }
95
+
96
+ type ShellToken = { kind: "word"; value: string } | { kind: "and" } | { kind: "redirect"; target: boolean };
97
+
98
+ /**
99
+ * A deliberately small tokenizer, not a shell parser (M.2): it accepts the
100
+ * subset above and returns undefined for everything else, so an unfamiliar
101
+ * construct can only lose a gate, never forge one. Quotes may hold plain text
102
+ * only; `$`, backticks and backslashes are rejected even inside them.
103
+ */
104
+ function shellTokens(command: string): ShellToken[] | undefined {
105
+ const tokens: ShellToken[] = [];
106
+ let word: string | undefined, i = 0;
107
+ const end = () => { if (word !== undefined) tokens.push({ kind: "word", value: word }); word = undefined; };
108
+ while (i < command.length) {
109
+ const c = command[i]!;
110
+ if (c === " " || c === "\t") { end(); i++; continue; }
111
+ if (c === "#" && word === undefined) break; // a comment runs to the end of the (single) line
112
+ if (c === "'" || c === '"') {
113
+ const close = command.indexOf(c, i + 1);
114
+ if (close < 0) return undefined;
115
+ const quoted = command.slice(i + 1, close);
116
+ if (/[$`\\\n\r]/.test(quoted)) return undefined;
117
+ word = (word ?? "") + quoted; i = close + 1; continue;
118
+ }
119
+ if (c === "&" && command[i + 1] === "&") { end(); tokens.push({ kind: "and" }); i += 2; continue; }
120
+ if (c === ">" || c === "&" && command[i + 1] === ">") {
121
+ // An unquoted 1 or 2 directly before `>` is the descriptor, not a word.
122
+ if (c === ">" && (word === "1" || word === "2") && /[12]$/.test(command.slice(0, i))) word = undefined;
123
+ end();
124
+ i += c === "&" ? 2 : 1;
125
+ if (command[i] === ">") i++;
126
+ if (c === ">" && command[i] === "&") {
127
+ if (!/^[12](?![^\s&])/.test(command.slice(i + 1))) return undefined;
128
+ tokens.push({ kind: "redirect", target: false }); i += 2; continue;
129
+ }
130
+ if (command[i] === "|") return undefined;
131
+ tokens.push({ kind: "redirect", target: true }); continue;
132
+ }
133
+ if (/[\n\r;&|<`$(){}\\]/.test(c)) return undefined;
134
+ word = (word ?? "") + c; i++;
135
+ }
136
+ end();
137
+ return tokens;
138
+ }
139
+
140
+ /** The `&&`-joined segments' words with trailing output redirects removed. */
141
+ function gateSegments(command: string): string[][] | undefined {
142
+ const tokens = shellTokens(command);
143
+ if (!tokens) return undefined;
144
+ const segments: string[][] = [[]];
145
+ let redirected = false, needTarget = false;
146
+ for (const token of tokens) {
147
+ const words = segments.at(-1)!;
148
+ if (token.kind === "and") {
149
+ if (needTarget || words.length === 0) return undefined;
150
+ segments.push([]); redirected = false; continue;
151
+ }
152
+ if (token.kind === "redirect") {
153
+ if (needTarget || words.length === 0) return undefined;
154
+ redirected = true; needTarget = token.target; continue;
155
+ }
156
+ if (needTarget) { needTarget = false; continue; }
157
+ // Only trailing redirects are stripped; one before an argument is rejected.
158
+ if (redirected) return undefined;
159
+ words.push(token.value);
160
+ }
161
+ return needTarget || segments.at(-1)!.length === 0 ? undefined : segments;
162
+ }
163
+
164
+ const INFORMATIONAL = ["--version", "-V", "--help", "-h", "--collect-only", "--listTests", "--showConfig", "--init", "--fixtures", "--markers"];
165
+
166
+ /** One simple command's gates: none, the runner's gate, or a target runner's gate targets. */
167
+ function segmentGates(gates: Readonly<Record<string, string>>, tokens: readonly string[]): string[] {
168
+ if (tokens.some((t) => INFORMATIONAL.includes(t))) return [];
169
+ const first = tokens[0] ?? "";
170
+ const runner = own(TARGET_RUNNERS, first);
171
+ if (runner && !Object.hasOwn(gates, first)) return targetGates(gates, runner, tokens.slice(1));
172
+ const single = runnerGate(gates, tokens);
173
+ return single === undefined ? [] : [single];
174
+ }
175
+
176
+ function runnerGate(gates: Readonly<Record<string, string>>, tokens: readonly string[]): string | undefined {
83
177
  let i = 0;
84
178
  const first = tokens[0];
85
- if (["bun", "npm", "pnpm", "yarn"].includes(first ?? "") && tokens[1] === "test") return gates["test"];
179
+ if (["bun", "npm", "pnpm", "yarn"].includes(first ?? "") && tokens[1] === "test") return own(gates, "test");
86
180
  if (first === "python" || first === "python3") {
87
181
  if (tokens[1] !== "-m") return undefined;
88
182
  i = 2;
@@ -94,5 +188,53 @@ export function classifyGate(gates: Readonly<Record<string, string>>, command: s
94
188
  const executable = tokens[i];
95
189
  if (executable === "cargo" && !["test", "check", "clippy", "build"].includes(tokens[i + 1] ?? "")) return undefined;
96
190
  if (executable === "vitest" && ["list", "init"].includes(tokens[i + 1] ?? "")) return undefined;
97
- return gates[executable ?? ""];
191
+ return own(gates, executable ?? "");
192
+ }
193
+
194
+ /** A table lookup that a word like `constructor` cannot satisfy through the prototype. */
195
+ function own<T>(table: Readonly<Record<string, T>>, key: string): T | undefined {
196
+ return Object.hasOwn(table, key) ? table[key] : undefined;
197
+ }
198
+
199
+ /**
200
+ * Options a target runner may carry and still prove its targets ran. An
201
+ * allowlist on purpose: an unknown option might be a dry run, a touch or an
202
+ * ignore-errors switch (exit 0 without the targets passing), so it makes the
203
+ * segment not a gate rather than being guessed at.
204
+ */
205
+ type TargetRunner = { withValue: readonly string[]; flags: readonly string[]; attached: RegExp; argsAfterDashes: boolean };
206
+ const TARGET_RUNNERS: Readonly<Record<string, TargetRunner>> = {
207
+ make: { withValue: ["-C", "-f", "--directory", "--file", "--makefile"],
208
+ flags: ["-k", "-s", "-B", "-w", "-r", "-R", "--keep-going", "--silent", "--quiet", "--always-make", "--print-directory", "--no-print-directory"],
209
+ attached: /^(?:-C.+|-f.+|-j\d*|--(?:directory|file|makefile|jobs)=.+|--jobs)$/, argsAfterDashes: false },
210
+ just: { withValue: ["-f", "-d", "--justfile", "--working-directory"], flags: ["-q", "-v", "--quiet", "--verbose"],
211
+ attached: /^--(?:justfile|working-directory)=.+$/, argsAfterDashes: false },
212
+ task: { withValue: ["-d", "-t", "--dir", "--taskfile"], flags: ["-s", "-v", "-p", "-f", "--silent", "--verbose", "--parallel", "--force"],
213
+ attached: /^--(?:dir|taskfile)=.+$/, argsAfterDashes: true },
214
+ };
215
+
216
+ /**
217
+ * `make build size test`: the runner exits 0 only when every target
218
+ * succeeded, so each target named after a configured gate key (exactly, or
219
+ * as `<key>-…` / `<key>:…`) is that gate; other targets do not weaken it. No
220
+ * gate target, or no target at all (the default goal is unknown), is no gate.
221
+ */
222
+ function targetGates(gates: Readonly<Record<string, string>>, runner: TargetRunner, args: readonly string[]): string[] {
223
+ const keys = Object.keys(gates).sort((a, b) => b.length - a.length);
224
+ const names: string[] = [];
225
+ for (let i = 0; i < args.length; i++) {
226
+ const arg = args[i]!;
227
+ if (arg === "--") { if (runner.argsAfterDashes) break; return []; }
228
+ if (arg.startsWith("-")) {
229
+ if (runner.withValue.includes(arg)) { if (++i >= args.length) return []; continue; }
230
+ if (arg === "-j" && /^\d+$/.test(args[i + 1] ?? "")) { i++; continue; }
231
+ if (runner.flags.includes(arg) || runner.attached.test(arg)) continue;
232
+ return [];
233
+ }
234
+ if (arg.includes("=")) continue; // a variable override, not a target
235
+ if (/[*?[\]~]/.test(arg)) return [];
236
+ const key = keys.find((k) => arg === k || arg.startsWith(`${k}-`) || arg.startsWith(`${k}:`));
237
+ if (key !== undefined) names.push(gates[key]!);
238
+ }
239
+ return names;
98
240
  }
@@ -137,6 +137,8 @@ function eventHtml(e: LogEvent): string {
137
137
  case "evidence_link":
138
138
  return wrap("event", "evidence link", e.at, `${e.work}: ${e.what} → ${e.source}`);
139
139
  case "work_owner": case "work_dependencies": return wrap("event", e.t, e.at, JSON.stringify(e));
140
+ case "approval": return wrap("event", "approval", e.at,
141
+ [`${e.tool} · ${e.call_id} · ${e.decision} · ${e.source}`, ...(e.rule ? [`rule: ${e.rule}`] : []), ...(e.command !== undefined ? [`$ ${e.command}`] : [])].join("\n"));
140
142
  case "skill_loaded": return wrap("event", "skill loaded", e.at, `${e.name} · ${e.path} · ${e.sha256} · ${e.mode}`);
141
143
  case "process_exit": return wrap("event", "process exit", e.at, `${e.session} · exit ${e.exit} · output ${e.output.sha256}`);
142
144
  case "acceptance_contract": return wrap("event", "acceptance contract", e.at, JSON.stringify(e.contract));