@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
@@ -0,0 +1,488 @@
1
+ /**
2
+ * What this request's executed commands did outside the workspace
3
+ * (feat-external-effects-summary).
4
+ *
5
+ * The permission design deliberately has no command-danger classifier
6
+ * (permissions.ts): a screen that misses something implies someone is
7
+ * checking. This is the opposite stance -- a statement after the fact, shown
8
+ * to the person when a request ends, never a gate. Its failure mode decides
9
+ * its shape: a missed item is one line fewer; a wrong item teaches the person
10
+ * to ignore the line. So everything here prefers silence to a guess:
11
+ * unparseable commands are skipped whole, and a path containing a variable or
12
+ * a command substitution is never resolved.
13
+ *
14
+ * Only executed commands count: the input is `tool_result` receipts with a
15
+ * `command` effect, which a denied or cancelled call never has. Nothing here
16
+ * is stored; the list is recomputed from the log whenever it is shown (D8).
17
+ *
18
+ * Pure: the home and temporary directories are parameters, never read here.
19
+ */
20
+ import type { LogEvent } from "./events.ts";
21
+ import { normalizePermissionPath } from "./permissions.ts";
22
+
23
+ export type ExternalCategory = "write" | "process" | "system" | "network" | "push";
24
+ export interface ExternalEffect {
25
+ readonly category: ExternalCategory;
26
+ /** The simple command that did it, truncated for display. */
27
+ readonly command: string;
28
+ /** The receipt that ran it. */
29
+ readonly event: string;
30
+ /** The resolved outside path, for writes. */
31
+ readonly path?: string;
32
+ }
33
+ export interface ExternalOptions {
34
+ /** Expands `~` and `$HOME`. Absent: such paths are dynamic and skipped. */
35
+ readonly home?: string;
36
+ /** Extra temporary roots (the platform's os.tmpdir()); /tmp and /var/tmp are always temporary. */
37
+ readonly temp?: readonly string[];
38
+ }
39
+
40
+ /** Display bound for one item; long heredoc commands would otherwise fill the line. */
41
+ const SNIPPET_CHARS = 60;
42
+ /** Assumed bound on nested `sh -c` analysis; deeper nesting is skipped, not guessed. */
43
+ const MAX_NESTING = 3;
44
+ const ALWAYS_TEMPORARY = ["/tmp", "/private/tmp", "/var/tmp", "/private/var/tmp", "/dev"];
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Lexer: a deliberately small subset of POSIX shell. Anything outside it makes
48
+ // the whole command unparseable, which means "report nothing".
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** A word with its literal value, or null when part of it is only known at run time. */
52
+ type Word = { text: string; value: string | null; quoted: boolean };
53
+ type Token =
54
+ | { kind: "word"; word: Word }
55
+ | { kind: "op"; op: string }
56
+ | { kind: "redirect"; op: string; fd: string };
57
+
58
+ const OPERATORS = ["&&", "||", ";;", "|&", ";", "|", "&", "(", ")", "\n"];
59
+
60
+ /** Tokens, or null when the command does not fit the subset (unclosed quote, unterminated heredoc). */
61
+ function lex(input: string, home: string | undefined): Token[] | null {
62
+ const tokens: Token[] = [];
63
+ const heredocs: { delimiter: string; strip: boolean }[] = [];
64
+ let i = 0;
65
+ const at = (offset = 0) => input[i + offset] ?? "";
66
+ while (i < input.length) {
67
+ const char = at();
68
+ if (char === "\n") {
69
+ tokens.push({ kind: "op", op: "\n" }); i++;
70
+ // Heredoc bodies are data, never commands: skip them line by line.
71
+ for (const doc of heredocs.splice(0)) {
72
+ for (;;) {
73
+ if (i >= input.length) return null;
74
+ const end = input.indexOf("\n", i);
75
+ const line = input.slice(i, end < 0 ? input.length : end);
76
+ i = end < 0 ? input.length : end + 1;
77
+ if ((doc.strip ? line.replace(/^\t+/, "") : line) === doc.delimiter) break;
78
+ }
79
+ }
80
+ continue;
81
+ }
82
+ if (char === " " || char === "\t") { i++; continue; }
83
+ if (char === "\\" && at(1) === "\n") { i += 2; continue; }
84
+ if (char === "#") { while (i < input.length && at() !== "\n") i++; continue; }
85
+ // Redirections, optionally with a leading descriptor (2>, &>).
86
+ const redirect = /^(\d*|&)(>>|>\||>&|>|<<<|<<-|<<|<>|<&|<)/.exec(input.slice(i));
87
+ if (redirect && (redirect[1] !== "&" || redirect[2]!.startsWith(">"))) {
88
+ const op = redirect[2]!;
89
+ i += redirect[0].length;
90
+ if (op === "<<" || op === "<<-") {
91
+ while (at() === " " || at() === "\t") i++;
92
+ const word = readWord();
93
+ if (word === null || word.text === "") return null;
94
+ heredocs.push({ delimiter: word.text, strip: op === "<<-" });
95
+ continue;
96
+ }
97
+ tokens.push({ kind: "redirect", op, fd: redirect[1]! });
98
+ continue;
99
+ }
100
+ const operator = OPERATORS.find(candidate => input.startsWith(candidate, i));
101
+ if (operator) { tokens.push({ kind: "op", op: operator }); i += operator.length; continue; }
102
+ const word = readWord();
103
+ if (word === null) return null;
104
+ tokens.push({ kind: "word", word });
105
+ }
106
+ if (heredocs.length) return null;
107
+ return tokens;
108
+
109
+ /** One word; `text` is the unquoted spelling (heredoc delimiters, display), `value` the resolved literal. */
110
+ function readWord(): Word | null {
111
+ let text = "", value: string | null = "", quoted = false;
112
+ const start = i;
113
+ const dynamic = () => { value = null; };
114
+ while (i < input.length) {
115
+ const char = at();
116
+ if (" \t\n;&|()<>".includes(char)) break;
117
+ if (char === "'") {
118
+ const end = input.indexOf("'", i + 1);
119
+ if (end < 0) return null;
120
+ const part = input.slice(i + 1, end);
121
+ text += part; if (value !== null) value += part; quoted = true; i = end + 1; continue;
122
+ }
123
+ if (char === '"') {
124
+ i++; quoted = true;
125
+ for (;;) {
126
+ if (i >= input.length) return null;
127
+ const inner = at();
128
+ if (inner === '"') { i++; break; }
129
+ if (inner === "\\" && '$`"\\\n'.includes(at(1))) { text += at(1); if (value !== null) value += at(1); i += 2; continue; }
130
+ if (inner === "$" || inner === "`") {
131
+ const expanded = expansion(true);
132
+ if (expanded === undefined) return null;
133
+ text += expanded.text; if (expanded.value === null) dynamic(); else if (value !== null) value += expanded.value;
134
+ continue;
135
+ }
136
+ text += inner; if (value !== null) value += inner; i++;
137
+ }
138
+ continue;
139
+ }
140
+ if (char === "\\") {
141
+ if (i + 1 >= input.length) return null;
142
+ text += at(1); if (value !== null) value += at(1); i += 2; continue;
143
+ }
144
+ if (char === "$" || char === "`") {
145
+ const expanded = expansion(false);
146
+ if (expanded === undefined) return null;
147
+ text += expanded.text; if (expanded.value === null) dynamic(); else if (value !== null) value += expanded.value;
148
+ continue;
149
+ }
150
+ if (char === "~" && i === start) {
151
+ // Only a bare leading ~ or ~/ is the home directory; ~user is not guessed.
152
+ const next = at(1);
153
+ if (next === "" || next === "/" || " \t\n;&|()<>".includes(next)) {
154
+ text += "~"; if (home === undefined) dynamic(); else if (value !== null) value += home; i++; continue;
155
+ }
156
+ text += char; dynamic(); i++; continue;
157
+ }
158
+ if ("*?[{".includes(char)) { text += char; dynamic(); i++; continue; }
159
+ text += char; if (value !== null) value += char; i++;
160
+ }
161
+ return { text, value, quoted };
162
+ }
163
+
164
+ /** `$HOME` / `${HOME}` expand; every other expansion makes the word dynamic. undefined: unparseable. */
165
+ function expansion(inDouble: boolean): { text: string; value: string | null } | undefined {
166
+ const start = i;
167
+ if (at() === "`") {
168
+ const end = input.indexOf("`", i + 1);
169
+ if (end < 0) return undefined;
170
+ i = end + 1; return { text: input.slice(start, i), value: null };
171
+ }
172
+ // $(...) / $((...)): balanced parentheses, quotes inside are skipped coarsely.
173
+ if (at(1) === "(") {
174
+ let depth = 0;
175
+ for (; i < input.length; i++) {
176
+ const char = at();
177
+ if (char === "'" && !inDouble) { const end = input.indexOf("'", i + 1); if (end < 0) return undefined; i = end; continue; }
178
+ if (char === "(") depth++;
179
+ if (char === ")" && --depth === 0) { i++; return { text: input.slice(start, i), value: null }; }
180
+ }
181
+ return undefined;
182
+ }
183
+ if (at(1) === "{") {
184
+ const end = input.indexOf("}", i);
185
+ if (end < 0) return undefined;
186
+ const name = input.slice(i + 2, end);
187
+ i = end + 1;
188
+ return { text: input.slice(start, i), value: name === "HOME" && home !== undefined ? home : null };
189
+ }
190
+ const name = /^[A-Za-z_][A-Za-z0-9_]*|^[0-9@*#?$!-]/.exec(input.slice(i + 1))?.[0];
191
+ if (!name) { i++; return { text: "$", value: "$" }; }
192
+ i += 1 + name.length;
193
+ return { text: input.slice(start, i), value: name === "HOME" && home !== undefined ? home : null };
194
+ }
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Commands
199
+ // ---------------------------------------------------------------------------
200
+
201
+ type Simple = { words: Word[]; redirects: { op: string; fd: string; target: Word | null }[]; text: string };
202
+ type Context = { root: string; options: ExternalOptions; event: string; out: ExternalEffect[]; depth: number };
203
+
204
+ const WRITE_COMMANDS = new Set(["cp", "mv", "ln", "rm", "rmdir", "mkdir", "touch", "chmod", "chown", "chgrp", "tee", "install", "rsync", "truncate"]);
205
+ /** Words that run the rest of the line as a command; their own options are skipped. */
206
+ const PREFIXES = new Set(["sudo", "doas", "env", "command", "builtin", "exec", "nohup", "time", "nice", "xargs"]);
207
+
208
+ function inside(path: string, root: string): boolean {
209
+ return path === root || path.startsWith(root.endsWith("/") ? root : `${root}/`);
210
+ }
211
+
212
+ function absolute(path: string, cwd: string): string {
213
+ return normalizePermissionPath(path.startsWith("/") ? path : `${cwd}/${path}`);
214
+ }
215
+
216
+ function snippet(text: string): string {
217
+ const line = text.replace(/\s+/g, " ").trim();
218
+ return line.length > SNIPPET_CHARS ? `${line.slice(0, SNIPPET_CHARS - 1)}…` : line;
219
+ }
220
+
221
+ /** The outside path a literal target resolves to, or null (inside, temporary, or dynamic). */
222
+ function outside(target: Word | null, cwd: string | null, ctx: Context): string | null {
223
+ if (!target || target.value === null || target.value === "" || cwd === null) return null;
224
+ // `-` is stdout/stdin for tee and friends.
225
+ if (target.value === "-") return null;
226
+ const path = absolute(target.value, cwd);
227
+ const root = normalizePermissionPath(ctx.root);
228
+ if (inside(path, root)) return null;
229
+ for (const temp of [...ALWAYS_TEMPORARY, ...ctx.options.temp ?? []]) if (inside(path, normalizePermissionPath(temp))) return null;
230
+ return path;
231
+ }
232
+
233
+ /** Splits tokens into simple commands; `cd` state is threaded across `&&`, `;` and newlines, restored by `)`. */
234
+ function analyse(tokens: Token[], cwd: string, ctx: Context): void {
235
+ const stack: (string | null)[] = [];
236
+ let current: string | null = cwd;
237
+ let simple: Simple = { words: [], redirects: [], text: "" };
238
+ let pendingRedirect: { op: string; fd: string } | null = null;
239
+ const flush = () => {
240
+ if (pendingRedirect) { simple.redirects.push({ ...pendingRedirect, target: null }); pendingRedirect = null; }
241
+ if (simple.words.length || simple.redirects.length) current = command(simple, current, ctx);
242
+ simple = { words: [], redirects: [], text: "" };
243
+ };
244
+ for (const token of tokens) {
245
+ if (token.kind === "op") {
246
+ flush();
247
+ if (token.op === "(") stack.push(current);
248
+ if (token.op === ")") current = stack.length ? stack.pop()! : current;
249
+ continue;
250
+ }
251
+ if (token.kind === "redirect") {
252
+ if (pendingRedirect) simple.redirects.push({ ...pendingRedirect, target: null });
253
+ pendingRedirect = { op: token.op, fd: token.fd };
254
+ simple.text += `${simple.text ? " " : ""}${token.fd}${token.op}`;
255
+ continue;
256
+ }
257
+ simple.text += `${simple.text && !pendingRedirect ? " " : ""}${token.word.text}`;
258
+ if (pendingRedirect) { simple.redirects.push({ ...pendingRedirect, target: token.word }); pendingRedirect = null; continue; }
259
+ simple.words.push(token.word);
260
+ }
261
+ flush();
262
+ }
263
+
264
+ /** One simple command; returns the working directory after it (null: unknown, so later paths are not guessed). */
265
+ function command(simple: Simple, cwd: string | null, ctx: Context): string | null {
266
+ const add = (category: ExternalCategory, path?: string) => {
267
+ if (ctx.out.some(item => item.event === ctx.event && item.category === category && item.path === path && item.command === snippet(simple.text))) return;
268
+ ctx.out.push({ category, command: snippet(simple.text), event: ctx.event, ...(path === undefined ? {} : { path }) });
269
+ };
270
+ for (const redirect of simple.redirects) {
271
+ if (!redirect.op.startsWith(">") && redirect.op !== "<>") continue;
272
+ // >&2 duplicates a descriptor; it names no file.
273
+ if (redirect.op === ">&" && redirect.target?.value !== null && /^\d+$|^-$/.test(redirect.target?.value ?? "")) continue;
274
+ const path = outside(redirect.target, cwd, ctx);
275
+ if (path) add("write", path);
276
+ }
277
+ let words = simple.words;
278
+ // Leading assignments (FOO=bar cmd) and wrappers (sudo, env -i) are not the command.
279
+ for (;;) {
280
+ while (words.length && words[0]!.value !== null && !words[0]!.quoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]!.value!)) words = words.slice(1);
281
+ const head = words[0]?.value;
282
+ if (head === undefined || head === null || !PREFIXES.has(baseName(head))) break;
283
+ words = words.slice(1);
284
+ while (words.length && (words[0]!.value ?? "").startsWith("-")) words = words.slice(1);
285
+ }
286
+ if (!words.length) return cwd;
287
+ const name = words[0]!.value;
288
+ if (name === null) return cwd;
289
+ const base = baseName(name);
290
+ const args = words.slice(1);
291
+ const values = args.map(word => word.value);
292
+
293
+ switch (base) {
294
+ case "cd": case "pushd": {
295
+ const target = args.find(word => !(word.value ?? "").startsWith("-") || word.value === "-");
296
+ if (!target) return ctx.options.home ?? null;
297
+ if (target.value === null || target.value === "-" || cwd === null) return null;
298
+ return absolute(target.value, cwd);
299
+ }
300
+ case "popd": return null;
301
+ case "sh": case "bash": case "zsh": case "dash": {
302
+ const flag = args.findIndex(word => /^-[a-z]*c[a-z]*$/.test(word.value ?? ""));
303
+ const script = flag >= 0 ? args[flag + 1] : undefined;
304
+ if (script?.value && ctx.depth < MAX_NESTING) {
305
+ const tokens = lex(script.value, ctx.options.home);
306
+ if (tokens && cwd !== null) analyse(tokens, cwd, { ...ctx, depth: ctx.depth + 1 });
307
+ }
308
+ return cwd;
309
+ }
310
+ case "kill":
311
+ // kill -0 probes and kill -l lists; neither changes a process.
312
+ if (values.some(value => value === "-0" || value === "-l" || value === "-L" || value === "-s0")) return cwd;
313
+ if (args.length) add("process");
314
+ return cwd;
315
+ case "killall": case "pkill":
316
+ if (args.length) add("process");
317
+ return cwd;
318
+ case "launchctl": {
319
+ const sub = values[0];
320
+ if (sub && ["load", "unload", "bootstrap", "bootout", "kickstart", "kill", "start", "stop", "enable", "disable", "remove", "submit", "setenv", "unsetenv"].includes(sub)) add("system");
321
+ return cwd;
322
+ }
323
+ case "systemctl": {
324
+ const sub = values.find(value => value !== null && !value.startsWith("-"));
325
+ if (sub && ["start", "stop", "restart", "reload", "enable", "disable", "mask", "unmask", "kill", "daemon-reload", "set-property", "edit", "isolate", "try-restart", "reload-or-restart"].includes(sub)) add("system");
326
+ return cwd;
327
+ }
328
+ case "defaults": {
329
+ const sub = values.find(value => value !== null && !value.startsWith("-"));
330
+ if (sub && ["write", "delete", "import", "rename"].includes(sub)) add("system");
331
+ return cwd;
332
+ }
333
+ case "brew": {
334
+ const sub = values.find(value => value !== null && !value.startsWith("-"));
335
+ if (sub && ["install", "upgrade", "uninstall", "reinstall", "remove", "rm", "link", "unlink", "tap", "untap"].includes(sub)) add("system");
336
+ return cwd;
337
+ }
338
+ case "npm": case "pnpm": case "yarn": case "bun": {
339
+ const global = values.some(value => value === "-g" || value === "--global" || value === "--location=global");
340
+ const sub = values.find(value => value !== null && !value.startsWith("-"));
341
+ if (global && sub && ["install", "i", "add", "uninstall", "remove", "rm", "un", "update", "upgrade", "link"].includes(sub)) add("system");
342
+ if (base === "yarn" && sub === "global") add("system");
343
+ return cwd;
344
+ }
345
+ case "pip": case "pip3": {
346
+ const sub = values.find(value => value !== null && !value.startsWith("-"));
347
+ if (sub === "install" || sub === "uninstall") add("system");
348
+ return cwd;
349
+ }
350
+ case "python": case "python3": {
351
+ const module = values.indexOf("-m");
352
+ if (module >= 0 && /^pip3?$/.test(values[module + 1] ?? "") && ["install", "uninstall"].includes(values[module + 2] ?? "")) add("system");
353
+ return cwd;
354
+ }
355
+ case "curl":
356
+ if (curlMutates(values)) add("network");
357
+ return cwd;
358
+ case "wget":
359
+ if (values.some(value => value !== null && /^--(post-data|post-file|body-data|body-file|method)(=|$)/.test(value))) add("network");
360
+ return cwd;
361
+ case "git": {
362
+ // Global options before the subcommand: -C dir, -c k=v, --git-dir=...
363
+ let index = 0;
364
+ while (index < values.length && (values[index] ?? "").startsWith("-")) index += ["-C", "-c"].includes(values[index] ?? "") ? 2 : 1;
365
+ if (values[index] === "push" && !values.slice(index + 1).some(value => value === "--dry-run" || value === "-n")) add("push");
366
+ return cwd;
367
+ }
368
+ }
369
+ // A dynamic operand could be any path; writeTargets judges only literal ones.
370
+ if (WRITE_COMMANDS.has(base)) for (const path of writeTargets(base, args, cwd, ctx)) add("write", path);
371
+ return cwd;
372
+ }
373
+
374
+ function baseName(path: string): string {
375
+ return path.slice(path.lastIndexOf("/") + 1);
376
+ }
377
+
378
+ /** Paths the command writes, among its literal operands, resolved and outside. */
379
+ function writeTargets(base: string, args: Word[], cwd: string | null, ctx: Context): string[] {
380
+ const operands: Word[] = [];
381
+ let targetDirectory: Word | null = null;
382
+ let options = true;
383
+ for (let index = 0; index < args.length; index++) {
384
+ const word = args[index]!;
385
+ const value = word.value;
386
+ if (options && value === "--") { options = false; continue; }
387
+ if (options && value !== null && value.startsWith("-") && value !== "-") {
388
+ if ((base === "cp" || base === "mv" || base === "ln" || base === "install") && (value === "-t" || value === "--target-directory")) { targetDirectory = args[++index] ?? null; continue; }
389
+ if (value.startsWith("--target-directory=")) { targetDirectory = { ...word, value: value.slice("--target-directory=".length) }; continue; }
390
+ // Options that take a separate value; the value is not an operand.
391
+ if ((base === "chmod" || base === "chown" || base === "chgrp") && value === "--reference") { index++; continue; }
392
+ if (base === "install" && ["-m", "-o", "-g"].includes(value)) { index++; continue; }
393
+ if (base === "truncate" && (value === "-s" || value === "-r")) { index++; continue; }
394
+ if (base === "mkdir" && value === "-m") { index++; continue; }
395
+ continue;
396
+ }
397
+ operands.push(word);
398
+ }
399
+ let targets: Word[];
400
+ switch (base) {
401
+ case "cp": case "mv": case "ln": case "install": case "rsync":
402
+ // The destination is the last operand (or -t); sources are read, not written.
403
+ targets = targetDirectory ? [targetDirectory] : operands.length >= 2 ? [operands.at(-1)!] : [];
404
+ // mv also removes its sources.
405
+ if (base === "mv") targets = [...targets, ...operands.slice(0, targetDirectory ? operands.length : -1)];
406
+ break;
407
+ case "chmod": case "chown": case "chgrp":
408
+ // The first operand is the mode or owner.
409
+ targets = operands.slice(1);
410
+ break;
411
+ default:
412
+ targets = operands;
413
+ }
414
+ // rsync destinations may be remote (host:path); those are network, not local writes.
415
+ if (base === "rsync") targets = targets.filter(word => word.value !== null && !/^[^/]*:/.test(word.value));
416
+ return targets.map(word => outside(word, cwd, ctx)).filter((path): path is string => path !== null);
417
+ }
418
+
419
+ function curlMutates(values: readonly (string | null)[]): boolean {
420
+ let get = false, data = false, method: string | null = null;
421
+ for (let index = 0; index < values.length; index++) {
422
+ const value = values[index];
423
+ if (value === null || value === undefined) continue;
424
+ if (value === "-G" || value === "--get") get = true;
425
+ else if (value === "-X" || value === "--request") method = (values[++index] ?? "").toUpperCase();
426
+ else if (value.startsWith("--request=")) method = value.slice("--request=".length).toUpperCase();
427
+ else if (/^-X./.test(value)) method = value.slice(2).toUpperCase();
428
+ else if (/^--(data|data-raw|data-binary|data-urlencode|data-ascii|json)(=|$)/.test(value) || /^-d/.test(value)) data = true;
429
+ else if (/^--(form|form-string|upload-file)(=|$)/.test(value) || /^-[FT]/.test(value)) return true;
430
+ else if (/^-[a-zA-Z]+$/.test(value) && !value.startsWith("--")) {
431
+ // Bundled short flags: -sSd ... or -sX POST.
432
+ const flags = value.slice(1);
433
+ if (flags.includes("G")) get = true;
434
+ if (/[FT]/.test(flags)) return true;
435
+ if (flags.endsWith("X")) method = (values[++index] ?? "").toUpperCase();
436
+ else if (flags.endsWith("d")) data = true;
437
+ }
438
+ }
439
+ if (method !== null) return method !== "GET" && method !== "HEAD";
440
+ return data && !get;
441
+ }
442
+
443
+ /** One command string's effects; exported for the property tests and for single-receipt views. */
444
+ export function commandExternalEffects(cmd: string, event: string, workspaceRoot: string, options: ExternalOptions = {}, cwd = workspaceRoot): ExternalEffect[] {
445
+ const tokens = lex(cmd, options.home);
446
+ if (!tokens) return [];
447
+ const out: ExternalEffect[] = [];
448
+ analyse(tokens, normalizePermissionPath(cwd), { root: workspaceRoot, options, event, out, depth: 0 });
449
+ return out;
450
+ }
451
+
452
+ /**
453
+ * The current request's outside effects, in log order. `events` is the
454
+ * request's own slice (currentRequestEvents); a workdir on the call is
455
+ * honoured when it is a literal relative or absolute path.
456
+ */
457
+ export function externalEffects(events: readonly LogEvent[], workspaceRoot: string, options: ExternalOptions = {}): ExternalEffect[] {
458
+ const workdirs = new Map<string, string>();
459
+ for (const event of events) {
460
+ if (event.t !== "assistant") continue;
461
+ for (const block of event.blocks) {
462
+ if (block.type !== "tool_use" || block.input === null || typeof block.input !== "object") continue;
463
+ const workdir = (block.input as Record<string, unknown>)["workdir"];
464
+ if (typeof workdir === "string" && workdir !== "") workdirs.set(block.call_id, workdir);
465
+ }
466
+ }
467
+ const root = normalizePermissionPath(workspaceRoot);
468
+ const out: ExternalEffect[] = [];
469
+ for (const event of events) {
470
+ if (event.t !== "tool_result" || event.effect?.kind !== "command") continue;
471
+ const workdir = workdirs.get(event.call_id);
472
+ out.push(...commandExternalEffects(event.effect.cmd, event.id, root, options, workdir ? absolute(workdir, root) : root));
473
+ }
474
+ return out;
475
+ }
476
+
477
+ /** Display bound: the line names the first few and counts the rest. */
478
+ export const EXTERNAL_SHOWN = 5;
479
+
480
+ /** One line for the end of a request, or null when nothing reached outside. */
481
+ export function formatExternalEffects(effects: readonly ExternalEffect[], options: { home?: string } = {}): string | null {
482
+ if (!effects.length) return null;
483
+ const tilde = (path: string) => options.home && inside(path, options.home) ? `~${path.slice(options.home.length)}` : path;
484
+ const label = (effect: ExternalEffect) => effect.category === "write" && effect.path ? `write ${tilde(effect.path)}` : effect.command;
485
+ const shown = effects.slice(0, EXTERNAL_SHOWN).map(label);
486
+ const rest = effects.length - shown.length;
487
+ return `Outside the workspace: ${shown.join(" · ")}${rest > 0 ? ` · and ${rest} more` : ""}`;
488
+ }
@@ -152,8 +152,22 @@ export type UserTurnRecord = {
152
152
  * common case, and deleting the turn would rewrite history.
153
153
  */
154
154
  abandoned?: true;
155
+ /**
156
+ * Working set v3 only: the original user event, present exactly when the turn
157
+ * carried images, so `history(event=ID)` can reattach them.
158
+ */
159
+ event?: EventId;
160
+ /**
161
+ * Working set v3 only: references to the turn's attached images (D8: digest
162
+ * and identity, never bytes). Absent from the full derived state, so legacy
163
+ * projections and frozen replay are unchanged.
164
+ */
165
+ images?: readonly UserTurnImage[];
155
166
  };
