@visual-z/xeno 0.4.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.
- package/CHANGELOG.md +20 -0
- package/README.md +41 -10
- package/docs/app-server.md +35 -4
- package/docs/foundations.md +88 -0
- package/docs/install.md +23 -2
- package/docs/providers.md +4 -0
- 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/preferences.ts +20 -0
- 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 +84 -0
- package/src/apps/shell.ts +88 -30
- package/src/apps/startup-input.ts +7 -2
- package/src/apps/startup.ts +180 -25
- package/src/apps/ui/app.ts +74 -91
- 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/agent-profile.ts +130 -0
- package/src/core/completion-review.ts +13 -4
- package/src/core/control-protocol.ts +8 -1
- package/src/core/file-precondition.ts +6 -0
- package/src/core/lsp-framing.ts +34 -0
- package/src/core/mcp-config.ts +68 -0
- package/src/core/model-preferences.ts +28 -0
- package/src/core/patch.ts +130 -0
- package/src/core/permissions.ts +156 -11
- package/src/core/question.ts +94 -0
- package/src/core/review.ts +68 -0
- package/src/core/settings.ts +1 -1
- 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 +10 -3
- package/src/runtime/control-client.ts +149 -0
- package/src/runtime/control-connection.ts +39 -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/integrations.ts +8 -2
- package/src/runtime/loop.ts +68 -10
- 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/output-schema.ts +26 -0
- package/src/runtime/patch-tool.ts +38 -0
- package/src/runtime/permission-resources.ts +11 -0
- package/src/runtime/provider.ts +2 -0
- package/src/runtime/question-tool.ts +62 -0
- package/src/runtime/review-tool.ts +48 -0
- package/src/runtime/session-control.ts +60 -2
- package/src/runtime/session-hub.ts +85 -0
- package/src/runtime/tool-discovery.ts +4 -3
- package/src/runtime/tools.ts +48 -16
- 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/context-files.ts +77 -1
- package/src/store/file-checkpoints.ts +5 -4
- package/src/store/foundation-config.ts +24 -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/resources.ts +13 -22
- package/src/store/review.ts +109 -0
- package/src/store/search.ts +48 -9
- package/src/store/user-settings.ts +30 -0
- package/src/tui/ui/control-panel.ts +7 -5
- package/src/tui/ui/control-sidebar.ts +49 -0
- package/src/tui/ui/screen.ts +107 -11
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** A hub owns live sessions, not transcript mirrors. Durable listing stays a store projection. */
|
|
2
|
+
import type { SessionControl } from "./session-control.ts";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Assumed live-owner bound includes admitted opens, so concurrent requests cannot exceed it.
|
|
6
|
+
export const MAX_LIVE_SESSIONS = 32;
|
|
7
|
+
|
|
8
|
+
export interface SessionHubHost {
|
|
9
|
+
list(): Promise<unknown>;
|
|
10
|
+
open(kind: "new" | "resume" | "fork", params: Record<string, unknown>): Promise<SessionControl>;
|
|
11
|
+
dispose(control: SessionControl): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export class SessionHub {
|
|
14
|
+
private readonly controls = new Map<string, SessionControl>();
|
|
15
|
+
private readonly opening = new Set<Promise<SessionControl>>();
|
|
16
|
+
private readonly resuming = new Map<string, Promise<SessionControl>>();
|
|
17
|
+
private readonly closing = new Map<string, Promise<void>>();
|
|
18
|
+
private reserved = 0;
|
|
19
|
+
private closed = false;
|
|
20
|
+
private disposal: Promise<void> | undefined;
|
|
21
|
+
private readonly host: SessionHubHost;
|
|
22
|
+
readonly initial: SessionControl;
|
|
23
|
+
constructor(initial: SessionControl, host: SessionHubHost) {
|
|
24
|
+
this.initial = initial; this.host = host; this.controls.set(initial.threadId, initial);
|
|
25
|
+
}
|
|
26
|
+
get(threadId: string): SessionControl {
|
|
27
|
+
if (this.closed || this.closing.has(threadId)) throw Error("session hub or thread is closing");
|
|
28
|
+
const control = this.controls.get(threadId);
|
|
29
|
+
if (!control) throw Error("unknown or closed threadId; resume the session first");
|
|
30
|
+
return control;
|
|
31
|
+
}
|
|
32
|
+
async list(): Promise<unknown> { if (this.closed) throw Error("session hub is closing"); return this.host.list(); }
|
|
33
|
+
open(kind: "new" | "resume" | "fork", params: Record<string, unknown>): Promise<SessionControl> {
|
|
34
|
+
if (this.closed) return Promise.reject(Error("session hub is closing; no new session was admitted"));
|
|
35
|
+
const captured = structuredClone(params);
|
|
36
|
+
// Coalesce resume only. Two explicit new/fork requests create two distinct sessions.
|
|
37
|
+
const key = kind === "resume" ? JSON.stringify(captured) : "";
|
|
38
|
+
const pending = key ? this.resuming.get(key) : undefined;
|
|
39
|
+
if (pending) return pending;
|
|
40
|
+
const requested = captured["session"];
|
|
41
|
+
const known = kind === "resume" && [...this.controls.values()].some(control => !this.closing.has(control.threadId)
|
|
42
|
+
&& [control.threadId, control.session.config.logDir, basename(control.session.config.logDir)].includes(String(requested)));
|
|
43
|
+
if (!known && this.controls.size + this.reserved >= MAX_LIVE_SESSIONS) return Promise.reject(Error("live session limit reached; close a session first"));
|
|
44
|
+
if (!known) this.reserved++;
|
|
45
|
+
const operation = (async () => {
|
|
46
|
+
try {
|
|
47
|
+
const control = await this.host.open(kind, captured), existing = this.controls.get(control.threadId);
|
|
48
|
+
if (this.closing.has(control.threadId) || !existing && this.controls.size >= MAX_LIVE_SESSIONS) {
|
|
49
|
+
if (existing !== control) await this.host.dispose(control);
|
|
50
|
+
throw Error("thread is closing or the live session limit was reached");
|
|
51
|
+
}
|
|
52
|
+
if (existing && existing !== control) { await this.host.dispose(control); if (this.closed) throw Error("session hub closed during opening"); return existing; }
|
|
53
|
+
this.controls.set(control.threadId, control);
|
|
54
|
+
if (this.closed) throw Error("session hub closed during opening; the admitted owner will be drained");
|
|
55
|
+
return control;
|
|
56
|
+
} finally { if (!known) this.reserved--; }
|
|
57
|
+
})();
|
|
58
|
+
this.opening.add(operation); if (key) this.resuming.set(key, operation);
|
|
59
|
+
// Observe rejected opens without changing the caller's promise or leaking unhandled cleanup rejections.
|
|
60
|
+
void operation.finally(() => { this.opening.delete(operation); if (key) this.resuming.delete(key); }).catch(() => {});
|
|
61
|
+
return operation;
|
|
62
|
+
}
|
|
63
|
+
close(threadId: string): Promise<void> {
|
|
64
|
+
const pending = this.closing.get(threadId); if (pending) return pending;
|
|
65
|
+
const control = this.controls.get(threadId); if (!control) return Promise.reject(Error("unknown or closed threadId"));
|
|
66
|
+
const operation = (async () => {
|
|
67
|
+
if (control.activeTurnId) control.interrupt(control.activeTurnId);
|
|
68
|
+
await control.whenIdle().catch(() => {});
|
|
69
|
+
await this.host.dispose(control); this.controls.delete(threadId);
|
|
70
|
+
})();
|
|
71
|
+
this.closing.set(threadId, operation);
|
|
72
|
+
void operation.then(() => { if (this.closing.get(threadId) === operation) this.closing.delete(threadId); }, () => {});
|
|
73
|
+
return operation;
|
|
74
|
+
}
|
|
75
|
+
dispose(): Promise<void> {
|
|
76
|
+
if (this.disposal) return this.disposal;
|
|
77
|
+
this.closed = true;
|
|
78
|
+
return this.disposal = (async () => {
|
|
79
|
+
// Every kind is tracked; late publication belongs to this same draining generation.
|
|
80
|
+
await Promise.allSettled([...this.opening]);
|
|
81
|
+
const results = await Promise.allSettled([...this.controls.keys()].map(id => this.close(id)));
|
|
82
|
+
if (results.some(result => result.status === "rejected")) throw Error("session cleanup could not be confirmed");
|
|
83
|
+
})();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** R11: a harness-owned discovery entry shares normal Session admission and result persistence. */
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { discoverTools, MAX_DISCOVERY_QUERY, MAX_DISCOVERY_RESULTS, type DeclarationRequest } from "../core/tool-discovery.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { decideWithSessionRules } from "../core/permissions.ts";
|
|
5
5
|
import { failureDiagnostic, NOT_STARTED } from "../core/tool-failure.ts";
|
|
6
6
|
import type { ToolSchema } from "./provider.ts";
|
|
7
7
|
import type { ToolContext, ToolDefinition, ToolResult } from "./tools.ts";
|
|
@@ -14,7 +14,7 @@ export const DISCOVERY_TOOL: ToolDefinition = {
|
|
|
14
14
|
query: { type: "string", minLength: 1, maxLength: MAX_DISCOVERY_QUERY },
|
|
15
15
|
limit: { type: "integer", minimum: 1, maximum: MAX_DISCOVERY_RESULTS, description: "Maximum matches to request; defaults to 5." },
|
|
16
16
|
}, required: ["query"] },
|
|
17
|
-
access: "
|
|
17
|
+
access: "read", execution: "exclusive", branch: "none",
|
|
18
18
|
run: async () => ({ ok: false, content: "Tool discovery requires a live Session checkpoint." }),
|
|
19
19
|
};
|
|
20
20
|
|
|
@@ -23,7 +23,8 @@ export function discoveryFingerprint(tool: ToolSchema): string {
|
|
|
23
23
|
return createHash("sha256").update(JSON.stringify({ name: tool.name, description: tool.description, input_schema: tool.input_schema })).digest("hex");
|
|
24
24
|
}
|
|
25
25
|
export function discoveryPermitted(ctx: ToolContext, name: string): boolean {
|
|
26
|
-
|
|
26
|
+
if (ctx.workflowMode === "plan" && ctx.extensions?.get(name)?.access !== "read") return false;
|
|
27
|
+
return decideWithSessionRules(ctx.rules ?? [], ctx.sessionRules ?? [], { tool: name }) !== "deny";
|
|
27
28
|
}
|
|
28
29
|
/** A directory entry without its matching live implementation is unavailable, regardless of history. */
|
|
29
30
|
export function currentDeferredTools(ctx: ToolContext): readonly ToolSchema[] {
|
package/src/runtime/tools.ts
CHANGED
|
@@ -54,7 +54,6 @@ import { parseQueryInput, MAX_SQL_CHARS, SQL_QUERY_HELP } from "../core/sql-quer
|
|
|
54
54
|
import { readQuerySources } from "../store/query-source.ts";
|
|
55
55
|
import {
|
|
56
56
|
askWithoutTtyMessage,
|
|
57
|
-
decide,
|
|
58
57
|
deniedMessage,
|
|
59
58
|
permissionRequest,
|
|
60
59
|
type PermissionRequest,
|
|
@@ -76,6 +75,10 @@ import { observeEnvironment } from "./environment-tools.ts";
|
|
|
76
75
|
import { beginCommandArtifacts, prepareArtifactArguments, commandArtifactSummary } from "./command-artifacts.ts";
|
|
77
76
|
import { MAX_COMMAND_ARTIFACTS } from "../core/command-artifacts.ts";
|
|
78
77
|
import { checkSourceCancellation } from "../store/source-cancellation.ts";
|
|
78
|
+
import { canonicalPermissionRequest } from "../store/permission-paths.ts";
|
|
79
|
+
import { permissionResourceAction } from "./permission-resources.ts";
|
|
80
|
+
import { parsePatch, patchPaths } from "../core/patch.ts";
|
|
81
|
+
import { decideWithSessionRules } from "../core/permissions.ts";
|
|
79
82
|
|
|
80
83
|
export interface ToolResult {
|
|
81
84
|
/** Broker-owned identity, returned to a composer only after the original receipt commits. */
|
|
@@ -98,6 +101,15 @@ export interface ToolResult {
|
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
export interface ToolContext {
|
|
104
|
+
historyEvents?: () => Promise<import("../core/events.ts").LogEvent[]>;
|
|
105
|
+
checkpointBlobs?: string;
|
|
106
|
+
frozenSystem?: () => string;
|
|
107
|
+
instructionCheck?: (ctx: ToolContext, name: string, input: unknown) => Promise<ToolResult | null>;
|
|
108
|
+
workflowMode?: "build" | "plan";
|
|
109
|
+
/** CLI owns its approval defaults; SDK callers retain their supplied host policy across reload. */
|
|
110
|
+
appOwnsPermissions?: true;
|
|
111
|
+
hostPermissionRules?: PermissionRule[];
|
|
112
|
+
questionSlot?: import("./question-tool.ts").QuestionSlot;
|
|
101
113
|
/** R10: model-visible names constrain new advice without granting tool admission. */
|
|
102
114
|
advice?: readonly string[];
|
|
103
115
|
/** Runtime adapter identity, never supplied by a model tool argument. */
|
|
@@ -285,6 +297,8 @@ export interface ToolDefinition extends ToolSchema {
|
|
|
285
297
|
/** Accepted by admission but not advertised to the model: harness and test switches. */
|
|
286
298
|
hiddenInputs?: readonly string[];
|
|
287
299
|
prepareArguments?: (input: unknown) => unknown;
|
|
300
|
+
/** Protocol adapters with complete schemas own validation, rather than the builtin vocabulary. */
|
|
301
|
+
validateInput?: (input: unknown) => void;
|
|
288
302
|
run?: (ctx: ToolContext, input: Record<string, unknown>) => Promise<ToolResult>;
|
|
289
303
|
}
|
|
290
304
|
|
|
@@ -370,7 +384,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
370
384
|
description:
|
|
371
385
|
"Read a file. Returns the contents with 1-indexed line numbers. " +
|
|
372
386
|
"Optionally limit to an inclusive line range. Reports total lines and the next range; end beyond EOF is clamped. " +
|
|
373
|
-
"Returns read_handle and whole-file sha256 for
|
|
387
|
+
"Session remembers the whole-file version for later changes; no digest copying is needed. Returns read_handle and whole-file sha256 for selecting an explicit observation. Long lines and large previews retain an immutable artifact. Line ranges describe the selection; the observation receipt describes exactly what was visible. An unavailable artifact-recovery capability leaves a gap, not proof of full coverage.",
|
|
374
388
|
input_schema: {
|
|
375
389
|
type: "object",
|
|
376
390
|
properties: {
|
|
@@ -384,14 +398,14 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
384
398
|
{
|
|
385
399
|
name: "write",
|
|
386
400
|
access: "write", execution: "exclusive", branch: "write", run: toolWrite,
|
|
387
|
-
description: "Write a file, creating parent directories. Replaces existing content.
|
|
401
|
+
description: "Write a file, creating parent directories. Replaces existing content. Normally omit read_handle and expected_sha256: Session checks its latest observed version. Without a prior observation, omission has no stale-version protection. To select a specific observation instead, pass its exact version receipt. create_only requires absence. A conflict requires a fresh read. Identical content makes no change.",
|
|
388
402
|
input_schema: {
|
|
389
403
|
type: "object",
|
|
390
404
|
properties: {
|
|
391
405
|
path: { type: "string" },
|
|
392
406
|
content: { type: "string" },
|
|
393
|
-
read_handle: { type: "string", description: "
|
|
394
|
-
expected_sha256: { type: "string", description: "
|
|
407
|
+
read_handle: { type: "string", description: "Optional explicit descriptor SHA for this file, workspace and host; normally omit" },
|
|
408
|
+
expected_sha256: { type: "string", description: "Optional explicit whole-file raw-byte SHA-256; normally omit" },
|
|
395
409
|
create_only: { type: "boolean", description: "Require that the target does not exist; excludes read_handle and expected_sha256" },
|
|
396
410
|
},
|
|
397
411
|
required: ["path", "content"],
|
|
@@ -400,14 +414,16 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
400
414
|
{
|
|
401
415
|
name: "edit",
|
|
402
416
|
example: { path: "src/main.ts", old_string: "const size = 1;", new_string: "const size = 2;" },
|
|
403
|
-
guidance: [{ requires: ["read"], text: "Use read to obtain current text
|
|
417
|
+
guidance: [{ requires: ["read"], text: "Use read to obtain current text before an edit; Session remembers its version automatically." }],
|
|
404
418
|
access: "write", execution: "exclusive", branch: "write", run: toolEdit,
|
|
405
419
|
description:
|
|
406
|
-
"Replace exact text in a file.
|
|
420
|
+
"Replace exact text in a file. Normally omit read_handle and expected_sha256: Session checks its latest observed version. " +
|
|
421
|
+
"Without a prior observation, omission has no stale-version protection. To select a specific observation instead, pass its exact version receipt. " +
|
|
422
|
+
"old_string must match once unless replace_all is set; copy it from observed file content, " +
|
|
407
423
|
"including indentation (line numbers are not part of the file). For several changes to one file pass edits[], " +
|
|
408
424
|
"each matched against the original; all apply or none do. If there is no exact match, a unique match that differs only in " +
|
|
409
425
|
"trailing whitespace, quote or dash style is applied and flagged fuzzy. A failure shows the closest text in the file. " +
|
|
410
|
-
"
|
|
426
|
+
"A conflict requires a fresh observation. A partial read handle checks the whole file version, not permission to edit unseen lines.",
|
|
411
427
|
input_schema: {
|
|
412
428
|
type: "object",
|
|
413
429
|
properties: {
|
|
@@ -419,8 +435,8 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
419
435
|
type: "object", required: ["old_string", "new_string"], properties: {
|
|
420
436
|
old_string: { type: "string", minLength: 1 }, new_string: { type: "string" }, replace_all: { type: "boolean" },
|
|
421
437
|
} } },
|
|
422
|
-
expected_sha256: { type: "string", description: "
|
|
423
|
-
read_handle: { type: "string", description: "
|
|
438
|
+
expected_sha256: { type: "string", description: "Optional explicit whole-file raw-byte SHA-256; normally omit" },
|
|
439
|
+
read_handle: { type: "string", description: "Optional explicit descriptor SHA for this file, workspace and host; normally omit" },
|
|
424
440
|
},
|
|
425
441
|
required: ["path"],
|
|
426
442
|
},
|
|
@@ -700,7 +716,7 @@ export function adviceTools(ctx: ToolContext): string[] {
|
|
|
700
716
|
return TOOL_DEFINITIONS.map(tool => tool.name).filter(name => !ctx.extensions?.has(name) &&
|
|
701
717
|
(ctx.advice === undefined || ctx.advice.includes(name)) &&
|
|
702
718
|
(ctx.allow === undefined || ctx.allow.includes(name)) &&
|
|
703
|
-
|
|
719
|
+
decideWithSessionRules(ctx.rules ?? [], ctx.sessionRules ?? [], { tool: name }) !== "deny");
|
|
704
720
|
}
|
|
705
721
|
|
|
706
722
|
/** A proposed recovery is data, not execution. Validate it against today's exact admission inputs. */
|
|
@@ -714,7 +730,7 @@ function scopeToolAdvice(ctx: ToolContext, result: ToolResult): ToolResult {
|
|
|
714
730
|
const input = normalizeToolInput(ctx, recover.tool, recover.input);
|
|
715
731
|
const definition = toolDefinition(recover.tool, ctx)!;
|
|
716
732
|
allowed = inputError(definition.input_schema, input, "input", definition.hiddenInputs) === null &&
|
|
717
|
-
|
|
733
|
+
decideWithSessionRules(ctx.rules ?? [], ctx.sessionRules ?? [], permissionRequest(recover.tool, input)) !== "deny";
|
|
718
734
|
} catch { /* Unavailable or invalid advice is omitted; the original failure remains. */ }
|
|
719
735
|
}
|
|
720
736
|
return scopeFailureGuidance(diagnostic, names, allowed);
|
|
@@ -1188,6 +1204,7 @@ async function toolGrep(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1188
1204
|
const limit = optionalLimit(input);
|
|
1189
1205
|
const result = await grep({
|
|
1190
1206
|
root: searchRoot(ctx, abs),
|
|
1207
|
+
...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "grep") === "allow" } : {}),
|
|
1191
1208
|
path: abs,
|
|
1192
1209
|
pattern: String(input["pattern"] ?? ""),
|
|
1193
1210
|
engine: grepEngine(input),
|
|
@@ -1247,6 +1264,7 @@ async function toolFind(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1247
1264
|
const limit = optionalLimit(input);
|
|
1248
1265
|
const result = await findFiles({
|
|
1249
1266
|
root: searchRoot(ctx, abs),
|
|
1267
|
+
...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "find") === "allow" } : {}),
|
|
1250
1268
|
path: abs,
|
|
1251
1269
|
pattern: String(input["pattern"] ?? ""),
|
|
1252
1270
|
timeoutMs: ctx.timeoutMs,
|
|
@@ -1262,6 +1280,7 @@ async function toolLs(ctx: ToolContext, input: Record<string, unknown>): Promise
|
|
|
1262
1280
|
const limit = optionalLimit(input);
|
|
1263
1281
|
const result = await ls({
|
|
1264
1282
|
root: searchRoot(ctx, abs),
|
|
1283
|
+
...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "ls") === "allow" } : {}),
|
|
1265
1284
|
path: abs,
|
|
1266
1285
|
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
1267
1286
|
...(limit !== undefined ? { limit } : {}),
|
|
@@ -1313,8 +1332,14 @@ export async function gateTool(
|
|
|
1313
1332
|
return failure("permission_denied", `refused: ${name} is not available here (allowed: ${ctx.allow.join(", ")})`);
|
|
1314
1333
|
}
|
|
1315
1334
|
const definition = toolDefinition(name, ctx);
|
|
1335
|
+
if (ctx.workflowMode === "plan" && definition && definition.access !== "read" &&
|
|
1336
|
+
!(definition.access === "state" && (name === "note" || name === "question" && ctx.questionSlot)))
|
|
1337
|
+
return failure("permission_denied", `${name} is unavailable in Plan mode; select Build at an idle checkpoint.`);
|
|
1316
1338
|
input = repairToolInput(name, input);
|
|
1317
|
-
|
|
1339
|
+
let invalid: string | null = null;
|
|
1340
|
+
if (definition?.validateInput) {
|
|
1341
|
+
try { definition.validateInput(input); } catch (error) { return caughtFailure(error, NOT_STARTED, "Invalid tool arguments: "); }
|
|
1342
|
+
} else if (definition) invalid = inputError(definition.input_schema, input, "input", definition.hiddenInputs);
|
|
1318
1343
|
if (invalid) {
|
|
1319
1344
|
const example = minimalInput(definition!.input_schema);
|
|
1320
1345
|
return failure("invalid_input", invalid, NOT_STARTED, example !== null && typeof example === "object" && !Array.isArray(example) &&
|
|
@@ -1322,9 +1347,14 @@ export async function gateTool(
|
|
|
1322
1347
|
}
|
|
1323
1348
|
if (ctx.rules === undefined) return null;
|
|
1324
1349
|
|
|
1325
|
-
const
|
|
1326
|
-
const
|
|
1327
|
-
|
|
1350
|
+
const permissionInput = name === "apply_patch" && input && typeof input === "object" && "patchText" in input ? { paths: patchPaths(parsePatch(String(input.patchText))) } : input;
|
|
1351
|
+
const request = await canonicalPermissionRequest(ctx.root, name, permissionInput);
|
|
1352
|
+
let decision = decideWithSessionRules(ctx.rules, ctx.sessionRules ?? [], request);
|
|
1353
|
+
if (definition?.access === "read" && request.paths) for (const path of request.paths) {
|
|
1354
|
+
const resource = await permissionResourceAction(ctx.root, path, ctx.rules, ctx.sessionRules ?? [], name);
|
|
1355
|
+
if (resource === "deny") { decision = "deny"; break; }
|
|
1356
|
+
if (resource === "ask" && decision === "allow") decision = "ask";
|
|
1357
|
+
}
|
|
1328
1358
|
switch (decision) {
|
|
1329
1359
|
case "allow":
|
|
1330
1360
|
return null;
|
|
@@ -1403,6 +1433,8 @@ export async function prepareNormalizedTool(ctx: ToolContext, name: string, inpu
|
|
|
1403
1433
|
try {
|
|
1404
1434
|
const gated = await gateTool(ctx, name, input);
|
|
1405
1435
|
if (gated) return scopeToolAdvice(ctx, finishToolResult(gated));
|
|
1436
|
+
const instructions = await ctx.instructionCheck?.(ctx, name, input);
|
|
1437
|
+
if (instructions) return scopeToolAdvice(ctx, finishToolResult(instructions));
|
|
1406
1438
|
const veto = await scoped(() => ctx.extensionHooks?.before(name, observerValue(input), ctx.callId, ctx.parentCallId));
|
|
1407
1439
|
await ctx.drainNested?.();
|
|
1408
1440
|
if (veto) return scopeToolAdvice(ctx, finishToolResult(failure("permission_denied", veto)));
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* timeout, falling back to the package's `latest` document. It carries
|
|
6
6
|
* Bun's default User-Agent and nothing that identifies the person: the
|
|
7
7
|
* registry sees an IP address, the time and the package name. Failure of
|
|
8
|
-
* any kind is "no answer";
|
|
8
|
+
* any kind is "no answer"; callers decide whether an explicit check needs feedback.
|
|
9
9
|
*/
|
|
10
10
|
import { distTagsUrl } from "../core/update-check.ts";
|
|
11
|
+
import { parseVersion } from "../core/version.ts";
|
|
11
12
|
|
|
12
13
|
/** How long to wait for the registry. Codex and pi wait longer; startup never waits on it anyway. Assumed. */
|
|
13
14
|
export const UPDATE_TIMEOUT_MS = 3000;
|
|
@@ -40,7 +41,7 @@ export async function fetchLatestVersion(registry: string | undefined, options:
|
|
|
40
41
|
const response = await fetcher(url, { signal, headers: { accept: "application/json" } });
|
|
41
42
|
if (!response.ok) continue;
|
|
42
43
|
const value = pick(await response.json() as Record<string, unknown>);
|
|
43
|
-
if (typeof value === "string" && value.length <= 64) return value;
|
|
44
|
+
if (typeof value === "string" && value.length <= 64 && parseVersion(value)) return value.trim();
|
|
44
45
|
} catch { if (signal.aborted) return null; }
|
|
45
46
|
}
|
|
46
47
|
return null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Shared bounded network leaves; request errors never include arbitrary peer text or credentials. */
|
|
2
|
+
import { webUrl } from "../core/web-content.ts";
|
|
3
|
+
import { READ_ATTEMPTED, ToolFault } from "../core/tool-failure.ts";
|
|
4
|
+
|
|
5
|
+
// Assumed resource bounds follow OpenCode's 5 MiB response cap; streamed enforcement prevents oversized allocation.
|
|
6
|
+
export const MAX_WEB_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
7
|
+
export const MAX_WEB_REDIRECTS = 5;
|
|
8
|
+
export type WebFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
9
|
+
export async function boundedResponseBytes(response: Response, limit = MAX_WEB_RESPONSE_BYTES): Promise<Uint8Array> {
|
|
10
|
+
const length = Number(response.headers.get("content-length"));
|
|
11
|
+
if (Number.isFinite(length) && length > limit) {
|
|
12
|
+
await response.body?.cancel(); throw new ToolFault("execution_failed", `Response exceeds ${limit} bytes`, READ_ATTEMPTED);
|
|
13
|
+
}
|
|
14
|
+
if (!response.body) return new Uint8Array();
|
|
15
|
+
const reader = response.body.getReader(), chunks: Uint8Array[] = [];
|
|
16
|
+
let size = 0;
|
|
17
|
+
try {
|
|
18
|
+
for (;;) {
|
|
19
|
+
const next = await reader.read();
|
|
20
|
+
if (next.done) break;
|
|
21
|
+
size += next.value.byteLength;
|
|
22
|
+
if (size > limit) { await reader.cancel(); throw new ToolFault("execution_failed", `Response exceeds ${limit} bytes`, READ_ATTEMPTED); }
|
|
23
|
+
chunks.push(next.value);
|
|
24
|
+
}
|
|
25
|
+
} finally { reader.releaseLock(); }
|
|
26
|
+
const bytes = new Uint8Array(size); let offset = 0;
|
|
27
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
|
28
|
+
return bytes;
|
|
29
|
+
}
|
|
30
|
+
export async function boundedWebRequest(url: string, init: RequestInit, timeoutMs: number, signal: AbortSignal | undefined,
|
|
31
|
+
fetcher: WebFetch = fetch, redirects = MAX_WEB_REDIRECTS): Promise<{ response: Response; bytes: Uint8Array; url: string; redirects: string[] }> {
|
|
32
|
+
const deadline = new AbortController(), combined = AbortSignal.any([deadline.signal, ...(signal ? [signal] : [])]);
|
|
33
|
+
const timer = setTimeout(() => deadline.abort(), timeoutMs), history: string[] = [];
|
|
34
|
+
let target = webUrl(url);
|
|
35
|
+
try {
|
|
36
|
+
combined.throwIfAborted();
|
|
37
|
+
for (;;) {
|
|
38
|
+
const response = await fetcher(target, { ...init, redirect: "manual", signal: combined });
|
|
39
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
40
|
+
await response.body?.cancel();
|
|
41
|
+
const location = response.headers.get("location");
|
|
42
|
+
if (!location || history.length >= redirects) throw new ToolFault("execution_failed", "Redirect limit exceeded or Location missing", READ_ATTEMPTED);
|
|
43
|
+
const next = webUrl(new URL(location, target).href);
|
|
44
|
+
if (target.startsWith("https:") && next.startsWith("http:")) throw new ToolFault("execution_failed", "HTTPS redirects cannot downgrade to HTTP", READ_ATTEMPTED);
|
|
45
|
+
history.push(target); target = next;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
return { response, bytes: await boundedResponseBytes(response), url: target, redirects: history };
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (signal?.aborted) throw new ToolFault("cancelled", "Network request cancelled", READ_ATTEMPTED);
|
|
52
|
+
if (deadline.signal.aborted) throw new ToolFault("timeout", "Network request timed out", READ_ATTEMPTED);
|
|
53
|
+
if (error instanceof ToolFault) throw error;
|
|
54
|
+
throw new ToolFault("execution_failed", "Network request failed; verify the URL and network connection", READ_ATTEMPTED);
|
|
55
|
+
} finally { clearTimeout(timer); }
|
|
56
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Network adapters share the normal tool admission and append-only observation path (D11/D13). */
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { webFetchArguments, webSearchArguments, webSearchHits, webSearchProjection, decodeWebEntities,
|
|
4
|
+
type WebFormat, type WebSearchConfig } from "../core/web-content.ts";
|
|
5
|
+
import { ToolFault, NOT_STARTED, READ_ATTEMPTED } from "../core/tool-failure.ts";
|
|
6
|
+
import { archiveObservation } from "../store/observations.ts";
|
|
7
|
+
import { presentObservation, type ToolContext, type ToolDefinition } from "./tools.ts";
|
|
8
|
+
import { boundedWebRequest, type WebFetch } from "./web-http.ts";
|
|
9
|
+
|
|
10
|
+
/** Bun's native HTML parser handles malformed/quoted markup; no homegrown HTML tokeniser or new dependency. */
|
|
11
|
+
export async function extractWebHtml(html: string, url: string, format: Exclude<WebFormat, "html">): Promise<{ title: string; content: string }> {
|
|
12
|
+
let title = "";
|
|
13
|
+
const cleaned = await new HTMLRewriter().on("title", { text(chunk) { title += chunk.text; } })
|
|
14
|
+
.on("head,script,style,noscript,iframe,object,embed,template", { element(element) { element.remove(); } })
|
|
15
|
+
.transform(new Response(html)).text();
|
|
16
|
+
const blocks = new Set(["p", "div", "section", "article", "main", "header", "footer", "blockquote", "ul", "ol", "table", "tr", "pre", "h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
17
|
+
const content = await new HTMLRewriter().on("*", { element(element) {
|
|
18
|
+
const tag = element.tagName;
|
|
19
|
+
if (blocks.has(tag) || tag === "br" || tag === "li") element.before("\n");
|
|
20
|
+
if (format === "markdown" && /^h[1-6]$/.test(tag)) element.before("#".repeat(Number(tag[1])) + " ");
|
|
21
|
+
if (format === "markdown" && tag === "li") element.before("- ");
|
|
22
|
+
if (format === "markdown" && tag === "pre") element.before("```\n");
|
|
23
|
+
if (tag === "a") {
|
|
24
|
+
const href = element.getAttribute("href");
|
|
25
|
+
if (href) try {
|
|
26
|
+
const link = new URL(decodeWebEntities(href), url);
|
|
27
|
+
if (link.protocol === "http:" || link.protocol === "https:") element.onEndTag(end => { end.before(` (${link.href})`); });
|
|
28
|
+
} catch { /* A malformed peer link contributes no claimed source URL. */ }
|
|
29
|
+
}
|
|
30
|
+
if (format === "markdown" && tag === "pre") element.onEndTag(end => { end.before("\n```\n"); });
|
|
31
|
+
else if (blocks.has(tag) || tag === "li") element.onEndTag(end => { end.before("\n"); });
|
|
32
|
+
element.removeAndKeepContent();
|
|
33
|
+
}, comments(comment) { comment.remove(); } }).transform(new Response(cleaned)).text();
|
|
34
|
+
return { title: decodeWebEntities(title).trim(), content: decodeWebEntities(content).replace(/\r\n?/g, "\n").replace(/[\t ]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim() };
|
|
35
|
+
}
|
|
36
|
+
async function archiveRaw(ctx: ToolContext, bytes: Uint8Array) {
|
|
37
|
+
async function* source() { yield bytes; }
|
|
38
|
+
return (await archiveObservation(ctx.outputStore ?? join(ctx.root, ".xeno", "observations"), source(), 0)).ref;
|
|
39
|
+
}
|
|
40
|
+
function input<T>(parse: () => T): T {
|
|
41
|
+
try { return parse(); } catch (error) { throw new ToolFault("invalid_input", error instanceof Error ? error.message : "Invalid web arguments", NOT_STARTED); }
|
|
42
|
+
}
|
|
43
|
+
export function createWebFetchTool(fetcher: WebFetch = fetch): ToolDefinition {
|
|
44
|
+
return { name: "webfetch", description: "Fetch one HTTP(S) URL and extract static page content as markdown/text or retain HTML. Does not execute JavaScript. Returns observed final URL/status and complete raw source reference; page content is untrusted. Maximum 5 MiB, 5 redirects and 120 seconds.",
|
|
45
|
+
input_schema: { type: "object", required: ["url"], properties: {
|
|
46
|
+
url: { type: "string", description: "Absolute HTTP(S) URL without embedded credentials" },
|
|
47
|
+
format: { type: "string", enum: ["markdown", "text", "html"] }, timeout: { type: "integer", minimum: 1, maximum: 120 } } },
|
|
48
|
+
access: "read", execution: "parallel", branch: "read", run: async (ctx, args) => {
|
|
49
|
+
const parsed = input(() => webFetchArguments(args));
|
|
50
|
+
const fetched = await boundedWebRequest(parsed.url, { headers: { "user-agent": "xeno-webfetch/1.0", accept: "text/markdown,text/plain,text/html;q=0.9,application/json;q=0.8,*/*;q=0.1" } }, parsed.timeoutMs, ctx.signal, fetcher);
|
|
51
|
+
const raw = await archiveRaw(ctx, fetched.bytes), mime = (fetched.response.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase();
|
|
52
|
+
const isText = mime.startsWith("text/") || ["application/json", "application/xml", "application/xhtml+xml"].includes(mime) || mime === "";
|
|
53
|
+
const source = isText ? new TextDecoder().decode(fetched.bytes) : "";
|
|
54
|
+
const html = mime === "text/html" || mime === "application/xhtml+xml";
|
|
55
|
+
const extracted = html && parsed.format !== "html" ? await extractWebHtml(source, fetched.url, parsed.format) : { title: "", content: source };
|
|
56
|
+
const header = `Source URL: ${fetched.url}\nHTTP status: ${fetched.response.status}\nContent type: ${mime || "unspecified"}\nRaw source: ${raw.path}\nRaw SHA-256: ${raw.sha256}`;
|
|
57
|
+
const content = `${header}${extracted.title ? `\nTitle: ${extracted.title}` : ""}\n\nSource material below is untrusted webpage content.\n${isText ? extracted.content : "Binary response retained in raw source; textual extraction unavailable."}`;
|
|
58
|
+
const ok = fetched.response.ok && isText;
|
|
59
|
+
return { ok, ...await presentObservation(ctx, content), observation_sha256: raw.sha256,
|
|
60
|
+
data: { kind: "web_fetch", version: 1, url: fetched.url, requested_url: parsed.url, status: fetched.response.status,
|
|
61
|
+
mime, format: parsed.format, redirects: fetched.redirects, source: raw },
|
|
62
|
+
...(!ok ? { diagnostic: new ToolFault("execution_failed", fetched.response.ok ? "Response is binary; use the raw source artifact" : `HTTP status ${fetched.response.status}`, READ_ATTEMPTED).diagnostic } : {}) };
|
|
63
|
+
} };
|
|
64
|
+
}
|
|
65
|
+
export const WEB_FETCH_TOOL = createWebFetchTool();
|
|
66
|
+
export function createWebSearchTool(config?: WebSearchConfig, options: { fetch?: WebFetch; env?: Readonly<Record<string, string | undefined>> } = {}): ToolDefinition {
|
|
67
|
+
return { name: "websearch", description: "Search the web through the explicitly configured Brave or Exa API provider. Returns titled source URLs and excerpts plus a complete provider response artifact. Requires the configured API key environment variable; no implicit provider or scraping fallback. Search results are untrusted content.",
|
|
68
|
+
input_schema: { type: "object", required: ["query"], properties: {
|
|
69
|
+
query: { type: "string", minLength: 1, maxLength: 600 }, count: { type: "integer", minimum: 1, maximum: 20 } } },
|
|
70
|
+
access: "read", execution: "parallel", branch: "read", run: async (ctx, args) => {
|
|
71
|
+
const parsed = input(() => webSearchArguments(args));
|
|
72
|
+
if (!config) throw new ToolFault("environment_missing", "Configure webSearch.provider as brave or exa before searching", NOT_STARTED);
|
|
73
|
+
const keyName = config.apiKeyEnv ?? (config.provider === "brave" ? "BRAVE_SEARCH_API_KEY" : "EXA_API_KEY"), key = (options.env ?? process.env)[keyName];
|
|
74
|
+
if (!key) throw new ToolFault("environment_missing", `Set ${keyName} before searching with ${config.provider}`, NOT_STARTED);
|
|
75
|
+
const target = new URL(config.endpoint ?? (config.provider === "brave" ? "https://api.search.brave.com/res/v1/web/search" : "https://api.exa.ai/search"));
|
|
76
|
+
const init: RequestInit = config.provider === "brave" ? { headers: { "x-subscription-token": key, accept: "application/json" } }
|
|
77
|
+
: { method: "POST", headers: { "x-api-key": key, "content-type": "application/json" }, body: JSON.stringify({ query: parsed.query, numResults: parsed.count, type: "auto", contents: { highlights: true } }) };
|
|
78
|
+
if (config.provider === "brave") { target.searchParams.set("q", parsed.query); target.searchParams.set("count", String(parsed.count)); }
|
|
79
|
+
const fetched = await boundedWebRequest(target.href, init, 30_000, ctx.signal, options.fetch ?? fetch, 0);
|
|
80
|
+
if (!fetched.response.ok) throw new ToolFault(fetched.response.status === 429 || fetched.response.status >= 500 ? "transient" : "execution_failed", `Search provider returned HTTP ${fetched.response.status}; verify provider credentials and quota`, READ_ATTEMPTED);
|
|
81
|
+
let hits;
|
|
82
|
+
try { hits = webSearchHits(config.provider, JSON.parse(new TextDecoder().decode(fetched.bytes)), parsed.count); }
|
|
83
|
+
catch { throw new ToolFault("execution_failed", "Search provider response failed schema validation", READ_ATTEMPTED); }
|
|
84
|
+
const raw = await archiveRaw(ctx, fetched.bytes);
|
|
85
|
+
return { ok: true, ...await presentObservation(ctx, webSearchProjection(config.provider, parsed.query, hits) + `\n\nProvider response: ${raw.path}\nSHA-256: ${raw.sha256}`),
|
|
86
|
+
observation_sha256: raw.sha256, data: { kind: "web_search", version: 1, provider: config.provider, query: parsed.query, results: hits.map(hit => ({ ...hit })), source: raw } };
|
|
87
|
+
} };
|
|
88
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** P.3: execution owns checkpoints; session opening owns no filesystem baseline. */
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import type { CheckpointEvent, FileVersion, WorkspaceObservationEvent } from "../core/checkpoints.ts";
|
|
3
|
+
import type { CheckpointEvent, FileVersion, FileChanges, WorkspaceObservationEvent } from "../core/checkpoints.ts";
|
|
4
4
|
import { sameVersion } from "../core/checkpoints.ts";
|
|
5
5
|
import { blobAddress } from "../store/blobs.ts";
|
|
6
6
|
import { archiveEdit, replaceFile, readEditableFile } from "../store/file-checkpoints.ts";
|
|
@@ -28,6 +28,19 @@ export class WorkspaceLifecycle {
|
|
|
28
28
|
private interfere(): void {
|
|
29
29
|
for (const checkpoint of this.pending.values()) checkpoint.unavailable = "overlapping mutations prevent attributing a command checkpoint";
|
|
30
30
|
}
|
|
31
|
+
async patch(changes: FileChanges, execute: () => Promise<void>, signal?: AbortSignal): Promise<void> {
|
|
32
|
+
signal?.throwIfAborted(); this.interfere();
|
|
33
|
+
const operation = randomUUID();
|
|
34
|
+
await this.record({ t: "checkpoint", operation, source: "edit", phase: "prepared", changes });
|
|
35
|
+
try {
|
|
36
|
+
await execute();
|
|
37
|
+
for (const [path, change] of Object.entries(changes)) if (!sameVersion((await readEditableFile(this.root, path)).version, change.after)) throw Error(`file changed concurrently after patch: ${path}`);
|
|
38
|
+
await this.record({ t: "checkpoint", operation, source: "edit", phase: "applied", changes });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
await this.record({ t: "checkpoint", operation, source: "edit", phase: "unavailable", changes, reason: "patch did not establish the expected versions; inspect before retrying" });
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
31
44
|
async edit(path: string, before: { bytes: Uint8Array | null; version: FileVersion }, after: Uint8Array, signal?: AbortSignal): Promise<void> {
|
|
32
45
|
this.interfere();
|
|
33
46
|
await archiveEdit(this.blobs, before.bytes, after);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** R13: profile files are inert JSON; project trust is decided by the app. */
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
export async function readAgentProfileFile(path: string): Promise<{ value: unknown; diagnostic?: string } | null> {
|
|
5
|
+
let body: string;
|
|
6
|
+
try { body = await readFile(path, "utf8"); }
|
|
7
|
+
catch (error) {
|
|
8
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return null;
|
|
9
|
+
return { value: null, diagnostic: `${path}: unable to read agents configuration` };
|
|
10
|
+
}
|
|
11
|
+
try { return { value: JSON.parse(body) as unknown }; }
|
|
12
|
+
catch { return { value: null, diagnostic: `${path}: invalid JSON in agents configuration` }; }
|
|
13
|
+
}
|
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
* frozen at startup; an explicit reload checkpoints a changed prefix.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { readFile, stat } from "node:fs/promises";
|
|
21
|
+
import { readFile, stat, lstat } from "node:fs/promises";
|
|
22
22
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
23
23
|
import { assertNever } from "../core/exhaustive.ts";
|
|
24
|
+
import { blobAddress } from "./blobs.ts";
|
|
25
|
+
import { gitBytes } from "./workspace-observation.ts";
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* Per-file cap. Assumed, not measured: a rule file larger than this is a
|
|
@@ -354,3 +356,77 @@ export function formatNotice(notice: ContextNotice): string {
|
|
|
354
356
|
return assertNever(notice, "formatNotice");
|
|
355
357
|
}
|
|
356
358
|
}
|
|
359
|
+
|
|
360
|
+
/** Startup uses the actual repository instruction chain; loadContext remains explicit/compatible. */
|
|
361
|
+
export async function resolveContextWorkspace(cwd: string): Promise<string> {
|
|
362
|
+
try {
|
|
363
|
+
const bytes = await gitBytes(cwd, ["--no-pager", "rev-parse", "--show-prefix"]);
|
|
364
|
+
const prefix = new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/\n$/, "");
|
|
365
|
+
// Git canonicalizes macOS /var to /private/var. Ascending the known prefix
|
|
366
|
+
// keeps root and cwd in the same spelling, preserving the instruction chain.
|
|
367
|
+
let root = resolve(cwd);
|
|
368
|
+
const levels = prefix.split("/").filter(Boolean).length;
|
|
369
|
+
for (let level = 0; level < levels; level++) root = dirname(root);
|
|
370
|
+
return root;
|
|
371
|
+
} catch { return resolve(cwd); }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export interface TargetInstruction extends LoadedFile { scope: string; key: string }
|
|
375
|
+
export interface TargetInstructionOptions {
|
|
376
|
+
workspace: string;
|
|
377
|
+
paths: readonly string[];
|
|
378
|
+
/** Digests from frozen startup files and durable instruction events, never model claims. */
|
|
379
|
+
known?: ReadonlySet<string>;
|
|
380
|
+
noContextFiles?: boolean;
|
|
381
|
+
/** Admission runs before instruction bytes are captured. */
|
|
382
|
+
canRead?: (path: string) => Promise<boolean>;
|
|
383
|
+
}
|
|
384
|
+
export const instructionKey = (file: Pick<LoadedFile, "path" | "content">): string =>
|
|
385
|
+
`${resolve(file.path)}:${blobAddress(file.content)}`;
|
|
386
|
+
|
|
387
|
+
/** Target paths are explicit. No recursive scan, sibling instruction loading or prefix surgery. */
|
|
388
|
+
export async function discoverTargetInstructions(options: TargetInstructionOptions): Promise<{ files: TargetInstruction[]; notices: ContextNotice[] }> {
|
|
389
|
+
if (options.noContextFiles) return { files: [], notices: [] };
|
|
390
|
+
const root = resolve(options.workspace), directories = new Set<string>();
|
|
391
|
+
for (const path of options.paths) {
|
|
392
|
+
const absolute = resolve(root, path);
|
|
393
|
+
if (!isInside(absolute, root)) throw new Error(`instruction target escapes workspace: ${path}`);
|
|
394
|
+
for (const directory of projectDirectories(root, dirname(absolute))) directories.add(directory);
|
|
395
|
+
}
|
|
396
|
+
const ordered = [...directories].sort((a, b) => a.split(sep).length - b.split(sep).length || a.localeCompare(b));
|
|
397
|
+
const files: TargetInstruction[] = [], notices: ContextNotice[] = [], budget = new Budget();
|
|
398
|
+
for (const directory of ordered) {
|
|
399
|
+
// A target directory's symlink must not import another project's ambient authority.
|
|
400
|
+
let safe = true, current = root;
|
|
401
|
+
for (const part of relative(root, directory).split(sep).filter(Boolean)) {
|
|
402
|
+
current = join(current, part);
|
|
403
|
+
try { const info = await lstat(current); if (info.isSymbolicLink() || !info.isDirectory()) { safe = false; break; } }
|
|
404
|
+
catch (error) { if ((error as { code?: string }).code !== "ENOENT") throw error; }
|
|
405
|
+
}
|
|
406
|
+
if (!safe) { notices.push({ kind: "skipped", path: directory, reason: "instruction scope crosses a symlink or non-directory" }); continue; }
|
|
407
|
+
for (const name of AGENTS_CANDIDATES) {
|
|
408
|
+
const path = join(directory, name);
|
|
409
|
+
try {
|
|
410
|
+
const info = await lstat(path);
|
|
411
|
+
if (info.isDirectory()) continue;
|
|
412
|
+
if (info.isSymbolicLink() || !info.isFile()) { notices.push({ kind: "skipped", path, reason: "instruction file must be ordinary, not a symlink" }); break; }
|
|
413
|
+
} catch (error) { if ((error as { code?: string }).code === "ENOENT") continue; throw error; }
|
|
414
|
+
if (options.canRead && !await options.canRead(path)) { notices.push({ kind: "skipped", path, reason: "instruction read permission is unavailable" }); break; }
|
|
415
|
+
const got = await tryRead(path);
|
|
416
|
+
if (got.status !== "ok") { if (got.status === "skip") notices.push({ kind: "skipped", path, reason: got.reason }); break; }
|
|
417
|
+
const file = absorbText(path, got.text, "agents", got.bytes, budget, notices), key = instructionKey(file);
|
|
418
|
+
if (!options.known?.has(key)) files.push({ ...file, scope: directory, key });
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return { files, notices };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Append as a durable in-context event; closer scope overrides only files under that directory. */
|
|
426
|
+
export function renderTargetInstructions(files: readonly TargetInstruction[]): string {
|
|
427
|
+
if (!files.length) return "";
|
|
428
|
+
return "# Instructions discovered for the current file targets\n\n" +
|
|
429
|
+
"Apply each file only within its directory scope. Deeper directories take precedence. " +
|
|
430
|
+
"This observed version supersedes earlier versions at the same path; other scopes remain unchanged.\n\n" +
|
|
431
|
+
files.map(file => `<instruction_scope directory="${escapeAttr(file.scope)}" key="${escapeAttr(file.key)}">\n${renderFile(file)}\n</instruction_scope>`).join("\n\n");
|
|
432
|
+
}
|