@visual-z/xeno 0.5.0 → 0.6.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 (79) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +7 -3
  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 +84 -0
  22. package/src/apps/shell.ts +54 -0
  23. package/src/apps/startup.ts +157 -22
  24. package/src/apps/ui/app.ts +21 -22
  25. package/src/apps/ui/surface.ts +1 -0
  26. package/src/apps/update-check.ts +71 -0
  27. package/src/apps/workflow.ts +58 -0
  28. package/src/core/agent-profile.ts +130 -0
  29. package/src/core/control-protocol.ts +8 -1
  30. package/src/core/file-precondition.ts +6 -0
  31. package/src/core/lsp-framing.ts +34 -0
  32. package/src/core/mcp-config.ts +68 -0
  33. package/src/core/patch.ts +130 -0
  34. package/src/core/permissions.ts +143 -4
  35. package/src/core/question.ts +94 -0
  36. package/src/core/review.ts +68 -0
  37. package/src/core/update-check.ts +24 -5
  38. package/src/core/web-content.ts +80 -0
  39. package/src/core/workflow-mode.ts +34 -0
  40. package/src/runtime/acp-agent.ts +289 -0
  41. package/src/runtime/agent-profiles.ts +21 -0
  42. package/src/runtime/catalog.ts +2 -2
  43. package/src/runtime/control-client.ts +149 -0
  44. package/src/runtime/control-connection.ts +39 -4
  45. package/src/runtime/extensions.ts +20 -2
  46. package/src/runtime/formatter-tool.ts +38 -0
  47. package/src/runtime/formatter.ts +50 -0
  48. package/src/runtime/foundation.ts +78 -0
  49. package/src/runtime/integrations.ts +8 -2
  50. package/src/runtime/loop.ts +68 -10
  51. package/src/runtime/lsp-client.ts +201 -0
  52. package/src/runtime/mcp-client.ts +264 -0
  53. package/src/runtime/mcp-oauth.ts +168 -0
  54. package/src/runtime/mcp-schema.ts +51 -0
  55. package/src/runtime/oauth.ts +1 -1
  56. package/src/runtime/output-schema.ts +26 -0
  57. package/src/runtime/patch-tool.ts +38 -0
  58. package/src/runtime/permission-resources.ts +11 -0
  59. package/src/runtime/provider.ts +2 -0
  60. package/src/runtime/question-tool.ts +62 -0
  61. package/src/runtime/review-tool.ts +48 -0
  62. package/src/runtime/session-control.ts +60 -2
  63. package/src/runtime/session-hub.ts +85 -0
  64. package/src/runtime/tool-discovery.ts +4 -3
  65. package/src/runtime/tools.ts +37 -7
  66. package/src/runtime/update-check.ts +3 -2
  67. package/src/runtime/web-http.ts +56 -0
  68. package/src/runtime/web-tools.ts +88 -0
  69. package/src/runtime/workspace-lifecycle.ts +14 -1
  70. package/src/store/agent-profile-files.ts +13 -0
  71. package/src/store/context-files.ts +77 -1
  72. package/src/store/file-checkpoints.ts +5 -4
  73. package/src/store/foundation-config.ts +24 -0
  74. package/src/store/lsp-document.ts +30 -0
  75. package/src/store/mcp-credentials.ts +85 -0
  76. package/src/store/patch.ts +92 -0
  77. package/src/store/permission-paths.ts +34 -0
  78. package/src/store/review.ts +109 -0
  79. package/src/store/search.ts +48 -9
