@visual-z/xeno 0.7.2 → 0.8.1
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 +60 -0
- package/README.md +36 -9
- package/docs/foundations.md +1 -1
- package/package.json +3 -1
- package/src/apps/cli.ts +4 -3
- package/src/apps/commands.ts +8 -0
- package/src/apps/goal-flow.ts +70 -0
- package/src/apps/render.ts +0 -2
- package/src/apps/session-hub.ts +6 -5
- package/src/apps/session-lifecycle.ts +64 -0
- package/src/apps/shell.ts +89 -34
- package/src/apps/startup.ts +32 -31
- package/src/apps/ui/app.ts +105 -13
- package/src/apps/ui/control-model.ts +18 -1
- package/src/apps/ui/surface.ts +2 -0
- package/src/apps/update-check.ts +1 -1
- package/src/core/acceptance.ts +41 -9
- package/src/core/blind-checks.ts +72 -0
- package/src/core/branch-receipt.ts +3 -1
- package/src/core/checkpoints.ts +21 -0
- package/src/core/churn.ts +2 -2
- package/src/core/completion-review.ts +18 -11
- package/src/core/continuation.ts +11 -10
- package/src/core/control-view.ts +5 -8
- package/src/core/deadline.ts +2 -1
- package/src/core/derive.ts +0 -7
- package/src/core/environment.ts +2 -2
- package/src/core/event-validation.ts +19 -6
- package/src/core/events.ts +41 -32
- package/src/core/evidence-ladder.ts +62 -0
- package/src/core/evidence-links.ts +0 -10
- package/src/core/evidence.ts +1 -2
- package/src/core/export.ts +6 -11
- package/src/core/goal-view.ts +64 -0
- package/src/core/goal.ts +200 -0
- package/src/core/history.ts +1 -11
- package/src/core/json-event.ts +0 -6
- package/src/core/outcome.ts +0 -1
- package/src/core/program-status.ts +11 -2
- package/src/core/progress.ts +14 -8
- package/src/core/project.ts +6 -16
- package/src/core/read-bounds.ts +11 -2
- package/src/core/read-observation.ts +24 -0
- package/src/core/request-budget.ts +13 -3
- package/src/core/request-state.ts +178 -19
- package/src/core/response-recovery.ts +15 -2
- package/src/core/session-entry.ts +8 -3
- package/src/core/session-exit.ts +19 -3
- package/src/core/session-format.ts +169 -0
- package/src/core/settings.ts +7 -27
- package/src/core/stall.ts +30 -7
- package/src/core/stop-report.ts +4 -3
- package/src/core/transcript.ts +22 -2
- package/src/runtime/blind-check.ts +36 -0
- package/src/runtime/counter-check.ts +74 -0
- package/src/runtime/exec-sessions.ts +11 -0
- package/src/runtime/foundation.ts +46 -8
- package/src/runtime/loop.ts +239 -430
- package/src/runtime/oauth.ts +1 -3
- package/src/runtime/openai.ts +2 -2
- package/src/runtime/pi-provider.ts +3 -3
- package/src/runtime/provider-request.ts +2 -1
- package/src/runtime/provider.ts +33 -3
- package/src/runtime/retry.ts +9 -2
- package/src/runtime/runtime-target.ts +2 -2
- package/src/runtime/session-control.ts +4 -4
- package/src/runtime/tools.ts +44 -42
- package/src/runtime/usage.ts +28 -0
- package/src/runtime/version.ts +12 -0
- package/src/runtime/workspace-lifecycle.ts +31 -0
- package/src/store/blind-checks.ts +32 -0
- package/src/store/checkpoint-history.ts +1 -12
- package/src/store/file-index.ts +88 -7
- package/src/store/log.ts +25 -27
- package/src/store/recovery.ts +1 -3
- package/src/store/replay.ts +2 -2
- package/src/store/restore.ts +10 -202
- package/src/store/search.ts +7 -5
- package/src/store/session-archive.ts +0 -1
- package/src/store/sessions.ts +31 -33
- package/src/store/workspace-clone.ts +42 -0
- package/src/store/workspace.ts +10 -157
- package/src/tui/ui/duration.ts +16 -0
- package/src/tui/ui/layout.ts +4 -4
- package/src/tui/ui/screen.ts +13 -7
- package/src/tui/ui/status.ts +99 -17
- package/src/core/benchmark.ts +0 -195
- package/src/core/evaluation-coverage.ts +0 -74
- package/src/core/experiment.ts +0 -173
- package/src/core/harness-comparison.ts +0 -90
- package/src/core/harness-diagnostics.ts +0 -206
- package/src/core/native-eval.ts +0 -25
- package/src/core/policy-evaluation.ts +0 -55
- package/src/core/query-eval-contract.ts +0 -14
- package/src/core/recall-analysis.ts +0 -90
- package/src/core/recall-trial.ts +0 -153
- package/src/core/recovery-corpus.ts +0 -17
- package/src/core/recovery-evaluation.ts +0 -60
- package/src/core/tool-mode-protocol.ts +0 -25
- package/src/core/trial-lifecycle.ts +0 -14
- package/src/core/window-trial.ts +0 -65
- package/src/index.ts +0 -10
- package/src/runtime/benchmark.ts +0 -187
- package/src/runtime/harness-comparison.ts +0 -376
- package/src/runtime/harness-eval.ts +0 -462
- package/src/runtime/native-eval-fixtures.ts +0 -33
- package/src/runtime/native-eval.ts +0 -146
- package/src/runtime/query-eval-verifier.ts +0 -28
- package/src/runtime/recall-trial.ts +0 -226
- package/src/runtime/recovery-coverage.ts +0 -71
- package/src/runtime/recovery-evidence.ts +0 -76
- package/src/runtime/recovery-fixtures.ts +0 -116
- package/src/runtime/tool-mode-eval.ts +0 -146
- package/src/runtime/trial-control.ts +0 -45
- package/src/runtime/window-trial.ts +0 -141
- package/src/store/git-branch.ts +0 -35
- package/src/tui/ui/testing.ts +0 -5
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S.3: a session directory holds exactly one on-disk format, and this build
|
|
3
|
+
* reads only that one. A directory written in any other format is refused
|
|
4
|
+
* with its location -- never reinterpreted under today's defaults. Reading old
|
|
5
|
+
* logs leniently is how a resumed session once ran without its stall watchdog:
|
|
6
|
+
* new sessions wrote stallLimit 12 while the resume path restored an absent
|
|
7
|
+
* field as 0.
|
|
8
|
+
*
|
|
9
|
+
* The format covers the metadata journal (whose first record declares it),
|
|
10
|
+
* the frozen session context parsed below, events and handoffs. Changing any
|
|
11
|
+
* of them incompatibly means bumping SESSION_FORMAT, not adding a fallback.
|
|
12
|
+
*/
|
|
13
|
+
import { parseAcceptanceContract, type AcceptanceContract } from "./acceptance.ts";
|
|
14
|
+
import type { BoundaryConfig } from "./boundary.ts";
|
|
15
|
+
import { isContextPolicy, type ContextPolicy } from "./context-policy.ts";
|
|
16
|
+
import type { FrozenPrefix } from "./project.ts";
|
|
17
|
+
import { validateOutputRecoveryLimit } from "./response-recovery.ts";
|
|
18
|
+
import { schedulingWindow } from "./scheduling.ts";
|
|
19
|
+
import type { Skill } from "./skills.ts";
|
|
20
|
+
import { isBlindChecks, type BlindChecks } from "./blind-checks.ts";
|
|
21
|
+
import { emptyStallState, stallDecision } from "./stall.ts";
|
|
22
|
+
|
|
23
|
+
export const SESSION_FORMAT = 1;
|
|
24
|
+
|
|
25
|
+
export type FoundFormat = number | "undeclared";
|
|
26
|
+
|
|
27
|
+
export function sessionFormatMessage(dir: string, found: FoundFormat): string {
|
|
28
|
+
const written = found === "undeclared" ? "a layout from before session formats were declared" : `session format ${found}`;
|
|
29
|
+
return `${dir} holds ${written}; this xeno reads session format ${SESSION_FORMAT} only. ` +
|
|
30
|
+
"The log is intact and was not modified. Start a new session with --fresh.";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Raised before any read or write of a directory in another format. */
|
|
34
|
+
export class SessionFormatError extends Error {
|
|
35
|
+
readonly dir: string;
|
|
36
|
+
readonly found: FoundFormat;
|
|
37
|
+
constructor(dir: string, found: FoundFormat) {
|
|
38
|
+
super(sessionFormatMessage(dir, found));
|
|
39
|
+
this.name = "SessionFormatError";
|
|
40
|
+
this.dir = dir;
|
|
41
|
+
this.found = found;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What a session freezes at start and restores on resume. Required fields
|
|
47
|
+
* are written by every session of this format, so resume restores them as
|
|
48
|
+
* recorded; there is no default to drift from what was written.
|
|
49
|
+
*/
|
|
50
|
+
export interface SessionContext {
|
|
51
|
+
frozen: FrozenPrefix;
|
|
52
|
+
gates: Record<string, string>;
|
|
53
|
+
shellCheckpoints: boolean;
|
|
54
|
+
skills: readonly Skill[];
|
|
55
|
+
strictVerification: boolean;
|
|
56
|
+
workingSet: boolean;
|
|
57
|
+
workNamespace: string;
|
|
58
|
+
maxParallelTools: number;
|
|
59
|
+
contextPolicy: ContextPolicy;
|
|
60
|
+
stallLimit: number;
|
|
61
|
+
outputRecoveryLimit: number;
|
|
62
|
+
acceptanceContract?: AcceptanceContract;
|
|
63
|
+
workflowMode?: "build" | "plan";
|
|
64
|
+
agentProfile?: { name: string; workflowExplicit: boolean };
|
|
65
|
+
workflowToolProfile?: "foundation";
|
|
66
|
+
boundary?: Partial<BoundaryConfig>;
|
|
67
|
+
guidance?: boolean;
|
|
68
|
+
blindChecks?: BlindChecks;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const REQUIRED = ["frozen", "gates", "shellCheckpoints", "skills", "strictVerification", "workingSet",
|
|
72
|
+
"workNamespace", "maxParallelTools", "contextPolicy", "stallLimit", "outputRecoveryLimit"] as const;
|
|
73
|
+
const OPTIONAL = ["acceptanceContract", "workflowMode", "agentProfile", "workflowToolProfile", "boundary", "guidance", "blindChecks"] as const;
|
|
74
|
+
const BOUNDARY_KEYS = ["triggerTokens", "hardTokens", "minNodeWorkTokens"] as const;
|
|
75
|
+
|
|
76
|
+
const record = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
77
|
+
const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === "string");
|
|
78
|
+
|
|
79
|
+
function invalid(field: string, why: string): never {
|
|
80
|
+
throw new Error(`invalid session context: ${field} ${why}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function skill(value: unknown, index: number): Skill {
|
|
84
|
+
const field = `skills[${index}]`;
|
|
85
|
+
if (!record(value)) invalid(field, "must be an object");
|
|
86
|
+
const text = (key: string): string => { const v = value[key]; if (typeof v !== "string") invalid(`${field}.${key}`, "must be a string"); return v; };
|
|
87
|
+
const disable = value["disableModelInvocation"];
|
|
88
|
+
if (typeof disable !== "boolean") invalid(`${field}.disableModelInvocation`, "must be a boolean");
|
|
89
|
+
const out: { -readonly [K in keyof Skill]: Skill[K] } = { name: text("name"), description: text("description"), filePath: text("filePath"), body: text("body"), disableModelInvocation: disable };
|
|
90
|
+
const invocable = value["userInvocable"];
|
|
91
|
+
if (invocable !== undefined) { if (typeof invocable !== "boolean") invalid(`${field}.userInvocable`, "must be a boolean"); out.userInvocable = invocable; }
|
|
92
|
+
if (value["context"] !== undefined) { if (value["context"] !== "fork") invalid(`${field}.context`, "must be fork"); out.context = "fork"; }
|
|
93
|
+
for (const key of ["allowedTools", "disallowedTools"] as const) {
|
|
94
|
+
const names = value[key];
|
|
95
|
+
if (names !== undefined) { if (!strings(names)) invalid(`${field}.${key}`, "must be strings"); out[key] = [...names]; }
|
|
96
|
+
}
|
|
97
|
+
if (value["sourceText"] !== undefined) out.sourceText = text("sourceText");
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Strict: every required field present and typed, no field this format does not define. */
|
|
102
|
+
export function parseSessionContext(value: unknown): SessionContext {
|
|
103
|
+
if (!record(value)) invalid("context", "must be an object");
|
|
104
|
+
for (const key of Object.keys(value)) {
|
|
105
|
+
if (!(REQUIRED as readonly string[]).includes(key) && !(OPTIONAL as readonly string[]).includes(key)) invalid(key, "is not part of this session format");
|
|
106
|
+
}
|
|
107
|
+
for (const key of REQUIRED) if (value[key] === undefined) invalid(key, "is missing");
|
|
108
|
+
|
|
109
|
+
const frozen = value["frozen"];
|
|
110
|
+
const system = record(frozen) ? frozen["system"] : undefined, tools = record(frozen) ? frozen["tools"] : undefined;
|
|
111
|
+
if (typeof system !== "string" || !Array.isArray(tools)) invalid("frozen", "must hold a system string and a tools array");
|
|
112
|
+
const gates = value["gates"], gateMap: Record<string, string> = {};
|
|
113
|
+
if (!record(gates)) invalid("gates", "must map names to commands");
|
|
114
|
+
for (const [name, command] of Object.entries(gates)) { if (typeof command !== "string") invalid(`gates.${name}`, "must be a command string"); gateMap[name] = command; }
|
|
115
|
+
const flag = (key: "shellCheckpoints" | "strictVerification" | "workingSet"): boolean => { const v = value[key]; if (typeof v !== "boolean") invalid(key, "must be a boolean"); return v; };
|
|
116
|
+
if (!Array.isArray(value["skills"])) invalid("skills", "must be an array");
|
|
117
|
+
const skills = value["skills"].map(skill);
|
|
118
|
+
const namespace = value["workNamespace"];
|
|
119
|
+
if (typeof namespace !== "string" || namespace.length === 0) invalid("workNamespace", "must be a non-empty string");
|
|
120
|
+
const limit = (key: "maxParallelTools" | "stallLimit" | "outputRecoveryLimit", check: (n: number) => void): number => {
|
|
121
|
+
const n = value[key];
|
|
122
|
+
if (typeof n !== "number") invalid(key, "must be a number");
|
|
123
|
+
try { check(n); } catch (error) { invalid(key, error instanceof Error ? `is out of range: ${error.message}` : "is out of range"); }
|
|
124
|
+
return n;
|
|
125
|
+
};
|
|
126
|
+
const maxParallelTools = limit("maxParallelTools", n => schedulingWindow([], 0, n));
|
|
127
|
+
const stallLimit = limit("stallLimit", n => stallDecision(emptyStallState(), n));
|
|
128
|
+
const outputRecoveryLimit = limit("outputRecoveryLimit", validateOutputRecoveryLimit);
|
|
129
|
+
const policy = value["contextPolicy"];
|
|
130
|
+
if (!isContextPolicy(policy)) invalid("contextPolicy", "is not a known policy");
|
|
131
|
+
|
|
132
|
+
const context: SessionContext = {
|
|
133
|
+
frozen: { system, tools }, gates: gateMap, shellCheckpoints: flag("shellCheckpoints"),
|
|
134
|
+
skills, strictVerification: flag("strictVerification"), workingSet: flag("workingSet"),
|
|
135
|
+
workNamespace: namespace, maxParallelTools, contextPolicy: policy, stallLimit, outputRecoveryLimit,
|
|
136
|
+
};
|
|
137
|
+
if (value["acceptanceContract"] !== undefined) {
|
|
138
|
+
try { context.acceptanceContract = parseAcceptanceContract(value["acceptanceContract"]); }
|
|
139
|
+
catch (error) { invalid("acceptanceContract", error instanceof Error ? `is invalid: ${error.message}` : "is invalid"); }
|
|
140
|
+
}
|
|
141
|
+
const mode = value["workflowMode"];
|
|
142
|
+
if (mode !== undefined) { if (mode !== "build" && mode !== "plan") invalid("workflowMode", "must be build or plan"); context.workflowMode = mode; }
|
|
143
|
+
const profile = value["agentProfile"];
|
|
144
|
+
if (profile !== undefined) {
|
|
145
|
+
const name = record(profile) ? profile["name"] : undefined, explicit = record(profile) ? profile["workflowExplicit"] : undefined;
|
|
146
|
+
if (!record(profile) || Object.keys(profile).length !== 2 || typeof name !== "string" || !/^[a-z][a-z0-9_-]{0,63}$/.test(name) || typeof explicit !== "boolean")
|
|
147
|
+
invalid("agentProfile", "must name a profile and whether its workflow was explicit");
|
|
148
|
+
context.agentProfile = { name, workflowExplicit: explicit };
|
|
149
|
+
}
|
|
150
|
+
if (value["workflowToolProfile"] !== undefined) {
|
|
151
|
+
if (value["workflowToolProfile"] !== "foundation") invalid("workflowToolProfile", "must be foundation");
|
|
152
|
+
context.workflowToolProfile = "foundation";
|
|
153
|
+
}
|
|
154
|
+
const boundary = value["boundary"];
|
|
155
|
+
if (boundary !== undefined) {
|
|
156
|
+
if (!record(boundary)) invalid("boundary", "must be an object");
|
|
157
|
+
const thresholds: Partial<BoundaryConfig> = {};
|
|
158
|
+
for (const [key, n] of Object.entries(boundary)) {
|
|
159
|
+
if (!(BOUNDARY_KEYS as readonly string[]).includes(key)) invalid(`boundary.${key}`, "is not a boundary threshold");
|
|
160
|
+
if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 0) invalid(`boundary.${key}`, "must be a non-negative integer");
|
|
161
|
+
thresholds[key as keyof BoundaryConfig] = n;
|
|
162
|
+
}
|
|
163
|
+
context.boundary = thresholds;
|
|
164
|
+
}
|
|
165
|
+
const guidance = value["guidance"], blind = value["blindChecks"];
|
|
166
|
+
if (guidance !== undefined) { if (typeof guidance !== "boolean") invalid("guidance", "must be a boolean"); context.guidance = guidance; }
|
|
167
|
+
if (blind !== undefined) { if (!isBlindChecks(blind)) invalid("blindChecks", "is not a frozen blind check set"); context.blindChecks = blind; }
|
|
168
|
+
return context;
|
|
169
|
+
}
|
package/src/core/settings.ts
CHANGED
|
@@ -7,7 +7,6 @@ export interface Settings {
|
|
|
7
7
|
/** Presentation only: no animation is allowed to advance a log or block input. */
|
|
8
8
|
readonly animations?: boolean;
|
|
9
9
|
readonly thinking?: string;
|
|
10
|
-
readonly statusBar?: boolean;
|
|
11
10
|
/** Look for a newer published xeno once a day and say so (docs/tasks/feat-update-check.md). Default on. */
|
|
12
11
|
readonly updateCheck?: boolean;
|
|
13
12
|
readonly hideThinkingBlock?: boolean;
|
|
@@ -25,11 +24,6 @@ export interface Settings {
|
|
|
25
24
|
readonly fullscreenExitOutput?: "transcript" | "resume-hint";
|
|
26
25
|
readonly fullscreenScrollbar?: "auto" | "always" | "hidden";
|
|
27
26
|
readonly fullscreenCopyOnSelect?: boolean;
|
|
28
|
-
readonly fullscreenWheelScrollLines?: "auto" | number;
|
|
29
|
-
readonly editorPaddingX?: number;
|
|
30
|
-
readonly outputPad?: 0 | 1;
|
|
31
|
-
readonly autocompleteMaxVisible?: number;
|
|
32
|
-
readonly showHardwareCursor?: boolean;
|
|
33
27
|
readonly quietStartup?: boolean;
|
|
34
28
|
readonly doubleEscapeAction?: "tree" | "fork" | "none";
|
|
35
29
|
readonly treeFilterMode?: "default" | "no-tools" | "user-only" | "labeled-only" | "all";
|
|
@@ -41,16 +35,15 @@ export interface Settings {
|
|
|
41
35
|
readonly packages?: readonly PackageDeclaration[];
|
|
42
36
|
readonly enableSkillCommands?: boolean;
|
|
43
37
|
readonly defaultProjectTrust?: "ask" | "always" | "never";
|
|
44
|
-
readonly terminal?: { readonly
|
|
38
|
+
readonly terminal?: { readonly showImages?: boolean; readonly imageWidthCells?: number;
|
|
45
39
|
/** How pictures are drawn; `auto` names them instead when the terminal can only draw block characters. */
|
|
46
40
|
readonly imageProtocol?: "auto" | "kitty" | "sixel" | "blocks" };
|
|
47
41
|
readonly images?: { readonly autoResize?: boolean; readonly blockImages?: boolean };
|
|
48
42
|
readonly markdown?: { readonly mermaid?: "off" | "final" | "streaming" };
|
|
49
43
|
}
|
|
50
|
-
export const SETTINGS_KEYS = ["theme", "animations", "thinking", "
|
|
44
|
+
export const SETTINGS_KEYS = ["theme", "animations", "thinking", "updateCheck", "hideThinkingBlock", "showCacheMissNotices", "defaultProvider", "defaultModel", "defaultThinking",
|
|
51
45
|
"modelThinkingLevels", "scopedModels", "recentModels", "steeringMode", "followUpMode", "tuiMode", "fullscreenExitOutput", "fullscreenScrollbar",
|
|
52
|
-
"fullscreenCopyOnSelect", "
|
|
53
|
-
"showHardwareCursor", "quietStartup", "doubleEscapeAction", "treeFilterMode", "externalEditor", "terminal", "images", "markdown",
|
|
46
|
+
"fullscreenCopyOnSelect", "quietStartup", "doubleEscapeAction", "treeFilterMode", "externalEditor", "terminal", "images", "markdown",
|
|
54
47
|
"skills", "prompts", "themes", "extensions", "packages", "enableSkillCommands", "defaultProjectTrust"] as const;
|
|
55
48
|
export type SettingsKey = (typeof SETTINGS_KEYS)[number];
|
|
56
49
|
export interface SettingsLayers { readonly builtin: Settings; readonly user?: Settings; readonly project?: Settings; readonly cli?: Settings }
|
|
@@ -60,23 +53,17 @@ type Rule = { readonly kind: "boolean" | "string" | "strings" } | { readonly kin
|
|
|
60
53
|
const levels: Rule = { kind: "enum", values: THINKING_LEVELS };
|
|
61
54
|
const delivery: Rule = { kind: "enum", values: ["all", "one-at-a-time"] };
|
|
62
55
|
const rules: Readonly<Record<string, Rule>> = {
|
|
63
|
-
theme: { kind: "string" }, animations: { kind: "boolean" }, thinking: levels,
|
|
56
|
+
theme: { kind: "string" }, animations: { kind: "boolean" }, thinking: levels, updateCheck: { kind: "boolean" },
|
|
64
57
|
hideThinkingBlock: { kind: "boolean" }, showCacheMissNotices: { kind: "boolean" },
|
|
65
58
|
defaultProvider: { kind: "string" }, defaultModel: { kind: "string" }, defaultThinking: levels,
|
|
66
59
|
scopedModels: { kind: "strings" }, recentModels: { kind: "strings" }, steeringMode: delivery, followUpMode: delivery,
|
|
67
60
|
tuiMode: { kind: "enum", values: ["auto", "regular", "fullscreen"] },
|
|
68
61
|
fullscreenExitOutput: { kind: "enum", values: ["transcript", "resume-hint"] },
|
|
69
62
|
fullscreenScrollbar: { kind: "enum", values: ["auto", "always", "hidden"] },
|
|
70
|
-
fullscreenCopyOnSelect: { kind: "boolean" },
|
|
71
|
-
// Pi's documented display bounds; these are not context/segment thresholds.
|
|
72
|
-
fullscreenWheelScrollLines: { kind: "integer", min: 1, max: 100, auto: true },
|
|
73
|
-
editorPaddingX: { kind: "integer", min: 0, max: 3 }, outputPad: { kind: "integer", min: 0, max: 1 },
|
|
74
|
-
autocompleteMaxVisible: { kind: "integer", min: 3, max: 20 },
|
|
75
|
-
showHardwareCursor: { kind: "boolean" }, quietStartup: { kind: "boolean" },
|
|
63
|
+
fullscreenCopyOnSelect: { kind: "boolean" }, quietStartup: { kind: "boolean" },
|
|
76
64
|
doubleEscapeAction: { kind: "enum", values: ["tree", "fork", "none"] },
|
|
77
65
|
treeFilterMode: { kind: "enum", values: ["default", "no-tools", "user-only", "labeled-only", "all"] },
|
|
78
|
-
externalEditor: { kind: "string" },
|
|
79
|
-
"terminal.showTerminalProgress": { kind: "boolean" },
|
|
66
|
+
externalEditor: { kind: "string" },
|
|
80
67
|
"terminal.showImages": { kind: "boolean" }, "terminal.imageWidthCells": { kind: "integer", min: 1, max: 240 },
|
|
81
68
|
"terminal.imageProtocol": { kind: "enum", values: ["auto", "kitty", "sixel", "blocks"] },
|
|
82
69
|
"images.autoResize": { kind: "boolean" }, "images.blockImages": { kind: "boolean" },
|
|
@@ -102,16 +89,9 @@ export const DISPLAY_SETTINGS = [
|
|
|
102
89
|
{ id: "fullscreenExitOutput", label: "Fullscreen exit output", fallback: "resume-hint" },
|
|
103
90
|
{ id: "fullscreenScrollbar", label: "Fullscreen scrollbar", fallback: "hidden" },
|
|
104
91
|
{ id: "fullscreenCopyOnSelect", label: "Copy on select", fallback: "on" },
|
|
105
|
-
{ id: "fullscreenWheelScrollLines", label: "Fullscreen wheel scroll lines", fallback: "auto" },
|
|
106
|
-
{ id: "editorPaddingX", label: "Editor padding", fallback: "0" },
|
|
107
|
-
{ id: "outputPad", label: "Output padding", fallback: "1" },
|
|
108
|
-
{ id: "autocompleteMaxVisible", label: "Autocomplete rows", fallback: "5" },
|
|
109
|
-
{ id: "showHardwareCursor", label: "Hardware cursor", fallback: "on" },
|
|
110
92
|
{ id: "quietStartup", label: "Quiet startup", fallback: "off" },
|
|
111
93
|
{ id: "doubleEscapeAction", label: "Double Escape", fallback: "tree" },
|
|
112
94
|
{ id: "treeFilterMode", label: "Tree filter", fallback: "default" },
|
|
113
|
-
{ id: "terminal.clearOnShrink", label: "Clear on shrink", fallback: "off" },
|
|
114
|
-
{ id: "terminal.showTerminalProgress", label: "Terminal progress", fallback: "on" },
|
|
115
95
|
] as const;
|
|
116
96
|
/** Only fields with a real picker may be suggested without a value. Raw config keys remain writable explicitly. */
|
|
117
97
|
export function interactiveSettingKeys(): string[] {
|
|
@@ -120,7 +100,7 @@ export function interactiveSettingKeys(): string[] {
|
|
|
120
100
|
.filter(key => pickers.includes(key) || settingValues(key).length > 0);
|
|
121
101
|
}
|
|
122
102
|
|
|
123
|
-
export function defaultSettings(): Settings { return { theme: "dark", thinking: "high"
|
|
103
|
+
export function defaultSettings(): Settings { return { theme: "dark", thinking: "high" }; }
|
|
124
104
|
export function mergeSettings(layers: SettingsLayers): Settings {
|
|
125
105
|
const { defaultProjectTrust: _projectCannotGrantTrust, ...project } = layers.project ?? {};
|
|
126
106
|
const all = [layers.builtin, layers.user ?? {}, project, layers.cli ?? {}];
|
package/src/core/stall.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { pendingToolCalls } from "./execution.ts";
|
|
|
4
4
|
import { exchangeProgressChecks } from "./evidence.ts";
|
|
5
5
|
import { deriveProgress, type ProgressEvidence } from "./progress.ts";
|
|
6
6
|
import { isProcessReceipt } from "./process-receipt.ts";
|
|
7
|
+
import { canonicalValue } from "./continuation.ts";
|
|
7
8
|
|
|
8
9
|
// A conservative hypothesis: enough room to change method after a mid-budget
|
|
9
10
|
// notice, while bounding repeated paid requests. Not a task-quality classifier.
|
|
@@ -17,20 +18,42 @@ export interface StallState {
|
|
|
17
18
|
failures: number;
|
|
18
19
|
emptyWaits: number;
|
|
19
20
|
warned: boolean;
|
|
21
|
+
/** The previous exchange's tool calls, canonical; empty after real user input. */
|
|
22
|
+
previous: string;
|
|
20
23
|
}
|
|
21
24
|
export function emptyStallState(): StallState {
|
|
22
|
-
return { evidence: [], pending: [], pendingNode: 0, consecutive: 0, unproductive: 0, failures: 0, emptyWaits: 0, warned: false };
|
|
25
|
+
return { evidence: [], pending: [], pendingNode: 0, consecutive: 0, unproductive: 0, failures: 0, emptyWaits: 0, warned: false, previous: "" };
|
|
23
26
|
}
|
|
27
|
+
|
|
28
|
+
/** Name and arguments of every call, order-independent: what the model asked for, not what came back. */
|
|
29
|
+
function exchangeSignature(assistant: Extract<LogEvent, { t: "assistant" }>): string {
|
|
30
|
+
return assistant.blocks.flatMap(block => block.type === "tool_use" ? [`${block.name}:${canonicalValue(block.input)}`] : []).sort().join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
export function finishStallExchange(state: StallState): StallState {
|
|
25
34
|
const assistant = state.pending[0];
|
|
26
35
|
if (assistant?.t !== "assistant" || pendingToolCalls(state.pending).length > 0) return state;
|
|
27
36
|
if (!assistant.blocks.some((block) => block.type === "tool_use")) return { ...state, pending: [] };
|
|
28
|
-
const
|
|
29
|
-
const
|
|
37
|
+
const checks = exchangeProgressChecks(state.pending);
|
|
38
|
+
const progress = deriveProgress(state.evidence, state.pending, state.pendingNode, checks);
|
|
39
|
+
const signature = exchangeSignature(assistant);
|
|
30
40
|
const waits = new Set(assistant.blocks.flatMap(block => block.type === "tool_use" && block.name === "write_stdin" ? [block.call_id] : []));
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
// S.6 item 2: with no request cap this ledger is the only backstop. Asking
|
|
42
|
+
// for exactly what the previous exchange asked for is a rerun on an
|
|
43
|
+
// unchanged premise, so output that differs only by timing (a failing test's
|
|
44
|
+
// "[123ms]") is not news. Reads and edits are content-addressed and keep
|
|
45
|
+
// their own rules; waiting on a running process is not a rerun. Only the
|
|
46
|
+
// immediately previous exchange: an edit, in any tool, between two runs is
|
|
47
|
+
// what makes the second one worth running (Gemini, Cline, OpenCode and Roo
|
|
48
|
+
// detect identical consecutive calls the same way).
|
|
49
|
+
const rerun = signature === state.previous
|
|
50
|
+
? deriveProgress(state.evidence, state.pending.filter(event => event.t !== "tool_result" || waits.has(event.call_id) ||
|
|
51
|
+
(event.effect?.kind !== "command" && !isProcessReceipt(event.data))), state.pendingNode, checks).progressed
|
|
52
|
+
: progress.progressed;
|
|
53
|
+
const results = state.pending.filter(event => event.t === "tool_result");
|
|
54
|
+
return { ...state, pending: [], evidence: progress.evidence, previous: signature,
|
|
55
|
+
consecutive: rerun ? 0 : state.consecutive + 1,
|
|
56
|
+
unproductive: state.unproductive + (rerun ? 0 : 1),
|
|
34
57
|
failures: state.failures + results.filter(result => !result.ok).length,
|
|
35
58
|
emptyWaits: state.emptyWaits + results.filter(result => waits.has(result.call_id) && isProcessReceipt(result.data) &&
|
|
36
59
|
result.data.running === true && result.data.cursor !== undefined && result.data.cursor.start === result.data.cursor.end).length };
|
|
@@ -42,7 +65,7 @@ export function advanceStall(state: StallState, entries: readonly { node: number
|
|
|
42
65
|
for (const { node, event } of entries) {
|
|
43
66
|
if (event.t === "assistant" || event.t === "user" || event.t === "cut" || (event.t === "guidance" && event.reason === "stall")) {
|
|
44
67
|
result = finishStallExchange(result);
|
|
45
|
-
if (event.t === "user") result = { ...result, pending: [], consecutive: 0, unproductive: 0, failures: 0, emptyWaits: 0, warned: false };
|
|
68
|
+
if (event.t === "user") result = { ...result, pending: [], consecutive: 0, unproductive: 0, failures: 0, emptyWaits: 0, warned: false, previous: "" };
|
|
46
69
|
if (event.t === "assistant") result = { ...result, pending: [event], pendingNode: node };
|
|
47
70
|
if (event.t === "guidance") result = { ...result, warned: true };
|
|
48
71
|
} else if (result.pending.length > 0) result = { ...result, pending: [...result.pending, event] };
|
package/src/core/stop-report.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
import type { LogEvent } from "./events.ts";
|
|
3
3
|
import { isProcessReceipt, latestProcessReceipt } from "./process-receipt.ts";
|
|
4
4
|
import { exitLine } from "./session-exit.ts";
|
|
5
|
+
import { currentRequestEvents } from "./request-state.ts";
|
|
5
6
|
|
|
6
7
|
export function stopReport(events: readonly LogEvent[], requests: number, limit: number, cause: string): string {
|
|
7
|
-
const
|
|
8
|
-
const current =
|
|
8
|
+
const scoped = currentRequestEvents(events);
|
|
9
|
+
const current = scoped.length > 0 ? scoped : events;
|
|
9
10
|
const results = current.filter(event => event.t === "tool_result");
|
|
10
11
|
const failures = results.filter(event => !event.ok);
|
|
11
12
|
const exits = results.filter(event => event.effect?.kind === "command" && event.effect.exit === 0).length;
|
|
@@ -16,7 +17,7 @@ export function stopReport(events: readonly LogEvent[], requests: number, limit:
|
|
|
16
17
|
}
|
|
17
18
|
const running = [...ids].filter(id => latestProcessReceipt([{ node: 0, events: current }], id)?.receipt.running === true);
|
|
18
19
|
return [
|
|
19
|
-
`Stopped: ${exitLine(cause)} (${requests}/${limit} model requests).`,
|
|
20
|
+
`Stopped: ${exitLine(cause)} (${limit === Infinity ? requests : `${requests}/${limit}`} model requests).`,
|
|
20
21
|
`Recorded evidence: ${results.length} tool receipts; ${exits} successful command exits; ${failures.length} failed tool receipts. Task completion is not verified by this stop.`,
|
|
21
22
|
...(failures.length ? [`Inspect failed receipts: ${failures.slice(-3).map(event => exitLine(event.id, 80)).join(", ")}.`] : []),
|
|
22
23
|
...(running.length ? [`Last observed running: ${running.slice(0, 3).join(", ")}${running.length > 3 ? ` (+${running.length - 3})` : ""}; inspect the process receipt before replaying a command.`] : []),
|
package/src/core/transcript.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Human history is a separate pure log projection from the model's bounded context (D1, D8). */
|
|
2
|
-
import type { LogEvent } from "./events.ts";
|
|
2
|
+
import type { CounterCheckEvent, LogEvent } from "./events.ts";
|
|
3
|
+
import { counterVerdict } from "./acceptance.ts";
|
|
3
4
|
import { assertNever } from "./exhaustive.ts";
|
|
4
5
|
import type { ImageRef } from "./images.ts";
|
|
5
6
|
|
|
@@ -74,8 +75,14 @@ export function projectTranscript(events: readonly LogEvent[], entryTypes: Reado
|
|
|
74
75
|
case "acceptance_contract": items.push({ ...identity, kind: "notice", text: `Acceptance contract · ${event.contract.id}` }); break;
|
|
75
76
|
case "target_observation": items.push({ ...identity, kind: "notice", text: `Target ${event.observation.target} · ${event.observation.state} · ${event.observation.reason}` }); break;
|
|
76
77
|
case "acceptance_decision": items.push({ ...identity, kind: "notice", text: `User accepted · ${event.criterion}` }); break;
|
|
78
|
+
case "counter_check": items.push({ ...identity, kind: "notice", text: counterNotice(event) }); break;
|
|
79
|
+
case "blind_check": items.push({ ...identity, kind: "notice", text: "reason" in event ? `Blind check ${event.check} could not run: ${event.reason}`
|
|
80
|
+
: `Blind check ${event.check} ${event.exit === 0 ? "passed" : `failed (exit ${event.exit})`}${event.exposed ? " · the model touched the blind checks, so this is not independent" : ""}` }); break;
|
|
81
|
+
case "goal": items.push({ ...identity, kind: "notice", text: event.action === "set"
|
|
82
|
+
? `Goal set · ${event.accepted.length} accepted${event.rejected.length ? `, ${event.rejected.length} rejected` : ""} · ${event.objective}`
|
|
83
|
+
: event.reason === "met" ? "Goal met · every accepted criterion has current evidence" : "Goal cleared" }); break;
|
|
77
84
|
case "checkpoint": case "workspace_observation":
|
|
78
|
-
case "guidance": case "
|
|
85
|
+
case "guidance": case "claim": case "decision": case "pitfall": case "blocked": case "question": case "next_action": case "work_status": case "work_owner": case "work_dependencies": case "evidence_link":
|
|
79
86
|
// These remain inspectable via /state and /export; they are not chat messages.
|
|
80
87
|
break;
|
|
81
88
|
default: assertNever(event, "projectTranscript");
|
|
@@ -83,3 +90,16 @@ export function projectTranscript(events: readonly LogEvent[], entryTypes: Reado
|
|
|
83
90
|
}
|
|
84
91
|
return items;
|
|
85
92
|
}
|
|
93
|
+
|
|
94
|
+
/** One counter-check in the words of the criterion's role (S.7); the verdict is derived, never stored. */
|
|
95
|
+
function counterNotice(event: CounterCheckEvent): string {
|
|
96
|
+
const verdict = counterVerdict(event);
|
|
97
|
+
switch (verdict) {
|
|
98
|
+
case "unavailable": return `Check ${event.criterion} could not be counter-checked: ${"reason" in event ? event.reason : "no observation"}`;
|
|
99
|
+
case "vacuous": return `Check ${event.criterion} also passes without this request's changes; it does not test them`;
|
|
100
|
+
case "discriminating": return `Check ${event.criterion} fails without this request's changes; it tests them`;
|
|
101
|
+
case "held": return `Guard ${event.criterion} also held before this request's changes, as a guard should`;
|
|
102
|
+
case "not_held": return `Guard ${event.criterion} did not hold before this request's changes; it tests them rather than guarding`;
|
|
103
|
+
default: return assertNever(verdict, "counterNotice");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running blind checks (feat-blind-checks): in a disposable clone of the
|
|
3
|
+
* final workspace, with the blind files copied in under `.xeno-blind/`. The
|
|
4
|
+
* files are verified against the digests frozen when the session began, so a
|
|
5
|
+
* directory edited since then is reported rather than silently run.
|
|
6
|
+
*/
|
|
7
|
+
import { cp, readFile } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { BlindChecks } from "../core/blind-checks.ts";
|
|
10
|
+
import { observeCommand } from "../store/command.ts";
|
|
11
|
+
import { cloneWorkspace } from "../store/workspace-clone.ts";
|
|
12
|
+
import { blobAddress } from "../store/blobs.ts";
|
|
13
|
+
|
|
14
|
+
export type BlindCheckResult = { id: string; command: string } & ({ exit: number } | { reason: string });
|
|
15
|
+
|
|
16
|
+
export async function runBlindChecks(input: { root: string; blind: BlindChecks; timeoutMs: number; maxOutputBytes: number;
|
|
17
|
+
outputStore: string; signal?: AbortSignal }): Promise<BlindCheckResult[]> {
|
|
18
|
+
const checks = input.blind.manifest.checks.map(check => ({ id: check.id, command: check.command }));
|
|
19
|
+
const unavailable = (reason: string) => checks.map(check => ({ ...check, reason }));
|
|
20
|
+
for (const [path, sha] of Object.entries(input.blind.files)) {
|
|
21
|
+
const bytes = await readFile(join(input.blind.dir, path)).catch(() => null);
|
|
22
|
+
if (bytes === null || blobAddress(bytes) !== sha) return unavailable(`blind check file ${path} changed or disappeared since the session began`);
|
|
23
|
+
}
|
|
24
|
+
const clone = await cloneWorkspace(input.root, input.signal);
|
|
25
|
+
if (!clone.ok) return unavailable(clone.reason);
|
|
26
|
+
try {
|
|
27
|
+
await cp(input.blind.dir, join(clone.path, ".xeno-blind"), { recursive: true });
|
|
28
|
+
const results: BlindCheckResult[] = [];
|
|
29
|
+
for (const check of checks) {
|
|
30
|
+
const captured = await observeCommand({ command: check.command, cwd: clone.path, outputStore: input.outputStore,
|
|
31
|
+
maxOutputBytes: input.maxOutputBytes, timeoutMs: input.timeoutMs, ...(input.signal ? { signal: input.signal } : {}) });
|
|
32
|
+
results.push(captured.stopped ? { ...check, reason: `the check did not finish (${captured.stopped})` } : { ...check, exit: captured.exit });
|
|
33
|
+
}
|
|
34
|
+
return results;
|
|
35
|
+
} finally { await clone.dispose(); }
|
|
36
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counter-checks: the discriminating rung of the evidence ladder (S.1).
|
|
3
|
+
*
|
|
4
|
+
* A check that passed is run once more against the workspace with this
|
|
5
|
+
* request's changes undone. If it still passes, it did not test the change --
|
|
6
|
+
* unless it is a guard, which must still pass there (S.7). That is the harness
|
|
7
|
+
* applying to the agent the rule verify-red applies to every card: "a test
|
|
8
|
+
* that cannot fail proves nothing". This module only runs the checks and
|
|
9
|
+
* reports their exit codes; what an exit code means for a criterion of a
|
|
10
|
+
* given role is decided in core (`counterVerdict`).
|
|
11
|
+
*
|
|
12
|
+
* Three rules keep the verdict honest:
|
|
13
|
+
* - Only commands that already ran and passed in this request are re-run --
|
|
14
|
+
* checks the user accepted -- and only in a disposable clone.
|
|
15
|
+
* - The clone must be reverted exactly: after undoing the request's changes
|
|
16
|
+
* its source fingerprint must equal the baseline recorded when the request
|
|
17
|
+
* began. The undo comes from a snapshot taken at the start when there is
|
|
18
|
+
* one, else from the journal; a change neither recorded (a shell command
|
|
19
|
+
* writing files without a checkpoint, with no snapshot) cannot be undone,
|
|
20
|
+
* so nothing runs and the result is unavailable rather than a guess.
|
|
21
|
+
* - The real workspace is never written.
|
|
22
|
+
*/
|
|
23
|
+
import { join, relative } from "node:path";
|
|
24
|
+
import { changedPaths, planFileTransition, type CheckpointEvent, type FileChanges } from "../core/checkpoints.ts";
|
|
25
|
+
import type { LogEvent } from "../core/events.ts";
|
|
26
|
+
import { applyFileChanges } from "../store/file-checkpoints.ts";
|
|
27
|
+
import { observeCommand } from "../store/command.ts";
|
|
28
|
+
import { observeWorkspace } from "../store/workspace-observation.ts";
|
|
29
|
+
import { cloneWorkspace } from "../store/workspace-clone.ts";
|
|
30
|
+
|
|
31
|
+
export type CounterCheckInput = {
|
|
32
|
+
root: string; blobs: string; ownedPaths: readonly string[];
|
|
33
|
+
/** The request's events, in order. */
|
|
34
|
+
events: readonly LogEvent[];
|
|
35
|
+
/**
|
|
36
|
+
* The exact transition back to the request's starting state, when the
|
|
37
|
+
* caller took a snapshot at the start (fix-counter-check-own-baseline). It
|
|
38
|
+
* covers changes a shell command made. Absent: the request's journaled
|
|
39
|
+
* file changes are undone instead.
|
|
40
|
+
*/
|
|
41
|
+
revert?: FileChanges;
|
|
42
|
+
checks: readonly { id: string; command: string; guard?: true }[];
|
|
43
|
+
timeoutMs: number; maxOutputBytes: number; outputStore: string; signal?: AbortSignal;
|
|
44
|
+
};
|
|
45
|
+
export type CounterCheckResult = { id: string; command: string; guard?: true } & ({ exit: number } | { reason: string });
|
|
46
|
+
|
|
47
|
+
export async function counterCheck(input: CounterCheckInput): Promise<CounterCheckResult[]> {
|
|
48
|
+
const unavailable = (reason: string): CounterCheckResult[] => input.checks.map(check => ({ ...check, reason }));
|
|
49
|
+
if (input.checks.length === 0) return [];
|
|
50
|
+
const baseline = input.events.findLast(event => event.t === "workspace_observation" && event.baseline === true);
|
|
51
|
+
if (baseline?.t !== "workspace_observation" || baseline.fingerprint === undefined) return unavailable("no workspace baseline was recorded when this request began");
|
|
52
|
+
const applied = input.events.filter((event): event is CheckpointEvent => event.t === "checkpoint" && event.phase === "applied");
|
|
53
|
+
// Undoing every applied change of this request and replaying none is the journal's revert.
|
|
54
|
+
const revert = input.revert ?? planFileTransition(applied, []);
|
|
55
|
+
if (changedPaths(revert).length === 0) return unavailable("this request recorded no file changes to undo");
|
|
56
|
+
const clone = await cloneWorkspace(input.root, input.signal);
|
|
57
|
+
if (!clone.ok) return unavailable(clone.reason);
|
|
58
|
+
try {
|
|
59
|
+
try { await applyFileChanges(clone.path, input.blobs, revert, input.signal); }
|
|
60
|
+
catch (error) { return unavailable(`the recorded changes could not be undone: ${error instanceof Error ? error.message : String(error)}`); }
|
|
61
|
+
const owned = input.ownedPaths.map(path => { const rel = relative(input.root, path); return rel.startsWith("..") ? path : join(clone.path, rel); });
|
|
62
|
+
const observed = await observeWorkspace(clone.path, input.signal, owned);
|
|
63
|
+
if (!observed.ok) return unavailable(`the reverted copy could not be observed: ${observed.reason}`);
|
|
64
|
+
if (observed.fingerprint !== baseline.fingerprint) return unavailable(input.revert ? "the request's starting state could not be rebuilt exactly"
|
|
65
|
+
: "some changes were made outside tracked edits, so the request's starting state could not be rebuilt");
|
|
66
|
+
const results: CounterCheckResult[] = [];
|
|
67
|
+
for (const check of input.checks) {
|
|
68
|
+
const captured = await observeCommand({ command: check.command, cwd: clone.path, outputStore: input.outputStore,
|
|
69
|
+
maxOutputBytes: input.maxOutputBytes, timeoutMs: input.timeoutMs, ...(input.signal ? { signal: input.signal } : {}) });
|
|
70
|
+
results.push(captured.stopped ? { ...check, reason: `the check did not finish in the reverted copy (${captured.stopped})` } : { ...check, exit: captured.exit });
|
|
71
|
+
}
|
|
72
|
+
return results;
|
|
73
|
+
} finally { await clone.dispose(); }
|
|
74
|
+
}
|
|
@@ -33,6 +33,17 @@ export const DEFAULT_YIELD_MS = 10_000;
|
|
|
33
33
|
/** Bounds on a foreground poll or input send. Owned observation waits have no polling deadline. */
|
|
34
34
|
export const MIN_YIELD_MS = 100;
|
|
35
35
|
export const MAX_YIELD_MS = 30_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The wait actually taken, clamped like Codex's `clamp_yield_time` (S.6
|
|
39
|
+
* item 5): an out-of-range value still runs the call, and `note` tells the
|
|
40
|
+
* model what it waited instead of what it asked for.
|
|
41
|
+
*/
|
|
42
|
+
export function yieldWait(value: unknown): { ms: number; note: string } {
|
|
43
|
+
const requested = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_YIELD_MS;
|
|
44
|
+
const ms = Math.min(MAX_YIELD_MS, Math.max(MIN_YIELD_MS, requested));
|
|
45
|
+
return { ms, note: ms === requested ? "" : ` · yield_time_ms ${requested} clamped to ${ms}` };
|
|
46
|
+
}
|
|
36
47
|
/**
|
|
37
48
|
* Concurrent sessions. An assumption, not a measurement: comfortably above a
|
|
38
49
|
* server + watcher + REPL, low enough that a runaway loop of starts is refused
|
|
@@ -12,15 +12,27 @@ import { LspManager, parseLspConfig } from "./lsp-client.ts";
|
|
|
12
12
|
import { validateFormatters } from "./formatter.ts";
|
|
13
13
|
import { createFormatterTool } from "./formatter-tool.ts";
|
|
14
14
|
import { permissionResourceAction } from "./permission-resources.ts";
|
|
15
|
-
import type { ToolContext, ToolDefinition
|
|
15
|
+
import type { InstructionCheck, ToolContext, ToolDefinition } from "./tools.ts";
|
|
16
16
|
import { McpManager } from "./mcp-client.ts";
|
|
17
17
|
import { parseMcpServers } from "../core/mcp-config.ts";
|
|
18
18
|
import type { ExtensionRegistry } from "./extensions.ts";
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { getBlob } from "../store/blobs.ts";
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Calls that only observe run and carry newly discovered rules (S.6 item 3:
|
|
24
|
+
* OpenCode, Gemini CLI and Claude Code do the same). A change is still held
|
|
25
|
+
* back until the rules have been read: the reason for blocking first was never
|
|
26
|
+
* to notify after a modification (foundation-editing research).
|
|
27
|
+
*/
|
|
28
|
+
const OBSERVING_TOOLS = new Set(["read", "query", "lsp"]);
|
|
29
|
+
/** Assumed: more batches than any interleaving of live sessions sharing this runtime. */
|
|
30
|
+
const RECENT_BATCHES = 16;
|
|
31
|
+
|
|
22
32
|
export class FoundationRuntime {
|
|
23
33
|
readonly questionSlot: QuestionSlot;
|
|
34
|
+
/** Per batch (assistant event id): instruction key -> the call that carried it. */
|
|
35
|
+
private readonly batches = new Map<string, Map<string, string>>();
|
|
24
36
|
readonly mcp: McpManager;
|
|
25
37
|
readonly tools: ToolDefinition[];
|
|
26
38
|
readonly permissions: PermissionRule[];
|
|
@@ -47,14 +59,32 @@ export class FoundationRuntime {
|
|
|
47
59
|
const discovered = await this.mcp.discover();
|
|
48
60
|
for (const integration of discovered.integrations) registry.api("xeno-mcp").registerIntegration(integration);
|
|
49
61
|
}
|
|
50
|
-
|
|
62
|
+
/** Calls are prepared one at a time in their batch, so "first to discover carries the rules" is deterministic. */
|
|
63
|
+
private claims(batch: string): Map<string, string> {
|
|
64
|
+
let claimed = this.batches.get(batch);
|
|
65
|
+
if (claimed === undefined) {
|
|
66
|
+
claimed = new Map();
|
|
67
|
+
this.batches.set(batch, claimed);
|
|
68
|
+
for (const old of this.batches.keys()) if (this.batches.size > RECENT_BATCHES) this.batches.delete(old);
|
|
69
|
+
}
|
|
70
|
+
return claimed;
|
|
71
|
+
}
|
|
72
|
+
async instructions(ctx: ToolContext, name: string, input: unknown, noContextFiles: boolean): Promise<InstructionCheck | null> {
|
|
51
73
|
if (noContextFiles || !["read", "write", "edit", "apply_patch", "query", "format", "lsp"].includes(name) || !input || typeof input !== "object") return null;
|
|
52
74
|
const args = input as Record<string, unknown>;
|
|
53
75
|
const paths = name === "apply_patch" ? patchPaths(parsePatch(String(args["patchText"]))) : typeof args["path"] === "string" ? [args["path"]] : Array.isArray(args["paths"]) ? args["paths"].filter((path): path is string => typeof path === "string") : [];
|
|
54
76
|
if (!paths.length) return null;
|
|
77
|
+
const blocked = (content: string): InstructionCheck => ({ kind: "block", result: { ok: false, content } });
|
|
55
78
|
const known = new Set<string>();
|
|
56
|
-
|
|
57
|
-
|
|
79
|
+
const history = await ctx.historyEvents?.() ?? [];
|
|
80
|
+
// Rules count as read only once the model has seen them: results of this
|
|
81
|
+
// same batch may already be committed, but they reach the model together
|
|
82
|
+
// with this call's result. Within the batch, claims decide instead.
|
|
83
|
+
const batch = history.findLast(event => event.t === "assistant");
|
|
84
|
+
const sameBatch = new Set(batch?.t === "assistant" ? batch.blocks.flatMap(block => block.type === "tool_use" ? [block.call_id] : []) : []);
|
|
85
|
+
for (const event of history) {
|
|
86
|
+
if (event.t !== "tool_result" || sameBatch.has(event.call_id)) continue;
|
|
87
|
+
for (const key of event.instructions?.keys ?? []) known.add(key);
|
|
58
88
|
let data: unknown = event.data;
|
|
59
89
|
if (event.data_ref && ctx.outputStore) {
|
|
60
90
|
const saved = await getBlob(ctx.outputStore, event.data_ref.sha256);
|
|
@@ -67,12 +97,20 @@ export class FoundationRuntime {
|
|
|
67
97
|
}
|
|
68
98
|
const found = await discoverTargetInstructions({ workspace: ctx.root, paths, known,
|
|
69
99
|
canRead: async path => await permissionResourceAction(ctx.root, path, ctx.rules ?? [], ctx.sessionRules ?? []) === "allow" });
|
|
70
|
-
if (found.notices.some(notice => notice.kind === "skipped" && notice.reason === "instruction read permission is unavailable")) return
|
|
100
|
+
if (found.notices.some(notice => notice.kind === "skipped" && notice.reason === "instruction read permission is unavailable")) return blocked("Scoped instructions require read permission before this operation.");
|
|
71
101
|
const files = found.files.filter(file => !ctx.frozenSystem?.().includes(file.content));
|
|
72
102
|
if (!files.length) return null;
|
|
73
|
-
for (const file of files) if (await permissionResourceAction(ctx.root, file.path, ctx.rules ?? [], ctx.sessionRules ?? []) !== "allow") return
|
|
74
|
-
|
|
75
|
-
|
|
103
|
+
for (const file of files) if (await permissionResourceAction(ctx.root, file.path, ctx.rules ?? [], ctx.sessionRules ?? []) !== "allow") return blocked("Scoped instructions require read permission before this operation.");
|
|
104
|
+
// Once per batch: a later call in the same assistant message does not receive the same rules again.
|
|
105
|
+
const claimed = this.claims(batch?.id ?? "");
|
|
106
|
+
const fresh = files.filter(file => !claimed.has(file.key));
|
|
107
|
+
const carriers = [...new Set(files.flatMap(file => claimed.get(file.key) ?? []))];
|
|
108
|
+
for (const file of fresh) claimed.set(file.key, ctx.callId ?? name);
|
|
109
|
+
if (OBSERVING_TOOLS.has(name)) return fresh.length ? { kind: "attach", instructions: { keys: fresh.map(file => file.key), text: renderTargetInstructions(fresh) } } : null;
|
|
110
|
+
const earlier = carriers.length ? `Scoped instructions for this target were attached to the result of ${carriers.join(", ")} in this batch. ` : "";
|
|
111
|
+
if (!fresh.length) return blocked(`${earlier}No original operation was executed. Follow them, then retry.`);
|
|
112
|
+
return { kind: "block", result: { ok: false, content: `Scoped instructions were discovered. ${earlier}Read and follow them before deciding whether to retry the original operation. No original operation was executed.\n\n` + renderTargetInstructions(fresh),
|
|
113
|
+
data: { kind: "scoped_instructions", keys: fresh.map(file => file.key), files: fresh.map(file => ({ path: file.path, scope: file.scope, key: file.key, content: file.content, truncated: file.truncated })) } } };
|
|
76
114
|
}
|
|
77
115
|
async dispose(): Promise<void> { await Promise.all([this.lsp.dispose(), this.mcp.dispose()]); }
|
|
78
116
|
}
|