@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
@@ -0,0 +1,130 @@
1
+ /** R13 / D13: explicit role layers share tool, model, workflow and budget ownership. */
2
+ import { isWorkflowMode, type WorkflowMode } from "./workflow-mode.ts";
3
+ import { isToolProfile, parsePermissionRule, type PermissionRule, type ToolSelection } from "./permissions.ts";
4
+ import { asThinkingLevel, type ThinkingLevel } from "./thinking.ts";
5
+
6
+ export interface AgentProfile {
7
+ readonly name: string;
8
+ readonly description?: string;
9
+ readonly mode?: WorkflowMode;
10
+ readonly instructions?: string;
11
+ readonly model?: { readonly provider: string; readonly id: string; readonly thinking?: ThinkingLevel };
12
+ readonly tools?: ToolSelection;
13
+ readonly permissions?: readonly PermissionRule[];
14
+ /** maxTurns is a hard cap; timeBudgetMs retains the existing advisory semantics. */
15
+ readonly budget?: { readonly maxTurns?: number; readonly timeBudgetMs?: number };
16
+ }
17
+ export type AgentProfileField = Exclude<keyof AgentProfile, "name">;
18
+ export interface AgentProfileSource { readonly kind: "builtin" | "user" | "project" | "cli"; readonly path?: string }
19
+ export interface AgentProfileLayer { readonly source: AgentProfileSource; readonly profiles: readonly AgentProfile[] }
20
+ export interface ResolvedAgentProfile {
21
+ readonly profile: AgentProfile;
22
+ readonly sources: readonly AgentProfileSource[];
23
+ readonly provenance: Readonly<Partial<Record<AgentProfileField, AgentProfileSource>>>;
24
+ }
25
+ export interface AgentProfileParse { readonly profiles: readonly AgentProfile[]; readonly diagnostics: readonly string[] }
26
+
27
+ /** Named builtins are explicit selectable presets; they never fill missing fields in another role. */
28
+ export function builtinAgentProfileLayer(): AgentProfileLayer {
29
+ return { source: { kind: "builtin" }, profiles: [{ name: "build", mode: "build", tools: { profile: "foundation" } }, { name: "plan", mode: "plan", tools: { profile: "foundation" } },
30
+ { name: "reviewer", mode: "plan", tools: { profile: "readonly" },
31
+ instructions: "Review current code for actionable defects. Cite concrete evidence and paths; do not edit the workspace." }] };
32
+ }
33
+
34
+ const FIELDS: readonly AgentProfileField[] = ["description", "mode", "instructions", "model", "tools", "permissions", "budget"];
35
+ export function isAgentProfileName(value: string): boolean { return /^[a-z][a-z0-9_-]{0,63}$/.test(value); }
36
+ function record(value: unknown, label: string, allowed: readonly string[]): Record<string, unknown> {
37
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw Error(`${label} must be an object`);
38
+ const obj = value as Record<string, unknown>;
39
+ const unknown = Object.keys(obj).find(key => !allowed.includes(key));
40
+ if (unknown) throw Error(`${label} has unknown field ${unknown}`);
41
+ return obj;
42
+ }
43
+ function text(value: unknown, label: string): string {
44
+ if (typeof value !== "string" || !value.trim() || value.includes("\0")) throw Error(`${label} must be a nonempty string`);
45
+ return value;
46
+ }
47
+ function strings(value: unknown, label: string): string[] {
48
+ if (!Array.isArray(value) || value.some(item => typeof item !== "string" || !item.trim()) || new Set(value).size !== value.length) throw Error(`${label} must contain unique nonempty names`);
49
+ return [...value] as string[];
50
+ }
51
+
52
+ export function parseAgentProfile(value: unknown): AgentProfile {
53
+ const obj = record(value, "agent profile", ["name", ...FIELDS]);
54
+ const name = text(obj["name"], "agent name");
55
+ if (!isAgentProfileName(name)) throw Error("agent name must begin with a lowercase letter and contain only letters, digits, _ or - (max 64)");
56
+ const profile: { name: string; description?: string; mode?: WorkflowMode; instructions?: string;
57
+ model?: NonNullable<AgentProfile["model"]>; tools?: ToolSelection; permissions?: PermissionRule[]; budget?: NonNullable<AgentProfile["budget"]> } = { name };
58
+ if (obj["description"] !== undefined) profile.description = text(obj["description"], "agent description");
59
+ if (obj["instructions"] !== undefined) profile.instructions = text(obj["instructions"], "agent instructions");
60
+ if (obj["mode"] !== undefined) {
61
+ if (typeof obj["mode"] !== "string" || !isWorkflowMode(obj["mode"])) throw Error("agent mode must be build or plan");
62
+ profile.mode = obj["mode"];
63
+ }
64
+ if (obj["model"] !== undefined) {
65
+ const model = record(obj["model"], "agent model", ["provider", "id", "thinking"]);
66
+ const provider = text(model["provider"], "agent model provider"), id = text(model["id"], "agent model id");
67
+ const thinking = asThinkingLevel(model["thinking"]);
68
+ if (model["thinking"] !== undefined && thinking === undefined) throw Error("agent model thinking is invalid");
69
+ profile.model = { provider, id, ...(thinking !== undefined ? { thinking } : {}) };
70
+ }
71
+ if (obj["tools"] !== undefined) {
72
+ const tools = record(obj["tools"], "agent tools", ["profile", "only", "exclude", "none", "noBuiltin"]);
73
+ const selected: { profile?: NonNullable<ToolSelection["profile"]>; only?: string[]; exclude?: string[]; none?: boolean; noBuiltin?: boolean } = {};
74
+ if (tools["profile"] !== undefined) {
75
+ if (typeof tools["profile"] !== "string" || !isToolProfile(tools["profile"])) throw Error("agent tool profile is invalid");
76
+ selected.profile = tools["profile"];
77
+ }
78
+ if (tools["only"] !== undefined) selected.only = strings(tools["only"], "agent tools.only");
79
+ if (tools["exclude"] !== undefined) selected.exclude = strings(tools["exclude"], "agent tools.exclude");
80
+ for (const key of ["none", "noBuiltin"] as const) {
81
+ if (tools[key] !== undefined) { if (typeof tools[key] !== "boolean") throw Error(`agent tools.${key} must be boolean`); selected[key] = tools[key]; }
82
+ }
83
+ profile.tools = selected;
84
+ }
85
+ if (obj["permissions"] !== undefined) {
86
+ if (!Array.isArray(obj["permissions"])) throw Error("agent permissions must be an array");
87
+ profile.permissions = obj["permissions"].map(parsePermissionRule);
88
+ }
89
+ if (obj["budget"] !== undefined) {
90
+ const budget = record(obj["budget"], "agent budget", ["maxTurns", "timeBudgetMs"]);
91
+ const selected: { maxTurns?: number; timeBudgetMs?: number } = {};
92
+ for (const key of ["maxTurns", "timeBudgetMs"] as const) {
93
+ if (budget[key] !== undefined) {
94
+ if (!Number.isSafeInteger(budget[key]) || Number(budget[key]) < 1) throw Error(`agent budget.${key} must be a positive safe integer`);
95
+ selected[key] = Number(budget[key]);
96
+ }
97
+ }
98
+ profile.budget = selected;
99
+ }
100
+ return profile;
101
+ }
102
+
103
+ /** A malformed document contributes no partial authority. */
104
+ export function parseAgentProfiles(value: unknown): AgentProfileParse {
105
+ try {
106
+ const obj = record(value, "agents document", ["profiles"]);
107
+ if (!Array.isArray(obj["profiles"])) throw Error("agents document profiles must be an array");
108
+ const profiles = obj["profiles"].map(parseAgentProfile);
109
+ if (new Set(profiles.map(profile => profile.name)).size !== profiles.length) throw Error("agents document contains duplicate profile names");
110
+ return { profiles, diagnostics: [] };
111
+ } catch (error) { return { profiles: [], diagnostics: [error instanceof Error ? error.message : "Invalid agents document"] }; }
112
+ }
113
+
114
+ /** Caller supplies low-to-high layers; whole-field replacement stays explainable. */
115
+ export function resolveAgentProfile(name: string, layers: readonly AgentProfileLayer[]): ResolvedAgentProfile | null {
116
+ let profile: AgentProfile | undefined;
117
+ const sources: AgentProfileSource[] = [], provenance: Partial<Record<AgentProfileField, AgentProfileSource>> = {};
118
+ for (const layer of layers) {
119
+ const matching = layer.profiles.find(item => item.name === name);
120
+ if (!matching) continue;
121
+ const source = structuredClone(layer.source);
122
+ sources.push(source); profile = { ...profile, ...structuredClone(matching) };
123
+ for (const field of FIELDS) if (matching[field] !== undefined) provenance[field] = structuredClone(source);
124
+ }
125
+ return profile ? { profile, sources, provenance } : null;
126
+ }
127
+
128
+ export function agentProfileNames(layers: readonly AgentProfileLayer[]): string[] {
129
+ return [...new Set(layers.flatMap(layer => layer.profiles.map(profile => profile.name)))].sort();
130
+ }
@@ -9,12 +9,19 @@ const turn = { ...thread, turnId: identity };
9
9
  export const CONTROL_METHODS = {
10
10
  initialize: object({ clientInfo: object({ name: identity, version: identity }) }),
11
11
  initialized: object({}),
12
+ "thread/list": object({}),
13
+ "thread/new": object({ name: identity }, []),
14
+ "thread/resume": object({ session: text }),
15
+ "thread/fork": object({ ...thread, name: identity }, ["threadId"]),
16
+ "thread/close": object(thread),
17
+ "thread/mode": object({ ...thread, mode: { enum: ["build", "plan"] } }),
12
18
  "thread/read": object(thread),
13
19
  "thread/subscribe": object({ ...thread, after: object({ streamId: identity, sequence: { type: "integer", minimum: 0 } }) }, ["threadId"]),
14
20
  "turn/start": object({ ...thread, input: text }),
15
21
  "turn/steer": object({ ...turn, input: text }),
16
22
  "turn/interrupt": object(turn),
17
23
  "approval/respond": object({ ...turn, approvalId: identity, choice: { enum: ["once", "command", "tool", "deny"] } }),
24
+ "question/respond": object({ ...turn, requestId: text, reply: text }),
18
25
  "acceptance/accept": object({ ...thread, requestId: identity, criterionId: identity }),
19
26
  "context/read": object({ ...thread, focus: { enum: ["messages", "omitted", "history"] }, offset: { type: "integer", minimum: 0 },
20
27
  limit: { type: "integer", minimum: 1, maximum: 100 } }, ["threadId", "focus"]),
@@ -24,7 +31,7 @@ export const CONTROL_METHODS = {
24
31
  } as const;
25
32
  export const CONTROL_SCHEMA = { protocol: "xeno-control/0.1", experimental: true,
26
33
  transport: "JSONL; JSON-RPC envelopes with jsonrpc omitted", methods: CONTROL_METHODS,
27
- notifications: ["turn/started", "turn/event", "item/completed", "turn/completed", "turn/error", "turn/steeringQueued", "approval/request", "approval/resolved", "thread/statusChanged"],
34
+ notifications: ["turn/started", "turn/event", "item/completed", "turn/completed", "turn/error", "turn/steeringQueued", "approval/request", "approval/resolved", "question/request", "question/resolved", "thread/statusChanged"],
28
35
  recovery: "subscribe first, then use the returned snapshot cursor; gaps or a new streamId require a snapshot and paginated item/list, never replaying commands",
29
36
  identity: "threadId hashes the resolved session directory path; turnId is the original user event ID; item identity is node plus event ID",
30
37
  limits: { frameBytes: 1048576, queuedRequests: 32, outputBytes: 2097152 } } as const;
@@ -26,6 +26,12 @@ export const isFileDigest = (value: unknown): value is string => typeof value ==
26
26
  export function latestObservedFileVersion(events: readonly LogEvent[], resource: string, identify: (path: string) => string): string | undefined {
27
27
  for (let index = events.length - 1; index >= 0; index--) {
28
28
  const event = events[index]!;
29
+ // P.3: multi-file Patch and captured commands observe each actual resulting version.
30
+ if (event.t === "checkpoint" && event.phase === "applied") {
31
+ for (const [path, change] of Object.entries(event.changes))
32
+ if (identify(path) === resource) return change.after?.sha256;
33
+ continue;
34
+ }
29
35
  if (event.t !== "tool_result") continue;
30
36
  const effect = event.effect;
31
37
  if ((effect?.kind === "read" || effect?.kind === "edit") && identify(effect.path) === resource)
@@ -0,0 +1,29 @@
1
+ /** Counts from Git porcelain v2, distinct from agent tool effects. No path decoding needed. */
2
+ export interface GitStatus {
3
+ readonly branch: string | null;
4
+ readonly staged: number;
5
+ readonly changed: number;
6
+ readonly untracked: number;
7
+ readonly conflicted: number;
8
+ readonly ahead: number;
9
+ readonly behind: number;
10
+ }
11
+ export function parseGitStatus(output: string): GitStatus {
12
+ let branch: string | null = null, staged = 0, changed = 0, untracked = 0, conflicted = 0, ahead = 0, behind = 0;
13
+ const records = output.split("\0");
14
+ for (let index = 0; index < records.length; index++) {
15
+ const line = records[index]!;
16
+ if (line.startsWith("# branch.head ")) branch = line.slice(14).trim();
17
+ else if (line.startsWith("# branch.ab ")) {
18
+ const match = /^# branch.ab \+(\d+) -(\d+)$/.exec(line.trim());
19
+ if (match) { ahead = Number(match[1]); behind = Number(match[2]); }
20
+ } else if (line.startsWith("? ")) untracked++;
21
+ else if (/^[12u] [A-Z?.]{2} /.test(line)) {
22
+ if (line[2] !== ".") staged++;
23
+ if (line[3] !== ".") changed++;
24
+ if (line[0] === "u") conflicted++;
25
+ if (line[0] === "2") index++; // Rename's next NUL field is a path, never a status record.
26
+ }
27
+ }
28
+ return { branch, staged, changed, untracked, conflicted, ahead, behind };
29
+ }
@@ -0,0 +1,72 @@
1
+ /** Admission, model detail and preview memory are separate budgets (D8). */
2
+ export const MAX_IMAGE_SOURCE_BYTES = 64 * 1024 * 1024;
3
+ /** Matches the native decoder's area cap; inspect headers before WASM allocates. */
4
+ export const MAX_IMAGE_SOURCE_PIXELS = 25_000_000;
5
+ /** More readable screenshots than 2000px, with at most 32 MiB RGBA per preview. */
6
+ export const MAX_MODEL_IMAGE_SIDE = 4096;
7
+ export const MAX_MODEL_IMAGE_PIXELS = 8 * 1024 * 1024;
8
+ export interface ImageDimensions { readonly width: number; readonly height: number }
9
+
10
+ /** Header-only probe. Decoders still validate the complete image afterward. */
11
+ export function imageDimensions(bytes: Uint8Array, mime: string): ImageDimensions | undefined {
12
+ const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
13
+ const result = (width: number, height: number): ImageDimensions | undefined =>
14
+ Number.isSafeInteger(width) && Number.isSafeInteger(height) && width > 0 && height > 0 ? { width, height } : undefined;
15
+ if (mime === "image/png" && bytes.length >= 24) return result(v.getUint32(16), v.getUint32(20));
16
+ if (mime === "image/gif" && bytes.length >= 10) return result(v.getUint16(6, true), v.getUint16(8, true));
17
+ if (mime === "image/bmp" && bytes.length >= 26) {
18
+ const header = v.getUint32(14, true);
19
+ if (header === 12) return result(v.getUint16(18, true), v.getUint16(20, true));
20
+ if (header >= 40) return result(v.getInt32(18, true), Math.abs(v.getInt32(22, true)));
21
+ return undefined;
22
+ }
23
+ if (mime === "image/jpeg") {
24
+ let offset = 2;
25
+ while (offset + 3 < bytes.length) {
26
+ if (bytes[offset++] !== 255) return undefined;
27
+ while (bytes[offset] === 255) offset++;
28
+ const marker = bytes[offset++];
29
+ if (marker === undefined || marker === 0xda || marker === 0xd9) return undefined;
30
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue;
31
+ if (offset + 2 > bytes.length) return undefined;
32
+ const size = v.getUint16(offset);
33
+ if (size < 2 || offset + size > bytes.length) return undefined;
34
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
35
+ return size >= 8 ? result(v.getUint16(offset + 5), v.getUint16(offset + 3)) : undefined;
36
+ }
37
+ offset += size;
38
+ }
39
+ }
40
+ if (mime === "image/webp") {
41
+ for (let offset = 12; offset + 8 <= bytes.length;) {
42
+ const size = v.getUint32(offset + 4, true), start = offset + 8;
43
+ if (start + size > bytes.length) return undefined;
44
+ const kind = String.fromCharCode(...bytes.subarray(offset, offset + 4));
45
+ const u24 = (at: number) => v.getUint8(at) | v.getUint8(at + 1) << 8 | v.getUint8(at + 2) << 16;
46
+ if (kind === "VP8X" && size >= 10) return result(u24(start + 4) + 1, u24(start + 7) + 1);
47
+ if (kind === "VP8L" && size >= 5 && bytes[start] === 0x2f) {
48
+ const packed = v.getUint32(start + 1, true);
49
+ return result((packed & 0x3fff) + 1, ((packed >>> 14) & 0x3fff) + 1);
50
+ }
51
+ if (kind === "VP8 " && size >= 10 && bytes[start + 3] === 0x9d && bytes[start + 4] === 1 && bytes[start + 5] === 0x2a)
52
+ return result(v.getUint16(start + 6, true) & 0x3fff, v.getUint16(start + 8, true) & 0x3fff);
53
+ offset = start + size + size % 2;
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ /** Floors keep the area bound exact; small inputs are never upscaled. */
60
+ export function modelImageDimensions(image: ImageDimensions): ImageDimensions {
61
+ const scale = Math.min(1, MAX_MODEL_IMAGE_SIDE / image.width, MAX_MODEL_IMAGE_SIDE / image.height,
62
+ Math.sqrt(MAX_MODEL_IMAGE_PIXELS / image.width / image.height));
63
+ return { width: Math.max(1, Math.floor(image.width * scale)), height: Math.max(1, Math.floor(image.height * scale)) };
64
+ }
65
+
66
+ /** Pi rotates EXIF before resizing and rounds the short side; symmetric bounds preserve both orientations. */
67
+ export function modelImageSide(image: ImageDimensions): number {
68
+ const long = Math.max(image.width, image.height), short = Math.min(image.width, image.height);
69
+ let side = Math.max(...Object.values(modelImageDimensions(image)));
70
+ while (side * Math.max(1, Math.round(short * side / long)) > MAX_MODEL_IMAGE_PIXELS) side--;
71
+ return side;
72
+ }
@@ -0,0 +1,34 @@
1
+ /** LSP 3.17 uses byte-counted Content-Length, not line-delimited JSON. */
2
+ export type LspFrame = { body: Uint8Array; consumed: number };
3
+ // Operational ceiling for optional server output; this is not a context segment threshold.
4
+ export const MAX_LSP_FRAME = 8 * 1024 * 1024;
5
+ // An operational bound, independent of context-segment thresholds: reject unbounded headers.
6
+ export const MAX_LSP_HEADER = 8192;
7
+ export function nextLspFrame(bytes: Uint8Array): LspFrame | null {
8
+ let end = -1;
9
+ for (let i = 0; i + 3 < bytes.length; i++) if (bytes[i] === 13 && bytes[i + 1] === 10 && bytes[i + 2] === 13 && bytes[i + 3] === 10) { end = i; break; }
10
+ if (end < 0) { if (bytes.length > MAX_LSP_HEADER) throw Error("LSP header exceeds 8 KiB"); return null; }
11
+ if (end > MAX_LSP_HEADER || bytes.subarray(0, end).some(byte => byte > 127)) throw Error("invalid LSP ASCII header");
12
+ const headers = new TextDecoder("ascii").decode(bytes.subarray(0, end)).split("\r\n");
13
+ if (headers.some(header => !/^[a-zA-Z0-9-]+:[\t\x20-\x7e]*$/.test(header))) throw Error("invalid LSP header field");
14
+ const lengths = headers.filter(header => /^content-length:/i.test(header));
15
+ if (lengths.length !== 1 || !/^content-length:\s*[0-9]+\s*$/i.test(lengths[0]!)) throw Error("invalid LSP Content-Length");
16
+ const types = headers.filter(header => /^content-type:/i.test(header));
17
+ if (types.length > 1) throw Error("ambiguous LSP Content-Type");
18
+ const charsets = types.flatMap(header => header.split(";").slice(1).filter(parameter => /^\s*charset\s*=/i.test(parameter)));
19
+ if (charsets.length > 1 || charsets.some(parameter => !/^\s*charset\s*=\s*(utf-?8|"utf-?8")\s*$/i.test(parameter))) throw Error("unsupported LSP charset");
20
+ const length = Number(lengths[0]!.split(":")[1]!.trim());
21
+ if (!Number.isSafeInteger(length) || length > MAX_LSP_FRAME) throw Error("LSP frame exceeds 8 MiB");
22
+ const consumed = end + 4 + length;
23
+ return bytes.length < consumed ? null : { body: bytes.slice(end + 4, consumed), consumed };
24
+ }
25
+ /** Unversioned server diagnostics cannot prove that they describe the current owned file. */
26
+ export function diagnosticFreshness(observedVersion: number | undefined, currentVersion: number): "current" | "stale" | "unversioned" {
27
+ return observedVersion === undefined ? "unversioned" : observedVersion === currentVersion ? "current" : "stale";
28
+ }
29
+ export function encodeLspFrame(value: unknown): Uint8Array {
30
+ const body = new TextEncoder().encode(JSON.stringify(value));
31
+ if (body.length > MAX_LSP_FRAME) throw Error("LSP frame exceeds 8 MiB");
32
+ const header = new TextEncoder().encode(`Content-Length: ${body.length}\r\n\r\n`);
33
+ const frame = new Uint8Array(header.length + body.length); frame.set(header); frame.set(body, header.length); return frame;
34
+ }
@@ -0,0 +1,68 @@
1
+ /** Explicit MCP configuration is authority; remote tool annotations never grant permissions (D11/D13). */
2
+ import { webUrl } from "./web-content.ts";
3
+
4
+ export interface McpOAuthConfig { clientId?: string; clientSecretEnv?: string; scope?: string; redirectUrl?: string }
5
+ interface McpCommonConfig { enabled?: boolean; timeoutMs?: number }
6
+ export type McpServerConfig = McpCommonConfig & ({ transport: "stdio"; command: string; args?: string[]; env?: Record<string, string> }
7
+ | { transport: "http"; url: string; bearerTokenEnv?: string; headers?: Record<string, string>; headersEnv?: Record<string, string>; oauth?: boolean | McpOAuthConfig });
8
+ export type McpServers = Readonly<Record<string, McpServerConfig>>;
9
+ function object(value: unknown, label: string): Record<string, unknown> {
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw Error(`${label} must be an object`);
11
+ return value as Record<string, unknown>;
12
+ }
13
+ function strings(value: unknown, label: string, envNames = false): Record<string, string> {
14
+ const source = object(value, label), result: Record<string, string> = Object.create(null);
15
+ for (const [key, entry] of Object.entries(source)) {
16
+ if (typeof entry !== "string" || /[\r\n\u0000]/.test(entry) || /[\r\n\u0000]/.test(key)) throw Error(`${label} must contain single-line string values`);
17
+ if (envNames && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(entry)) throw Error(`${label} values must name environment variables`);
18
+ result[key] = entry;
19
+ }
20
+ return result;
21
+ }
22
+ function optionalString(source: Record<string, unknown>, key: string, envName = false): string | undefined {
23
+ const value = source[key];
24
+ if (value === undefined) return undefined;
25
+ if (typeof value !== "string" || !value || /[\r\n\u0000]/.test(value) || envName && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw Error(`${key} must be a valid ${envName ? "environment variable name" : "single-line string"}`);
26
+ return value;
27
+ }
28
+ export function mcpCallbackUrl(value: string): string {
29
+ const parsed = new URL(webUrl(value));
30
+ if (parsed.protocol !== "http:" || parsed.hostname !== "127.0.0.1" || parsed.search || !parsed.port || !parsed.pathname.startsWith("/"))
31
+ throw Error("OAuth redirectUrl must be an HTTP 127.0.0.1 URL with an explicit port and no query");
32
+ return parsed.href;
33
+ }
34
+ function oauth(value: unknown): boolean | McpOAuthConfig {
35
+ if (typeof value === "boolean") return value;
36
+ const source = object(value, "oauth");
37
+ if (Object.keys(source).some(key => !["clientId", "clientSecretEnv", "scope", "redirectUrl"].includes(key))) throw Error("oauth has an unsupported field");
38
+ const clientId = optionalString(source, "clientId"), clientSecretEnv = optionalString(source, "clientSecretEnv", true), scope = optionalString(source, "scope"), redirectUrl = optionalString(source, "redirectUrl");
39
+ if (clientSecretEnv && !clientId) throw Error("oauth.clientSecretEnv requires oauth.clientId");
40
+ return { ...(clientId ? { clientId } : {}), ...(clientSecretEnv ? { clientSecretEnv } : {}), ...(scope ? { scope } : {}), ...(redirectUrl ? { redirectUrl: mcpCallbackUrl(redirectUrl) } : {}) };
41
+ }
42
+ export function parseMcpServers(value: unknown): McpServers {
43
+ if (value === undefined || value === null) return {};
44
+ const source = object(value, "mcpServers"), result: Record<string, McpServerConfig> = Object.create(null);
45
+ for (const [name, raw] of Object.entries(source)) {
46
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) throw Error("MCP server names must contain 1 to 64 letters, digits, underscores or hyphens");
47
+ const config = object(raw, `MCP server ${name}`), enabled = config["enabled"], timeout = config["timeoutMs"];
48
+ if (enabled !== undefined && typeof enabled !== "boolean") throw Error(`MCP server ${name}.enabled must be boolean`);
49
+ // Assumed cap protects startup and tool execution from a misconfigured peer, not an OS sandbox.
50
+ if (timeout !== undefined && (!Number.isSafeInteger(timeout) || Number(timeout) < 1 || Number(timeout) > 300_000)) throw Error("MCP timeoutMs must be an integer from 1 to 300000");
51
+ const common = { ...(typeof enabled === "boolean" ? { enabled } : {}), ...(typeof timeout === "number" ? { timeoutMs: timeout } : {}) };
52
+ if (config["transport"] === "stdio") {
53
+ if (Object.keys(config).some(key => !["transport", "command", "args", "env", "enabled", "timeoutMs"].includes(key))) throw Error(`MCP server ${name} has an unsupported stdio field`);
54
+ const command = optionalString(config, "command"), args = config["args"], env = config["env"];
55
+ if (!command) throw Error(`MCP server ${name}.command is required`);
56
+ if (args !== undefined && (!Array.isArray(args) || !args.every(arg => typeof arg === "string" && !arg.includes("\u0000")))) throw Error("MCP args must be strings without NUL");
57
+ result[name] = { transport: "stdio", command, ...common, ...(Array.isArray(args) ? { args: args.slice() as string[] } : {}), ...(env === undefined ? {} : { env: strings(env, "env") }) };
58
+ } else if (config["transport"] === "http") {
59
+ if (Object.keys(config).some(key => !["transport", "url", "bearerTokenEnv", "headers", "headersEnv", "oauth", "enabled", "timeoutMs"].includes(key))) throw Error(`MCP server ${name} has an unsupported HTTP field`);
60
+ const token = optionalString(config, "bearerTokenEnv", true), headers = config["headers"], headersEnv = config["headersEnv"];
61
+ if (config["oauth"] && token) throw Error("Use bearerTokenEnv or oauth for one MCP server, not both");
62
+ result[name] = { transport: "http", url: webUrl(config["url"]), ...common, ...(token ? { bearerTokenEnv: token } : {}),
63
+ ...(headers === undefined ? {} : { headers: strings(headers, "headers") }), ...(headersEnv === undefined ? {} : { headersEnv: strings(headersEnv, "headersEnv", true) }),
64
+ ...(config["oauth"] === undefined ? {} : { oauth: oauth(config["oauth"]) }) };
65
+ } else throw Error(`MCP server ${name}.transport must be stdio or http`);
66
+ }
67
+ return result;
68
+ }
@@ -0,0 +1,130 @@
1
+ /** Codex patch grammar with exact, unambiguous matching (D9 / R4). No I/O. */
2
+ import { assertNever } from "./exhaustive.ts";
3
+
4
+ // Assumed resource ceilings, not measured: reject oversized plans before retaining hunks.
5
+ export const MAX_PATCH_CHARS = 2 * 1024 * 1024;
6
+ export const MAX_PATCH_FILES = 100;
7
+ export interface PatchChunk {
8
+ oldLines: readonly string[];
9
+ newLines: readonly string[];
10
+ anchor?: string;
11
+ endOfFile: boolean;
12
+ }
13
+ export type PatchOperation =
14
+ | { kind: "add"; path: string; content: string }
15
+ | { kind: "delete"; path: string }
16
+ | { kind: "update"; path: string; movePath?: string; chunks: readonly PatchChunk[] };
17
+ export class PatchError extends Error {
18
+ readonly code: "invalid_input" | "not_found" | "ambiguous_edit";
19
+ constructor(code: PatchError["code"], message: string) { super(message); this.code = code; }
20
+ }
21
+ const invalid = (message: string): never => { throw new PatchError("invalid_input", message); };
22
+ const pathValue = (text: string): string => {
23
+ if (!text.trim() || text.includes("\0")) return invalid("patch paths must be nonempty and contain no NUL");
24
+ return text;
25
+ };
26
+ const fileHeader = (line: string) => /^\*\*\* (?:Add|Delete|Update) File: /.test(line);
27
+
28
+ /** Parse all operations before the store resolves any target or changes a byte. */
29
+ export function parsePatch(text: string): PatchOperation[] {
30
+ if (text.length > MAX_PATCH_CHARS) return invalid("patch exceeds the character budget");
31
+ const lines = text.replaceAll("\r\n", "\n").trim().split("\n");
32
+ if (lines[0] !== "*** Begin Patch" || lines.at(-1) !== "*** End Patch")
33
+ return invalid("patch must begin with *** Begin Patch and end with *** End Patch");
34
+ const operations: PatchOperation[] = [];
35
+ let index = 1;
36
+ while (index < lines.length - 1) {
37
+ if (operations.length >= MAX_PATCH_FILES) return invalid("patch exceeds the file budget");
38
+ const header = lines[index++]!;
39
+ if (header.startsWith("*** Add File: ")) {
40
+ const path = pathValue(header.slice("*** Add File: ".length));
41
+ const added: string[] = [];
42
+ while (index < lines.length - 1 && !fileHeader(lines[index]!)) {
43
+ const line = lines[index++]!;
44
+ if (!line.startsWith("+")) return invalid(`added lines must start with +: ${path}`);
45
+ added.push(line.slice(1));
46
+ }
47
+ operations.push({ kind: "add", path, content: added.length ? added.join("\n") + "\n" : "" });
48
+ continue;
49
+ }
50
+ if (header.startsWith("*** Delete File: ")) {
51
+ operations.push({ kind: "delete", path: pathValue(header.slice("*** Delete File: ".length)) });
52
+ continue;
53
+ }
54
+ if (!header.startsWith("*** Update File: ")) return invalid(`unexpected patch line ${index}: ${header}`);
55
+ const path = pathValue(header.slice("*** Update File: ".length));
56
+ const moveLine = lines[index];
57
+ const movePath = moveLine?.startsWith("*** Move to: ") ? pathValue(moveLine.slice("*** Move to: ".length)) : undefined;
58
+ if (movePath !== undefined) index++;
59
+ const chunks: PatchChunk[] = [];
60
+ while (index < lines.length - 1 && !fileHeader(lines[index]!)) {
61
+ const marker = lines[index++]!;
62
+ if (marker !== "@@" && !marker.startsWith("@@ ")) return invalid(`update hunks must start with @@: ${path}`);
63
+ const oldLines: string[] = [], newLines: string[] = [];
64
+ let endOfFile = false, changed = false;
65
+ while (index < lines.length - 1 && !fileHeader(lines[index]!) && !lines[index]!.startsWith("@@")) {
66
+ const line = lines[index++]!;
67
+ if (line === "*** End of File") { endOfFile = true; break; }
68
+ const prefix = line[0];
69
+ if (prefix !== " " && prefix !== "+" && prefix !== "-") return invalid(`invalid update line: ${path}`);
70
+ if (prefix !== "+") oldLines.push(line.slice(1));
71
+ if (prefix !== "-") newLines.push(line.slice(1));
72
+ if (prefix !== " ") changed = true;
73
+ }
74
+ if (!changed) return invalid(`update hunk makes no change: ${path}`);
75
+ chunks.push({ oldLines, newLines, endOfFile, ...(marker === "@@" ? {} : { anchor: marker.slice(3) }) });
76
+ if (endOfFile && index < lines.length - 1 && !fileHeader(lines[index]!)) return invalid(`End of File must finish the update: ${path}`);
77
+ }
78
+ if (!chunks.length && movePath === undefined) return invalid(`update has no hunks: ${path}`);
79
+ operations.push({ kind: "update", path, chunks, ...(movePath === undefined ? {} : { movePath }) });
80
+ }
81
+ if (!operations.length) return invalid("patch is empty");
82
+ return operations;
83
+ }
84
+
85
+ /** Keep BOM, line endings and final-newline status; ambiguous contexts require a reread. */
86
+ export function applyPatchChunks(before: string, chunks: readonly PatchChunk[]): string {
87
+ const bom = before.startsWith("\uFEFF") ? "\uFEFF" : "";
88
+ const body = before.slice(bom.length);
89
+ const ending = body.includes("\r\n") ? "\r\n" : "\n";
90
+ const normalized = body.replaceAll("\r\n", "\n");
91
+ if (normalized.includes("\r")) return invalid("patch updates do not support bare CR line endings");
92
+ if (ending === "\r\n" && body.replaceAll("\r\n", "").includes("\n"))
93
+ return invalid("patch updates require a consistent line ending; mixed LF/CRLF bytes were preserved");
94
+ const finalNewline = normalized.endsWith("\n");
95
+ const lines = normalized ? normalized.split("\n") : [];
96
+ if (finalNewline) lines.pop();
97
+ let cursor = 0;
98
+ for (const chunk of chunks) {
99
+ let start = cursor;
100
+ if (chunk.anchor !== undefined) {
101
+ const anchors = lines.flatMap((line, at) => at >= cursor && line === chunk.anchor ? [at] : []);
102
+ if (anchors.length !== 1) throw new PatchError(anchors.length ? "ambiguous_edit" : "not_found", "patch anchor must match exactly once after the preceding hunk");
103
+ start = anchors[0]! + 1;
104
+ }
105
+ const matches: number[] = [];
106
+ for (let at = start; at <= lines.length - chunk.oldLines.length; at++) {
107
+ if (chunk.endOfFile && at + chunk.oldLines.length !== lines.length) continue;
108
+ if (chunk.oldLines.every((line, offset) => lines[at + offset] === line)) matches.push(at);
109
+ }
110
+ if (!chunk.oldLines.length && !chunk.endOfFile) {
111
+ if (chunk.anchor !== undefined || lines.length === 0) matches.splice(0, matches.length, start);
112
+ else throw new PatchError("ambiguous_edit", "insertion needs an anchor, End of File, or an empty source");
113
+ }
114
+ if (matches.length !== 1) throw new PatchError(matches.length ? "ambiguous_edit" : "not_found", "patch context must match exactly once; read current content and add context");
115
+ const at = matches[0]!;
116
+ lines.splice(at, chunk.oldLines.length, ...chunk.newLines);
117
+ cursor = at + chunk.newLines.length;
118
+ }
119
+ return bom + lines.join(ending) + (finalNewline && lines.length ? ending : "");
120
+ }
121
+
122
+ export function patchPaths(operations: readonly PatchOperation[]): string[] {
123
+ return operations.flatMap(operation => {
124
+ switch (operation.kind) {
125
+ case "add": case "delete": return [operation.path];
126
+ case "update": return operation.movePath === undefined ? [operation.path] : [operation.path, operation.movePath];
127
+ default: return assertNever(operation, "patchPaths");
128
+ }
129
+ });
130
+ }