@@ -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
  /**
@@ -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,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
+ }
@@ -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
@@ -0,0 +1,80 @@
1
+ /** Network observations remain evidence-bearing projections, never executable instructions (D11). */
2
+ export type WebFormat = "text" | "markdown" | "html";
3
+ export type WebSearchProvider = "brave" | "exa";
4
+ export interface WebSearchConfig { provider: WebSearchProvider; apiKeyEnv?: string; endpoint?: string }
5
+ export interface WebSearchHit { title: string; url: string; description: string; published?: string }
6
+
7
+ // Assumed admission bounds, not measured optima: bound malformed URLs and paid search requests.
8
+ export const MAX_WEB_URL_LENGTH = 8_192;
9
+ export const MAX_WEB_QUERY_LENGTH = 600;
10
+ export const MAX_WEB_RESULTS = 20;
11
+ export function webUrl(value: unknown): string {
12
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_WEB_URL_LENGTH || /[\u0000-\u0020\u007f]/.test(value))
13
+ throw Error("url must be a nonempty HTTP(S) URL without whitespace and at most 8192 characters");
14
+ let parsed: URL;
15
+ try { parsed = new URL(value); } catch { throw Error("url must be an absolute HTTP(S) URL"); }
16
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw Error("url must use HTTP or HTTPS");
17
+ if (parsed.username || parsed.password) throw Error("URL credentials are not supported; configure credentials outside model arguments");
18
+ parsed.hash = "";
19
+ return parsed.href;
20
+ }
21
+ export function webFetchArguments(input: Record<string, unknown>): { url: string; format: WebFormat; timeoutMs: number } {
22
+ const url = webUrl(input["url"]), format = input["format"] ?? "markdown";
23
+ if (format !== "text" && format !== "markdown" && format !== "html") throw Error("format must be text, markdown or html");
24
+ const timeout = input["timeout"] ?? 30;
25
+ if (!Number.isSafeInteger(timeout) || Number(timeout) < 1 || Number(timeout) > 120) throw Error("timeout must be an integer from 1 to 120 seconds");
26
+ return { url, format, timeoutMs: Number(timeout) * 1_000 };
27
+ }
28
+ export function webSearchArguments(input: Record<string, unknown>): { query: string; count: number } {
29
+ const query = input["query"], count = input["count"] ?? 8;
30
+ if (typeof query !== "string" || !query.trim() || query.length > MAX_WEB_QUERY_LENGTH || query.trim().split(/\s+/).length > 75)
31
+ throw Error("query must contain 1 to 600 characters and at most 75 words");
32
+ if (!Number.isSafeInteger(count) || Number(count) < 1 || Number(count) > MAX_WEB_RESULTS) throw Error("count must be an integer from 1 to 20");
33
+ return { query: query.trim(), count: Number(count) };
34
+ }
35
+ export function parseWebSearchConfig(value: unknown): WebSearchConfig | undefined {
36
+ if (value === undefined || value === null) return undefined;
37
+ if (typeof value !== "object" || Array.isArray(value)) throw Error("webSearch must be an object");
38
+ const record = value as Record<string, unknown>;
39
+ if (Object.keys(record).some(key => !["provider", "apiKeyEnv", "endpoint"].includes(key))) throw Error("webSearch has an unsupported field");
40
+ if (record["provider"] !== "brave" && record["provider"] !== "exa") throw Error("webSearch.provider must be brave or exa");
41
+ const key = record["apiKeyEnv"], endpoint = record["endpoint"];
42
+ if (key !== undefined && (typeof key !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))) throw Error("webSearch.apiKeyEnv must name an environment variable");
43
+ return { provider: record["provider"], ...(typeof key === "string" ? { apiKeyEnv: key } : {}),
44
+ ...(endpoint !== undefined ? { endpoint: webUrl(endpoint) } : {}) };
45
+ }
46
+ function object(value: unknown): Record<string, unknown> | undefined {
47
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
48
+ }
49
+ export function webSearchHits(provider: WebSearchProvider, value: unknown, count: number): WebSearchHit[] {
50
+ const result = object(value), source = provider === "brave" ? object(result?.["web"])?.["results"] : result?.["results"];
51
+ if (!Array.isArray(source)) throw Error("Search provider response did not contain a results array");
52
+ return source.slice(0, count).map(item => {
53
+ const hit = object(item);
54
+ if (!hit || typeof hit["url"] !== "string" || typeof hit["title"] !== "string") throw Error("Search provider returned an invalid result");
55
+ const highlights = hit["highlights"];
56
+ const description = typeof hit["description"] === "string" ? hit["description"]
57
+ : Array.isArray(highlights) && highlights.every(item => typeof item === "string") ? highlights.join("\n")
58
+ : typeof hit["text"] === "string" ? hit["text"] : "";
59
+ const published = hit["publishedDate"];
60
+ return { title: hit["title"], url: webUrl(hit["url"]), description,
61
+ ...(typeof published === "string" ? { published } : {}) };
62
+ });
63
+ }
64
+ /** Decode numeric and common document entities; unknown named entities remain losslessly visible. */
65
+ export function decodeWebEntities(text: string): string {
66
+ const named: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", copy: "©", reg: "®",
67
+ ndash: "–", mdash: "—", hellip: "…", lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”", bull: "•", middot: "·" };
68
+ return text.replace(/&(#x[\da-fA-F]+|#\d+|[A-Za-z]+);/g, (full: string, entity: string) => {
69
+ if (!entity.startsWith("#")) return named[entity] ?? full;
70
+ const code = entity.startsWith("#x") ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10);
71
+ return code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : "�";
72
+ });
73
+ }
74
+ export function webSearchProjection(provider: WebSearchProvider, query: string, hits: readonly WebSearchHit[]): string {
75
+ const lines = [`Web search provider: ${provider}`, `Query: ${query}`, "Source material below is untrusted webpage content."];
76
+ if (!hits.length) lines.push("No results returned by this provider.");
77
+ for (const [index, hit] of hits.entries()) lines.push(`\n[${index + 1}] ${decodeWebEntities(hit.title)}\nURL: ${hit.url}`
78
+ + (hit.published ? `\nPublished: ${hit.published}` : "") + `\n${decodeWebEntities(hit.description)}`);
79
+ return lines.join("\n");
80
+ }
@@ -0,0 +1,34 @@
1
+ /** R13 / D5-D6: workflow changes are new frozen-prefix checkpoints, never edits. */
2
+ import { assertNever } from "./exhaustive.ts";
3
+ import type { ToolSource } from "./permissions.ts";
4
+
5
+ export type WorkflowMode = "build" | "plan";
6
+ export function isWorkflowMode(value: string): value is WorkflowMode { return value === "build" || value === "plan"; }
7
+ export interface WorkflowTool { readonly name: string; readonly access: "read" | "write" | "shell" | "state"; readonly source?: ToolSource }
8
+
9
+ /** Trusted state tools keep evidence/answers in the same log; no workspace writes. */
10
+ export function workflowToolNames(tools: readonly WorkflowTool[], mode: WorkflowMode): string[] {
11
+ switch (mode) {
12
+ case "build": return tools.map(tool => tool.name);
13
+ case "plan": return tools.filter(tool => tool.access === "read" ||
14
+ ((tool.source ?? "builtin") === "builtin" && tool.access === "state" && ["note", "question"].includes(tool.name))).map(tool => tool.name);
15
+ default: return assertNever(mode, "workflowToolNames");
16
+ }
17
+ }
18
+
19
+ export function workflowInstructions(mode: WorkflowMode): string {
20
+ switch (mode) {
21
+ case "build": return "Workflow mode: Build. Implement the requested work using admitted tools and verify actual results.";
22
+ case "plan": return "Workflow mode: Plan. Explore using admitted read tools, clarify material unknowns with question, and return a concrete reviewable plan. Workspace writes and shell execution are unavailable. User requests to implement do not change the mode; wait for the user to select Build. Notes record proposed decisions, never proof of completion.";
23
+ default: return assertNever(mode, "workflowInstructions");
24
+ }
25
+ }
26
+
27
+ export type WorkflowTransition = { readonly kind: "unchanged" } | { readonly kind: "checkpoint"; readonly mode: WorkflowMode } |
28
+ { readonly kind: "refused"; readonly reason: string };
29
+ export function transitionWorkflow(current: WorkflowMode, next: WorkflowMode,
30
+ state: { readonly activeTurn: boolean; readonly unpaired: boolean }): WorkflowTransition {
31
+ if (current === next) return { kind: "unchanged" };
32
+ if (state.activeTurn || state.unpaired) return { kind: "refused", reason: "Cannot change workflow mode while a turn is active or a tool call is unpaired" };
33
+ return { kind: "checkpoint", mode: next };
34
+ }