@visual-z/xeno 0.5.0 → 0.7.0

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 (102) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +19 -4
  3. package/docs/app-server.md +32 -3
  4. package/docs/foundations.md +88 -0
  5. package/docs/install.md +23 -2
  6. package/docs/research/foundation-clients-2026-10-09.md +27 -0
  7. package/docs/research/foundation-editing-2026-10-09.md +56 -0
  8. package/docs/research/foundation-network-2026-10-09.md +37 -0
  9. package/docs/research/foundation-workflow-2026-10-09.md +37 -0
  10. package/package.json +10 -2
  11. package/src/apps/acp-server.ts +41 -0
  12. package/src/apps/agent-profiles.ts +19 -0
  13. package/src/apps/app-server.ts +4 -2
  14. package/src/apps/cli.ts +18 -2
  15. package/src/apps/commands.ts +20 -0
  16. package/src/apps/control-transport.ts +3 -2
  17. package/src/apps/model-catalog.ts +57 -31
  18. package/src/apps/question-ui.ts +27 -0
  19. package/src/apps/resources.ts +63 -25
  20. package/src/apps/review.ts +109 -0
  21. package/src/apps/session-hub.ts +95 -0
  22. package/src/apps/shell.ts +55 -1
  23. package/src/apps/startup.ts +157 -22
  24. package/src/apps/ui/app.ts +89 -38
  25. package/src/apps/ui/image-cache.ts +71 -28
  26. package/src/apps/ui/surface.ts +1 -0
  27. package/src/apps/update-check.ts +71 -0
  28. package/src/apps/workflow.ts +58 -0
  29. package/src/core/activity-status.ts +28 -0
  30. package/src/core/agent-profile.ts +130 -0
  31. package/src/core/control-protocol.ts +8 -1
  32. package/src/core/file-precondition.ts +6 -0
  33. package/src/core/git-status.ts +29 -0
  34. package/src/core/image-limits.ts +72 -0
  35. package/src/core/lsp-framing.ts +34 -0
  36. package/src/core/mcp-config.ts +68 -0
  37. package/src/core/patch.ts +130 -0
  38. package/src/core/permissions.ts +143 -4
  39. package/src/core/progress.ts +9 -0
  40. package/src/core/question.ts +94 -0
  41. package/src/core/request-budget.ts +13 -0
  42. package/src/core/review.ts +68 -0
  43. package/src/core/session-exit.ts +26 -0
  44. package/src/core/settings.ts +1 -1
  45. package/src/core/stall.ts +18 -6
  46. package/src/core/stop-report.ts +25 -0
  47. package/src/core/update-check.ts +24 -5
  48. package/src/core/web-content.ts +80 -0
  49. package/src/core/workflow-mode.ts +34 -0
  50. package/src/runtime/acp-agent.ts +289 -0
  51. package/src/runtime/agent-profiles.ts +21 -0
  52. package/src/runtime/catalog.ts +2 -2
  53. package/src/runtime/control-client.ts +149 -0
  54. package/src/runtime/control-connection.ts +39 -4
  55. package/src/runtime/exec-sessions.ts +40 -4
  56. package/src/runtime/extensions.ts +20 -2
  57. package/src/runtime/formatter-tool.ts +38 -0
  58. package/src/runtime/formatter.ts +50 -0
  59. package/src/runtime/foundation.ts +78 -0
  60. package/src/runtime/images.ts +6 -2
  61. package/src/runtime/integrations.ts +8 -2
  62. package/src/runtime/loop.ts +124 -21
  63. package/src/runtime/lsp-client.ts +201 -0
  64. package/src/runtime/mcp-client.ts +264 -0
  65. package/src/runtime/mcp-oauth.ts +168 -0
  66. package/src/runtime/mcp-schema.ts +51 -0
  67. package/src/runtime/oauth.ts +1 -1
  68. package/src/runtime/openai-responses.ts +6 -4
  69. package/src/runtime/openai.ts +6 -1
  70. package/src/runtime/output-schema.ts +26 -0
  71. package/src/runtime/patch-tool.ts +38 -0
  72. package/src/runtime/permission-resources.ts +11 -0
  73. package/src/runtime/pi-provider.ts +5 -4
  74. package/src/runtime/provider-tool-policy.ts +33 -0
  75. package/src/runtime/provider.ts +8 -1
  76. package/src/runtime/question-tool.ts +62 -0
  77. package/src/runtime/review-tool.ts +48 -0
  78. package/src/runtime/session-control.ts +61 -2
  79. package/src/runtime/session-hub.ts +85 -0
  80. package/src/runtime/tool-discovery.ts +4 -3
  81. package/src/runtime/tools.ts +44 -10
  82. package/src/runtime/update-check.ts +3 -2
  83. package/src/runtime/web-http.ts +56 -0
  84. package/src/runtime/web-tools.ts +88 -0
  85. package/src/runtime/workspace-lifecycle.ts +14 -1
  86. package/src/store/agent-profile-files.ts +13 -0
  87. package/src/store/blobs.ts +2 -1
  88. package/src/store/context-files.ts +77 -1
  89. package/src/store/file-checkpoints.ts +5 -4
  90. package/src/store/foundation-config.ts +24 -0
  91. package/src/store/git-status.ts +15 -0
  92. package/src/store/lsp-document.ts +30 -0
  93. package/src/store/mcp-credentials.ts +85 -0
  94. package/src/store/patch.ts +92 -0
  95. package/src/store/permission-paths.ts +34 -0
  96. package/src/store/review.ts +109 -0
  97. package/src/store/search.ts +48 -9
  98. package/src/tui/ui/layout.ts +7 -7
  99. package/src/tui/ui/messages.ts +1 -1
  100. package/src/tui/ui/pictures.ts +26 -9
  101. package/src/tui/ui/screen.ts +12 -8
  102. package/src/tui/ui/status.ts +32 -6
