@visual-z/xeno 0.8.2 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/package.json +1 -1
- package/src/apps/shell.ts +13 -3
- package/src/apps/startup.ts +4 -4
- package/src/apps/ui/app.ts +32 -14
- package/src/apps/ui/project.ts +1 -1
- package/src/apps/ui/standalone.ts +4 -2
- package/src/apps/ui/surface.ts +2 -0
- package/src/core/activity-status.ts +17 -2
- package/src/core/command-contract.ts +58 -16
- package/src/core/completion-review.ts +1 -1
- package/src/core/derive.ts +9 -1
- package/src/core/event-validation.ts +19 -3
- package/src/core/events.ts +38 -2
- package/src/core/evidence.ts +149 -7
- package/src/core/export.ts +2 -0
- package/src/core/external-effects.ts +488 -0
- package/src/core/handoff.ts +24 -3
- package/src/core/history-query.ts +1 -1
- package/src/core/history.ts +124 -20
- package/src/core/observation-view.ts +24 -0
- package/src/core/path-suggestions.ts +59 -0
- package/src/core/permissions.ts +60 -0
- package/src/core/prompt-policy.ts +4 -2
- package/src/core/secrets.ts +188 -0
- package/src/core/stop-report.ts +25 -3
- package/src/core/tool-failure.ts +11 -3
- package/src/core/transcript.ts +3 -0
- package/src/core/work-state.ts +7 -2
- package/src/core/working-set.ts +34 -6
- package/src/runtime/connection-reuse.ts +40 -0
- package/src/runtime/exec-sessions.ts +66 -18
- package/src/runtime/history-tools.ts +12 -2
- package/src/runtime/loop.ts +98 -14
- package/src/runtime/open-image.ts +42 -0
- package/src/runtime/openai-responses.ts +15 -7
- package/src/runtime/openai.ts +15 -7
- package/src/runtime/permission-resources.ts +11 -4
- package/src/runtime/pi-provider.ts +34 -9
- package/src/runtime/provider.ts +19 -8
- package/src/runtime/retry.ts +152 -9
- package/src/runtime/secrets.ts +79 -0
- package/src/runtime/session-control.ts +9 -1
- package/src/runtime/stream-idle.ts +80 -0
- package/src/runtime/tools.ts +97 -27
- package/src/runtime/turn-effects.ts +31 -0
- package/src/store/context-files.ts +5 -1
- package/src/store/file-checkpoints.ts +1 -1
- package/src/store/log.ts +20 -0
- package/src/store/replay.ts +4 -3
- package/src/store/secrets.ts +44 -0
- package/src/store/sessions.ts +49 -6
- package/src/tui/ui/image-size.ts +6 -2
- package/src/tui/ui/items.ts +3 -1
- package/src/tui/ui/pictures.ts +14 -1
package/src/core/events.ts
CHANGED
|
@@ -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";
|
|
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
|
-
|
|
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",
|
package/src/core/evidence.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/src/core/export.ts
CHANGED
|
@@ -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));
|