@visual-z/xeno 0.8.0 → 0.8.2
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 +41 -0
- package/docs/install.md +25 -0
- package/package.json +1 -1
- package/src/apps/cli.ts +4 -1
- package/src/apps/startup.ts +27 -2
- package/src/apps/ui/app.ts +69 -9
- package/src/core/activity-status.ts +10 -1
- package/src/core/command-contract.ts +79 -5
- package/src/core/completion-review.ts +1 -1
- package/src/core/environment.ts +7 -0
- package/src/core/goal-view.ts +64 -0
- package/src/core/history.ts +7 -1
- package/src/core/permissions.ts +4 -3
- package/src/core/prompt-policy.ts +9 -2
- package/src/core/regex-dialect.ts +25 -0
- package/src/core/response-recovery.ts +13 -1
- package/src/core/ripgrep-release.ts +64 -0
- package/src/core/stall.ts +63 -18
- package/src/core/stop-report.ts +21 -1
- package/src/core/tool-contract.ts +2 -0
- package/src/core/tool-failure.ts +17 -4
- package/src/core/update-check.ts +2 -1
- package/src/runtime/exec-sessions.ts +3 -1
- package/src/runtime/loop.ts +98 -12
- package/src/runtime/openai.ts +2 -2
- package/src/runtime/pi-provider.ts +3 -3
- package/src/runtime/provider.ts +33 -3
- package/src/runtime/retry.ts +9 -2
- package/src/runtime/tools.ts +46 -8
- package/src/runtime/usage.ts +28 -0
- package/src/store/command.ts +3 -1
- package/src/store/file-checkpoints.ts +6 -3
- package/src/store/search.ts +9 -5
- package/src/store/tool-binaries.ts +90 -0
- package/src/store/worktree.ts +55 -10
- package/src/tui/ui/duration.ts +16 -0
- package/src/tui/ui/layout.ts +4 -4
- package/src/tui/ui/screen.ts +18 -9
- package/src/tui/ui/status.ts +115 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# xeno changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.2 — 2026-10-11
|
|
4
|
+
|
|
5
|
+
The stall watchdog no longer stops work that is merely long:
|
|
6
|
+
- A run is treated as stuck only when it repeats the same tool call, or the same cycle of up to five calls, three times without learning anything new. Re-reading files, rerunning a test after an edit, a failing test whose output changes, and waiting on a running process never count.
|
|
7
|
+
- The first time, the model is told to change its method. If the cycle comes back, an interactive session asks you: Continue, Stop, or type instructions for the agent. Without anyone to ask (headless), the request still ends as stalled.
|
|
8
|
+
- The old request-wide allowance of twelve exchanges without new evidence is gone. It stopped a long, finished task right after its final answer.
|
|
9
|
+
|
|
10
|
+
The input box after a turn:
|
|
11
|
+
- A finished turn reads `✓ Turn finished · 32m 34s` in the success colour, with how long the turn ran. The time stays until the next turn starts.
|
|
12
|
+
- A turn the harness stopped reads `■ Stopped · no progress` (or request limit, context budget, output limit, time limit) in the warning colour, instead of `Awaiting input`. `Awaiting input` now means a question is waiting for you. A failed turn reads `✗ Failed` in the error colour.
|
|
13
|
+
- A stop report names the tools that failed (`Failed tools: exec_command ×2, edit ×1`) instead of listing receipt ids, and says when the model had already given a final reply before the stop.
|
|
14
|
+
|
|
15
|
+
Tools:
|
|
16
|
+
- Where `rg` is missing, interactive sessions install a pinned ripgrep (14.1.1, checked against its SHA-256) into `~/.xeno/bin` in the background and add it to the end of the command PATH. Nothing is downloaded with `--offline`, in CI or from a source checkout. The environment tells the model whether `rg` is available.
|
|
17
|
+
- A new `worktree` tool lets the model create an isolated Git worktree under `~/.xeno/worktrees`, on a branch `xeno/<name>`, and move the session into it; leaving returns to the main checkout and keeps the worktree and branch. Resumed sessions add the tool after their existing tools.
|
|
18
|
+
- `exec_command` and `write_stdin` accept Codex's `max_output_tokens`. It lowers the inline preview only, and near spellings such as `max_output_chars` are pointed at the right name.
|
|
19
|
+
- `grep`, `rg`, `diff`, `cmp` and `test` exiting 1 as the last command is read as their answer (no match, files differ), not a failure. The receipt keeps the real exit code.
|
|
20
|
+
- A leading `(?i)` in a grep pattern means the same with or without ripgrep.
|
|
21
|
+
|
|
22
|
+
Fixes:
|
|
23
|
+
- A new segment no longer sends the model to `history` first when every user message is already in its handoff. Before, the first tool call of almost every session was that read.
|
|
24
|
+
- The completion check says that a verification command counts only when run on its own, without redirects, pipes, `$()`, `;` or `&&`. The read it asks for no longer counts toward the stall watchdog.
|
|
25
|
+
- Refusing an edit under `.git` or `.xeno` now says it is a fixed rule, instead of asking for a policy change that no setting can make.
|
|
26
|
+
|
|
27
|
+
## 0.8.1 — 2026-10-11
|
|
28
|
+
|
|
29
|
+
The sidebar now follows your goal:
|
|
30
|
+
- The Goal block appears only for `/goal`. It lists every accepted check — ✓ passed, ✗ failed with its exit code, ○ not yet — with `guard` or `/accept <id>` beside it, then the evidence ladder and what the goal has used: requests, and tokens in this run.
|
|
31
|
+
- The heading reads working, needs you, met or stopped, with how long the goal has been running.
|
|
32
|
+
- A met goal stays as one line until your next message.
|
|
33
|
+
- A request without a goal shows nothing, or one `Checks` line once it has changed files: not run since the last edit, or the latest check with ✓ or ✗ and its exit code. A declared acceptance contract shows how many of its criteria hold.
|
|
34
|
+
- While a goal's criteria are being drafted the sidebar shows `Goal · drafting` and your objective, not the drafting instructions.
|
|
35
|
+
- Without a sidebar (narrower than 110 columns) the goal sits on the input box's lower edge: `goal 2/3 · needs you · 4m 12s`.
|
|
36
|
+
- Context and Usage fit in five rows instead of up to eight. Both budgets keep their names and shares. The node number, the observation and retry counts, the tool count when nothing failed and the `/control` hint moved out; they remain in `/control` and `/state`.
|
|
37
|
+
- Generation speed: Usage and the input box show tokens per second for the current turn, measured as OpenCode does: output over the time its model requests took. Reasoning that a provider does not stream is never left out of the time, so the figure does not overstate. Requests shorter than 250 ms are not counted.
|
|
38
|
+
- Running time reads `59s`, `1m 05s`, `1h 02m 05s` instead of `3725s`, and so does an update's progress against its limit.
|
|
39
|
+
|
|
40
|
+
Fixes:
|
|
41
|
+
- A gateway that answers "about to call a tool" with no tool call no longer ends the request silently. The response is retried automatically; once retries run out it is reported as a provider error.
|
|
42
|
+
- The Anthropic SDK path now sends `xeno/<version>` as its User-Agent, as the other paths do, so gateways attribute its traffic to xeno instead of a custom client.
|
|
43
|
+
|
|
3
44
|
## 0.8.0 — 2026-10-10
|
|
4
45
|
|
|
5
46
|
xeno now proves completion with receipts, and proves the receipts test the change. Two changes need attention when upgrading:
|
package/docs/install.md
CHANGED
|
@@ -144,6 +144,31 @@ check described above is a separate action):
|
|
|
144
144
|
- the run is not the interactive app: `-p`, `--mode json`, `--export`, `--list-models`, `models --refresh`, package and `config` commands, `--version`
|
|
145
145
|
- xeno runs from a checkout: its package root is not under `node_modules`, or has a `.git` (`bun src/apps/cli.ts`, `bun run agent`, `bun link`)
|
|
146
146
|
|
|
147
|
+
## ripgrep
|
|
148
|
+
|
|
149
|
+
Agents reach for `rg` in shell commands, and xeno's grep tool is faster with
|
|
150
|
+
it. When neither `PATH` nor `~/.xeno/bin` has one, the interactive app
|
|
151
|
+
installs ripgrep 14.1.1 into `~/.xeno/bin/rg` in the background:
|
|
152
|
+
|
|
153
|
+
- **Only that build.** The archive comes from the ripgrep GitHub release and
|
|
154
|
+
must match a sha256 pinned in xeno (`src/core/ripgrep-release.ts`).
|
|
155
|
+
Anything else is discarded before extraction; only the `rg` executable is
|
|
156
|
+
unpacked.
|
|
157
|
+
- **Yours wins.** `~/.xeno/bin` is appended to the end of the PATH of tool
|
|
158
|
+
commands, so an `rg` you install yourself is used instead.
|
|
159
|
+
- **What the model is told.** The session's environment section says whether
|
|
160
|
+
`rg` exists in the shell at session start. A copy that finishes
|
|
161
|
+
downloading during the first session appears in the next one's line, but
|
|
162
|
+
shell commands can use it immediately.
|
|
163
|
+
- **When not.** The same conditions as background update checks, except the
|
|
164
|
+
update settings: `--offline` / `XENO_OFFLINE`, `CI` /
|
|
165
|
+
`CONTINUOUS_INTEGRATION`, a non-interactive run, or running from a
|
|
166
|
+
checkout. `XENO_NO_TOOL_INSTALL` turns off only this download. Quitting
|
|
167
|
+
cancels it.
|
|
168
|
+
|
|
169
|
+
Platforms: macOS arm64 and x64, Linux x64 (static musl) and arm64 (glibc).
|
|
170
|
+
Elsewhere grep uses its built-in TypeScript search.
|
|
171
|
+
|
|
147
172
|
## Credentials
|
|
148
173
|
|
|
149
174
|
Resolution order, first match wins:
|
package/package.json
CHANGED
package/src/apps/cli.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
|
-
parseArgs, startup, readPipedStdin, mergePrintPrompt, isInteractiveSession, printCliHelp,
|
|
16
|
+
parseArgs, startup, readPipedStdin, mergePrintPrompt, isInteractiveSession, printCliHelp, provisionToolsInBackground,
|
|
17
17
|
} from "./startup.ts";
|
|
18
18
|
import { createStartupInput } from "./startup-input.ts";
|
|
19
19
|
import { runPrompt, runRepl } from "./shell.ts";
|
|
@@ -37,6 +37,7 @@ const args = parseArgs(process.argv.slice(serverMode || acpMode || reviewMode ?
|
|
|
37
37
|
if (serverMode || acpMode) { args.print = true; args.mode = "json"; if (args.prompt) throw Error("protocol servers receive prompts through their client"); }
|
|
38
38
|
if (reviewMode) args.print = true;
|
|
39
39
|
let closeControl: (() => Promise<void>) | undefined;
|
|
40
|
+
let stopToolInstall = (): void => {};
|
|
40
41
|
const input = createStartupInput({ interactive: isInteractiveSession(args) && !args.packageCommand && !args.version && !args.exportFile && !args.listModels && !args.modelsCommand });
|
|
41
42
|
process.once("exit", input.restore);
|
|
42
43
|
try {
|
|
@@ -60,6 +61,7 @@ try {
|
|
|
60
61
|
catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = controller.signal.aborted ? 130 : 1; }
|
|
61
62
|
finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
|
|
62
63
|
} else {
|
|
64
|
+
stopToolInstall = provisionToolsInBackground(args);
|
|
63
65
|
const app = await startup(args, input);
|
|
64
66
|
if (args.controlSocket) closeControl = await listenControl(args.controlSocket, () => sessionControl(app.session));
|
|
65
67
|
if (serverMode) {
|
|
@@ -90,6 +92,7 @@ try {
|
|
|
90
92
|
} catch (error) {
|
|
91
93
|
console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1;
|
|
92
94
|
} finally {
|
|
95
|
+
stopToolInstall();
|
|
93
96
|
await closeControl?.();
|
|
94
97
|
process.off("exit", input.restore);
|
|
95
98
|
input.restore();
|
package/src/apps/startup.ts
CHANGED
|
@@ -29,6 +29,10 @@ import {
|
|
|
29
29
|
resolveCredential, envLabel, storedIsOAuth,
|
|
30
30
|
type AuthFile, type ResolveEffects, type Resolved, describeMissingCredential } from "../runtime/auth.ts";
|
|
31
31
|
import { authPath, configDir, readJsonFile } from "../store/config.ts";
|
|
32
|
+
import { findRipgrep, provisionRipgrep } from "../store/tool-binaries.ts";
|
|
33
|
+
import { packageLocation } from "../store/update-cache.ts";
|
|
34
|
+
import { installKind } from "../core/update-check.ts";
|
|
35
|
+
import { toolInstallAllowed } from "../core/ripgrep-release.ts";
|
|
32
36
|
import { loadUiResources, loadTextResources, type UiResources } from "../store/resources.ts";
|
|
33
37
|
import { discoverResources, type ResourceIndex } from "../store/resource-index.ts";
|
|
34
38
|
import { pickProjectTrust, pickStartupSession, runResourceConfig } from "./ui/standalone.ts";
|
|
@@ -108,9 +112,29 @@ export function sessionEnvironment(workspace: string): SessionEnvironment {
|
|
|
108
112
|
cwd: workspace,
|
|
109
113
|
date: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
|
|
110
114
|
git,
|
|
115
|
+
ripgrep: findRipgrep(configDir()) !== null,
|
|
111
116
|
};
|
|
112
117
|
}
|
|
113
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Fills the rg gap in the background for interactive sessions only: scripts,
|
|
121
|
+
* protocol servers and test subprocesses must not reach the network on their
|
|
122
|
+
* own. Failure is silent because grep keeps its TypeScript engine; the next
|
|
123
|
+
* session's environment line reports what is actually there. The returned
|
|
124
|
+
* stop aborts the download, so quitting never waits for it.
|
|
125
|
+
*/
|
|
126
|
+
export function provisionToolsInBackground(args: Args): () => void {
|
|
127
|
+
const location = packageLocation();
|
|
128
|
+
if (!isInteractiveSession(args) || !toolInstallAllowed({ env: process.env, offline: args.offline === true, install: installKind(location.root, location.git) })) return () => {};
|
|
129
|
+
const controller = new AbortController();
|
|
130
|
+
void provisionRipgrep({ configDir: configDir(), signal: controller.signal, fetchBytes: async (url, signal) => {
|
|
131
|
+
const response = await fetch(url, { ...(signal ? { signal } : {}), redirect: "follow" });
|
|
132
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
133
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
134
|
+
} }).catch(() => { /* grep falls back to its own engine */ });
|
|
135
|
+
return () => controller.abort();
|
|
136
|
+
}
|
|
137
|
+
|
|
114
138
|
/**
|
|
115
139
|
* Session policy a CLI coding session differs from a benchmark run in.
|
|
116
140
|
* Benchmarks construct their own SessionConfig and keep the core defaults.
|
|
@@ -366,8 +390,9 @@ export function parseArgs(argv: string[], options: { deferExtensions?: boolean;
|
|
|
366
390
|
let updateExtensions = false;
|
|
367
391
|
const args: Args = {
|
|
368
392
|
cliFlags: argv.filter(value => value.startsWith("-")),
|
|
369
|
-
// No default request cap (S.6 item 1): the stall watchdog
|
|
370
|
-
//
|
|
393
|
+
// No default request cap (S.6 item 1): the stall watchdog catches a run
|
|
394
|
+
// repeating a cycle without evidence, and asks the user when one can answer
|
|
395
|
+
// (S.8); --max-turns or an agent budget sets a hard cap.
|
|
371
396
|
workspace: process.cwd(), logDir: "", maxTurns: Infinity, prompt: "",
|
|
372
397
|
// Overridable so a node boundary can be forced in a short session --
|
|
373
398
|
// otherwise cutting is only reachable after tens of thousands of tokens,
|
package/src/apps/ui/app.ts
CHANGED
|
@@ -55,6 +55,8 @@ import { sessionsDir } from "../../store/sessions.ts";
|
|
|
55
55
|
import { readGitStatus } from "../../store/git-status.ts";
|
|
56
56
|
import type { GitStatus } from "../../core/git-status.ts";
|
|
57
57
|
import { activityStatus } from "../../core/activity-status.ts";
|
|
58
|
+
import { goalStatus } from "../../core/goal-view.ts";
|
|
59
|
+
import { generationSpeed } from "../../runtime/usage.ts";
|
|
58
60
|
import { executionOutcome, type ExecutionOutcome } from "../../core/outcome.ts";
|
|
59
61
|
import { projectTrust } from "../../store/trust.ts";
|
|
60
62
|
import { discoverResources, setResourceOverride } from "../../store/resource-index.ts";
|
|
@@ -161,6 +163,14 @@ export class InteractiveApp implements Ui {
|
|
|
161
163
|
private gitRun: Promise<void> | undefined;
|
|
162
164
|
private gitAgain = false;
|
|
163
165
|
private attention: ControlSnapshot["waiting"] = null;
|
|
166
|
+
/** The objective of a /goal whose criteria are being drafted. */
|
|
167
|
+
private drafting: string | undefined;
|
|
168
|
+
/** Index of this turn's first usage sample; generation speed reads from here. */
|
|
169
|
+
private turnUsageStart = 0;
|
|
170
|
+
/** Where this process began counting a goal's tokens: usage samples are kept per process, not in the log. */
|
|
171
|
+
private goalUsage: { goal: string; start: number } | undefined;
|
|
172
|
+
/** The last goal view and what it was derived from; see goalAndChecks. */
|
|
173
|
+
private goalCache: { key: string; view: import("../../core/goal-view.ts").GoalStatus | null; goal?: string } | undefined;
|
|
164
174
|
private lastOutcome: ExecutionOutcome | undefined;
|
|
165
175
|
private paintTimer: ReturnType<typeof setTimeout> | undefined;
|
|
166
176
|
/** Handoff-derived status (sidebar / top bar), refreshed single-flight and debounced (DoD 9). */
|
|
@@ -594,21 +604,61 @@ export class InteractiveApp implements Ui {
|
|
|
594
604
|
...(budget ? { context: { used: budget.used, trigger: budget.trigger, estimated: budget.estimated, segment: app.session.state.node, ...(spec.contextWindow ? { window: spec.contextWindow } : {}) } } : {}),
|
|
595
605
|
...(t.requests ? { usage: { input: t.prompt_tokens, output: t.output_tokens, cacheRate: t.cache_hit_rate,
|
|
596
606
|
// Under half a cent reads as $0.00, which says nothing (OpenCode #15903; Grok's status line hides it the same way).
|
|
597
|
-
cost: cost.kind === "usd" && cost.amount >= 0.005 ? `~$${cost.amount.toFixed(2)}` : cost.kind === "partial" && cost.known >= 0.005 ? `≥$${cost.known.toFixed(2)}` : null
|
|
607
|
+
cost: cost.kind === "usd" && cost.amount >= 0.005 ? `~$${cost.amount.toFixed(2)}` : cost.kind === "partial" && cost.known >= 0.005 ? `≥$${cost.known.toFixed(2)}` : null,
|
|
608
|
+
...speedOf(app.session.state.usage.slice(this.turnUsageStart)) } } : {}),
|
|
598
609
|
tools: { running: tools.filter(tool => tool.status === "pending").length, done: tools.filter(tool => tool.status === "ok").length, failed: tools.filter(tool => tool.status === "error").length },
|
|
599
610
|
...(this.derived ?? {}),
|
|
600
|
-
...
|
|
601
|
-
status: this.controlSnapshot.goal.state.status,
|
|
602
|
-
execution: this.inflight ? "running" : this.controlSnapshot.goal.state.execution?.reason ?? "idle",
|
|
603
|
-
observations: this.controlSnapshot.goal.state.progress.observations.length,
|
|
604
|
-
...ladderView(this.controlSnapshot.goal.state),
|
|
605
|
-
retries: this.controlSnapshot.goal.state.progress.unchanged_retries.length } } : {}),
|
|
611
|
+
...this.goalAndChecks(),
|
|
606
612
|
files: this.files,
|
|
607
613
|
resources: { contextFiles: app.loadedContext.agents.map(file => basename(file.path)), skills: app.slash?.skills.size ?? 0, extensions: app.extensions?.loaded.length ?? 0 },
|
|
608
614
|
...(extension ? { extension } : {}),
|
|
609
615
|
};
|
|
610
616
|
}
|
|
611
617
|
|
|
618
|
+
/**
|
|
619
|
+
* The sidebar's evidence: an explicit goal's block, or -- without one -- a
|
|
620
|
+
* Checks line for a request that changed files or declared a contract
|
|
621
|
+
* (feat-sidebar-goal-and-speed). Both read the request ledger the control
|
|
622
|
+
* snapshot already derived. The goal view is a scan of the whole log, so it
|
|
623
|
+
* is cached per log length; only its clock is read on every paint.
|
|
624
|
+
*/
|
|
625
|
+
private goalAndChecks(): Pick<StatusModel, "goal" | "checks"> {
|
|
626
|
+
const state = this.controlSnapshot?.goal?.state, session = this.app.session;
|
|
627
|
+
const key = `${this.app.logDir}:${session.state.node}:${this.ancestors.length}:${session.state.events.length}:${this.inflight}:${this.drafting ?? ""}:${state?.request.event ?? ""}:${state?.status ?? ""}`;
|
|
628
|
+
if (this.goalCache?.key !== key) {
|
|
629
|
+
const events = [...this.ancestors, ...session.state.events];
|
|
630
|
+
const set = events.findLast(event => event.t === "goal" && event.action === "set");
|
|
631
|
+
this.goalCache = { key, view: goalStatus(events, state, { running: this.inflight, ...(this.drafting !== undefined ? { drafting: this.drafting } : {}) }),
|
|
632
|
+
...(set ? { goal: set.id } : {}) };
|
|
633
|
+
}
|
|
634
|
+
const view = this.goalCache.view;
|
|
635
|
+
if (view) {
|
|
636
|
+
// The session's own clock stamps the log, so a running goal's time is read on it too.
|
|
637
|
+
const now = Date.parse(session.clock.now());
|
|
638
|
+
const since = this.goalCache.goal ? session.state.usage.slice(this.goalUsageStart(this.goalCache.goal)) : [];
|
|
639
|
+
const tokens = since.reduce((total, sample) => total + sample.input_tokens + sample.cache_creation_input_tokens + sample.cache_read_input_tokens + sample.output_tokens, 0);
|
|
640
|
+
return { goal: { phase: view.phase, objective: view.objective, checks: view.checks, requests: view.requests,
|
|
641
|
+
elapsedMs: view.phase === "drafting" ? 0 : Math.max(0, (view.endedAt ?? now) - view.startedAt),
|
|
642
|
+
...(state && view.phase !== "drafting" ? ladderView(state) : {}), ...(tokens ? { tokens } : {}) } };
|
|
643
|
+
}
|
|
644
|
+
if (state?.acceptance) {
|
|
645
|
+
// A declared contract without /goal: how many of its criteria hold; /acceptance lists them.
|
|
646
|
+
const items = state.acceptance.criteria, passed = items.filter(item => item.status === "pass" || item.status === "accepted").length;
|
|
647
|
+
return { checks: { status: passed === items.length ? "pass" : "partial", command: `${passed}/${items.length} passed · /acceptance` } };
|
|
648
|
+
}
|
|
649
|
+
if (!state?.changed) return {};
|
|
650
|
+
const fresh = state.criteria.find(item => item.kind === "fresh_check");
|
|
651
|
+
const run = fresh?.command ? state.commands.findLast(command => command.command === fresh.command) : state.commands.at(-1);
|
|
652
|
+
if (!fresh || fresh.status === "not_run" || !run) return { checks: { status: "unchecked" } };
|
|
653
|
+
return { checks: { status: fresh.status === "pass" ? "pass" : fresh.status === "stale" ? "stale" : "fail", command: run.command, exit: run.exit } };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Usage samples belong to this process; a goal's spend counts those recorded after this process first saw it. */
|
|
657
|
+
private goalUsageStart(goal: string): number {
|
|
658
|
+
if (this.goalUsage?.goal !== goal) this.goalUsage = { goal, start: this.app.session.state.usage.length };
|
|
659
|
+
return this.goalUsage.start;
|
|
660
|
+
}
|
|
661
|
+
|
|
612
662
|
private writeTitle(title: string): void {
|
|
613
663
|
if (title === this.writtenTitle || this.disposed) return;
|
|
614
664
|
this.writtenTitle = title;
|
|
@@ -865,11 +915,13 @@ export class InteractiveApp implements Ui {
|
|
|
865
915
|
*/
|
|
866
916
|
startGoal(objective: string): void {
|
|
867
917
|
this.track(async () => {
|
|
918
|
+
// The draft request's own text is an instruction to the model, not the goal; the sidebar shows the objective.
|
|
919
|
+
this.drafting = objective;
|
|
868
920
|
const pending = await draftGoal(this.app, objective, async (prompt, options) => {
|
|
869
921
|
await this.runPromptTurn(prompt, [], undefined, options);
|
|
870
922
|
// A cancelled draft is not a draft to accept, nor one to send back.
|
|
871
923
|
return this.lastOutcome?.status !== "cancelled";
|
|
872
|
-
});
|
|
924
|
+
}).finally(() => { this.drafting = undefined; this.paint(); });
|
|
873
925
|
if (!pending) return;
|
|
874
926
|
this.push({ kind: "list", title: `Accept the checks for: ${objective}`, multi: true, checked: pending.criteria.map(item => item.id), search: false,
|
|
875
927
|
items: pending.criteria.map(item => ({ value: item.id, label: item.id, description: criterionSummary(item) })),
|
|
@@ -886,6 +938,8 @@ export class InteractiveApp implements Ui {
|
|
|
886
938
|
this.abort = new AbortController();
|
|
887
939
|
this.inflight = true;
|
|
888
940
|
this.lastOutcome = undefined;
|
|
941
|
+
// Speed is per turn: only this turn's model requests count (feat-sidebar-goal-and-speed).
|
|
942
|
+
this.turnUsageStart = this.app.session.state.usage.length;
|
|
889
943
|
this.programResult = false;
|
|
890
944
|
this.program?.update(programStatus({ running: true }));
|
|
891
945
|
this.executionKind = text.startsWith("!") ? "bash" : "model";
|
|
@@ -1140,7 +1194,7 @@ export class InteractiveApp implements Ui {
|
|
|
1140
1194
|
const detachInput = control.attachInput(phase => this.pullQueued(phase));
|
|
1141
1195
|
const unsubscribe = control.subscribe(notice => {
|
|
1142
1196
|
if (this.disposed) return;
|
|
1143
|
-
if (notice.method === "turn/started") { this.lastOutcome = undefined; this.program?.update(programStatus({ running: true })); }
|
|
1197
|
+
if (notice.method === "turn/started") { this.lastOutcome = undefined; this.turnUsageStart = this.app.session.state.usage.length; this.program?.update(programStatus({ running: true })); }
|
|
1144
1198
|
if (notice.method === "turn/error") {
|
|
1145
1199
|
this.lastOutcome = executionOutcome("execution_error");
|
|
1146
1200
|
this.programResult = true; this.program?.update({ state: "error", msg: "Turn failed; inspect the session" });
|
|
@@ -1623,6 +1677,12 @@ function projectWithStart(items: ReturnType<typeof projectItems>, clock: string
|
|
|
1623
1677
|
}
|
|
1624
1678
|
|
|
1625
1679
|
/** "~/Projects/workspace/xeno-tui" → "~/P/w/xeno-tui": every directory but the last cut to its first letter. */
|
|
1680
|
+
/** The turn's speed when one can be measured; nothing otherwise, so the row never shows a made-up figure. */
|
|
1681
|
+
function speedOf(samples: readonly import("../../runtime/usage.ts").UsageSample[]): { speed?: number } {
|
|
1682
|
+
const speed = generationSpeed(samples);
|
|
1683
|
+
return speed === undefined ? {} : { speed };
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1626
1686
|
export function shortPath(path: string): string {
|
|
1627
1687
|
const parts = path.split("/");
|
|
1628
1688
|
return parts.map((part, i) => i === parts.length - 1 || part === "~" || part === "" ? part : part.startsWith(".") ? part.slice(0, 2) : part.slice(0, 1)).join("/");
|
|
@@ -14,10 +14,19 @@ export interface ActivityFacts {
|
|
|
14
14
|
}
|
|
15
15
|
export interface ActivityStatus { readonly state: ProgramStatus["state"]; readonly label: string; readonly detail: string; readonly queued: number }
|
|
16
16
|
|
|
17
|
+
const STOP_CAUSE: Partial<Record<ExecutionOutcome["reason"], string>> = {
|
|
18
|
+
stalled: "no progress", no_progress: "no progress", max_turns: "request limit", context_budget: "context budget",
|
|
19
|
+
output_limit: "output limit", timeout: "time limit",
|
|
20
|
+
};
|
|
21
|
+
|
|
17
22
|
export function activityStatus(facts: ActivityFacts): ActivityStatus {
|
|
18
23
|
const status = programStatus(facts), queued = Math.max(0, Math.floor(facts.queued));
|
|
19
24
|
const view = (label: string, detail = status.msg ?? ""): ActivityStatus => ({ state: status.state, label, detail, queued });
|
|
20
|
-
|
|
25
|
+
// A harness stop is not a question: saying "Awaiting input" left the user
|
|
26
|
+
// guessing what to answer (fix-finished-turn-status). The OSC state stays blocked.
|
|
27
|
+
if (!facts.running && !facts.waiting && facts.outcome?.status === "stopped") return view("Stopped", STOP_CAUSE[facts.outcome.reason] ?? facts.outcome.reason.replaceAll("_", " "));
|
|
28
|
+
if (status.state === "blocked") return view(status.kind === "auth" ? "Sign in required" : status.kind === "permission" ? "Awaiting approval"
|
|
29
|
+
: !facts.waiting && facts.goal === "awaiting_human" ? "Awaiting review" : "Awaiting input");
|
|
21
30
|
if (status.state === "error") return view("Failed");
|
|
22
31
|
if (!facts.running) return view(status.state === "done" ? "Turn finished" : facts.outcome?.status === "cancelled" ? "Cancelled" : "Ready");
|
|
23
32
|
if (facts.retry) return view("Retrying", `attempt ${facts.retry.attempt} · ${Math.max(0, Math.ceil(facts.retry.seconds))}s`);
|
|
@@ -10,10 +10,18 @@ export function commandArgv(command: string): string[] {
|
|
|
10
10
|
return ["bash", "-o", "pipefail", "-lc", command];
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Preserve inherited values except documented noninteractive defaults; never mutate the host.
|
|
15
|
+
*
|
|
16
|
+
* `appendPath` is xeno's own tool directory (store/tool-binaries.ts). It goes
|
|
17
|
+
* last, so anything the user installed wins and xeno only fills a gap.
|
|
18
|
+
*/
|
|
19
|
+
export function commandEnvironment(base: Readonly<Record<string, string | undefined>>, appendPath?: string): Record<string, string> {
|
|
15
20
|
const env: Record<string, string> = {};
|
|
16
21
|
for (const [key, value] of Object.entries(base)) if (value !== undefined) env[key] = value;
|
|
22
|
+
if (appendPath !== undefined && appendPath.length > 0 && !(env["PATH"] ?? "").split(":").includes(appendPath)) {
|
|
23
|
+
env["PATH"] = env["PATH"] ? `${env["PATH"]}:${appendPath}` : appendPath;
|
|
24
|
+
}
|
|
17
25
|
Object.assign(env, {
|
|
18
26
|
TERM: "dumb", PAGER: "cat", GIT_PAGER: "cat", NO_COLOR: "1",
|
|
19
27
|
GIT_TERMINAL_PROMPT: "0", DEBIAN_FRONTEND: "noninteractive",
|
|
@@ -22,14 +30,80 @@ export function commandEnvironment(base: Readonly<Record<string, string | undefi
|
|
|
22
30
|
return env;
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Codex's `max_output_tokens` as an inline byte budget for one call. It can
|
|
35
|
+
* only lower the session ceiling: full output is still captured and
|
|
36
|
+
* recoverable, so this shapes the preview, never the receipt.
|
|
37
|
+
*
|
|
38
|
+
* Assumed, not measured: 4 bytes per token rounds the ASCII estimate in
|
|
39
|
+
* tokens.ts (about 3.6) up, so the model never gets less than it asked for.
|
|
40
|
+
* The 1 KiB floor keeps room for the header and recovery hint (about 300
|
|
41
|
+
* bytes); below it a preview shows almost no output and forces a re-read.
|
|
42
|
+
*/
|
|
43
|
+
export const OUTPUT_BYTES_PER_TOKEN = 4;
|
|
44
|
+
export const MIN_OUTPUT_BYTES = 1_024;
|
|
45
|
+
export function outputBytesForTokens(tokens: unknown, ceiling: number): number {
|
|
46
|
+
if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens <= 0) return ceiling;
|
|
47
|
+
return Math.min(ceiling, Math.max(MIN_OUTPUT_BYTES, Math.floor(tokens) * OUTPUT_BYTES_PER_TOKEN));
|
|
48
|
+
}
|
|
49
|
+
|
|
25
50
|
/** Model seconds and host milliseconds meet once; a yield is never a command deadline. */
|
|
26
51
|
export function commandTimeout(requestedSeconds: number | undefined, defaultMs: number, maximumMs = defaultMs): number {
|
|
27
52
|
return Math.min(requestedSeconds === undefined ? defaultMs : requestedSeconds * 1000, maximumMs);
|
|
28
53
|
}
|
|
29
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Programs whose exit 1 is an answer rather than a fault (POSIX: grep's 1 is
|
|
57
|
+
* "no match" and >1 an error; diff and cmp use 1 for "differ"; test for
|
|
58
|
+
* false). Reporting them as failures tells the model to inspect and retry an
|
|
59
|
+
* answer it already holds -- the same reading Claude Code's command semantics
|
|
60
|
+
* give these programs.
|
|
61
|
+
*/
|
|
62
|
+
const NEGATIVE_ANSWERS: Readonly<Record<string, string>> = {
|
|
63
|
+
grep: "no match", egrep: "no match", fgrep: "no match", rg: "no match",
|
|
64
|
+
diff: "files differ", cmp: "files differ", test: "condition false", "[": "condition false",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What exit 1 of the command's final simple command means, or null.
|
|
69
|
+
*
|
|
70
|
+
* Only the last segment after a top-level `;`, newline, `||` or `|` is read.
|
|
71
|
+
* After `&&` the 1 may come from the command that short-circuited, so it is
|
|
72
|
+
* not classified. Under pipefail an earlier pipeline stage can also set the
|
|
73
|
+
* status; the exit is kept in the receipt either way, and the label states
|
|
74
|
+
* the program's meaning rather than claiming success. Anything this scanner
|
|
75
|
+
* cannot read with certainty -- unbalanced quotes, subshells, compound
|
|
76
|
+
* statements, substitutions -- yields null.
|
|
77
|
+
*/
|
|
78
|
+
export function negativeAnswer(command: string, exit: number): string | null {
|
|
79
|
+
if (exit !== 1) return null;
|
|
80
|
+
let quote: "'" | "\"" | null = null, start = 0, separator = "";
|
|
81
|
+
for (let i = 0; i < command.length; i++) {
|
|
82
|
+
const c = command[i]!;
|
|
83
|
+
if (quote !== null) {
|
|
84
|
+
if (c === "\\" && quote === "\"") i++;
|
|
85
|
+
else if (c === quote) quote = null;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (c === "\\") { i++; continue; }
|
|
89
|
+
if (c === "'" || c === "\"") { quote = c; continue; }
|
|
90
|
+
if (c === "`" || c === "(" || c === ")" || c === "{" || c === "}" || (c === "$" && command[i + 1] === "(")) return null;
|
|
91
|
+
const two = command.slice(i, i + 2);
|
|
92
|
+
if (two === "&&" || two === "||") { separator = two; start = i + 2; i++; continue; }
|
|
93
|
+
if (c === ";" || c === "\n" || c === "|") { separator = c; start = i + 1; continue; }
|
|
94
|
+
if (c === "&" && command[i + 1] !== ">" && command[i - 1] !== ">") return null;
|
|
95
|
+
}
|
|
96
|
+
if (quote !== null || separator === "&&") return null;
|
|
97
|
+
const words = command.slice(start).trim().split(/\s+/).filter(word => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word));
|
|
98
|
+
const program = (words[0] ?? "").replace(/^.*\//, "");
|
|
99
|
+
if (program === "git") return words[1] === "grep" ? "no match" : null;
|
|
100
|
+
return Object.hasOwn(NEGATIVE_ANSWERS, program) ? NEGATIVE_ANSWERS[program]! : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
30
103
|
/** A trap can exit zero after cancellation: successful exit alone does not erase the stop. */
|
|
31
|
-
export function commandCompletion(exit: number, stopped?: "cancelled" | "timeout", cleanup?: ToolDiagnostic) {
|
|
32
|
-
|
|
33
|
-
|
|
104
|
+
export function commandCompletion(exit: number, stopped?: "cancelled" | "timeout", cleanup?: ToolDiagnostic, command?: string) {
|
|
105
|
+
const answered = exit === 0 || command !== undefined && negativeAnswer(command, exit) !== null;
|
|
106
|
+
return { ok: stopped === undefined && answered && cleanup === undefined,
|
|
107
|
+
outcome: executionOutcome(stopped ?? (answered && cleanup === undefined ? "tool_completed" : "tool_failed")),
|
|
34
108
|
...(cleanup ? { warnings: [cleanup] } : {}) };
|
|
35
109
|
}
|
|
@@ -69,7 +69,7 @@ export function requestCompletion(state: RequestState, remainingRequests: number
|
|
|
69
69
|
if (state.used_tools && text.length === 0) gaps.push("the final reply is empty");
|
|
70
70
|
// Strict verification counts only configured gates (O.11); say so, since a successful `cat` would otherwise look like a check.
|
|
71
71
|
if (freshCheck && freshCheck.status !== "pass") gaps.push(freshCheck.gate_only
|
|
72
|
-
? "files were changed but no configured verification gate (a test, typecheck, lint, build or check command) has passed since the last edit; reading or printing files does not count"
|
|
72
|
+
? "files were changed but no configured verification gate (a test, typecheck, lint, build or check command) has passed since the last edit; reading or printing files does not count, and a gate counts only when run as its own plain command, without redirects, pipes, $(), ; or &&"
|
|
73
73
|
: "files were changed but no command has succeeded since the last edit");
|
|
74
74
|
if (acceptance?.status === "needs_work") gaps.push(`declared acceptance criteria need evidence: ${[
|
|
75
75
|
...missingCommands.map(item => `${item.id}=${item.status}`),
|
package/src/core/environment.ts
CHANGED
|
@@ -19,6 +19,12 @@ export interface SessionEnvironment {
|
|
|
19
19
|
/** Local calendar date, YYYY-MM-DD. */
|
|
20
20
|
date: string;
|
|
21
21
|
git: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Whether `rg` resolves in tool shells at session start. Models reach for
|
|
24
|
+
* it by habit; without this line a missing one costs an exit-127 request.
|
|
25
|
+
* Optional so environments recorded before it existed still render.
|
|
26
|
+
*/
|
|
27
|
+
ripgrep?: boolean;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
export function environmentSection(env: SessionEnvironment): string {
|
|
@@ -29,6 +35,7 @@ export function environmentSection(env: SessionEnvironment): string {
|
|
|
29
35
|
`- Working directory: ${env.cwd}`,
|
|
30
36
|
`- Date: ${env.date}`,
|
|
31
37
|
`- Git repository: ${env.git ? "yes" : "no"}`,
|
|
38
|
+
...(env.ripgrep === undefined ? [] : [`- ripgrep (rg) in the shell: ${env.ripgrep ? "yes" : "no — use the grep and find tools"}`]),
|
|
32
39
|
].join("\n");
|
|
33
40
|
}
|
|
34
41
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the sidebar shows about an explicit goal (feat-sidebar-goal-and-speed),
|
|
3
|
+
* derived from the log alone so it reads the same after a restart.
|
|
4
|
+
*
|
|
5
|
+
* The block exists only while a /goal is set, and for one message after it is
|
|
6
|
+
* met. Its verdicts are the request ledger's (D11); this only chooses words:
|
|
7
|
+
* the phase answers "do I need to act, and is it still running", and each
|
|
8
|
+
* accepted check says what it needs from the user.
|
|
9
|
+
*/
|
|
10
|
+
import type { LogEvent } from "./events.ts";
|
|
11
|
+
import type { RequestCriterion, RequestState } from "./request-state.ts";
|
|
12
|
+
|
|
13
|
+
export type GoalPhase = "drafting" | "working" | "needs_you" | "met" | "stopped";
|
|
14
|
+
export type GoalCheck = { id: string; kind: RequestCriterion["kind"]; status: RequestCriterion["status"]; guard?: true; exit?: number };
|
|
15
|
+
/**
|
|
16
|
+
* `startedAt` is when the goal was set; `endedAt` when it was met, absent while
|
|
17
|
+
* it runs. Times, not a duration, so a caller can cache this per log length and
|
|
18
|
+
* read the clock only when it paints.
|
|
19
|
+
*/
|
|
20
|
+
export type GoalStatus = { phase: GoalPhase; objective: string; checks: GoalCheck[]; startedAt: number; endedAt?: number; requests: number };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The goal block for the end of an ancestry, or null when no goal applies.
|
|
24
|
+
* `running` is whether a request is in flight now; `drafting` is the objective
|
|
25
|
+
* of a /goal whose criteria are still being drafted.
|
|
26
|
+
*/
|
|
27
|
+
export function goalStatus(events: readonly LogEvent[], state: RequestState | undefined,
|
|
28
|
+
live: { running: boolean; drafting?: string }): GoalStatus | null {
|
|
29
|
+
if (live.drafting !== undefined) return { phase: "drafting", objective: live.drafting, checks: [], startedAt: 0, requests: 0 };
|
|
30
|
+
const lastSet = events.findLastIndex(event => event.t === "goal" && event.action === "set");
|
|
31
|
+
if (lastSet < 0) return null;
|
|
32
|
+
const set = events[lastSet]!;
|
|
33
|
+
if (set.t !== "goal" || set.action !== "set") return null;
|
|
34
|
+
const after = events.slice(lastSet + 1);
|
|
35
|
+
// The last clear decides: a met goal the user then cleared is gone like any other cleared goal.
|
|
36
|
+
const clear = after.findLastIndex(event => event.t === "goal" && event.action === "clear");
|
|
37
|
+
const cleared = clear < 0 ? undefined : after[clear];
|
|
38
|
+
// A goal the user cleared is gone; a met goal stays for one summary, until the next message opens a new request.
|
|
39
|
+
if (cleared?.t === "goal" && cleared.action === "clear" && cleared.reason !== "met") return null;
|
|
40
|
+
const recorded = cleared !== undefined;
|
|
41
|
+
if (recorded && after.slice(clear + 1).some(event => event.t === "user" && event.request === undefined)) return null;
|
|
42
|
+
// Only the goal's own request carries its contract; the ledger of any other request says nothing about it.
|
|
43
|
+
const own = state?.acceptance?.contract_id === set.contract.id && state.acceptance.contract_event !== undefined
|
|
44
|
+
&& events.some((event, index) => index > lastSet && event.id === state.acceptance!.contract_event) ? state : undefined;
|
|
45
|
+
// The ledger is the verdict (D11): verified is met even before the clear is recorded -- an /accept outside a
|
|
46
|
+
// request, a log written before /accept recorded it -- and its clock stops at the last recorded fact.
|
|
47
|
+
const met = recorded || own?.status === "verified";
|
|
48
|
+
const end = recorded ? Date.parse(cleared.at) : met ? Date.parse(after.at(-1)?.at ?? set.at) : undefined;
|
|
49
|
+
const checks: GoalCheck[] = set.contract.criteria.map(item => {
|
|
50
|
+
const entry = own?.criteria.find(criterion => criterion.id === item.id);
|
|
51
|
+
const run = item.kind === "command" ? own?.commands.findLast(command => command.command === item.command) : undefined;
|
|
52
|
+
return { id: item.id, kind: item.kind, status: entry?.status ?? (item.kind === "human" ? "awaiting_human" : "not_run"),
|
|
53
|
+
...(item.kind === "command" && item.guard ? { guard: true as const } : {}),
|
|
54
|
+
...(entry?.status === "fail" && run ? { exit: run.exit } : {}) };
|
|
55
|
+
});
|
|
56
|
+
return { phase: met ? "met" : live.running ? "working" : own?.status === "awaiting_human" ? "needs_you" : "stopped",
|
|
57
|
+
objective: set.objective, checks, startedAt: Date.parse(set.at), ...(end === undefined ? {} : { endedAt: end }),
|
|
58
|
+
requests: (recorded ? after.slice(0, clear) : after).filter(event => event.t === "assistant").length };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The words the sidebar and the input edge use for a phase. */
|
|
62
|
+
export function goalPhaseLabel(phase: GoalPhase): string {
|
|
63
|
+
return phase === "needs_you" ? "needs you" : phase;
|
|
64
|
+
}
|
package/src/core/history.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { emitHandoff, type Handoff } from "./handoff.ts";
|
|
3
3
|
import { estimateTokens } from "./tokens.ts";
|
|
4
4
|
import { recoveryWorkingSet } from "./working-set.ts";
|
|
5
|
+
import { recoveryInstructions } from "./prompt-policy.ts";
|
|
5
6
|
import type { RecoveryNodes } from "./recovery-sources.ts";
|
|
6
7
|
|
|
7
8
|
// A carried handoff must leave room for another segment's work. This budget is
|
|
@@ -122,6 +123,11 @@ export function boundRecoveryHandoff(full: Handoff, nodes: RecoveryNodes, budget
|
|
|
122
123
|
const legacy = boundHandoff(full, budget);
|
|
123
124
|
const view = recoveryWorkingSet(full, nodes, budget * .4, tools);
|
|
124
125
|
if (!view) return legacy;
|
|
125
|
-
|
|
126
|
+
let carried: Handoff;
|
|
127
|
+
try { carried = boundHandoff({ ...full, working_set: view }, budget); }
|
|
126
128
|
catch (error) { if (error instanceof RangeError) return legacy; throw error; }
|
|
129
|
+
// Whether the user turns survived bounding is known only now. The inline
|
|
130
|
+
// wording is shorter, so swapping it in cannot push the handoff over budget.
|
|
131
|
+
const inline = tools?.includes("history") && carried.user_turns_truncated === undefined && (carried.history?.sections["user_turns"]?.offset ?? 0) === 0;
|
|
132
|
+
return inline && carried.working_set ? { ...carried, working_set: { ...carried.working_set, recovery: recoveryInstructions(tools!, true) } } : carried;
|
|
127
133
|
}
|
package/src/core/permissions.ts
CHANGED
|
@@ -45,9 +45,10 @@ export type ToolSource = "builtin" | "extension";
|
|
|
45
45
|
|
|
46
46
|
/** Task capabilities are frozen once at session creation (O.7); not permissions. */
|
|
47
47
|
export const TOOL_PROFILES = {
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
|
|
48
|
+
// worktree joined the measured ten (2026-10-11, feat-worktree-tool): without
|
|
49
|
+
// it a model needing isolation had no checkout it was allowed to edit.
|
|
50
|
+
coding: ["read", "grep", "find", "write", "edit", "environment", "exec_command", "write_stdin", "history", "note", "worktree"],
|
|
51
|
+
foundation: ["read", "grep", "find", "write", "edit", "environment", "exec_command", "write_stdin", "history", "note", "worktree", "question", "apply_patch", "diff", "webfetch", "websearch", "format", "lsp"],
|
|
51
52
|
readonly: ["read", "grep", "find", "history", "note"],
|
|
52
53
|
research: ["read", "grep", "find", "history", "note", "branch", "resolve"],
|
|
53
54
|
data: ["read", "grep", "find", "history", "note", "query"],
|
|
@@ -23,9 +23,16 @@ export function toolDescription(description: string, guidance: readonly ToolGuid
|
|
|
23
23
|
return [description, ...guidance.filter(item => item.requires.every(name => tools.includes(name))).map(item => item.text)].join(" ");
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* `usersInline`: the carried `user_turns` hold every user message verbatim.
|
|
28
|
+
* Telling the model to page them anyway costs a guaranteed first-call read
|
|
29
|
+
* that returns what it already has -- or an empty page after a model switch
|
|
30
|
+
* on a fresh session (Horizyn mv3nsske-x50u, fix-recovery-inline-users).
|
|
31
|
+
*/
|
|
32
|
+
export function recoveryInstructions(tools: readonly string[], usersInline = false): string {
|
|
27
33
|
return (tools.includes("history")
|
|
28
|
-
? "Read original user messages before continuing: history(node=source.node,recovery=users,offset=0). Other pages: failures, environment, next_actions;
|
|
34
|
+
? (usersInline ? "User messages are verbatim in user_turns. Other history(node=source.node,recovery=...) pages: failures, environment, next_actions; " : "Read original user messages before continuing: history(node=source.node,recovery=users,offset=0). Other pages: failures, environment, next_actions; ") +
|
|
35
|
+
"event=ID reads one original. history(work_id=ID) recovers work. "
|
|
29
36
|
: "Original user messages and evidence remain at the source references. The original-source recovery capability is unavailable; state any resulting evidence gap instead of guessing. ") +
|
|
30
37
|
"Abandoned messages are cancelled; annotations are not user instructions. Observations are historical: recheck resource, host and current permissions before acting.";
|
|
31
38
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brings the ripgrep regex dialect to JS RegExp where the two differ in
|
|
3
|
+
* spelling, not meaning.
|
|
4
|
+
*
|
|
5
|
+
* grep runs ripgrep when it is on PATH and a TypeScript walk otherwise, and
|
|
6
|
+
* the model writes for ripgrep (Rust regex), where a leading `(?i)` is the
|
|
7
|
+
* usual way to ask for case-insensitivity. JS rejects that spelling but
|
|
8
|
+
* accepts the scoped form `(?i:...)`, so a leading global group is rewritten
|
|
9
|
+
* into a scoped group around the rest of the pattern. Without this the same
|
|
10
|
+
* call works or fails depending on whether rg happens to be installed.
|
|
11
|
+
*
|
|
12
|
+
* Only the leading form and only flags JS modifiers support (i, m, s) are
|
|
13
|
+
* rewritten; anything else is passed through so RegExp reports it as it is.
|
|
14
|
+
*/
|
|
15
|
+
const LEADING_FLAGS = /^\(\?([ims]+)\)/;
|
|
16
|
+
|
|
17
|
+
export function jsPatternFromRipgrep(pattern: string): string {
|
|
18
|
+
const match = LEADING_FLAGS.exec(pattern);
|
|
19
|
+
if (match === null) return pattern;
|
|
20
|
+
const flags = match[1]!;
|
|
21
|
+
// A repeated flag is a SyntaxError inside a modifier group; ripgrep
|
|
22
|
+
// tolerates it, so duplicates are dropped rather than surfaced.
|
|
23
|
+
const unique = [...new Set(flags)].join("");
|
|
24
|
+
return `(?${unique}:${pattern.slice(match[0].length)})`;
|
|
25
|
+
}
|