@@ -19,6 +19,8 @@ export interface PermissionRequest {
19
19
  readonly tool: string;
20
20
  /** Exact command string for bash; omitted for tools that have none. */
21
21
  readonly command?: string;
22
+ /** Normalized workspace-relative or external absolute file resources. */
23
+ readonly paths?: readonly string[];
22
24
  }
23
25
 
24
26
  export interface PermissionRule {
@@ -27,6 +29,11 @@ export interface PermissionRule {
27
29
  readonly tool?: string;
28
30
  /** Absent matches every command. When set, matches only this exact string. */
29
31
  readonly command?: string;
32
+ /** Literal argv prefix, matched only for a single command without expansion. */
33
+ readonly commandPrefix?: readonly string[];
34
+ readonly path?: string;
35
+ /** Portable path glob: *, ?, and **; no brace or character-class expansion. */
36
+ readonly pathGlob?: string;
30
37
  }
31
38
 
32
39
  /**
@@ -39,6 +46,8 @@ export type ToolSource = "builtin" | "extension";
39
46
  /** Task capabilities are frozen once at session creation (O.7); not permissions. */
40
47
  export const TOOL_PROFILES = {
41
48
  coding: ["read", "grep", "find", "write", "edit", "environment", "exec_command", "write_stdin", "history", "note"],
49
+ // An explicit preset preserves the measured legacy ten-tool coding contract.
50
+ foundation: ["read", "grep", "find", "write", "edit", "environment", "exec_command", "write_stdin", "history", "note", "question", "apply_patch", "diff", "webfetch", "websearch", "format", "lsp"],
42
51
  readonly: ["read", "grep", "find", "history", "note"],
43
52
  research: ["read", "grep", "find", "history", "note", "branch", "resolve"],
44
53
  data: ["read", "grep", "find", "history", "note", "query"],
@@ -80,6 +89,11 @@ export const ALLOW_TOOLS_FLAG = "--allow-tools";
80
89
  */
81
90
  export function defaultRules(): readonly PermissionRule[] {
82
91
  return [
92
+ // Templates are ordinary source; actual environment values deserve the
93
+ // same last look as commands. Indirect readers also check read resources.
94
+ { action: "allow", tool: "read", pathGlob: "**/.env.example" },
95
+ { action: "ask", tool: "read", pathGlob: "**/.env" },
96
+ { action: "ask", tool: "read", pathGlob: "**/.env.*" },
83
97
  { action: "ask", tool: "bash" },
84
98
  // Same reach as bash. write_stdin needs no rule of its own: it can only
85
99
  // address a session exec_command already started, under this approval.
@@ -109,6 +123,12 @@ export function rulesWithAllowTools(
109
123
  export function ruleMatches(rule: PermissionRule, request: PermissionRequest): boolean {
110
124
  if (rule.tool !== undefined && rule.tool !== request.tool) return false;
111
125
  if (rule.command !== undefined && rule.command !== request.command) return false;
126
+ if (rule.commandPrefix !== undefined) {
127
+ const words = request.command === undefined ? null : literalCommandWords(request.command);
128
+ if (!words || rule.commandPrefix.length === 0 || rule.commandPrefix.some((word, i) => words[i] !== word)) return false;
129
+ }
130
+ if (rule.path !== undefined && !request.paths?.some(path => normalizePermissionPath(path) === normalizePermissionPath(rule.path!))) return false;
131
+ if (rule.pathGlob !== undefined && !request.paths?.some(path => permissionPathMatches(rule.pathGlob!, path))) return false;
112
132
  return true;
113
133
  }
114
134
 
@@ -121,12 +141,128 @@ export function decide(
121
141
  rules: readonly PermissionRule[],
122
142
  request: PermissionRequest,
123
143
  ): PermissionAction {
144
+ // A multi-file call needs every resource admitted. A broad allow for its
145
+ // first path must not conceal a denial on a later file (D13 / R13).
146
+ if (request.paths && request.paths.length > 1) {
147
+ const actions = request.paths.map(path => decide(rules, { ...request, paths: [path] }));
148
+ return actions.includes("deny") ? "deny" : actions.includes("ask") ? "ask" : "allow";
149
+ }
124
150
  for (const rule of rules) {
125
151
  if (ruleMatches(rule, request)) return rule.action;
126
152
  }
127
153
  return "allow";
128
154
  }
129
155
 
156
+ /** Configured denial is a ceiling; in-memory approval can only resolve asks. */
157
+ export function decideWithSessionRules(
158
+ configured: readonly PermissionRule[], remembered: readonly PermissionRule[], request: PermissionRequest,
159
+ ): PermissionAction {
160
+ if (decide(configured, request) === "deny") return "deny";
161
+ return decide([...remembered, ...configured], request);
162
+ }
163
+
164
+ /** Lexical normalization is pure; the store adds canonical symlink resources. */
165
+ export function normalizePermissionPath(path: string): string {
166
+ const absolute = path.startsWith("/");
167
+ const parts: string[] = [];
168
+ for (const part of path.split("/")) {
169
+ if (!part || part === ".") continue;
170
+ if (part === ".." && parts.length > 0 && parts.at(-1) !== "..") parts.pop();
171
+ else if (part === ".." && absolute) continue;
172
+ else parts.push(part);
173
+ }
174
+ return (absolute ? "/" : "") + parts.join("/") || ".";
175
+ }
176
+
177
+ /** Dynamic programming avoids exponential regexp backtracking on model input. */
178
+ export function permissionPathMatches(pattern: string, path: string): boolean {
179
+ const glob = normalizePermissionPath(pattern), value = normalizePermissionPath(path);
180
+ let row = Array<boolean>(value.length + 1).fill(false); row[0] = true;
181
+ for (let i = 0; i < glob.length; i++) {
182
+ const char = glob[i]!;
183
+ const next = Array<boolean>(value.length + 1).fill(false);
184
+ if (char === "*") {
185
+ const deep = glob[i + 1] === "*";
186
+ if (deep) i++;
187
+ // **/ also matches zero directories, as in **/.env at the root.
188
+ const directories = deep && glob[i + 1] === "/";
189
+ if (directories) i++;
190
+ next[0] = row[0]!;
191
+ for (let j = 1; j <= value.length; j++) {
192
+ next[j] = row[j]! || ((deep || value[j - 1] !== "/") && next[j - 1]!);
193
+ if (directories && value[j - 1] !== "/") next[j] = row[j]!;
194
+ }
195
+ // A globstar followed by slash may consume complete directories only.
196
+ if (directories) {
197
+ let reachable = row[0]!;
198
+ for (let j = 1; j <= value.length; j++) {
199
+ reachable = reachable || row[j]!;
200
+ next[j] = row[j]! || (value[j - 1] === "/" && reachable);
201
+ }
202
+ }
203
+ } else {
204
+ for (let j = 1; j <= value.length; j++) next[j] = row[j - 1]! &&
205
+ (char === "?" ? value[j - 1] !== "/" : char === value[j - 1]);
206
+ }
207
+ row = next;
208
+ }
209
+ return row[value.length]!;
210
+ }
211
+
212
+ /**
213
+ * This is a prefix matcher, never a command-danger classifier. Unknown shell
214
+ * syntax fails to match and therefore retains the full-command ask (D13).
215
+ */
216
+ export function literalCommandWords(command: string): string[] | null {
217
+ const words: string[] = [];
218
+ let word = "", started = false, quote: "'" | '"' | null = null;
219
+ for (let i = 0; i < command.length; i++) {
220
+ const char = command[i]!;
221
+ if (char === "\n" || char === "\r" || char === "\0") return null;
222
+ if (quote === "'") {
223
+ if (char === "'") quote = null; else word += char;
224
+ continue;
225
+ }
226
+ if (char === "\\") {
227
+ const escaped = command[++i];
228
+ if (escaped === undefined || escaped === "\n" || escaped === "\r") return null;
229
+ // Bash double quotes retain backslash before ordinary characters.
230
+ word += quote === '"' && !['$', '`', '"', "\\"].includes(escaped) ? `\\${escaped}` : escaped;
231
+ started = true; continue;
232
+ }
233
+ if (quote === '"') {
234
+ if (char === '"') quote = null;
235
+ else if (char === "$" || char === "`") return null;
236
+ else word += char;
237
+ continue;
238
+ }
239
+ if (char === "'" || char === '"') { quote = char; started = true; continue; }
240
+ if (char === " " || char === "\t") { if (started) words.push(word); word = ""; started = false; continue; }
241
+ if (";|&<>($`*?[]{}~#".includes(char)) return null;
242
+ word += char; started = true;
243
+ }
244
+ if (quote !== null) return null;
245
+ if (started) words.push(word);
246
+ return words.length ? words : null;
247
+ }
248
+
249
+ /** Unknown config keys are errors: a typo must not silently weaken a policy. */
250
+ export function parsePermissionRule(value: unknown): PermissionRule {
251
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("permission rule must be an object");
252
+ const rule = value as Record<string, unknown>;
253
+ if (Object.keys(rule).some(key => !["action", "tool", "command", "commandPrefix", "path", "pathGlob"].includes(key))) throw Error("unknown permission rule field");
254
+ if (!["allow", "ask", "deny"].includes(String(rule["action"]))) throw Error("permission rule action must be allow, ask or deny");
255
+ for (const key of ["tool", "command", "path", "pathGlob"]) {
256
+ if (rule[key] !== undefined && (typeof rule[key] !== "string" || !String(rule[key]).trim() || String(rule[key]).includes("\0"))) throw Error(`permission ${key} must be a nonempty string`);
257
+ }
258
+ if (rule["path"] !== undefined && rule["pathGlob"] !== undefined) throw Error("permission path and pathGlob are mutually exclusive");
259
+ if (typeof rule["pathGlob"] === "string" && /[\[\]{}]/.test(rule["pathGlob"])) throw Error("permission pathGlob supports only *, ? and **");
260
+ const prefix = rule["commandPrefix"];
261
+ if (prefix !== undefined && (!Array.isArray(prefix) || prefix.length === 0 || prefix.some(word => typeof word !== "string" || !word || /[\r\n\0]/.test(word)))) throw Error("permission commandPrefix must contain literal argv tokens");
262
+ if (prefix !== undefined && rule["command"] !== undefined) throw Error("permission command and commandPrefix are mutually exclusive");
263
+ return structuredClone(value) as PermissionRule;
264
+ }
265
+
130
266
  /**
131
267
  * Comma-separated flag values. Empty tokens are dropped so `--tools a,,b`
132
268
  * does not invent a blank name.
@@ -186,12 +322,15 @@ export function selectTools<T extends { name: string; source?: ToolSource }>(
186
322
  }
187
323
 
188
324
  export function permissionRequest(name: string, input: unknown): PermissionRequest {
189
- if (!["bash", "exec_command", "environment"].includes(name) || typeof input !== "object" || input === null) {
325
+ if (typeof input !== "object" || input === null) {
190
326
  return { tool: name };
191
327
  }
192
- const command = (input as { readonly [k: string]: unknown })[name === "exec_command" ? "cmd" : "command"];
193
- if (typeof command !== "string") return { tool: name };
194
- return { tool: name, command };
328
+ const fields = input as { readonly [k: string]: unknown };
329
+ const command = ["bash", "exec_command", "environment"].includes(name) ? fields[name === "exec_command" ? "cmd" : "command"] : undefined;
330
+ const paths = typeof fields["path"] === "string" ? [fields["path"]]
331
+ : Array.isArray(fields["paths"]) && fields["paths"].every(path => typeof path === "string") ? fields["paths"] as string[] : undefined;
332
+ return { tool: name, ...(typeof command === "string" ? { command } : {}),
333
+ ...(paths ? { paths: paths.map(normalizePermissionPath) } : {}) };
195
334
  }
196
335
 
197
336
  /**
@@ -5,6 +5,7 @@
5
5
  * its carried handoff view may be paged like the other structural state (D20).
6
6
  */
7
7
  import type { LineRange, LogEvent } from "./events.ts";
8
+ import { isProcessReceipt } from "./process-receipt.ts";
8
9
 
9
10
  export type ProgressEvidence = (
10
11
  | { kind: "read"; sha256: string; ranges?: readonly LineRange[]; observation_sha256?: string }
@@ -123,6 +124,14 @@ export function deriveProgress(
123
124
  evidence.push({ kind: "observation", sha256: key, source });
124
125
  if (isAfter) progressed = true;
125
126
  }
127
+ } else if (isProcessReceipt(event.data) && !event.data.replayed && event.data.output !== undefined && event.data.output.bytes > 0 &&
128
+ !observations.has(event.data.output.sha256)) {
129
+ // A managed command can produce useful evidence before it exits. Credit
130
+ // raw captured bytes, never its elapsed header, changing ID or poll count.
131
+ const sha256 = event.data.output.sha256;
132
+ observations.add(sha256);
133
+ evidence.push({ kind: "observation", sha256, source });
134
+ progressed = true;
126
135
  } else if ((name === "grep" || name === "find" || name === "ls" || name === "query") &&
127
136
  observation !== undefined && (event.content_ref?.bytes ?? event.content.length) > 0 &&
128
137
  !observations.has(observation)) {
@@ -0,0 +1,94 @@
1
+ /** R13 / D11: an answer is bound to its original active invocation, never guessed. */
2
+
3
+ // Product limits keep one interaction reviewable, not measured model limits.
4
+ export const MAX_QUESTIONS = 3;
5
+ export const MAX_QUESTION_CHOICES = 8;
6
+ export interface QuestionChoice { readonly id: string; readonly label: string; readonly description?: string }
7
+ export interface QuestionSpec {
8
+ readonly id: string;
9
+ readonly header: string;
10
+ readonly question: string;
11
+ readonly options: readonly QuestionChoice[];
12
+ readonly multiple: boolean;
13
+ readonly custom: boolean;
14
+ }
15
+ export interface QuestionRequest {
16
+ readonly request_id: string;
17
+ readonly turn_id: string;
18
+ readonly call_id: string;
19
+ readonly questions: readonly QuestionSpec[];
20
+ }
21
+ export interface QuestionAnswer { readonly id: string; readonly choices: readonly string[]; readonly text?: string }
22
+ export interface QuestionReply {
23
+ readonly request_id: string;
24
+ readonly turn_id: string;
25
+ readonly answers: readonly QuestionAnswer[];
26
+ }
27
+ function object(value: unknown, label: string, fields: readonly string[]): Record<string, unknown> {
28
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw Error(`${label} must be an object`);
29
+ const result = value as Record<string, unknown>;
30
+ if (Object.keys(result).some(key => !fields.includes(key))) throw Error(`${label} contains an unknown field`);
31
+ return result;
32
+ }
33
+ function string(value: unknown, label: string, max = 2000): string {
34
+ if (typeof value !== "string" || !value.trim() || value.length > max || /[\0\x1b]/.test(value)) throw Error(`${label} must be nonempty plain text (max ${max} characters)`);
35
+ return value;
36
+ }
37
+ function identifier(value: unknown, label: string): string {
38
+ const id = string(value, label, 64);
39
+ if (!/^[a-z][a-z0-9_-]*$/.test(id)) throw Error(`${label} must be a stable lowercase identifier`);
40
+ return id;
41
+ }
42
+
43
+ export function parseQuestions(value: unknown): QuestionSpec[] {
44
+ const input = object(value, "question input", ["questions"]);
45
+ const questions = input["questions"];
46
+ if (!Array.isArray(questions) || questions.length < 1 || questions.length > MAX_QUESTIONS) throw Error(`question requires 1..${MAX_QUESTIONS} questions`);
47
+ const parsed = questions.map(value => {
48
+ const item = object(value, "question", ["id", "header", "question", "options", "multiple", "custom"]);
49
+ const options = item["options"] ?? [];
50
+ if (!Array.isArray(options) || options.length > MAX_QUESTION_CHOICES) throw Error(`question options must contain at most ${MAX_QUESTION_CHOICES} choices`);
51
+ const choices = options.map(value => {
52
+ const choice = object(value, "question option", ["id", "label", "description"]);
53
+ return { id: identifier(choice["id"], "option id"), label: string(choice["label"], "option label", 120),
54
+ ...(choice["description"] !== undefined ? { description: string(choice["description"], "option description", 600) } : {}) };
55
+ });
56
+ if (new Set(choices.map(choice => choice.id)).size !== choices.length) throw Error("question option ids must be unique");
57
+ if (new Set(choices.map(choice => choice.label)).size !== choices.length) throw Error("question option labels must be unique");
58
+ for (const key of ["multiple", "custom"]) if (item[key] !== undefined && typeof item[key] !== "boolean") throw Error(`question ${key} must be boolean`);
59
+ const multiple = item["multiple"] === true, custom = item["custom"] !== false;
60
+ if (!choices.length && !custom) throw Error("question must offer choices or custom text");
61
+ return { id: identifier(item["id"], "question id"), header: string(item["header"], "question header", 40),
62
+ question: string(item["question"], "question text"), options: choices, multiple, custom };
63
+ });
64
+ if (new Set(parsed.map(question => question.id)).size !== parsed.length) throw Error("question ids must be unique");
65
+ return parsed;
66
+ }
67
+
68
+ export function questionRequest(turnId: string, callId: string, questions: readonly QuestionSpec[]): QuestionRequest {
69
+ if (!turnId || !callId || /[\r\n\0]/.test(turnId + callId)) throw Error("question requires a trusted active turn and call identity");
70
+ // Tuple encoding avoids slash/delimiter collisions in opaque runtime IDs.
71
+ return { request_id: JSON.stringify([turnId, callId]), turn_id: turnId, call_id: callId, questions: structuredClone(questions) };
72
+ }
73
+
74
+ export function validateQuestionReply(request: QuestionRequest, value: unknown): QuestionReply {
75
+ const reply = object(value, "question reply", ["request_id", "turn_id", "answers"]);
76
+ if (reply["request_id"] !== request.request_id || reply["turn_id"] !== request.turn_id) throw Error("question reply belongs to a different invocation or turn");
77
+ const answers = reply["answers"];
78
+ if (!Array.isArray(answers) || answers.length !== request.questions.length) throw Error("question reply must answer every question exactly once");
79
+ const parsed = answers.map(value => {
80
+ const answer = object(value, "question answer", ["id", "choices", "text"]);
81
+ const id = identifier(answer["id"], "answer id"), question = request.questions.find(question => question.id === id);
82
+ if (!question) throw Error("question reply contains an unknown question id");
83
+ const choices = answer["choices"];
84
+ if (!Array.isArray(choices) || choices.some(choice => typeof choice !== "string" || !question.options.some(option => option.id === choice)) || new Set(choices).size !== choices.length) throw Error("question reply contains unknown or duplicate choices");
85
+ const text = answer["text"] === undefined ? undefined : string(answer["text"], "answer text", 10000);
86
+ if (text !== undefined && !question.custom) throw Error("custom text is not permitted for this question");
87
+ const count = choices.length + (text === undefined ? 0 : 1);
88
+ if (count === 0 || (!question.multiple && count !== 1)) throw Error("question reply requires one answer, or multiple only when enabled");
89
+ return { id, choices: [...choices] as string[], ...(text !== undefined ? { text } : {}) };
90
+ });
91
+ if (new Set(parsed.map(answer => answer.id)).size !== parsed.length) throw Error("question reply contains duplicate question ids");
92
+ return { request_id: request.request_id, turn_id: request.turn_id,
93
+ answers: request.questions.map(question => parsed.find(answer => answer.id === question.id)!) };
94
+ }
@@ -0,0 +1,13 @@
1
+ /** D6 / Q.26: finalization spends the existing budget and cannot admit new work. */
2
+ export function requestPhase(index: number, limit: number): "work" | "finalize" | "exhausted" {
3
+ if (!Number.isSafeInteger(limit) || limit < 1 || !Number.isSafeInteger(index) || index < 0)
4
+ throw new RangeError("request budget requires positive integer limit and nonnegative index");
5
+ if (index >= limit) return "exhausted";
6
+ // One request cannot both investigate and close. Preserve that work opportunity;
7
+ // its deterministic outcome is the exit, without a second paid request.
8
+ return limit > 1 && index === limit - 1 ? "finalize" : "work";
9
+ }
10
+
11
+ export function finalizationNotice(): string {
12
+ return "Harness: this is the final request in the existing budget. Tools are disabled by the host. Give the user a concise answer from the observations already recorded: what is established, what remains unresolved, and the next useful action. Do not claim unobserved execution or verified completion.";
13
+ }
@@ -0,0 +1,68 @@
1
+ /** Review scopes and checkpoint projection are data, never authorization (D8 / D11). */
2
+ import type { LogEvent } from "./events.ts";
3
+ import { sameVersion, changedPaths, type FileChanges } from "./checkpoints.ts";
4
+ export type ReviewScope = { kind: "working" } | { kind: "branch"; base: string } |
5
+ { kind: "commit"; commit: string } | { kind: "last-turn" };
6
+ export interface ReviewSnapshot {
7
+ scope: ReviewScope;
8
+ label: string;
9
+ diff: string;
10
+ paths: string[];
11
+ limitations: string[];
12
+ /** Resolved commits are immutable review premises, never raw shell expressions. */
13
+ base?: string;
14
+ }
15
+
16
+ /** Fold observed operations after the latest request; uncaptured effects remain unknown. */
17
+ export function lastTurnChanges(events: readonly LogEvent[]): { changes: FileChanges; limitations: string[] } {
18
+ let start = 0;
19
+ for (let i = events.length - 1; i >= 0; i--) if (events[i]!.t === "user" || events[i]!.t === "user_bash") { start = i + 1; break; }
20
+ const changes: FileChanges = Object.create(null) as FileChanges;
21
+ const limitations = new Set<string>(["Last-turn diff covers durable file checkpoints; uncaptured command or external changes are unknown."]);
22
+ const suffix = events.slice(start);
23
+ const finalPhases = new Map(suffix.filter(event => event.t === "checkpoint").map(event => [event.operation, event.phase]));
24
+ for (const event of suffix) {
25
+ if (event.t !== "checkpoint") continue;
26
+ if (event.phase === "unavailable" && finalPhases.get(event.operation) !== "applied") limitations.add(event.reason ?? "A file checkpoint is unavailable.");
27
+ if (event.phase !== "applied" || finalPhases.get(event.operation) !== "applied") continue;
28
+ for (const path of changedPaths(event.changes)) {
29
+ const next = event.changes[path]!, previous = changes[path];
30
+ if (previous && !sameVersion(previous.after, next.before)) throw new Error(`discontinuous last-turn checkpoint: ${path}; external changes cannot be attributed`);
31
+ changes[path] = { before: previous ? previous.before : next.before, after: next.after };
32
+ }
33
+ }
34
+ return { changes: Object.fromEntries(changedPaths(changes).map(path => [path, changes[path]!])), limitations: [...limitations] };
35
+ }
36
+
37
+ /** A linear contiguous hunk avoids quadratic whole-file diff memory. No bytes are omitted. */
38
+ export function unifiedFileDiff(path: string, before: string | null, after: string | null, destination = path): string {
39
+ if (before === after && path === destination) return "";
40
+ const lines = (text: string | null): { text: string; newline: boolean }[] => {
41
+ if (!text) return [];
42
+ const result = text.split("\n");
43
+ if (text.endsWith("\n")) result.pop();
44
+ return result.map((line, index) => ({ text: line, newline: index < result.length - 1 || text.endsWith("\n") }));
45
+ };
46
+ const oldLines = lines(before), newLines = lines(after);
47
+ const equal = (left: typeof oldLines[number] | undefined, right: typeof oldLines[number] | undefined) =>
48
+ left !== undefined && right !== undefined && left.text === right.text && left.newline === right.newline;
49
+ let prefix = 0, suffix = 0;
50
+ while (prefix < oldLines.length && prefix < newLines.length && equal(oldLines[prefix], newLines[prefix])) prefix++;
51
+ while (suffix < oldLines.length - prefix && suffix < newLines.length - prefix && equal(oldLines.at(-suffix - 1), newLines.at(-suffix - 1))) suffix++;
52
+ // Assumed familiar Git default: retain three lines of unchanged context per side.
53
+ const start = Math.max(0, prefix - 3), oldEnd = Math.min(oldLines.length, oldLines.length - suffix + 3), newEnd = Math.min(newLines.length, newLines.length - suffix + 3);
54
+ const quoted = (value: string) => /[\s"\\]/.test(value) ? JSON.stringify(value) : value;
55
+ const emit = (entries: typeof oldLines, marker: string) => entries.flatMap(line =>
56
+ [marker + line.text, ...(line.newline ? [] : ["\"])]);
57
+ const body = [...emit(oldLines.slice(start, prefix), " "),
58
+ ...emit(oldLines.slice(prefix, oldLines.length - suffix), "-"),
59
+ ...emit(newLines.slice(prefix, newLines.length - suffix), "+"),
60
+ ...emit(oldLines.slice(oldLines.length - suffix, oldEnd), " ")];
61
+ const range = (count: number) => count ? start + 1 : 0;
62
+ const oldCount = oldEnd - start, newCount = newEnd - start;
63
+ const headers = [`diff --git ${quoted("a/" + path)} ${quoted("b/" + destination)}`,
64
+ ...(path === destination ? [] : [`rename from ${quoted(path)}`, `rename to ${quoted(destination)}`]),
65
+ `--- ${before === null ? "/dev/null" : quoted("a/" + path)}`, `+++ ${after === null ? "/dev/null" : quoted("b/" + destination)}`];
66
+ if (body.length) headers.push(`@@ -${range(oldCount)},${oldCount} +${range(newCount)},${newCount} @@`, ...body);
67
+ return headers.join("\n") + "\n";
68
+ }
@@ -0,0 +1,26 @@
1
+ /** D8 / D11: terminal exit is a bounded projection of execution facts, never a replay. */
2
+ import type { LogEvent } from "./events.ts";
3
+ import { pendingToolCalls } from "./execution.ts";
4
+ import { modelOutcome } from "./outcome.ts";
5
+
6
+ export const DEFAULT_FULLSCREEN_EXIT_OUTPUT = "resume-hint";
7
+
8
+ /** Terminal controls and arbitrarily large reason text do not own the shell surface. */
9
+ export function exitLine(text: string, limit = 180): string {
10
+ return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim().slice(0, limit);
11
+ }
12
+
13
+ export function sessionExitStatus(events: readonly LogEvent[]): string {
14
+ const start = events.findLastIndex(event => event.t === "user");
15
+ const current = events.slice(Math.max(0, start));
16
+ const outcome = current.findLast(event => event.t === "outcome" || event.t === "user_bash");
17
+ if (outcome?.t === "outcome") return exitLine(`${outcome.outcome.status} · ${outcome.outcome.reason} · ${outcome.reason}`);
18
+ if (outcome?.t === "user_bash") return `${outcome.outcome.status} · ${outcome.outcome.reason} · command exit ${outcome.exit}`;
19
+ if (current.some(event => event.t === "interrupt")) return "cancelled · user interrupted";
20
+ const assistant = current.findLast(event => event.t === "assistant");
21
+ if (assistant?.t === "assistant" && !assistant.blocks.some(block => block.type === "tool_use") && pendingToolCalls(current).length === 0) {
22
+ const result = modelOutcome(assistant.stop_reason ?? null);
23
+ return result.reason === "model_end" ? "model finished · verification remains in session evidence" : `${result.status} · ${result.reason}`;
24
+ }
25
+ return start < 0 ? "session saved" : "unfinished · inspect recorded receipts before continuing";
26
+ }
@@ -99,7 +99,7 @@ export const DISPLAY_SETTINGS = [
99
99
  { id: "images.blockImages", label: "Block image transmission", fallback: "off" },
100
100
  { id: "markdown.mermaid", label: "Mermaid diagrams", fallback: "streaming" },
101
101
  { id: "tuiMode", label: "TUI mode", fallback: "fullscreen" },
102
- { id: "fullscreenExitOutput", label: "Fullscreen exit output", fallback: "transcript" },
102
+ { id: "fullscreenExitOutput", label: "Fullscreen exit output", fallback: "resume-hint" },
103
103
  { id: "fullscreenScrollbar", label: "Fullscreen scrollbar", fallback: "hidden" },
104
104
  { id: "fullscreenCopyOnSelect", label: "Copy on select", fallback: "on" },
105
105
  { id: "fullscreenWheelScrollLines", label: "Fullscreen wheel scroll lines", fallback: "auto" },
package/src/core/stall.ts CHANGED
@@ -3,6 +3,7 @@ import type { LogEvent } from "./events.ts";
3
3
  import { pendingToolCalls } from "./execution.ts";
4
4
  import { exchangeProgressChecks } from "./evidence.ts";
5
5
  import { deriveProgress, type ProgressEvidence } from "./progress.ts";
6
+ import { isProcessReceipt } from "./process-receipt.ts";
6
7
 
7
8
  // A conservative hypothesis: enough room to change method after a mid-budget
8
9
  // notice, while bounding repeated paid requests. Not a task-quality classifier.
@@ -12,18 +13,27 @@ export interface StallState {
12
13
  pending: readonly LogEvent[];
13
14
  pendingNode: number;
14
15
  consecutive: number;
16
+ unproductive: number;
17
+ failures: number;
18
+ emptyWaits: number;
15
19
  warned: boolean;
16
20
  }
17
21
  export function emptyStallState(): StallState {
18
- return { evidence: [], pending: [], pendingNode: 0, consecutive: 0, warned: false };
22
+ return { evidence: [], pending: [], pendingNode: 0, consecutive: 0, unproductive: 0, failures: 0, emptyWaits: 0, warned: false };
19
23
  }
20
24
  export function finishStallExchange(state: StallState): StallState {
21
25
  const assistant = state.pending[0];
22
26
  if (assistant?.t !== "assistant" || pendingToolCalls(state.pending).length > 0) return state;
23
27
  if (!assistant.blocks.some((block) => block.type === "tool_use")) return { ...state, pending: [] };
24
28
  const progress = deriveProgress(state.evidence, state.pending, state.pendingNode, exchangeProgressChecks(state.pending));
29
+ const results = state.pending.filter(event => event.t === "tool_result");
30
+ const waits = new Set(assistant.blocks.flatMap(block => block.type === "tool_use" && block.name === "write_stdin" ? [block.call_id] : []));
25
31
  return { ...state, pending: [], evidence: progress.evidence,
26
- consecutive: progress.progressed ? 0 : state.consecutive + 1, warned: progress.progressed ? false : state.warned };
32
+ consecutive: progress.progressed ? 0 : state.consecutive + 1,
33
+ unproductive: state.unproductive + (progress.progressed ? 0 : 1),
34
+ failures: state.failures + results.filter(result => !result.ok).length,
35
+ emptyWaits: state.emptyWaits + results.filter(result => waits.has(result.call_id) && isProcessReceipt(result.data) &&
36
+ result.data.running === true && result.data.cursor !== undefined && result.data.cursor.start === result.data.cursor.end).length };
27
37
  }
28
38
 
29
39
  /** Fold original events, never the retained projection where evidence can repeat. */
@@ -32,7 +42,7 @@ export function advanceStall(state: StallState, entries: readonly { node: number
32
42
  for (const { node, event } of entries) {
33
43
  if (event.t === "assistant" || event.t === "user" || event.t === "cut" || (event.t === "guidance" && event.reason === "stall")) {
34
44
  result = finishStallExchange(result);
35
- if (event.t === "user") result = { ...result, pending: [], consecutive: 0, warned: false };
45
+ if (event.t === "user") result = { ...result, pending: [], consecutive: 0, unproductive: 0, failures: 0, emptyWaits: 0, warned: false };
36
46
  if (event.t === "assistant") result = { ...result, pending: [event], pendingNode: node };
37
47
  if (event.t === "guidance") result = { ...result, warned: true };
38
48
  } else if (result.pending.length > 0) result = { ...result, pending: [...result.pending, event] };
@@ -42,7 +52,9 @@ export function advanceStall(state: StallState, entries: readonly { node: number
42
52
  export function stallDecision(state: StallState, limit: number) {
43
53
  if (!Number.isSafeInteger(limit) || limit < 0 || limit === 1) throw new RangeError("stall limit must be zero or an integer of at least two");
44
54
  const current = finishStallExchange(state);
45
- return { consecutive: current.consecutive,
46
- stop: limit > 0 && current.consecutive >= limit,
47
- warn: limit > 0 && current.consecutive >= Math.max(1, Math.floor(limit / 2)) && !current.warned };
55
+ // Reuse the existing conservative allowance. Novelty resets a streak, not
56
+ // the request's spent no-progress budget (research Q.26); failures are diagnostics.
57
+ return { consecutive: current.consecutive, unproductive: current.unproductive, failures: current.failures, emptyWaits: current.emptyWaits,
58
+ stop: limit > 0 && (current.consecutive >= limit || current.unproductive >= limit),
59
+ warn: limit > 0 && current.unproductive >= Math.max(1, Math.floor(limit / 2)) && !current.warned };
48
60
  }
@@ -0,0 +1,25 @@
1
+ /** D8 / D11: summarize termination mechanically; assistant claims never become receipts. */
2
+ import type { LogEvent } from "./events.ts";
3
+ import { isProcessReceipt, latestProcessReceipt } from "./process-receipt.ts";
4
+ import { exitLine } from "./session-exit.ts";
5
+
6
+ export function stopReport(events: readonly LogEvent[], requests: number, limit: number, cause: string): string {
7
+ const start = events.findLastIndex(event => event.t === "user" && event.request === undefined);
8
+ const current = events.slice(Math.max(0, start));
9
+ const results = current.filter(event => event.t === "tool_result");
10
+ const failures = results.filter(event => !event.ok);
11
+ const exits = results.filter(event => event.effect?.kind === "command" && event.effect.exit === 0).length;
12
+ const ids = new Set<number>();
13
+ for (const event of current) {
14
+ if (event.t === "tool_result" && isProcessReceipt(event.data)) ids.add(event.data.session_id);
15
+ if (event.t === "process_exit") ids.add(event.session);
16
+ }
17
+ const running = [...ids].filter(id => latestProcessReceipt([{ node: 0, events: current }], id)?.receipt.running === true);
18
+ return [
19
+ `Stopped: ${exitLine(cause)} (${requests}/${limit} model requests).`,
20
+ `Recorded evidence: ${results.length} tool receipts; ${exits} successful command exits; ${failures.length} failed tool receipts. Task completion is not verified by this stop.`,
21
+ ...(failures.length ? [`Inspect failed receipts: ${failures.slice(-3).map(event => exitLine(event.id, 80)).join(", ")}.`] : []),
22
+ ...(running.length ? [`Last observed running: ${running.slice(0, 3).join(", ")}${running.length > 3 ? ` (+${running.length - 3})` : ""}; inspect the process receipt before replaying a command.`] : []),
23
+ "Next: inspect /state or the recorded tool output, then provide a focused continuation; completed commands should not be replayed just to recover their output.",
24
+ ].join("\n");
25
+ }
@@ -2,13 +2,14 @@
2
2
  * Whether to look for a newer xeno and what to say about it (docs/tasks/feat-update-check.md). Pure.
3
3
  *
4
4
  * Notify only: xeno names the command for the way it was installed and never
5
- * runs it. At most one registry request a day, recorded even when it fails,
6
- * and none at all offline, in CI, when turned off, or from a source checkout
5
+ * runs it. Automatic checks run at most once a day, recorded even when they fail,
6
+ * and no automatic requests offline, in CI, when turned off, or from a source checkout
7
7
  * (where the right update is `git pull`, not a package manager). Clock,
8
8
  * environment and the cached answer are all arguments, so every rule is a
9
- * plain function of its inputs.
9
+ * plain function of its inputs. Explicit checks bypass automatic scheduling
10
+ * and opt-outs, while retaining the offline and source-checkout boundaries.
10
11
  */
11
- import { isUpgrade } from "./version.ts";
12
+ import { compareVersions, isUpgrade, parseVersion } from "./version.ts";
12
13
 
13
14
  export const PACKAGE_NAME = "@visual-z/xeno";
14
15
  /** One request a day, like update-notifier; Codex uses 20 hours. Assumed. */
@@ -32,6 +33,8 @@ export interface UpdateInput {
32
33
  /** Settings `updateCheck`; undefined means on. */
33
34
  readonly enabled: boolean | undefined;
34
35
  readonly install: InstallKind;
36
+ /** Explicit checks bypass only automatic scheduling and opt-outs (D16 manual-check extension). */
37
+ readonly manual?: boolean;
35
38
  }
36
39
 
37
40
  export interface UpdatePlan {
@@ -46,9 +49,10 @@ function truthy(value: string | undefined): boolean {
46
49
  }
47
50
 
48
51
  /** Reasons not to send a request at all, in the order they are checked. */
49
- export function updateDisabled(input: Pick<UpdateInput, "env" | "offline" | "enabled" | "install">): string | undefined {
52
+ export function updateDisabled(input: Pick<UpdateInput, "env" | "offline" | "enabled" | "install" | "manual">): string | undefined {
50
53
  if (input.install === "checkout") return "running from a source checkout";
51
54
  if (input.offline || truthy(input.env["XENO_OFFLINE"])) return "offline";
55
+ if (input.manual) return undefined;
52
56
  if (input.enabled === false) return "updateCheck is off";
53
57
  if (truthy(input.env["XENO_NO_UPDATE_CHECK"]) || truthy(input.env["NO_UPDATE_NOTIFIER"])) return "turned off by environment";
54
58
  if (truthy(input.env["CI"]) || truthy(input.env["CONTINUOUS_INTEGRATION"])) return "running in CI";
@@ -57,6 +61,7 @@ export function updateDisabled(input: Pick<UpdateInput, "env" | "offline" | "ena
57
61
 
58
62
  export function updatePlan(input: UpdateInput): UpdatePlan {
59
63
  if (updateDisabled(input)) return { check: false };
64
+ if (input.manual) return { check: true };
60
65
  const due = !input.cache || !Number.isFinite(input.cache.checkedAt) || input.now - input.cache.checkedAt >= CHECK_INTERVAL_MS
61
66
  || input.cache.checkedAt > input.now; // a clock set back must not silence checks for a year
62
67
  const latest = input.cache?.latest;
@@ -77,6 +82,20 @@ export function updateNotice(latest: string, current: string, install: InstallKi
77
82
  return `xeno ${latest} is available (you have ${current})\n ${command ?? "see docs/install.md to update"}`;
78
83
  }
79
84
 
85
+ /** A manual result describes this request, never an older cached answer. */
86
+ export function manualUpdateResult(latest: string | null, current: string, install: InstallKind): {
87
+ readonly status: "available" | "current" | "ahead" | "ineligible" | "failed";
88
+ readonly text: string;
89
+ } {
90
+ const next = latest === null ? null : parseVersion(latest), now = parseVersion(current);
91
+ if (!next || !now || latest === null) return { status: "failed", text: `Could not check for xeno updates (current ${current}). Check your connection or registry setting and try again.` };
92
+ if (isUpgrade(latest, current)) return { status: "available", text: updateNotice(latest, current, install) };
93
+ const order = compareVersions(next, now);
94
+ if (order === 0) return { status: "current", text: `xeno is up to date (${current}).` };
95
+ if (order < 0) return { status: "ahead", text: `xeno ${current} is newer than the registry's latest release (${latest}).` };
96
+ return { status: "ineligible", text: `No newer stable xeno release is available (current ${current}; registry latest ${latest}).` };
97
+ }
98
+
80
99
  /**
81
100
  * How this copy was installed, from where its package root really lives.
82
101
  * Detected from the path rather than by running package managers, which