@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.
- package/CHANGELOG.md +19 -0
- package/README.md +19 -4
- package/docs/app-server.md +32 -3
- package/docs/foundations.md +88 -0
- package/docs/install.md +23 -2
- package/docs/research/foundation-clients-2026-10-09.md +27 -0
- package/docs/research/foundation-editing-2026-10-09.md +56 -0
- package/docs/research/foundation-network-2026-10-09.md +37 -0
- package/docs/research/foundation-workflow-2026-10-09.md +37 -0
- package/package.json +10 -2
- package/src/apps/acp-server.ts +41 -0
- package/src/apps/agent-profiles.ts +19 -0
- package/src/apps/app-server.ts +4 -2
- package/src/apps/cli.ts +18 -2
- package/src/apps/commands.ts +20 -0
- package/src/apps/control-transport.ts +3 -2
- package/src/apps/model-catalog.ts +57 -31
- package/src/apps/question-ui.ts +27 -0
- package/src/apps/resources.ts +63 -25
- package/src/apps/review.ts +109 -0
- package/src/apps/session-hub.ts +95 -0
- package/src/apps/shell.ts +55 -1
- package/src/apps/startup.ts +157 -22
- package/src/apps/ui/app.ts +89 -38
- package/src/apps/ui/image-cache.ts +71 -28
- package/src/apps/ui/surface.ts +1 -0
- package/src/apps/update-check.ts +71 -0
- package/src/apps/workflow.ts +58 -0
- package/src/core/activity-status.ts +28 -0
- package/src/core/agent-profile.ts +130 -0
- package/src/core/control-protocol.ts +8 -1
- package/src/core/file-precondition.ts +6 -0
- package/src/core/git-status.ts +29 -0
- package/src/core/image-limits.ts +72 -0
- package/src/core/lsp-framing.ts +34 -0
- package/src/core/mcp-config.ts +68 -0
- package/src/core/patch.ts +130 -0
- package/src/core/permissions.ts +143 -4
- package/src/core/progress.ts +9 -0
- package/src/core/question.ts +94 -0
- package/src/core/request-budget.ts +13 -0
- package/src/core/review.ts +68 -0
- package/src/core/session-exit.ts +26 -0
- package/src/core/settings.ts +1 -1
- package/src/core/stall.ts +18 -6
- package/src/core/stop-report.ts +25 -0
- package/src/core/update-check.ts +24 -5
- package/src/core/web-content.ts +80 -0
- package/src/core/workflow-mode.ts +34 -0
- package/src/runtime/acp-agent.ts +289 -0
- package/src/runtime/agent-profiles.ts +21 -0
- package/src/runtime/catalog.ts +2 -2
- package/src/runtime/control-client.ts +149 -0
- package/src/runtime/control-connection.ts +39 -4
- package/src/runtime/exec-sessions.ts +40 -4
- package/src/runtime/extensions.ts +20 -2
- package/src/runtime/formatter-tool.ts +38 -0
- package/src/runtime/formatter.ts +50 -0
- package/src/runtime/foundation.ts +78 -0
- package/src/runtime/images.ts +6 -2
- package/src/runtime/integrations.ts +8 -2
- package/src/runtime/loop.ts +124 -21
- package/src/runtime/lsp-client.ts +201 -0
- package/src/runtime/mcp-client.ts +264 -0
- package/src/runtime/mcp-oauth.ts +168 -0
- package/src/runtime/mcp-schema.ts +51 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/openai-responses.ts +6 -4
- package/src/runtime/openai.ts +6 -1
- package/src/runtime/output-schema.ts +26 -0
- package/src/runtime/patch-tool.ts +38 -0
- package/src/runtime/permission-resources.ts +11 -0
- package/src/runtime/pi-provider.ts +5 -4
- package/src/runtime/provider-tool-policy.ts +33 -0
- package/src/runtime/provider.ts +8 -1
- package/src/runtime/question-tool.ts +62 -0
- package/src/runtime/review-tool.ts +48 -0
- package/src/runtime/session-control.ts +61 -2
- package/src/runtime/session-hub.ts +85 -0
- package/src/runtime/tool-discovery.ts +4 -3
- package/src/runtime/tools.ts +44 -10
- package/src/runtime/update-check.ts +3 -2
- package/src/runtime/web-http.ts +56 -0
- package/src/runtime/web-tools.ts +88 -0
- package/src/runtime/workspace-lifecycle.ts +14 -1
- package/src/store/agent-profile-files.ts +13 -0
- package/src/store/blobs.ts +2 -1
- package/src/store/context-files.ts +77 -1
- package/src/store/file-checkpoints.ts +5 -4
- package/src/store/foundation-config.ts +24 -0
- package/src/store/git-status.ts +15 -0
- package/src/store/lsp-document.ts +30 -0
- package/src/store/mcp-credentials.ts +85 -0
- package/src/store/patch.ts +92 -0
- package/src/store/permission-paths.ts +34 -0
- package/src/store/review.ts +109 -0
- package/src/store/search.ts +48 -9
- package/src/tui/ui/layout.ts +7 -7
- package/src/tui/ui/messages.ts +1 -1
- package/src/tui/ui/pictures.ts +26 -9
- package/src/tui/ui/screen.ts +12 -8
- package/src/tui/ui/status.ts +32 -6
package/src/core/permissions.ts
CHANGED
|
@@ -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 (
|
|
325
|
+
if (typeof input !== "object" || input === null) {
|
|
190
326
|
return { tool: name };
|
|
191
327
|
}
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
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
|
/**
|
package/src/core/progress.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/core/settings.ts
CHANGED
|
@@ -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: "
|
|
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,
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|
package/src/core/update-check.ts
CHANGED
|
@@ -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.
|
|
6
|
-
* and
|
|
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
|