156
167
 
168
+ /** Enough for the model to know an image exists and which one; bytes stay in the blob store. */
169
+ export type UserTurnImage = { sha256: Sha256; name: string; width: number; height: number };
170
+
157
171
  export type Handoff = {
158
172
  handoff_version: typeof HANDOFF_VERSION | 2;
159
173
  working_set?: WorkingSet;
@@ -244,7 +258,8 @@ const ORDER: Record<string, readonly string[]> = {
244
258
  "continuation_valid", "budget",
245
259
  ],
246
260
  predecessor: ["node", "log_ref", "sha256"],
247
- user_turns: ["at", "text", "abandoned"],
261
+ user_turns: ["at", "text", "abandoned", "event", "images"],
262
+ "user_turns.images": ["sha256", "name", "width", "height"],
248
263
  user_turns_truncated: ["dropped", "recover_from"],
249
264
  state: ["completed", "unverified_claims", "in_flight", "blocked", "last_activity", "work_items"],
250
265
  "state.completed": ["work_id", "what", "evidence", "evidence_superseded"],
@@ -301,6 +316,7 @@ export type Violation = {
301
316
  | "INVALID_WORK_STATE"
302
317
  | "INVALID_EVIDENCE_LINK"
303
318
  | "INVALID_WORKING_SET"
319
+ | "INVALID_USER_TURN"
304
320
  | "INVALID_ACCEPTANCE"
305
321
  | "INVALID_SKILL_SOURCE"
306
322
  | "INVALID_PROCESS_STATE"
@@ -349,11 +365,11 @@ export function validateHandoff(h: Handoff): Violation[] {
349
365
  }
350
366
  if (h.working_set) {
351
367
  const view = h.working_set;
352
- if (![1, 2].includes(view.version) || view.source.node !== h.predecessor?.node || view.source.sha256 !== h.predecessor?.sha256 ||
368
+ if (![1, 2, 3].includes(view.version) || view.source.node !== h.predecessor?.node || view.source.sha256 !== h.predecessor?.sha256 ||
353
369
  !Number.isSafeInteger(view.omitted.work) || view.omitted.work < 0 || !Number.isSafeInteger(view.omitted.references) || view.omitted.references < 0 ||
354
370
  new Set(view.work.map(item => item.id)).size !== view.work.length || view.work.some(item => !["active", "blocked"].includes(item.status)) ||
355
371
  view.references.some(reference => !reference.event) ||
356
- view.version === 2 && (!view.recovery_sections || RECOVERY_SECTIONS.some(section => {
372
+ view.version >= 2 && (!view.recovery_sections || RECOVERY_SECTIONS.some(section => {
357
373
  const range = view.recovery_sections![section], references = view.references.filter(ref => ref.kind === RECOVERY_KINDS[section]);
358
374
  return !range || !Number.isSafeInteger(range.total) || !Number.isSafeInteger(range.omitted) || range.omitted < 0 || range.omitted > range.total ||
359
375
  range.total - range.omitted !== references.length || new Set(references.map(ref => ref.event)).size !== references.length ||
@@ -363,6 +379,11 @@ export function validateHandoff(h: Handoff): Violation[] {
363
379
  v.push({ code: "INVALID_WORKING_SET", detail: "working set must reference its source and explicit active state" });
364
380
  }
365
381
  }
382
+ // D8: an image reference must name its blob and the original event that reattaches it.
383
+ if (h.user_turns.some(turn => turn.event !== undefined && !turn.images?.length ||
384
+ turn.images !== undefined && (!turn.event || turn.images.length === 0 || turn.images.some(image => !/^[a-f0-9]{64}$/.test(image.sha256))))) {
385
+ v.push({ code: "INVALID_USER_TURN", detail: "user turn images need their original event and blob digests" });
386
+ }
366
387
  if (h.handoff_version === 2) {
367
388
  const history = h.history;
368
389
  if (!history || !h.predecessor || history.source.node !== h.predecessor.node ||
@@ -72,7 +72,7 @@ export function historyEntries(nodes: readonly { node: number; events: readonly
72
72
  ...(e.observation.manifest ? { artifact: e.observation.manifest.sha256 } : {}) });
73
73
  } else if (e.t === "process_exit") {
74
74
  entries.push({ node, event: e.id, kind: e.t, text: canonicalValue(e), artifact: e.output.sha256 });
75
- } else if (["work_owner", "work_dependencies", "skill_loaded", "acceptance_contract", "acceptance_decision", "user", "next_action", "decision", "pitfall", "blocked", "question", "claim", "work_status", "evidence_link"].includes(e.t)) {
75
+ } else if (["work_owner", "work_dependencies", "approval", "skill_loaded", "acceptance_contract", "acceptance_decision", "user", "next_action", "decision", "pitfall", "blocked", "question", "claim", "work_status", "evidence_link"].includes(e.t)) {
76
76
  entries.push({ node, event: e.id, kind: e.t, text: e.t === "user" ? e.text : canonicalValue(e),
77
77
  ...("work_id" in e && e.work_id ? { work_ids: [e.work_id] } : {}),
78
78
  ...(e.t === "evidence_link" ? { work_ids: [e.work], ...(e.resource ? { resources: [resourceKey(e.resource)] } : {}) } : {}) });