mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6379bc0628 | ||
|
|
82c5627123 | ||
|
|
e991644416 | ||
|
|
39dbe19bcd | ||
|
|
c1e8a933d3 | ||
|
|
b401913910 | ||
|
|
5662b57a4b | ||
|
|
e40e1911cc | ||
|
|
fdea3bb837 | ||
|
|
3efa4cc374 | ||
|
|
26ca84dd8f | ||
|
|
e91d136dbc | ||
|
|
12a3dab7e9 | ||
|
|
deefafed10 | ||
|
|
882a7ad2d9 | ||
|
|
b248a87455 | ||
|
|
ff858ed744 | ||
|
|
a5adca41ac | ||
|
|
aed4d431cb | ||
|
|
0acfcefb89 | ||
|
|
9cb0bdbc5f | ||
|
|
2ca403670d | ||
|
|
c6e5bf4b02 | ||
|
|
08c7f2be81 | ||
|
|
8befb44e5e | ||
|
|
b65ed747c9 | ||
|
|
86be00299e | ||
|
|
b634b5e171 | ||
|
|
3e91082456 | ||
|
|
638eaeeb46 | ||
|
|
1a7b88c72f | ||
|
|
6ee2428802 | ||
|
|
febecb3d6f | ||
|
|
795a1e2f35 | ||
|
|
77cd4f7f66 | ||
|
|
3ec46356f5 | ||
|
|
825c6e9608 | ||
|
|
affa03b235 | ||
|
|
4ba0199c42 | ||
|
|
5f80b252e8 | ||
|
|
4b77671103 | ||
|
|
900eae2a3a | ||
|
|
94047eadf3 | ||
|
|
0945a1189e | ||
|
|
bba8199ba4 | ||
|
|
fc8639a697 | ||
|
|
8c51dd6f0f | ||
|
|
56f1cbb835 | ||
|
|
abb1b1ec7a | ||
|
|
4f1e65a629 | ||
|
|
7096e43895 |
No files matched your search
+227
@@ -3,6 +3,233 @@
|
||||
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
||||
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.30.0] — 2026-05-06
|
||||
|
||||
**Headline:** slash commands grow first-class aliases, and the
|
||||
cell-diff renderer hardens column targeting against per-cell width
|
||||
miscounts. `/quit` and `/q` now resolve to `/exit` from a single
|
||||
declaration on the spec instead of ad-hoc handler mirrors; `/?` →
|
||||
`/help`, `/reset` → `/new`, `/lang` → `/language` follow the same
|
||||
path. The renderer's `moveTo()` now uses CHA absolute (`\x1b[N+1G`)
|
||||
for column targeting instead of CUF relative (`\x1b[NC`), making the
|
||||
diff stream immune to the cursor-drift class of bug Anthropic
|
||||
documented in `claude-code#14208`.
|
||||
|
||||
**Features:**
|
||||
|
||||
- feat(slash): `aliases?: readonly string[]` on `SlashCommandSpec`.
|
||||
Adding a new alias is now a one-line edit to the canonical command
|
||||
— dispatch, autocomplete, arg-context resolution, and the
|
||||
dashboard `/api/slash` response all route through one
|
||||
`resolveSlashAlias()` map built from `SLASH_COMMANDS` at module
|
||||
init. Suggestion rows display aliases dimly (` · /quit /q`) so
|
||||
they stay discoverable without doubling the autocomplete list.
|
||||
Removes the per-handler alias mirrors that used to live in
|
||||
`handlers/basic.ts` and `handlers/language.ts`. (#332, PR #347)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
- fix(renderer): switch the X-axis branch of `moveTo()` from CUF
|
||||
relative (`\x1b[NC`) to CHA absolute (`\x1b[N+1G`). Y-axis stays
|
||||
on CUU/CUD since we don't track absolute terminal rows. Relative
|
||||
column moves accumulate drift across frames whenever an earlier
|
||||
write miscounts cell width — `▸` (U+25B8) rendered 2-cell on
|
||||
fonts with East Asian fallback, ambiguous-width chars on
|
||||
terminals that font-detect width, OSC8 hyperlinks parsed as
|
||||
visible chars, etc. The next CUF lands at the wrong column,
|
||||
ghost rows leak into adjacent hint lines, and the modal "shifts"
|
||||
as users navigate. CHA targets the absolute column regardless of
|
||||
what the terminal thinks — immune to the desync chain. Same fix
|
||||
Anthropic shipped in claude-code per their issue #14208
|
||||
post-mortem. (#346, PR #348)
|
||||
|
||||
## [0.29.1] — 2026-05-06
|
||||
|
||||
**Headline:** four user-reported bugs from the 0.29.0 release window.
|
||||
The markdown renderer no longer turns English abbreviations like
|
||||
`e.g.` into broken hyperlinks (which on cmd.exe / non-OSC-8 terminals
|
||||
showed up as visible `]8;;file://e.g…` garbage and on the cell-diff
|
||||
side desynced the renderer's prev-frame model). The cell-diff
|
||||
renderer now defensively trail-clears any row whose content shrank
|
||||
between frames. Resumed sessions keep their cumulative session cost
|
||||
instead of resetting to `$0`. The Approve plan modal now shows the
|
||||
plan body inline when the model didn't supply structured steps.
|
||||
Wide markdown tables fall back to row-grouped key/value lines
|
||||
instead of the previous column-grouped output.
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
- fix(markdown): stop linkifying English abbreviations + drop OSC 8
|
||||
escape emission. The `FILE_REF_RE` extension class was too loose
|
||||
(`{1,6}`), so `e.g`, `i.e`, `a.m` matched as file paths; `osc8()`
|
||||
baked OSC 8 escape bytes into Text content, which the cell-diff
|
||||
renderer's wrapLine stripped of zero-width chars but kept the
|
||||
printable body — producing visible `]8;;file://e.ge.g]8;;` garbage
|
||||
on every terminal. Tightened the regex (now requires path-shape,
|
||||
line-number suffix, or extension >= 2 chars) and removed the OSC 8
|
||||
escape — file refs still stand out via color + underline. (#330,
|
||||
PR #341)
|
||||
- fix(renderer): trail-clear rows that shrank between frames in the
|
||||
cell-diff diff. The diff skipped cells where prev and next were
|
||||
byte-equal (including trailing EMPTY cells), so any earlier ANSI
|
||||
desync left stale chars in shrunken rows — manifested as the
|
||||
shell-confirm modal showing `allow always` + `mand, ask again next
|
||||
time` after Up/Down navigation. New `clearToEOL` patch type and a
|
||||
per-row sweep after `diffEach`. (#330, PR #341)
|
||||
- fix(stats): carry session cost / turn count across resume. The
|
||||
TUI's `$X session` figure reset to `$0` on every resume even
|
||||
though the disk meta still held the cumulative `totalCostUsd`.
|
||||
`SessionStats` gains `seedCarryover()`; `CacheFirstLoop` reads the
|
||||
meta on resume and seeds the carryover when prior messages exist.
|
||||
(#333, PR #342)
|
||||
- fix(plan): show the plan body in the Approve plan modal. When the
|
||||
model called `submit_plan` with a markdown body but no structured
|
||||
`steps`, the modal showed only the choice list — users had no way
|
||||
to see what they were approving without scrolling back. The modal
|
||||
now renders the body via `MarkdownView`, capped at 24 lines with
|
||||
an overflow hint. (#336, PR #343)
|
||||
- fix(markdown): row-group the table fallback layout. When a table
|
||||
was too wide for the viewport, the fallback flattened it as N
|
||||
"Component:" lines, then N "What:" lines, then N "Manual TCs:"
|
||||
lines — the reader couldn't tell which value belonged to which
|
||||
row. Swapped to row-first iteration with a blank separator
|
||||
between rows. (#340, PR #344)
|
||||
|
||||
## [0.29.0] — 2026-05-06
|
||||
|
||||
**Headline:** tool dispatch is no longer strictly serial. When the model
|
||||
emits multiple `parallelSafe`-annotated tool calls in one turn (multiple
|
||||
`read_file`, multiple `spawn_subagent`, etc.), the loop now races them
|
||||
together via `Promise.allSettled`; a non-`parallelSafe` call ends the
|
||||
chunk and runs alone, so read-after-write ordering still holds. Tool
|
||||
yields and history append still land in declared order regardless of
|
||||
which call settles first — the model and UI see the same shape they
|
||||
would under serial dispatch. The TUI's `SubagentRow` becomes
|
||||
`SubagentLiveStack`, rendering 1 → rich card, 2..max → compact rows,
|
||||
> max → "+N more running…" fold. Closes umbrella #325.
|
||||
|
||||
**Tool dispatch:**
|
||||
|
||||
- feat(tools): `ToolDefinition.parallelSafe?: boolean` — opt-in
|
||||
annotation, default `false`. `ToolRegistry.isParallelSafe(name)` for
|
||||
the dispatcher to query; unknown / unannotated tools resolve to
|
||||
`false` so third-party MCP tools must explicitly opt in. Built-in
|
||||
read-only filesystem (`read_file`, `list_directory`,
|
||||
`directory_tree`, `search_files`, `search_content`,
|
||||
`get_file_info`), web (`web_search`, `web_fetch`), `recall_memory`,
|
||||
`semantic_search`, isolated child loops (`run_skill`,
|
||||
`spawn_subagent`), and in-memory job queries (`job_output`,
|
||||
`list_jobs`) are annotated. Mutating tools stay default. (PR #326)
|
||||
- feat(loop): chunked parallel tool dispatch. Replaces `for...of +
|
||||
await` in the dispatch loop with a chunking loop that groups
|
||||
consecutive `parallelSafe` calls and races them; unsafe calls form
|
||||
serial barriers. `runOneToolCall` extracts per-call lifecycle
|
||||
(PreToolUse + dispatch + PostToolUse) so the chunk can fan out via
|
||||
`Promise.allSettled` while the loop body keeps yielding events in
|
||||
declared order. Two new env knobs: `REASONIX_PARALLEL_MAX` (chunk
|
||||
size cap, default 3, hard max 16) and `REASONIX_TOOL_DISPATCH=serial`
|
||||
(escape hatch). Tests cover parallel timing, serial barrier on mixed
|
||||
safe/unsafe, declared-order yields under racey completion, and both
|
||||
env-knob overrides. (PR #327)
|
||||
|
||||
**TUI:**
|
||||
|
||||
- feat(ui): `SubagentEvent` carries a stable `runId` per spawn so the
|
||||
sink can key concurrent runs apart instead of overwriting one shared
|
||||
row. `useSubagent` keeps an array of in-flight activities;
|
||||
`SubagentLiveStack` renders 1 → rich card (unchanged), 2..max →
|
||||
compact rows with per-row spinner + iter + last tool, > max →
|
||||
compact rows + "+N more running…" fold. (PR #327)
|
||||
|
||||
**Docs:**
|
||||
|
||||
- docs(architecture): `docs/ARCHITECTURE.md` Pillar 1 gains a
|
||||
"Parallel tool dispatch" section explaining the chunking rule, both
|
||||
env knobs, and the list of built-in tools that opt in. (PR #328)
|
||||
|
||||
## [0.28.0] — 2026-05-06
|
||||
|
||||
**Headline:** subagent capability sharpened on three axes — skills can
|
||||
now scope a child to a specific tool subset via `allowed-tools`
|
||||
frontmatter, callers can request a per-spawn iter budget via the new
|
||||
`max_iters` arg (clamped 1-32), and two built-in personas (`explore`,
|
||||
`verify`) are selectable inline via a `type` arg without writing a
|
||||
skill. Closes umbrella #316.
|
||||
|
||||
**Subagent:**
|
||||
|
||||
- feat(subagent): honor skill `allowed-tools` frontmatter when forking
|
||||
the child registry. The field was parsed but ignored ("Unused in v1");
|
||||
now it scopes the subagent to the named tools only. New
|
||||
`forkRegistryWithAllowList` helper alongside `forkRegistryExcluding`;
|
||||
`NEVER_INHERITED` (`spawn_subagent` / `submit_plan`) still wins so
|
||||
depth=1 + plan-mode guarantees hold even if a skill names them. An
|
||||
allow-list naming a tool the parent doesn't have returns a structured
|
||||
error result (no API call burned). (#317, PR #320)
|
||||
- feat(subagent): expose `max_iters` on the `spawn_subagent` tool
|
||||
schema. Clamped to 1-32 at the boundary; floats round down; non-numeric
|
||||
/ missing falls back to the registration-time default (still 16).
|
||||
Verify-style tasks can ask for 6-8, explore-style can ask for 24+.
|
||||
(#318, PR #321)
|
||||
- feat(subagent): two built-in personas selectable via `type` arg —
|
||||
`explore` (wide-net read-only investigation, 20-iter budget) and
|
||||
`verify` (narrow yes/no with evidence, 8-iter budget). Caller's
|
||||
explicit `system` / `max_iters` override the type's defaults. Prompts
|
||||
live in new `src/tools/subagent-types.ts` so `subagent.ts` stays
|
||||
under the 500-line target. (#319, PR #322)
|
||||
|
||||
## [0.27.3] — 2026-05-06
|
||||
|
||||
**Headline:** USD-account users now see `$` instead of `¥` everywhere
|
||||
money is shown in the TUI — wallet balance, turn cost, session cost,
|
||||
top-bar cost label, subagent end-event cost suffix, and the UsageCard
|
||||
header / body / wallet line. Pre-fix a USD wallet rendered
|
||||
`¥0.0352 turn · ¥0.461 session · wallet ¥0.91`; now it renders
|
||||
`$0.0308 turn · $0.064 session · wallet $0.91`. The display follows
|
||||
the wallet currency reported by the DeepSeek API (`currency: "USD"|"CNY"`),
|
||||
not the UI language — a CNY account on an English UI still sees `¥`,
|
||||
and vice versa. Originally reported in #278 by @Explosion-Scratch.
|
||||
|
||||
**UI / currency:**
|
||||
|
||||
- fix(ui): USD wallets render `$` for wallet balance, turn cost, and
|
||||
session cost. State + event schemas now carry `balanceCurrency`
|
||||
through `App.tsx → reducer → StatusBar` so every render site sees
|
||||
the wallet symbol the API reported. Originally drafted by @wviana
|
||||
in #272; the TUI plumbing through state.ts / cards.ts / events.ts /
|
||||
reducer.ts / useScrollback.ts / slash/types.ts was the bulk of the
|
||||
fix.
|
||||
- fix(ui): balance color threshold checks USD against the CNY scale
|
||||
(`$0.91 ≈ ¥6.55`) rather than treating `0.91` as `< ¥5 → red`. USD
|
||||
wallets now correctly show yellow at low-but-not-empty balances.
|
||||
- fix(ui): `StatsPanel.ChromeRow` cost label and `useSubagent`
|
||||
end-event cost suffix follow the wallet currency too — pre-fix
|
||||
these always rendered `$`. (#313)
|
||||
- refactor(ui): seven currency helpers in `theme/tokens.ts`
|
||||
(`formatCNY` / `formatBalance` / `formatBalanceLabel` /
|
||||
`formatWalletDisplay` / `formatCost` / `balanceColorCny` /
|
||||
`balanceColorForBalance`) collapsed to three: `formatBalance`,
|
||||
`formatCost`, `balanceColor`. Undefined currency defaults to CNY
|
||||
(matches pre-fix unconditional `¥`) so the transient first-turn
|
||||
case where balance arrived but currency hasn't is consistent.
|
||||
- chore(ui): remove orphan `ChromeBar.tsx` (258 lines). `App.tsx`
|
||||
mounts `StatsPanel`'s diverged `ChromeRow`, which is the bar users
|
||||
actually see. The two formatter helpers ChromeBar once owned now
|
||||
live in `theme/tokens.ts`. (#314)
|
||||
|
||||
**Loop:**
|
||||
|
||||
- refactor(loop): `loop.ts` 1331 → 1219 (−112). Three sibling files
|
||||
under `src/loop/`: `messages.ts` (pure ChatMessage builders),
|
||||
`turn-failure-tracker.ts` (per-turn failure count + threshold
|
||||
tipping), `force-summary.ts` (forced-summary generator behind a
|
||||
small DI context). Continues the #308 / #309 cadence — small
|
||||
per-helper extractions, no behavior change. (#311)
|
||||
|
||||
**Known follow-up:** `SessionPicker` still hardcodes `¥` for
|
||||
per-session cost in the session-history list, tracked in #312
|
||||
(good-first-issue).
|
||||
|
||||
## [0.25.1] — 2026-05-05
|
||||
|
||||
**Headline:** `run_command` learns the four common shell chain
|
||||
|
||||
@@ -45,7 +45,7 @@ const PROMPTS = [
|
||||
{ id: "m3", level: "medium", task: "A function \`debounceAsync<T extends any[], R>(fn: (...args: T) => Promise<R>, ms: number): (...args: T) => Promise<R>\` in src/util/debounce.ts. Resolves only the latest call's promise; earlier callers reject with an AbortError-like." },
|
||||
|
||||
// hard (2) — these touch domain types from the repo
|
||||
{ id: "h1", level: "hard", task: "A function \`extractTestId(file: string, fullName: string, source: string): { id: string, source: 'native' | 'annotation' }\` in src/repair/test-id.ts. If \`source\` contains a '// @reasonix-test-id: <slug>' comment within 3 lines above an it()/test() whose name matches \`fullName\`, return that slug with source='annotation'. Otherwise return \`\${file}::\${fullName}\` with source='native'." },
|
||||
{ id: "h1", level: "hard", task: "A function \`extractTestId(file: string, fullName: string, source: string): { id: string, source: 'native' | 'annotation' }\` in src/repair/test-id.ts. If \`source\` contains a '// @reasonix-test-id: <slug>' comment within 3 lines above an it()/test() whose name matches \`fullName\`, return that slug with source='annotation'. Otherwise return \`${file}::${fullName}\` with source='native'." },
|
||||
{ id: "h2", level: "hard", task: "A function \`pairRedGreen(events: Array<{type:string, test_id?:string, status?:string, ts:number}>): Array<{ test_id: string, red_ts: number, green_ts: number }>\` in src/events/pair.ts. For each test_id, find the most recent fail→pass transition and return one entry per test_id. Ignore test_ids that never went green." },
|
||||
];
|
||||
|
||||
|
||||
@@ -812,6 +812,9 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
|
||||
}
|
||||
.chip-f:hover { background: var(--bg-hover); color: var(--fg-1); }
|
||||
.chip-f.active { color: var(--c-brand); border-color: var(--c-brand); background: rgba(121,192,255,.08); }
|
||||
.chip-f.static { cursor: default; }
|
||||
.chip-f.static:hover { background: var(--bg-elev); color: var(--fg-2); }
|
||||
.chip-f.static.active:hover { color: var(--c-brand); background: rgba(121,192,255,.08); }
|
||||
.chip-f .ct { color: var(--fg-4); font-size: 10px; }
|
||||
.chip-f.active .ct { color: var(--c-brand); }
|
||||
.chip-f .x { color: var(--fg-4); padding: 0 2px; }
|
||||
|
||||
@@ -559,7 +559,7 @@ export function ChatPanel() {
|
||||
<div class="chat-shell">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">
|
||||
<div class="chips" style="padding:0">
|
||||
<span class="chip-f active">${MODE === "attached" ? t("chat.modeMirror") : t("chat.modeView")}</span>
|
||||
<span class="chip-f static active">${MODE === "attached" ? t("chat.modeMirror") : t("chat.modeView")}</span>
|
||||
</div>
|
||||
<div class="header-pickers" style="margin-left:auto">
|
||||
${
|
||||
|
||||
@@ -68,6 +68,7 @@ export function HooksPanel() {
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [eventFilter, setEventFilter] = useState<string>("all");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -129,19 +130,31 @@ export function HooksPanel() {
|
||||
data.events.length > 0
|
||||
? data.events
|
||||
: Array.from(new Set(matrixRows.flatMap((r) => Object.keys(r.cells))));
|
||||
const visibleRows =
|
||||
eventFilter === "all"
|
||||
? matrixRows
|
||||
: matrixRows.filter((r) => r.cells[eventFilter]?.on);
|
||||
const gridCols = `220px repeat(${Math.max(events.length, 1)}, minmax(0, 1fr))`;
|
||||
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<div class="chips">
|
||||
<span class="chip-f active">${t("hooks.resolved")} <span class="ct">${data.resolved.length}</span></span>
|
||||
${data.events.map((ev) => html`<span class="chip-f">${ev}</span>`)}
|
||||
<span
|
||||
class=${`chip-f ${eventFilter === "all" ? "active" : ""}`}
|
||||
onClick=${() => setEventFilter("all")}
|
||||
>${t("hooks.resolved")} <span class="ct">${data.resolved.length}</span></span>
|
||||
${data.events.map(
|
||||
(ev) => html`<span
|
||||
class=${`chip-f ${eventFilter === ev ? "active" : ""}`}
|
||||
onClick=${() => setEventFilter(ev)}
|
||||
>${ev}</span>`,
|
||||
)}
|
||||
</div>
|
||||
${info ? html`<div><span class="pill ok">${info}</span></div>` : null}
|
||||
${error ? html`<div class="card accent-err">${error}</div>` : null}
|
||||
|
||||
${sectionH3(t("hooks.eventMatrix"), t("hooks.matrixSub", { scripts: matrixRows.length, s: matrixRows.length === 1 ? "" : "s", events: events.length }))}${
|
||||
matrixRows.length === 0
|
||||
visibleRows.length === 0
|
||||
? html`<div class="card" style="color:var(--fg-3)">
|
||||
${t("hooks.noHooks")}
|
||||
</div>`
|
||||
@@ -152,7 +165,7 @@ export function HooksPanel() {
|
||||
<div>${t("hooks.colScript")}</div>
|
||||
${events.map((ev) => html`<div>${ev}</div>`)}
|
||||
</div>
|
||||
${matrixRows.map(
|
||||
${visibleRows.map(
|
||||
(r) => html`
|
||||
<div class="row" style=${`grid-template-columns:${gridCols}`}>
|
||||
<div class="cell" title=${r.command}>
|
||||
|
||||
@@ -122,8 +122,8 @@ export function PermissionsPanel() {
|
||||
}
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip-f active">${t("permissions.project")} <span class="ct">${p.project.length}</span></span>
|
||||
<span class="chip-f">${t("permissions.builtin")} <span class="ct">${p.builtin.length}</span></span>
|
||||
<span class="chip-f static active">${t("permissions.project")} <span class="ct">${p.project.length}</span></span>
|
||||
<span class="chip-f static">${t("permissions.builtin")} <span class="ct">${p.builtin.length}</span></span>
|
||||
</div>
|
||||
|
||||
${
|
||||
|
||||
@@ -37,6 +37,7 @@ export function PlansPanel() {
|
||||
const { data, error, loading } = usePoll<PlansData>("/plans", 8000);
|
||||
const [openIdx, setOpenIdx] = useState<number | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "done">("all");
|
||||
|
||||
if (loading && !data)
|
||||
return html`<div class="card" style="color:var(--fg-3)">${t("plans.loading")}</div>`;
|
||||
@@ -48,13 +49,19 @@ export function PlansPanel() {
|
||||
${t("plans.noPlans")}
|
||||
</div>`;
|
||||
|
||||
const statusFiltered =
|
||||
statusFilter === "all"
|
||||
? plans
|
||||
: statusFilter === "active"
|
||||
? plans.filter((p) => p.completionRatio > 0 && p.completionRatio < 1)
|
||||
: plans.filter((p) => p.completionRatio >= 1);
|
||||
const filtered = filter.trim()
|
||||
? plans.filter(
|
||||
? statusFiltered.filter(
|
||||
(p) =>
|
||||
p.session.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(p.summary ?? "").toLowerCase().includes(filter.toLowerCase()),
|
||||
)
|
||||
: plans;
|
||||
: statusFiltered;
|
||||
|
||||
const open = openIdx !== null ? plans[openIdx] : null;
|
||||
|
||||
@@ -71,12 +78,21 @@ export function PlansPanel() {
|
||||
/>
|
||||
</div>
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span class="chip-f active">${t("common.all")} <span class="ct">${plans.length}</span></span>
|
||||
<span class="chip-f">
|
||||
<span
|
||||
class=${`chip-f ${statusFilter === "all" ? "active" : ""}`}
|
||||
onClick=${() => setStatusFilter("all")}
|
||||
>${t("common.all")} <span class="ct">${plans.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${statusFilter === "active" ? "active" : ""}`}
|
||||
onClick=${() => setStatusFilter("active")}
|
||||
>
|
||||
${t("plans.active")}
|
||||
<span class="ct">${plans.filter((p) => p.completionRatio > 0 && p.completionRatio < 1).length}</span>
|
||||
</span>
|
||||
<span class="chip-f">
|
||||
<span
|
||||
class=${`chip-f ${statusFilter === "done" ? "active" : ""}`}
|
||||
onClick=${() => setStatusFilter("done")}
|
||||
>
|
||||
${t("plans.done")} <span class="ct">${plans.filter((p) => p.completionRatio >= 1).length}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -179,13 +179,13 @@ export function SemanticPanel() {
|
||||
<div style="display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:14px;align-items:start">
|
||||
<div style="display:flex;flex-direction:column;gap:10px;min-width:0">
|
||||
<div class="chips">
|
||||
<span class=${`chip-f ${idx?.exists ? "active" : ""}`}>
|
||||
<span class=${`chip-f static ${idx?.exists ? "active" : ""}`}>
|
||||
${idx?.exists ? t("semantic.indexBuilt") : t("semantic.noIndex")}
|
||||
</span>
|
||||
${
|
||||
ready
|
||||
? html`<span class="chip-f" style="border-color:var(--c-ok);color:var(--c-ok)">${t("semantic.ready")}</span>`
|
||||
: html`<span class="chip-f" style="border-color:var(--c-warn);color:var(--c-warn)">${t("semantic.setupNeeded")}</span>`
|
||||
? html`<span class="chip-f static" style="border-color:var(--c-ok);color:var(--c-ok)">${t("semantic.ready")}</span>`
|
||||
: html`<span class="chip-f static" style="border-color:var(--c-warn);color:var(--c-warn)">${t("semantic.setupNeeded")}</span>`
|
||||
}
|
||||
</div>
|
||||
${info ? html`<div><span class="pill info">${info}</span></div>` : null}
|
||||
@@ -730,7 +730,7 @@ function ChipFormRow({
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px">
|
||||
${value.map(
|
||||
(e) => html`
|
||||
<span class="chip-f">
|
||||
<span class="chip-f static">
|
||||
<span>${e}</span>
|
||||
<span class="x" style="cursor:pointer" onClick=${() => remove(e)} title="remove">×</span>
|
||||
</span>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function SessionsPanel() {
|
||||
/>
|
||||
</div>
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span class="chip-f active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
|
||||
<span class="chip-f static active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
|
||||
</div>
|
||||
<div class="ssl-rows">
|
||||
${filtered.map(
|
||||
|
||||
@@ -29,6 +29,7 @@ export function SkillsPanel() {
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newScope, setNewScope] = useState<"global" | "project">("global");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [scopeFilter, setScopeFilter] = useState<"all" | Scope>("all");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -120,13 +121,15 @@ export function SkillsPanel() {
|
||||
...data.global.map((s) => ({ scope: "global" as Scope, ...s })),
|
||||
...data.builtin.map((s) => ({ scope: "builtin" as Scope, ...s })),
|
||||
];
|
||||
const scopeFiltered =
|
||||
scopeFilter === "all" ? allWith : allWith.filter((s) => s.scope === scopeFilter);
|
||||
const filtered = filter.trim()
|
||||
? allWith.filter(
|
||||
? scopeFiltered.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(s.description ?? "").toLowerCase().includes(filter.toLowerCase()),
|
||||
)
|
||||
: allWith;
|
||||
: scopeFiltered;
|
||||
|
||||
return html`
|
||||
<div class="sessions-grid">
|
||||
@@ -141,10 +144,22 @@ export function SkillsPanel() {
|
||||
/>
|
||||
</div>
|
||||
<div class="chips" style="padding:0 12px 8px">
|
||||
<span class="chip-f active">${t("common.all")} <span class="ct">${allWith.length}</span></span>
|
||||
<span class="chip-f">${t("skills.project")} <span class="ct">${data.project.length}</span></span>
|
||||
<span class="chip-f">${t("skills.global")} <span class="ct">${data.global.length}</span></span>
|
||||
<span class="chip-f">${t("skills.builtin")} <span class="ct">${data.builtin.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "all" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("all")}
|
||||
>${t("common.all")} <span class="ct">${allWith.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "project" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("project")}
|
||||
>${t("skills.project")} <span class="ct">${data.project.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "global" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("global")}
|
||||
>${t("skills.global")} <span class="ct">${data.global.length}</span></span>
|
||||
<span
|
||||
class=${`chip-f ${scopeFilter === "builtin" ? "active" : ""}`}
|
||||
onClick=${() => setScopeFilter("builtin")}
|
||||
>${t("skills.builtin")} <span class="ct">${data.builtin.length}</span></span>
|
||||
</div>
|
||||
|
||||
<div style="padding:0 12px 8px;display:flex;gap:6px;flex-wrap:wrap">
|
||||
|
||||
@@ -37,8 +37,8 @@ export function ToolsPanel() {
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
<div class="chips">
|
||||
<span class="chip-f active">${t("common.all")} <span class="ct">${d.total}</span></span>
|
||||
${d.planMode ? html`<span class="chip-f" style="border-color:var(--c-warn);color:var(--c-warn)">${t("tools.planMode")}</span>` : null}
|
||||
<span class="chip-f static active">${t("common.all")} <span class="ct">${d.total}</span></span>
|
||||
${d.planMode ? html`<span class="chip-f static" style="border-color:var(--c-warn);color:var(--c-warn)">${t("tools.planMode")}</span>` : null}
|
||||
</div>
|
||||
|
||||
${
|
||||
|
||||
@@ -174,8 +174,8 @@ export function UsagePanel() {
|
||||
return html`
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<div class="chips">
|
||||
<span class="chip-f active">${t("usage.records", { count: u.recordCount.toLocaleString() })}</span>
|
||||
<span class="chip-f">${u.logSize}</span>
|
||||
<span class="chip-f static active">${t("usage.records", { count: u.recordCount.toLocaleString() })}</span>
|
||||
<span class="chip-f static">${u.logSize}</span>
|
||||
</div>
|
||||
|
||||
${
|
||||
|
||||
@@ -43,6 +43,29 @@ timestamps each turn — cache hit rate in practice: <20%.
|
||||
**Metric.** `prompt_cache_hit_tokens / (hit + miss)` exposed per-turn and
|
||||
aggregated per-session. Visible in the TUI's top-bar cache cell.
|
||||
|
||||
#### Parallel tool dispatch
|
||||
|
||||
Each tool declares `parallelSafe?: boolean` (default `false`). The loop
|
||||
dispatcher groups consecutive parallel-safe calls into chunks and races
|
||||
them via `Promise.allSettled`; the first non-parallel-safe call ends the
|
||||
chunk and runs alone (serial barrier — read-after-write order
|
||||
preserved). Tool-result yields and history append still land in declared
|
||||
order regardless of which call settles first, so the model sees the
|
||||
same shape it would under a fully serial dispatch.
|
||||
|
||||
| Env var | Default | Effect |
|
||||
|---|---|---|
|
||||
| `REASONIX_PARALLEL_MAX` | `3` (hard cap `16`) | Max chunk size. |
|
||||
| `REASONIX_TOOL_DISPATCH=serial` | unset | Forces serial dispatch — escape hatch. |
|
||||
|
||||
Built-in opt-ins: read-only filesystem (`read_file`, `list_directory`,
|
||||
`directory_tree`, `search_files`, `search_content`, `get_file_info`),
|
||||
web (`web_search`, `web_fetch`), `recall_memory`, `semantic_search`,
|
||||
isolated child loops (`run_skill`, `spawn_subagent`), in-memory job
|
||||
queries (`job_output`, `list_jobs`). Mutating / side-effecting tools
|
||||
stay default. MCP-bridged tools default `false` — third-party tools
|
||||
opt in only when the server explicitly declares parallel safety.
|
||||
|
||||
### Pillar 2 — R1 Thought Harvesting *(opt-in)*
|
||||
|
||||
**Problem.** R1 / V4-thinking emits extensive `reasoning_content`. DeepSeek's
|
||||
|
||||
Generated
+135
-2
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.27.1",
|
||||
"version": "0.30.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "reasonix",
|
||||
"version": "0.27.1",
|
||||
"version": "0.30.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cli-highlight": "^2.1.11",
|
||||
"commander": "^12.1.0",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"ignore": "^7.0.5",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-reconciler": "^0.29.2",
|
||||
@@ -2580,6 +2581,12 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
@@ -3084,6 +3091,34 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -3137,6 +3172,61 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -3172,6 +3262,18 @@
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/environment": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
|
||||
@@ -3718,6 +3820,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.10.0",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.10.0.tgz",
|
||||
@@ -4337,6 +4448,16 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-html-parser": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz",
|
||||
"integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-select": "^5.1.0",
|
||||
"he": "1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.38",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
|
||||
@@ -4374,6 +4495,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.27.1",
|
||||
"version": "0.30.0",
|
||||
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -72,6 +72,7 @@
|
||||
"commander": "^12.1.0",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"ignore": "^7.0.5",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-reconciler": "^0.29.2",
|
||||
|
||||
+4
-2
@@ -315,8 +315,10 @@ export function expandAtMentions(
|
||||
for (const match of text.matchAll(AT_MENTION_PATTERN)) {
|
||||
const rawPath = match[1] ?? "";
|
||||
// Strip trailing dot (sentence terminator): `@foo.ts.` → `@foo.ts`.
|
||||
// Keep internal dots intact.
|
||||
const cleaned = rawPath.replace(/\.+$/, "");
|
||||
// Keep internal dots intact. Manual loop instead of `/\.+$/` — the
|
||||
// regex is O(n²) on dot-heavy non-matches per CodeQL js/polynomial-redos.
|
||||
let cleaned = rawPath;
|
||||
while (cleaned.endsWith(".")) cleaned = cleaned.slice(0, -1);
|
||||
if (!cleaned) continue;
|
||||
const token = `@${cleaned}`;
|
||||
if (seen.has(token)) continue;
|
||||
|
||||
@@ -80,6 +80,14 @@ function detailsFor(e: Event): string {
|
||||
return `${e.callId} reason=${e.reason}`;
|
||||
case "tool.result":
|
||||
return `${e.callId} ${e.ok ? "ok" : "err"} ${e.output.length}B${e.truncated ? " [trunc]" : ""}`;
|
||||
case "tool.call":
|
||||
return `${e.name} args=${truncate(JSON.stringify(e.args), 60)}`;
|
||||
case "tool.confirm.allow":
|
||||
return `${e.kind} ${quote(e.payload.command, 60)}`;
|
||||
case "tool.confirm.deny":
|
||||
return `${e.kind} ${quote(e.payload.command, 60)}${e.denyContext ? ` — ${truncate(e.denyContext, 40)}` : ""}`;
|
||||
case "tool.confirm.always_allow":
|
||||
return `${e.kind} ${quote(e.payload.command, 60)} prefix=${truncate(e.prefix, 30)}`;
|
||||
case "effect.file.touched":
|
||||
return `${e.mode} ${e.path} (${e.bytes}B)`;
|
||||
case "effect.memory.written":
|
||||
|
||||
@@ -35,6 +35,32 @@ export async function mcpInspectCommand(opts: McpInspectOptions): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMcpInspectFailure(err: unknown): string {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const message = error.message;
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
|
||||
if (code === "ENOENT") {
|
||||
const command = message.match(/^spawn\s+([^\s]+)\s+ENOENT$/)?.[1] ?? "the command";
|
||||
return `${message} — try: install or verify \`${command}\`, then check the MCP spec's command spelling`;
|
||||
}
|
||||
|
||||
if (code === "ECONNREFUSED") {
|
||||
const target = message.match(/\b(https?:\/\/\S+|\d+\.\d+\.\d+\.\d+:\d+|localhost:\d+)\b/i)?.[1];
|
||||
return `${message} — try: confirm ${target ?? "the MCP server"} is running and the host/port match the spec`;
|
||||
}
|
||||
|
||||
if (/^MCP request initialize \(id=\d+\) timed out after \d+ms$/.test(message)) {
|
||||
return `${message} — try: confirm the target speaks MCP and completes the handshake before the request timeout`;
|
||||
}
|
||||
|
||||
if (/^(empty MCP spec|MCP spec ".*" has name but no command)/.test(message)) {
|
||||
return `${message} — try: pass \`name=command args\` or an http(s):// URL`;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function formatReport(nsName: string, r: InspectionReport): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`MCP server [${nsName}]`);
|
||||
|
||||
+2
-2
@@ -35,7 +35,7 @@ import { doctorCommand } from "./commands/doctor.js";
|
||||
import { eventsCommand } from "./commands/events.js";
|
||||
import { indexCommand } from "./commands/index.js";
|
||||
import { mcpBrowseCommand } from "./commands/mcp-browse.js";
|
||||
import { mcpInspectCommand } from "./commands/mcp-inspect.js";
|
||||
import { formatMcpInspectFailure, mcpInspectCommand } from "./commands/mcp-inspect.js";
|
||||
import { mcpInstallCommand, mcpListCommand, mcpSearchCommand } from "./commands/mcp.js";
|
||||
import { pruneSessionsCommand } from "./commands/prune-sessions.js";
|
||||
import { replayCommand } from "./commands/replay.js";
|
||||
@@ -414,7 +414,7 @@ mcp
|
||||
try {
|
||||
await mcpInspectCommand({ spec, json: !!opts.json });
|
||||
} catch (err) {
|
||||
process.stderr.write(`mcp inspect failed: ${(err as Error).message}\n`);
|
||||
process.stderr.write(`mcp inspect failed: ${formatMcpInspectFailure(err)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
+112
-254
@@ -14,7 +14,7 @@ import {
|
||||
snapshotBeforeEdits,
|
||||
toWholeFileEditBlock,
|
||||
} from "../../code/edit-blocks.js";
|
||||
import { clearPendingEdits, loadPendingEdits, savePendingEdits } from "../../code/pending-edits.js";
|
||||
import { clearPendingEdits, loadPendingEdits } from "../../code/pending-edits.js";
|
||||
import {
|
||||
clearPlanState,
|
||||
loadPlanState,
|
||||
@@ -67,7 +67,7 @@ import { formatCommandResult, runCommand } from "../../tools/shell.js";
|
||||
import { registerSkillTools } from "../../tools/skills.js";
|
||||
import { formatSubagentResult, spawnSubagent } from "../../tools/subagent.js";
|
||||
import { webFetch } from "../../tools/web.js";
|
||||
import { openTranscriptFile, recordFromLoopEvent, writeRecord } from "../../transcript/log.js";
|
||||
import { openTranscriptFile } from "../../transcript/log.js";
|
||||
import { AtMentionSuggestions } from "./AtMentionSuggestions.js";
|
||||
import { ChoiceConfirm, type ChoiceConfirmChoice } from "./ChoiceConfirm.js";
|
||||
import { EditConfirm, type EditReviewChoice } from "./EditConfirm.js";
|
||||
@@ -84,7 +84,7 @@ import { SlashArgPicker } from "./SlashArgPicker.js";
|
||||
import { SlashSuggestions } from "./SlashSuggestions.js";
|
||||
import { WelcomeBanner } from "./WelcomeBanner.js";
|
||||
import { detectBangCommand, formatBangUserMessage } from "./bang.js";
|
||||
import { formatEditResults, partitionEdits } from "./edit-history.js";
|
||||
import { formatEditResults } from "./edit-history.js";
|
||||
import { loopEventToDashboard } from "./effects/loop-to-dashboard.js";
|
||||
import { appendGlobalMemory, appendProjectMemory, detectHashMemory } from "./hash-memory.js";
|
||||
import { applySlashResult } from "./hooks/apply-slash-result.js";
|
||||
@@ -96,13 +96,18 @@ import {
|
||||
} from "./hooks/handle-stream-events.js";
|
||||
import { handleToolEvent } from "./hooks/handle-tool-event.js";
|
||||
import { useAgentSession } from "./hooks/useAgentSession.js";
|
||||
import { useCodeMode } from "./hooks/useCodeMode.js";
|
||||
import { useInputRecall } from "./hooks/useInputRecall.js";
|
||||
import { useLoopMode } from "./hooks/useLoopMode.js";
|
||||
import { useQuit } from "./hooks/useQuit.js";
|
||||
import { useScrollback } from "./hooks/useScrollback.js";
|
||||
import { useTranscriptWriter } from "./hooks/useTranscriptWriter.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { CardStream } from "./layout/CardStream.js";
|
||||
import {
|
||||
ModeStatusBar,
|
||||
OngoingToolRow,
|
||||
SubagentRow,
|
||||
SubagentLiveStack,
|
||||
ThinkingRow,
|
||||
UndoBanner,
|
||||
} from "./layout/LiveRows.js";
|
||||
@@ -389,10 +394,14 @@ function AppInner({
|
||||
}, [stdout]);
|
||||
// Subagent UI wiring: live activity row + sink ref the loop closure
|
||||
// captures. Must be declared BEFORE loop construction so the
|
||||
// subagentRunner closure can read the ref.
|
||||
const { activity: subagentActivity, sinkRef: subagentSinkRef } = useSubagent({
|
||||
// subagentRunner closure can read the ref. The wallet-currency thunk
|
||||
// reads from a ref populated AFTER useSessionInfo loads balance, so the
|
||||
// subagent-end cost suffix renders in the live wallet's symbol.
|
||||
const walletCurrencyRef = useRef<string | undefined>(undefined);
|
||||
const { activities: subagentActivities, sinkRef: subagentSinkRef } = useSubagent({
|
||||
session,
|
||||
log,
|
||||
getWalletCurrency: () => walletCurrencyRef.current,
|
||||
});
|
||||
// Transient "what's happening" text set by the loop during silent
|
||||
// phases (harvest round-trip, between-iteration R1 thinking, forced
|
||||
@@ -613,13 +622,10 @@ function AppInner({
|
||||
// so we abort the in-flight turn and let the effect below fire the
|
||||
// submit once busy clears.
|
||||
const [queuedSubmit, setQueuedSubmit] = useState<string | null>(null);
|
||||
// Shell-style history of user prompts. ↑/↓ while idle walks it;
|
||||
// submit pushes to the end. Cursor -1 = "live input", 0+ = "N turns
|
||||
// back from newest". We don't persist history to disk — sessions
|
||||
// already keep the message log, and cross-session bash-style recall
|
||||
// would need per-project scoping we haven't designed.
|
||||
const promptHistory = useRef<string[]>([]);
|
||||
const historyCursor = useRef<number>(-1);
|
||||
// ↑/↓ recall over a turn-local prompt history. We don't persist to
|
||||
// disk — the session log already keeps the messages, and cross-
|
||||
// session bash-style recall would need per-project scoping.
|
||||
const { recallPrev, recallNext, pushHistory, resetCursor } = useInputRecall(setInput);
|
||||
// Disambiguates <Static> keys when a single turn yields multiple assistant_final events.
|
||||
const assistantIterCounter = useRef<number>(0);
|
||||
// Per-session @url fetch cache. Keyed by stripped URL; same URL
|
||||
@@ -628,32 +634,12 @@ function AppInner({
|
||||
// have changed. Shape mirrors AtUrlExpansion + an optional `body`
|
||||
// so the trailing block can be reconstructed from cache alone.
|
||||
const atUrlCache = useRef<Map<string, AtUrlExpansion & { body?: string }>>(new Map());
|
||||
// Active /loop state. Null when no loop is running. Re-issuing /loop
|
||||
// replaces the slot. Cancellation is centralized in stopLoop() so
|
||||
// every cancel-trigger (Esc, /clear, /new, user-typed submit, /loop
|
||||
// stop, exit) goes through one path. The timer is held in a sibling
|
||||
// ref so React effects don't have to re-run on every timer tick.
|
||||
const [activeLoop, setActiveLoop] = useState<{
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
nextFireAt: number;
|
||||
iter: number;
|
||||
} | null>(null);
|
||||
const loopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// handleSubmit is defined far below as a useCallback. The /loop timer
|
||||
// needs to call the LATEST closure on each firing (config could have
|
||||
// shifted mid-loop), so we mirror it through a ref. The mirror is
|
||||
// synced in a useEffect once handleSubmit is defined.
|
||||
const handleSubmitRef = useRef<((raw: string) => Promise<void>) | null>(null);
|
||||
const busyRef = useRef<boolean>(false);
|
||||
const activeLoopRef = useRef<typeof activeLoop>(activeLoop);
|
||||
// Set true by the loop timer just before it calls handleSubmitRef so
|
||||
// handleSubmit's "any user submit cancels the loop" guard knows to
|
||||
// skip itself. Reset to false at the top of every handleSubmit.
|
||||
const loopFiringRef = useRef<boolean>(false);
|
||||
useEffect(() => {
|
||||
activeLoopRef.current = activeLoop;
|
||||
}, [activeLoop]);
|
||||
// Full untruncated tool results, in arrival order. ToolCard clips
|
||||
// output at 400 chars for display; `/tool N` reads from this ref to
|
||||
// show the real thing. Not persisted — a
|
||||
@@ -794,6 +780,7 @@ function AppInner({
|
||||
// Per-skill model override (frontmatter `model: ...`),
|
||||
// else falls through to spawnSubagent's default.
|
||||
model: skill.model,
|
||||
allowedTools: skill.allowedTools,
|
||||
sink: subagentSinkRef.current,
|
||||
// Stamped onto every event so the TUI sink + usage log can
|
||||
// attribute the run to a skill without extra bookkeeping.
|
||||
@@ -827,6 +814,50 @@ function AppInner({
|
||||
return l;
|
||||
}, [model, system, harvest, branch, budgetUsd, session, tools, codeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !tools) return;
|
||||
tools.setAuditListener((event) => {
|
||||
const sink = eventSinkRef.current;
|
||||
const eventizer = eventizerRef.current;
|
||||
if (!sink || !eventizer) return;
|
||||
sink.append(eventizer.emitToolCall(loop.currentTurn, event.name, event.args));
|
||||
});
|
||||
pauseGate.setAuditListener((event) => {
|
||||
const sink = eventSinkRef.current;
|
||||
const eventizer = eventizerRef.current;
|
||||
if (!sink || !eventizer) return;
|
||||
switch (event.type) {
|
||||
case "tool.confirm.allow":
|
||||
sink.append(eventizer.emitToolConfirmAllow(loop.currentTurn, event.kind, event.payload));
|
||||
break;
|
||||
case "tool.confirm.deny":
|
||||
sink.append(
|
||||
eventizer.emitToolConfirmDeny(
|
||||
loop.currentTurn,
|
||||
event.kind,
|
||||
event.payload,
|
||||
event.denyContext,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case "tool.confirm.always_allow":
|
||||
sink.append(
|
||||
eventizer.emitToolConfirmAlwaysAllow(
|
||||
loop.currentTurn,
|
||||
event.kind,
|
||||
event.payload,
|
||||
event.prefix,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
tools.setAuditListener(null);
|
||||
pauseGate.setAuditListener(null);
|
||||
};
|
||||
}, [loop, session, tools]);
|
||||
|
||||
// Keep the loop's hook list in sync after a `/hooks reload`. The
|
||||
// loop's field is intentionally mutable for exactly this case —
|
||||
// construction happens once, hook edits are picked up live.
|
||||
@@ -861,8 +892,12 @@ function AppInner({
|
||||
const balanceRef = useRef<typeof balance>(null);
|
||||
useEffect(() => {
|
||||
balanceRef.current = balance;
|
||||
walletCurrencyRef.current = balance?.currency;
|
||||
if (balance) {
|
||||
agentStore.dispatch({ type: "session.update", patch: { balance: balance.total } });
|
||||
agentStore.dispatch({
|
||||
type: "session.update",
|
||||
patch: { balance: balance.total, balanceCurrency: balance.currency },
|
||||
});
|
||||
}
|
||||
}, [balance, agentStore]);
|
||||
|
||||
@@ -1114,25 +1149,8 @@ function AppInner({
|
||||
}
|
||||
}, [session, loop, codeMode, syncPendingCount, log]);
|
||||
|
||||
// Ctrl+C exits, period. SIGINT (cooked-mode terminals + Node's
|
||||
// Windows console handler) and \x03 byte (raw-mode stdin) both
|
||||
// converge on `quitProcess`. We call `process.exit` directly rather
|
||||
// than Ink's `exit()` because the singleton stdin-reader keeps a
|
||||
// `data` listener attached — `exit()` would unmount the React tree
|
||||
// but the event loop would stay alive and the terminal would hang.
|
||||
// Esc handles "abort the current turn" separately; Ctrl+C is the
|
||||
// universal "I'm done" key, no banner, no double-press dance.
|
||||
const quitProcess = useCallback(() => {
|
||||
transcriptRef.current?.end();
|
||||
process.exit(0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
process.on("SIGINT", quitProcess);
|
||||
return () => {
|
||||
process.off("SIGINT", quitProcess);
|
||||
};
|
||||
}, [quitProcess]);
|
||||
// Esc handles "abort the current turn" separately; Ctrl+C is the universal "I'm done" key.
|
||||
const quitProcess = useQuit(transcriptRef);
|
||||
|
||||
// Esc during busy → forward to the loop as an abort signal. The loop
|
||||
// finishes the tool call in flight (we can't kill subprocess stdio
|
||||
@@ -1176,14 +1194,14 @@ function AppInner({
|
||||
// Esc during a busy turn also kills any active /loop — the user
|
||||
// is taking over. Loops persist past plain Esc when the system is
|
||||
// idle so a long-cadence loop doesn't die from random key noise.
|
||||
if (activeLoopRef.current) stopLoop();
|
||||
if (isLoopActive()) stopLoop();
|
||||
loop.abort();
|
||||
return;
|
||||
}
|
||||
// Esc when idle ALSO cancels an active loop, since hitting Esc with
|
||||
// nothing else going on is a clear "stop whatever's running"
|
||||
// gesture. No-op when no loop is active.
|
||||
if (key.escape && !busy && activeLoopRef.current) {
|
||||
if (key.escape && !busy && isLoopActive()) {
|
||||
stopLoop();
|
||||
return;
|
||||
}
|
||||
@@ -1349,26 +1367,6 @@ function AppInner({
|
||||
// buffer.
|
||||
});
|
||||
|
||||
// History recall callbacks passed into PromptInput. Walking the
|
||||
// in-memory `promptHistory` list: ↑ moves backward through prior
|
||||
// prompts (increments historyCursor); ↓ moves forward until we
|
||||
// fall off the end, then resets to empty. No-op when the history
|
||||
// is empty or we're already at the boundary.
|
||||
const recallPrev = useCallback(() => {
|
||||
const hist = promptHistory.current;
|
||||
if (hist.length === 0) return;
|
||||
const nextCursor = Math.min(historyCursor.current + 1, hist.length - 1);
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(hist[hist.length - 1 - nextCursor] ?? "");
|
||||
}, []);
|
||||
const recallNext = useCallback(() => {
|
||||
if (historyCursor.current < 0) return;
|
||||
const hist = promptHistory.current;
|
||||
const nextCursor = historyCursor.current - 1;
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(nextCursor < 0 ? "" : (hist[hist.length - 1 - nextCursor] ?? ""));
|
||||
}, []);
|
||||
|
||||
// Edit-gate interceptor. Reroutes `edit_file` / `write_file` tool
|
||||
// calls through the review queue (in `review` mode) or the auto-apply
|
||||
// snapshot/banner path (in `auto` mode) so the model's tool usage
|
||||
@@ -1479,83 +1477,18 @@ function AppInner({
|
||||
};
|
||||
}, [tools, codeMode, session, recordEdit, armUndoBanner, syncPendingCount, setEditMode]);
|
||||
|
||||
/**
|
||||
* /apply callback — write pending edit blocks to disk, snapshot
|
||||
* beforehand so /undo still works, report per-file results. With
|
||||
* `indices` (1-based) only those blocks are applied; the rest stay
|
||||
* pending so the user can iterate on them. Empty / undefined indices
|
||||
* apply every pending block (the all-or-nothing original behavior).
|
||||
*/
|
||||
const codeApply = useCallback(
|
||||
(indices?: readonly number[]): string => {
|
||||
if (!codeMode) return "not in code mode";
|
||||
const blocks = pendingEdits.current;
|
||||
if (blocks.length === 0) {
|
||||
return "nothing pending — the model hasn't proposed edits since the last /apply or /discard.";
|
||||
}
|
||||
const useSubset = indices !== undefined && indices.length > 0;
|
||||
const { selected, remaining } = useSubset
|
||||
? partitionEdits(blocks, indices)
|
||||
: { selected: blocks, remaining: [] as EditBlock[] };
|
||||
if (selected.length === 0) {
|
||||
return "▸ no edits matched those indices — nothing applied. Use /apply with no args to commit them all.";
|
||||
}
|
||||
const snaps = snapshotBeforeEdits(selected, currentRootDir);
|
||||
const results = applyEditBlocks(selected, currentRootDir);
|
||||
const anyApplied = results.some((r) => r.status === "applied" || r.status === "created");
|
||||
if (anyApplied) recordEdit("review-apply", selected, results, snaps);
|
||||
pendingEdits.current = remaining;
|
||||
if (remaining.length === 0) clearPendingEdits(session ?? null);
|
||||
else savePendingEdits(session ?? null, remaining);
|
||||
syncPendingCount();
|
||||
const tail =
|
||||
remaining.length > 0
|
||||
? `\n▸ ${remaining.length} edit block(s) still pending — /apply or /discard to clear them.`
|
||||
: "";
|
||||
return formatEditResults(results) + tail;
|
||||
},
|
||||
[codeMode, currentRootDir, session, syncPendingCount, recordEdit],
|
||||
);
|
||||
|
||||
/**
|
||||
* /discard callback — forget the pending edits without touching
|
||||
* disk. With `indices` (1-based) only those blocks are dropped; the
|
||||
* rest stay pending. Empty / undefined indices drop everything.
|
||||
*/
|
||||
const codeDiscard = useCallback(
|
||||
(indices?: readonly number[]): string => {
|
||||
const blocks = pendingEdits.current;
|
||||
if (blocks.length === 0) return "nothing pending to discard.";
|
||||
const useSubset = indices !== undefined && indices.length > 0;
|
||||
const { selected, remaining } = useSubset
|
||||
? partitionEdits(blocks, indices)
|
||||
: { selected: blocks, remaining: [] as EditBlock[] };
|
||||
if (selected.length === 0) {
|
||||
return "▸ no edits matched those indices — nothing discarded.";
|
||||
}
|
||||
pendingEdits.current = remaining;
|
||||
if (remaining.length === 0) clearPendingEdits(session ?? null);
|
||||
else savePendingEdits(session ?? null, remaining);
|
||||
syncPendingCount();
|
||||
const tail =
|
||||
remaining.length > 0
|
||||
? ` (${remaining.length} block(s) still pending)`
|
||||
: ". Nothing was written to disk.";
|
||||
return `▸ discarded ${selected.length} pending edit block(s)${tail}`;
|
||||
},
|
||||
[session, syncPendingCount],
|
||||
);
|
||||
const { codeApply, codeDiscard } = useCodeMode({
|
||||
codeMode: !!codeMode,
|
||||
pendingEdits,
|
||||
currentRootDir,
|
||||
session: session ?? null,
|
||||
syncPendingCount,
|
||||
recordEdit,
|
||||
});
|
||||
|
||||
const prefixHash = loop.prefix.fingerprint;
|
||||
|
||||
const writeTranscript = useCallback(
|
||||
(ev: LoopEvent) => {
|
||||
const stream = transcriptRef.current;
|
||||
if (!stream) return;
|
||||
writeRecord(stream, recordFromLoopEvent(ev, { model, prefixHash }));
|
||||
},
|
||||
[model, prefixHash],
|
||||
);
|
||||
const writeTranscript = useTranscriptWriter(transcriptRef, model, prefixHash);
|
||||
|
||||
/**
|
||||
* Toggle plan mode on the local state AND on the ToolRegistry. The
|
||||
@@ -1576,39 +1509,15 @@ function AppInner({
|
||||
setPendingPlan(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Cancel the active /loop. Centralized so every cancel-trigger
|
||||
* (explicit /loop stop, Esc, /clear, /new, exit, the very first
|
||||
* user-typed prompt while a loop is active) goes through one path.
|
||||
* Idempotent — calling with no active loop is a no-op.
|
||||
*/
|
||||
const stopLoop = useCallback(() => {
|
||||
if (loopTimerRef.current) {
|
||||
clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return;
|
||||
setActiveLoop(null);
|
||||
log.pushInfo(`▸ loop stopped (after ${cur.iter} iter${cur.iter === 1 ? "" : "s"}).`);
|
||||
}, [log]);
|
||||
|
||||
/**
|
||||
* Start a new /loop. Replaces any prior loop. The actual timer is
|
||||
* scheduled by the useEffect downstream that watches `activeLoop`.
|
||||
*/
|
||||
const startLoop = useCallback((intervalMs: number, prompt: string) => {
|
||||
if (loopTimerRef.current) {
|
||||
clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
setActiveLoop({
|
||||
prompt,
|
||||
intervalMs,
|
||||
nextFireAt: Date.now() + intervalMs,
|
||||
iter: 0,
|
||||
});
|
||||
}, []);
|
||||
const {
|
||||
startLoop,
|
||||
stopLoop,
|
||||
getLoopStatus,
|
||||
isLoopActive,
|
||||
isLoopFiring,
|
||||
clearFiringFlag,
|
||||
activeLoop,
|
||||
} = useLoopMode({ log, busyRef, handleSubmitRef });
|
||||
|
||||
/**
|
||||
* Mount the per-block walkthrough modal against the pending-edits
|
||||
@@ -1966,30 +1875,17 @@ function AppInner({
|
||||
[codeApply, codeDiscard, log],
|
||||
);
|
||||
|
||||
/** Snapshot for the `/loop` (no-arg) status branch. */
|
||||
const getLoopStatus = useCallback(() => {
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return null;
|
||||
return {
|
||||
prompt: cur.prompt,
|
||||
intervalMs: cur.intervalMs,
|
||||
iter: cur.iter,
|
||||
nextFireMs: Math.max(0, cur.nextFireAt - Date.now()),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (raw: string) => {
|
||||
let text = raw.trim();
|
||||
if (!text) return;
|
||||
// Cancel-on-user-input: any user-typed submit cancels an active
|
||||
// /loop, regardless of busy state. Loop-fired submits set the
|
||||
// `loopFiringRef` flag so the timer's own re-submit doesn't
|
||||
// self-cancel.
|
||||
if (activeLoopRef.current && !loopFiringRef.current) {
|
||||
// firing flag so the timer's own re-submit doesn't self-cancel.
|
||||
if (isLoopActive() && !isLoopFiring()) {
|
||||
stopLoop();
|
||||
}
|
||||
loopFiringRef.current = false;
|
||||
clearFiringFlag();
|
||||
if (busy) return;
|
||||
|
||||
// @-mention picker intercept. When the picker is open (trailing
|
||||
@@ -2037,7 +1933,7 @@ function AppInner({
|
||||
}
|
||||
|
||||
setInput("");
|
||||
historyCursor.current = -1;
|
||||
resetCursor();
|
||||
|
||||
// Y/N fast-path when edits are pending. One keystroke is all it
|
||||
// takes to commit or drop — matches the muscle memory of `git
|
||||
@@ -2046,7 +1942,7 @@ function AppInner({
|
||||
// when nothing's waiting.
|
||||
if (codeMode && pendingEdits.current.length > 0 && (text === "y" || text === "n")) {
|
||||
log.pushInfo(text === "y" ? codeApply() : codeDiscard());
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2059,7 +1955,7 @@ function AppInner({
|
||||
if (hashParse?.kind === "memory" || hashParse?.kind === "memory-global") {
|
||||
const isGlobal = hashParse.kind === "memory-global";
|
||||
const memRoot = currentRootDir;
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
try {
|
||||
const result = isGlobal
|
||||
? appendGlobalMemory(hashParse.note)
|
||||
@@ -2088,7 +1984,7 @@ function AppInner({
|
||||
const bangCmd = detectBangCommand(text);
|
||||
if (bangCmd !== null) {
|
||||
const bangRoot = currentRootDir;
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
log.pushUser(text);
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -2120,7 +2016,7 @@ function AppInner({
|
||||
if (mcpBrowseMatch) {
|
||||
const kind = mcpBrowseMatch[1] as "resource" | "prompt";
|
||||
const arg = mcpBrowseMatch[2]?.trim() ?? "";
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
log.pushUser(text);
|
||||
await handleMcpBrowseSlash(kind, arg, liveMcpServers, log);
|
||||
return;
|
||||
@@ -2196,16 +2092,16 @@ function AppInner({
|
||||
if (result.openSessionsPicker) {
|
||||
setSessionsPickerList(listSessionsForWorkspace(currentRootDir));
|
||||
setPendingSessionsPicker(true);
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
if (result.openMcpHub) {
|
||||
setPendingMcpHub({ tab: result.openMcpHub.tab });
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
return;
|
||||
}
|
||||
if (result.openArgPickerFor) {
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
setInput(`/${result.openArgPickerFor} `);
|
||||
return;
|
||||
}
|
||||
@@ -2216,10 +2112,10 @@ function AppInner({
|
||||
syncPendingCount,
|
||||
session: session ?? null,
|
||||
codeModeOn: !!codeMode,
|
||||
activeLoopRef,
|
||||
isLoopActive,
|
||||
stopLoop,
|
||||
quitProcess,
|
||||
promptHistory,
|
||||
pushHistory,
|
||||
text,
|
||||
});
|
||||
if (outcome.kind === "resubmit") {
|
||||
@@ -2250,7 +2146,7 @@ function AppInner({
|
||||
// Large pastes (stack traces, log dumps, file contents) get a
|
||||
// collapsed preview in scrollback; the model still receives the full
|
||||
// text below via modelInput.
|
||||
promptHistory.current.push(text);
|
||||
pushHistory(text);
|
||||
const pasteDisplay = formatLongPaste(text);
|
||||
const userId = log.pushUser(pasteDisplay.displayText);
|
||||
broadcastDashboardEvent({ kind: "user", id: userId, text });
|
||||
@@ -2591,6 +2487,9 @@ function AppInner({
|
||||
stopLoop,
|
||||
startLoop,
|
||||
getLoopStatus,
|
||||
isLoopActive,
|
||||
isLoopFiring,
|
||||
clearFiringFlag,
|
||||
startWalkthrough,
|
||||
startDashboard,
|
||||
stopDashboard,
|
||||
@@ -2602,6 +2501,8 @@ function AppInner({
|
||||
log,
|
||||
agentStore.dispatch,
|
||||
mcpRuntime,
|
||||
pushHistory,
|
||||
resetCursor,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2612,49 +2513,6 @@ function AppInner({
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
|
||||
// /loop timer. Re-runs whenever activeLoop's `nextFireAt` shifts —
|
||||
// either because startLoop set a fresh schedule or because the
|
||||
// previous timer fired and bumped the next-fire time. Cleanup
|
||||
// clears the in-flight timer so a stopLoop / replacement doesn't
|
||||
// leak a fire after cancel.
|
||||
useEffect(() => {
|
||||
if (!activeLoop) return;
|
||||
const delay = Math.max(0, activeLoop.nextFireAt - Date.now());
|
||||
const timer = setTimeout(async () => {
|
||||
loopTimerRef.current = null;
|
||||
// Skip the firing entirely when a prior turn is still running.
|
||||
// Re-arm in 1s so the loop catches up the moment busy clears,
|
||||
// rather than waiting a full interval after a slow turn.
|
||||
if (busyRef.current) {
|
||||
setActiveLoop((cur) => (cur ? { ...cur, nextFireAt: Date.now() + 1000 } : cur));
|
||||
return;
|
||||
}
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return;
|
||||
const nextIter = cur.iter + 1;
|
||||
// Schedule the NEXT firing now (independent of how long this
|
||||
// turn takes). Keeps the cadence honest even when individual
|
||||
// turns are slow.
|
||||
setActiveLoop((c) =>
|
||||
c ? { ...c, iter: nextIter, nextFireAt: Date.now() + cur.intervalMs } : c,
|
||||
);
|
||||
log.pushInfo(`▸ /loop iter ${nextIter} → ${cur.prompt}`);
|
||||
loopFiringRef.current = true;
|
||||
try {
|
||||
await handleSubmitRef.current?.(cur.prompt);
|
||||
} catch {
|
||||
// Persistent submission errors → kill the loop rather than
|
||||
// spam the screen. User can re-issue /loop once they fix
|
||||
// whatever's wrong.
|
||||
stopLoop();
|
||||
} finally {
|
||||
loopFiringRef.current = false;
|
||||
}
|
||||
}, delay);
|
||||
loopTimerRef.current = timer;
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeLoop, stopLoop, log]);
|
||||
|
||||
/**
|
||||
* ShellConfirm callback. Resolves the PauseGate so the
|
||||
* blocked tool function can proceed. The tool handles running the
|
||||
@@ -3218,8 +3076,8 @@ function AppInner({
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview &&
|
||||
subagentActivity ? (
|
||||
<SubagentRow activity={subagentActivity} />
|
||||
subagentActivities.length > 0 ? (
|
||||
<SubagentLiveStack activities={subagentActivities} max={3} />
|
||||
) : null}
|
||||
{!PLAIN_UI &&
|
||||
!pendingShell &&
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import stringWidth from "string-width";
|
||||
import type { SessionSummary } from "../../telemetry/stats.js";
|
||||
import { ChromeRule } from "./primitives.js";
|
||||
import { COLOR, GRADIENT } from "./theme.js";
|
||||
|
||||
const COLD_START_TURNS = 3;
|
||||
const CACHE_BAR_CELLS = 10;
|
||||
|
||||
export type Preset = "auto" | "flash" | "pro";
|
||||
|
||||
export interface ChromeBarProps {
|
||||
summary: SessionSummary;
|
||||
rootDir?: string;
|
||||
sessionName?: string | null;
|
||||
preset?: Preset;
|
||||
planMode?: boolean;
|
||||
proArmed?: boolean;
|
||||
escalated?: boolean;
|
||||
updateAvailable?: string | null;
|
||||
balance?: { currency: string; total: number } | null;
|
||||
budgetUsd?: number | null;
|
||||
/** Scroll progress: 0 = at latest, 1 = at oldest. Hides the pill at 0. */
|
||||
scrollRatio?: number;
|
||||
}
|
||||
|
||||
export function ChromeBar(props: ChromeBarProps): React.ReactElement {
|
||||
const { stdout } = useStdout();
|
||||
const cols = (stdout?.columns ?? 80) - 2; // subtract paddingX={1} on both sides
|
||||
const cold = props.summary.turns <= COLD_START_TURNS;
|
||||
const projectName = props.rootDir ? basename(props.rootDir) : null;
|
||||
const mode = pickModePill(props.planMode, props.preset);
|
||||
const proPill = props.escalated
|
||||
? { label: "⇧ pro", color: COLOR.err }
|
||||
: props.proArmed
|
||||
? { label: "⇧ pro", color: COLOR.warn }
|
||||
: null;
|
||||
|
||||
// Greedy width-aware fit. Layout (each gap is exactly ONE space — single
|
||||
// suffix on update/mode/pro/scroll, single prefix on balance/cache):
|
||||
// [brand][·project][›session]<spacer>[scroll][update][mode][pro]$cost w bal cache █▓▓▓
|
||||
// Always-shown carve out the budget first; optional pieces are dropped
|
||||
// by priority — balance > cache > session > update — until the row fits.
|
||||
// (`flexGrow` spacer can shrink to 0, so no minimum reserve.) Cache is
|
||||
// shown unconditionally now: pre-turn-4 it renders dim with a "—" so
|
||||
// it's "default on" instead of materializing only after a few turns.
|
||||
const SEP = 3; // " · " / " › "
|
||||
const GAP = 1;
|
||||
const scrollLabel =
|
||||
props.scrollRatio !== undefined && props.scrollRatio > 0
|
||||
? `[↑ ${Math.round(props.scrollRatio * 100)}%]`
|
||||
: "";
|
||||
const updateLabel = props.updateAvailable ? `↑ ${props.updateAvailable}` : "";
|
||||
const balanceLabel = props.balance ? formatBalanceLabel(props.balance) : "";
|
||||
const cachePct = Math.round(props.summary.cacheHitRatio * 100);
|
||||
// Use the worst-case rendered cache string (3-digit pct, full bar) so the
|
||||
// shed decision doesn't oscillate as the percentage changes turn-to-turn.
|
||||
const cacheLabel = `cache ${"█".repeat(CACHE_BAR_CELLS)} 100%`;
|
||||
const costLabel = `$${props.summary.totalCostUsd.toFixed(4)}${
|
||||
props.budgetUsd && props.budgetUsd > 0 ? ` / $${props.budgetUsd.toFixed(2)}` : ""
|
||||
}`;
|
||||
|
||||
const brandW = stringWidth("◈ reasonix");
|
||||
const projectW = projectName ? SEP + stringWidth(projectName) : 0;
|
||||
const fixedLeft = brandW + projectW;
|
||||
const scrollW = scrollLabel ? stringWidth(scrollLabel) + GAP : 0;
|
||||
const modeW = mode ? stringWidth(`[${mode.label}]`) + GAP : 0;
|
||||
const proW = proPill ? stringWidth(`[${proPill.label}]`) + GAP : 0;
|
||||
const costW = stringWidth(costLabel);
|
||||
const fixedRight = scrollW + modeW + proW + costW;
|
||||
let budget = cols - fixedLeft - fixedRight;
|
||||
|
||||
const balanceOptW = balanceLabel ? GAP + stringWidth(balanceLabel) : 0;
|
||||
const cacheOptW = GAP + stringWidth(cacheLabel);
|
||||
const sessionOptW = props.sessionName ? SEP + stringWidth(props.sessionName) : 0;
|
||||
const updateOptW = updateLabel ? stringWidth(updateLabel) + GAP : 0;
|
||||
|
||||
const showBalance = balanceOptW > 0 && budget >= balanceOptW;
|
||||
if (showBalance) budget -= balanceOptW;
|
||||
const showCache = budget >= cacheOptW;
|
||||
if (showCache) budget -= cacheOptW;
|
||||
const showSession = sessionOptW > 0 && budget >= sessionOptW;
|
||||
if (showSession) budget -= sessionOptW;
|
||||
const showUpdate = updateOptW > 0 && budget >= updateOptW;
|
||||
if (showUpdate) budget -= updateOptW;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box>
|
||||
<Text bold color={GRADIENT[0]}>
|
||||
{"◈ "}
|
||||
</Text>
|
||||
<Text color={COLOR.brand} bold>
|
||||
reasonix
|
||||
</Text>
|
||||
{projectName ? (
|
||||
<>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{" · "}
|
||||
</Text>
|
||||
<Text>{projectName}</Text>
|
||||
{showSession && props.sessionName ? (
|
||||
<>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{" › "}
|
||||
</Text>
|
||||
<Text color={COLOR.info}>{props.sessionName}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Box flexGrow={1} />
|
||||
|
||||
{scrollLabel ? (
|
||||
<>
|
||||
<Text color={COLOR.accent} bold>
|
||||
{scrollLabel}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
{showUpdate ? (
|
||||
<>
|
||||
<Text color={COLOR.warn} bold>
|
||||
{updateLabel}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
{mode ? (
|
||||
<>
|
||||
<Text color={mode.color} bold>
|
||||
{`[${mode.label}]`}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
{proPill ? (
|
||||
<>
|
||||
<Text color={proPill.color} bold>
|
||||
{`[${proPill.label}]`}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
</>
|
||||
) : null}
|
||||
<CostPill summary={props.summary} cold={cold} budgetUsd={props.budgetUsd} />
|
||||
{showBalance && props.balance ? (
|
||||
<>
|
||||
<Text> </Text>
|
||||
<BalancePill balance={props.balance} />
|
||||
</>
|
||||
) : null}
|
||||
{showCache ? (
|
||||
<>
|
||||
<Text> </Text>
|
||||
<CachePill ratio={props.summary.cacheHitRatio} cold={cold} pct={cachePct} />
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
<ChromeRule />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBalanceLabel(balance: { currency: string; total: number }): string {
|
||||
const sym = balance.currency === "USD" ? "$" : balance.currency === "CNY" ? "¥" : "";
|
||||
const suf = sym ? "" : ` ${balance.currency}`;
|
||||
return `w ${sym}${balance.total.toFixed(2)}${suf}`;
|
||||
}
|
||||
|
||||
function CostPill({
|
||||
summary,
|
||||
cold,
|
||||
budgetUsd,
|
||||
}: {
|
||||
summary: SessionSummary;
|
||||
cold: boolean;
|
||||
budgetUsd?: number | null;
|
||||
}): React.ReactElement {
|
||||
const cost = summary.totalCostUsd;
|
||||
const color = cold ? COLOR.info : sessionCostColor(cost);
|
||||
return (
|
||||
<>
|
||||
<Text color={color} bold={!cold} dimColor={cold}>
|
||||
{`$${cost.toFixed(4)}`}
|
||||
</Text>
|
||||
{budgetUsd && budgetUsd > 0 ? (
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{` / $${budgetUsd.toFixed(2)}`}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BalancePill({
|
||||
balance,
|
||||
}: {
|
||||
balance: { currency: string; total: number };
|
||||
}): React.ReactElement {
|
||||
const { total } = balance;
|
||||
const color = total < 1 ? COLOR.err : total < 5 ? COLOR.warn : COLOR.ok;
|
||||
return <Text color={color}>{formatBalanceLabel(balance)}</Text>;
|
||||
}
|
||||
|
||||
function CachePill({
|
||||
ratio,
|
||||
cold,
|
||||
pct,
|
||||
}: {
|
||||
ratio: number;
|
||||
cold: boolean;
|
||||
pct: number;
|
||||
}): React.ReactElement {
|
||||
const color = ratio >= 0.7 ? COLOR.ok : ratio >= 0.4 ? COLOR.warn : COLOR.err;
|
||||
const filled = Math.max(0, Math.min(CACHE_BAR_CELLS, Math.round(ratio * CACHE_BAR_CELLS)));
|
||||
const empty = CACHE_BAR_CELLS - filled;
|
||||
return (
|
||||
<>
|
||||
<Text color={COLOR.info} dimColor>
|
||||
{"cache "}
|
||||
</Text>
|
||||
<Text color={cold ? COLOR.info : color} dimColor={cold}>
|
||||
{"█".repeat(filled)}
|
||||
</Text>
|
||||
<Text dimColor>{"░".repeat(empty)}</Text>
|
||||
<Text color={cold ? undefined : color} bold={!cold} dimColor={cold}>
|
||||
{cold && pct === 0 ? " —" : ` ${pct}%`}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function pickModePill(
|
||||
planMode: boolean | undefined,
|
||||
preset: Preset | undefined,
|
||||
): { label: string; color: string } | null {
|
||||
if (planMode) return { label: "PLAN", color: COLOR.err };
|
||||
if (!preset) return null;
|
||||
if (preset === "pro") return { label: "pro", color: COLOR.accent };
|
||||
if (preset === "flash") return { label: "flash", color: COLOR.info };
|
||||
return { label: "auto", color: COLOR.primary };
|
||||
}
|
||||
|
||||
function sessionCostColor(cost: number): string | undefined {
|
||||
if (cost <= 0) return undefined;
|
||||
if (cost >= 5) return COLOR.err;
|
||||
if (cost >= 0.5) return COLOR.warn;
|
||||
return COLOR.ok;
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
const norm = path.replace(/\\/g, "/");
|
||||
const i = norm.lastIndexOf("/");
|
||||
return i === -1 ? norm : norm.slice(i + 1);
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import { PlanStepList } from "./PlanStepList.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
import { ApprovalCard } from "./cards/ApprovalCard.js";
|
||||
import { useReserveRows } from "./layout/viewport-budget.js";
|
||||
import { CARD, TONE } from "./theme/tokens.js";
|
||||
import { MarkdownView } from "./markdown-view.js";
|
||||
import { CARD, FG, TONE } from "./theme/tokens.js";
|
||||
|
||||
export type PlanConfirmChoice = "approve" | "refine" | "revise" | "cancel";
|
||||
|
||||
@@ -21,9 +22,19 @@ export interface PlanConfirmProps {
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
const PLAN_BODY_PREVIEW_LINES = 24;
|
||||
|
||||
function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
|
||||
const stepRows = steps?.length ?? 0;
|
||||
useReserveRows("modal", { min: 10, max: Math.max(16, stepRows + 14) });
|
||||
const hasSteps = stepRows > 0;
|
||||
const planLines = plan.split("\n");
|
||||
const truncatedBody = planLines.length > PLAN_BODY_PREVIEW_LINES;
|
||||
const previewBody = truncatedBody ? planLines.slice(0, PLAN_BODY_PREVIEW_LINES).join("\n") : plan;
|
||||
const previewRows = truncatedBody
|
||||
? PLAN_BODY_PREVIEW_LINES
|
||||
: Math.min(planLines.length, PLAN_BODY_PREVIEW_LINES);
|
||||
const reservedFor = hasSteps ? stepRows : previewRows;
|
||||
useReserveRows("modal", { min: 10, max: Math.max(16, reservedFor + 14) });
|
||||
|
||||
const hasOpenQuestions =
|
||||
/^#{1,6}\s*(open[-\s]?questions?|risks?|unknowns?|assumptions?|unclear)/im.test(plan) ||
|
||||
@@ -45,9 +56,20 @@ function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{steps && steps.length > 0 ? (
|
||||
{hasSteps ? (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<PlanStepList steps={steps} />
|
||||
<PlanStepList steps={steps!} />
|
||||
</Box>
|
||||
) : plan.trim().length > 0 ? (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<MarkdownView text={previewBody} />
|
||||
{truncatedBody ? (
|
||||
<Text color={FG.faint}>
|
||||
{`… ${planLines.length - PLAN_BODY_PREVIEW_LINES} more line${
|
||||
planLines.length - PLAN_BODY_PREVIEW_LINES === 1 ? "" : "s"
|
||||
} above in scrollback`}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
<SingleSelect
|
||||
|
||||
@@ -86,6 +86,7 @@ function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelecte
|
||||
const key = `slash.${spec.cmd}.description`;
|
||||
const translated = t(key);
|
||||
const summary = translated === key ? spec.summary : translated;
|
||||
const aliasHint = spec.aliases?.length ? ` · /${spec.aliases.join(" /")}` : "";
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? COLOR.primary : COLOR.info} bold={isSelected}>
|
||||
@@ -99,6 +100,7 @@ function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelecte
|
||||
<Text color={isSelected ? COLOR.user : COLOR.info} dimColor={!isSelected}>
|
||||
{summary}
|
||||
</Text>
|
||||
{aliasHint ? <Text dimColor>{aliasHint}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { EditMode } from "../../config.js";
|
||||
import type { SessionSummary } from "../../telemetry/stats.js";
|
||||
import { Bar, ChromeRule } from "./primitives.js";
|
||||
import { COLOR, GRADIENT } from "./theme.js";
|
||||
import { formatBalance, formatCost } from "./theme/tokens.js";
|
||||
|
||||
const COLD_START_TURNS = 3;
|
||||
|
||||
@@ -91,9 +92,9 @@ function ChromeRow({
|
||||
const cacheColor =
|
||||
summary.cacheHitRatio >= 0.7 ? COLOR.ok : summary.cacheHitRatio >= 0.4 ? COLOR.warn : COLOR.err;
|
||||
const balanceLabel = balance
|
||||
? `[w ${balance.currency === "USD" ? "$" : ""}${balance.total.toFixed(2)}${balance.currency !== "USD" ? ` ${balance.currency}` : ""}]`
|
||||
? `[${formatBalance(balance.total, balance.currency, { label: true })}]`
|
||||
: "";
|
||||
const costLabel = `[$${summary.totalCostUsd.toFixed(4)}]`;
|
||||
const costLabel = `[${formatCost(summary.totalCostUsd, balance?.currency)}]`;
|
||||
const cacheLabel = "[c ▰▰▰▰▰▰ 100%]";
|
||||
const updateLabel = updateAvailable ? `↑ ${updateAvailable}` : "";
|
||||
|
||||
|
||||
@@ -443,5 +443,8 @@ export function buildSpec(name: string, argsByName: Record<string, string>): str
|
||||
}
|
||||
|
||||
function quoteIfNeeded(s: string): string {
|
||||
return /\s|"/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
|
||||
// Escape backslashes BEFORE quotes — otherwise a trailing `\` in the
|
||||
// input would consume the closing quote when a downstream parser
|
||||
// un-escapes the output (CodeQL js/incomplete-sanitization).
|
||||
return /\s|"/.test(s) ? `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : s;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { UsageCard as UsageCardData } from "../state/cards.js";
|
||||
import { FG, TONE, formatCNY } from "../theme/tokens.js";
|
||||
import { FG, TONE, formatBalance, formatCost } from "../theme/tokens.js";
|
||||
|
||||
const BAR_CELLS = 30;
|
||||
|
||||
@@ -32,7 +32,7 @@ export function UsageCard({ card }: { card: UsageCardData }): React.ReactElement
|
||||
const reasonRatio = card.tokens.reason / cap;
|
||||
const outputRatio = card.tokens.output / cap;
|
||||
|
||||
const headerMeta: string[] = [`turn ${card.turn}`, formatCNY(card.cost)];
|
||||
const headerMeta: string[] = [`turn ${card.turn}`, formatCost(card.cost, card.balanceCurrency)];
|
||||
if (card.elapsedMs !== undefined) headerMeta.push(`${(card.elapsedMs / 1000).toFixed(1)}s`);
|
||||
return (
|
||||
<Card tone={FG.meta}>
|
||||
@@ -66,11 +66,15 @@ export function UsageCard({ card }: { card: UsageCardData }): React.ReactElement
|
||||
</Box>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={FG.faint}>session</Text>
|
||||
<Text bold color={FG.body}>{`⛁ ${formatCNY(card.sessionCost, 3)}`}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
{`⛁ ${formatCost(card.sessionCost, card.balanceCurrency, 3)}`}
|
||||
</Text>
|
||||
{card.balance !== undefined ? (
|
||||
<>
|
||||
<Text color={FG.faint}>· balance</Text>
|
||||
<Text bold color={TONE.brand}>{`¥${card.balance.toFixed(2)}`}</Text>
|
||||
<Text bold color={TONE.brand}>
|
||||
{formatBalance(card.balance, card.balanceCurrency)}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
@@ -89,9 +93,9 @@ function CompactUsageRow({ card }: { card: UsageCardData }): React.ReactElement
|
||||
</Text>
|
||||
<Text color={FG.faint}>· cache</Text>
|
||||
<Text color={TONE.ok}>{`${(card.cacheHit * 100).toFixed(0)}%`}</Text>
|
||||
<Text color={FG.faint}>{`· ${formatCNY(card.cost)}${elapsed}`}</Text>
|
||||
<Text color={FG.faint}>{`· ${formatCost(card.cost, card.balanceCurrency)}${elapsed}`}</Text>
|
||||
{card.balance !== undefined ? (
|
||||
<Text color={TONE.brand}>{`· ¥${card.balance.toFixed(2)}`}</Text>
|
||||
<Text color={TONE.brand}>{`· ${formatBalance(card.balance, card.balanceCurrency)}`}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** OSC 52 clipboard write + temp-file fallback. */
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -13,7 +13,11 @@ export interface ClipboardWrite {
|
||||
}
|
||||
|
||||
export function writeClipboard(text: string): ClipboardWrite {
|
||||
const filePath = join(tmpdir(), `reasonix-clip-${Date.now()}.txt`);
|
||||
// mkdtemp creates a private 0700 directory atomically — keeps the
|
||||
// file out of the shared tmp namespace where a sibling process can
|
||||
// race or read it (CodeQL js/insecure-temporary-file).
|
||||
const dir = mkdtempSync(join(tmpdir(), "reasonix-clip-"));
|
||||
const filePath = join(dir, "clip.txt");
|
||||
let osc52 = false;
|
||||
if (text.length <= OSC_52_LIMIT) {
|
||||
const b64 = Buffer.from(text, "utf8").toString("base64");
|
||||
|
||||
+23
-13
@@ -1,6 +1,6 @@
|
||||
/** `#` writes project memory, `#g` global; `##+` stays a markdown heading; `\#` escapes and submits the literal `#`. */
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { closeSync, fstatSync, mkdirSync, openSync, readSync, writeSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { PROJECT_MEMORY_FILE } from "../../memory/project.js";
|
||||
@@ -79,18 +79,28 @@ function appendBulletToFile(path: string, note: string, newFileHeader: string):
|
||||
const trimmed = note.trim();
|
||||
if (!trimmed) throw new Error("note body cannot be empty");
|
||||
const bullet = `- ${trimmed}\n`;
|
||||
if (!existsSync(path)) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${newFileHeader}${bullet}`, "utf8");
|
||||
return { path, created: true };
|
||||
}
|
||||
let prefix = "";
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
// One `a+` open covers both branches: O_APPEND lands every write
|
||||
// atomically at end-of-file (concurrent appenders interleave whole
|
||||
// bullets), O_CREAT creates the file when it's missing, and we use
|
||||
// `fstat().size === 0` as the "we just created it" signal to decide
|
||||
// whether to emit the file header. Single fd from open through
|
||||
// write — no path-based check between (CodeQL js/file-system-race).
|
||||
const fd = openSync(path, "a+");
|
||||
try {
|
||||
const existing = readFileSync(path, "utf8");
|
||||
if (existing.length > 0 && !existing.endsWith("\n")) prefix = "\n";
|
||||
} catch {
|
||||
// Unreadable but exists — let appendFileSync surface the real error.
|
||||
const stat = fstatSync(fd);
|
||||
if (stat.size === 0) {
|
||||
writeSync(fd, `${newFileHeader}${bullet}`);
|
||||
return { path, created: true };
|
||||
}
|
||||
// Existing file — peek the trailing byte to decide whether to
|
||||
// insert a leading newline. Same fd → no separate stat→read race.
|
||||
const tail = Buffer.alloc(1);
|
||||
readSync(fd, tail, 0, 1, stat.size - 1);
|
||||
const prefix = tail[0] !== 0x0a ? "\n" : "";
|
||||
writeSync(fd, `${prefix}${bullet}`);
|
||||
return { path, created: false };
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
appendFileSync(path, `${prefix}${bullet}`, "utf8");
|
||||
return { path, created: false };
|
||||
}
|
||||
@@ -6,13 +6,6 @@ import type { Scrollback } from "./useScrollback.js";
|
||||
|
||||
export type SlashOutcome = { kind: "consumed" } | { kind: "resubmit"; text: string };
|
||||
|
||||
interface ActiveLoopShape {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
nextFireAt: number;
|
||||
iter: number;
|
||||
}
|
||||
|
||||
export interface ApplySlashResultContext {
|
||||
log: Scrollback;
|
||||
stdoutWrite: (chunk: string) => void;
|
||||
@@ -20,10 +13,10 @@ export interface ApplySlashResultContext {
|
||||
syncPendingCount: () => void;
|
||||
session: string | null;
|
||||
codeModeOn: boolean;
|
||||
activeLoopRef: MutableRefObject<ActiveLoopShape | null>;
|
||||
isLoopActive: () => boolean;
|
||||
stopLoop: () => void;
|
||||
quitProcess: () => void;
|
||||
promptHistory: MutableRefObject<string[]>;
|
||||
pushHistory: (text: string) => void;
|
||||
/** The verbatim text the user typed; used for promptHistory bookkeeping. */
|
||||
text: string;
|
||||
}
|
||||
@@ -34,7 +27,7 @@ export function applySlashResult(result: SlashResult, ctx: ApplySlashResultConte
|
||||
// the process is exiting. Use quitProcess (process.exit) rather than
|
||||
// Ink's exit(): the singleton stdin reader keeps a `data` listener
|
||||
// attached, so exit() unmounts React but leaves the event loop alive.
|
||||
if (ctx.activeLoopRef.current) ctx.stopLoop();
|
||||
if (ctx.isLoopActive()) ctx.stopLoop();
|
||||
ctx.quitProcess();
|
||||
return { kind: "consumed" };
|
||||
}
|
||||
@@ -50,7 +43,7 @@ export function applySlashResult(result: SlashResult, ctx: ApplySlashResultConte
|
||||
clearPendingEdits(ctx.session);
|
||||
ctx.syncPendingCount();
|
||||
}
|
||||
if (ctx.activeLoopRef.current) ctx.stopLoop();
|
||||
if (ctx.isLoopActive()) ctx.stopLoop();
|
||||
return { kind: "consumed" };
|
||||
}
|
||||
if (result.info) {
|
||||
@@ -77,6 +70,6 @@ export function applySlashResult(result: SlashResult, ctx: ApplySlashResultConte
|
||||
if (result.resubmit) {
|
||||
return { kind: "resubmit", text: result.resubmit };
|
||||
}
|
||||
ctx.promptHistory.current.push(ctx.text);
|
||||
ctx.pushHistory(ctx.text);
|
||||
return { kind: "consumed" };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { type MutableRefObject, useCallback } from "react";
|
||||
import {
|
||||
type ApplyResult,
|
||||
type EditBlock,
|
||||
type EditSnapshot,
|
||||
applyEditBlocks,
|
||||
snapshotBeforeEdits,
|
||||
} from "../../../code/edit-blocks.js";
|
||||
import { clearPendingEdits, savePendingEdits } from "../../../code/pending-edits.js";
|
||||
import { formatEditResults, partitionEdits } from "../edit-history.js";
|
||||
|
||||
export interface UseCodeModeResult {
|
||||
/** /apply callback. Empty `indices` means "all"; specific 1-based indices apply only those. */
|
||||
codeApply: (indices?: readonly number[]) => string;
|
||||
/** /discard callback. Same indices semantics as codeApply. */
|
||||
codeDiscard: (indices?: readonly number[]) => string;
|
||||
}
|
||||
|
||||
export interface UseCodeModeOptions {
|
||||
codeMode: boolean;
|
||||
pendingEdits: MutableRefObject<EditBlock[]>;
|
||||
currentRootDir: string;
|
||||
session: string | null;
|
||||
syncPendingCount: () => void;
|
||||
recordEdit: (
|
||||
source: string,
|
||||
blocks: readonly EditBlock[],
|
||||
results: readonly ApplyResult[],
|
||||
snaps: readonly EditSnapshot[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
/** Slash-command callbacks for `/apply` and `/discard` over the pending-edits queue. Owns the partition / snapshot / save / sync sequence; AppInner just forwards the strings to its log. */
|
||||
export function useCodeMode(opts: UseCodeModeOptions): UseCodeModeResult {
|
||||
const { codeMode, pendingEdits, currentRootDir, session, syncPendingCount, recordEdit } = opts;
|
||||
|
||||
const codeApply = useCallback(
|
||||
(indices?: readonly number[]): string => {
|
||||
if (!codeMode) return "not in code mode";
|
||||
const blocks = pendingEdits.current;
|
||||
if (blocks.length === 0) {
|
||||
return "nothing pending — the model hasn't proposed edits since the last /apply or /discard.";
|
||||
}
|
||||
const useSubset = indices !== undefined && indices.length > 0;
|
||||
const { selected, remaining } = useSubset
|
||||
? partitionEdits(blocks, indices)
|
||||
: { selected: blocks, remaining: [] as EditBlock[] };
|
||||
if (selected.length === 0) {
|
||||
return "▸ no edits matched those indices — nothing applied. Use /apply with no args to commit them all.";
|
||||
}
|
||||
const snaps = snapshotBeforeEdits(selected, currentRootDir);
|
||||
const results = applyEditBlocks(selected, currentRootDir);
|
||||
const anyApplied = results.some((r) => r.status === "applied" || r.status === "created");
|
||||
if (anyApplied) recordEdit("review-apply", selected, results, snaps);
|
||||
pendingEdits.current = remaining;
|
||||
if (remaining.length === 0) clearPendingEdits(session ?? null);
|
||||
else savePendingEdits(session ?? null, remaining);
|
||||
syncPendingCount();
|
||||
const tail =
|
||||
remaining.length > 0
|
||||
? `\n▸ ${remaining.length} edit block(s) still pending — /apply or /discard to clear them.`
|
||||
: "";
|
||||
return formatEditResults(results) + tail;
|
||||
},
|
||||
[codeMode, currentRootDir, session, syncPendingCount, recordEdit, pendingEdits],
|
||||
);
|
||||
|
||||
const codeDiscard = useCallback(
|
||||
(indices?: readonly number[]): string => {
|
||||
const blocks = pendingEdits.current;
|
||||
if (blocks.length === 0) return "nothing pending to discard.";
|
||||
const useSubset = indices !== undefined && indices.length > 0;
|
||||
const { selected, remaining } = useSubset
|
||||
? partitionEdits(blocks, indices)
|
||||
: { selected: blocks, remaining: [] as EditBlock[] };
|
||||
if (selected.length === 0) {
|
||||
return "▸ no edits matched those indices — nothing discarded.";
|
||||
}
|
||||
pendingEdits.current = remaining;
|
||||
if (remaining.length === 0) clearPendingEdits(session ?? null);
|
||||
else savePendingEdits(session ?? null, remaining);
|
||||
syncPendingCount();
|
||||
const tail =
|
||||
remaining.length > 0
|
||||
? ` (${remaining.length} block(s) still pending)`
|
||||
: ". Nothing was written to disk.";
|
||||
return `▸ discarded ${selected.length} pending edit block(s)${tail}`;
|
||||
},
|
||||
[session, syncPendingCount, pendingEdits],
|
||||
);
|
||||
|
||||
return { codeApply, codeDiscard };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
export interface UseInputRecallResult {
|
||||
recallPrev: () => void;
|
||||
recallNext: () => void;
|
||||
pushHistory: (text: string) => void;
|
||||
/** Reset cursor to the "fresh input" position — call after a successful submit. */
|
||||
resetCursor: () => void;
|
||||
}
|
||||
|
||||
/** Bash-style ↑/↓ recall over a turn-local prompt history. Cursor is `useRef` so toggles don't re-render. */
|
||||
export function useInputRecall(setInput: (s: string) => void): UseInputRecallResult {
|
||||
const promptHistory = useRef<string[]>([]);
|
||||
const historyCursor = useRef<number>(-1);
|
||||
|
||||
const recallPrev = useCallback(() => {
|
||||
const hist = promptHistory.current;
|
||||
if (hist.length === 0) return;
|
||||
const nextCursor = Math.min(historyCursor.current + 1, hist.length - 1);
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(hist[hist.length - 1 - nextCursor] ?? "");
|
||||
}, [setInput]);
|
||||
|
||||
const recallNext = useCallback(() => {
|
||||
if (historyCursor.current < 0) return;
|
||||
const hist = promptHistory.current;
|
||||
const nextCursor = historyCursor.current - 1;
|
||||
historyCursor.current = nextCursor;
|
||||
setInput(nextCursor < 0 ? "" : (hist[hist.length - 1 - nextCursor] ?? ""));
|
||||
}, [setInput]);
|
||||
|
||||
const pushHistory = useCallback((text: string) => {
|
||||
promptHistory.current.push(text);
|
||||
}, []);
|
||||
|
||||
const resetCursor = useCallback(() => {
|
||||
historyCursor.current = -1;
|
||||
}, []);
|
||||
|
||||
return { recallPrev, recallNext, pushHistory, resetCursor };
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Scrollback } from "./useScrollback.js";
|
||||
|
||||
interface ActiveLoop {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
nextFireAt: number;
|
||||
iter: number;
|
||||
}
|
||||
|
||||
export interface ActiveLoopSnapshot {
|
||||
prompt: string;
|
||||
intervalMs: number;
|
||||
iter: number;
|
||||
nextFireMs: number;
|
||||
}
|
||||
|
||||
export interface UseLoopModeResult {
|
||||
startLoop: (intervalMs: number, prompt: string) => void;
|
||||
stopLoop: () => void;
|
||||
/** Snapshot for the `/loop` (no-arg) status branch. Returns null when no loop is active. */
|
||||
getLoopStatus: () => ActiveLoopSnapshot | null;
|
||||
/** Cheap predicate — used by handleSubmit's cancel-on-user-input guard and by apply-slash-result. */
|
||||
isLoopActive: () => boolean;
|
||||
/** True only during the timer's `handleSubmit` invocation — tells handleSubmit's cancel guard to skip itself so the loop's own re-submit doesn't kill the loop. */
|
||||
isLoopFiring: () => boolean;
|
||||
/** Reset by handleSubmit at the top of every call so the firing flag is one-shot. */
|
||||
clearFiringFlag: () => void;
|
||||
/** Reactive state for the LoopStatusRow render — null when no loop is active. */
|
||||
activeLoop: ActiveLoop | null;
|
||||
}
|
||||
|
||||
export interface UseLoopModeOptions {
|
||||
log: Scrollback;
|
||||
busyRef: MutableRefObject<boolean>;
|
||||
/** Forward-ref to the latest `handleSubmit` — the closure shifts as state changes, so the timer dereferences fresh on each fire. */
|
||||
handleSubmitRef: MutableRefObject<((raw: string) => Promise<void>) | null>;
|
||||
}
|
||||
|
||||
/** Owns the active /loop config + its setTimeout-based scheduler. Re-issuing /loop replaces the slot; cancellation is centralized in stopLoop. */
|
||||
export function useLoopMode(opts: UseLoopModeOptions): UseLoopModeResult {
|
||||
const { log, busyRef, handleSubmitRef } = opts;
|
||||
const [activeLoop, setActiveLoop] = useState<ActiveLoop | null>(null);
|
||||
const activeLoopRef = useRef<ActiveLoop | null>(null);
|
||||
const loopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const loopFiringRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
activeLoopRef.current = activeLoop;
|
||||
}, [activeLoop]);
|
||||
|
||||
const stopLoop = useCallback(() => {
|
||||
if (loopTimerRef.current) {
|
||||
clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return;
|
||||
setActiveLoop(null);
|
||||
log.pushInfo(`▸ loop stopped (after ${cur.iter} iter${cur.iter === 1 ? "" : "s"}).`);
|
||||
}, [log]);
|
||||
|
||||
const startLoop = useCallback((intervalMs: number, prompt: string) => {
|
||||
if (loopTimerRef.current) {
|
||||
clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
setActiveLoop({
|
||||
prompt,
|
||||
intervalMs,
|
||||
nextFireAt: Date.now() + intervalMs,
|
||||
iter: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getLoopStatus = useCallback((): ActiveLoopSnapshot | null => {
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return null;
|
||||
return {
|
||||
prompt: cur.prompt,
|
||||
intervalMs: cur.intervalMs,
|
||||
iter: cur.iter,
|
||||
nextFireMs: Math.max(0, cur.nextFireAt - Date.now()),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isLoopActive = useCallback(() => activeLoopRef.current !== null, []);
|
||||
const isLoopFiring = useCallback(() => loopFiringRef.current, []);
|
||||
const clearFiringFlag = useCallback(() => {
|
||||
loopFiringRef.current = false;
|
||||
}, []);
|
||||
|
||||
// /loop scheduler. Re-runs whenever activeLoop's `nextFireAt` shifts —
|
||||
// either because startLoop set a fresh schedule or because a previous
|
||||
// firing bumped the next-fire time. Cleanup clears the in-flight
|
||||
// timer so a stopLoop / replacement doesn't leak a fire after cancel.
|
||||
useEffect(() => {
|
||||
if (!activeLoop) return;
|
||||
const delay = Math.max(0, activeLoop.nextFireAt - Date.now());
|
||||
const timer = setTimeout(async () => {
|
||||
loopTimerRef.current = null;
|
||||
// Skip the firing entirely when a prior turn is still running.
|
||||
// Re-arm in 1s so the loop catches up the moment busy clears,
|
||||
// rather than waiting a full interval after a slow turn.
|
||||
if (busyRef.current) {
|
||||
setActiveLoop((cur) => (cur ? { ...cur, nextFireAt: Date.now() + 1000 } : cur));
|
||||
return;
|
||||
}
|
||||
const cur = activeLoopRef.current;
|
||||
if (!cur) return;
|
||||
const nextIter = cur.iter + 1;
|
||||
// Schedule the NEXT firing now (independent of how long this turn
|
||||
// takes). Keeps the cadence honest even when individual turns run
|
||||
// long.
|
||||
setActiveLoop((c) =>
|
||||
c ? { ...c, iter: nextIter, nextFireAt: Date.now() + cur.intervalMs } : c,
|
||||
);
|
||||
log.pushInfo(`▸ /loop iter ${nextIter} → ${cur.prompt}`);
|
||||
loopFiringRef.current = true;
|
||||
try {
|
||||
await handleSubmitRef.current?.(cur.prompt);
|
||||
} catch {
|
||||
// Persistent submission errors → kill the loop rather than spam
|
||||
// the screen. User can re-issue /loop once they fix the cause.
|
||||
stopLoop();
|
||||
} finally {
|
||||
loopFiringRef.current = false;
|
||||
}
|
||||
}, delay);
|
||||
loopTimerRef.current = timer;
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeLoop, stopLoop, log, busyRef, handleSubmitRef]);
|
||||
|
||||
return {
|
||||
startLoop,
|
||||
stopLoop,
|
||||
getLoopStatus,
|
||||
isLoopActive,
|
||||
isLoopFiring,
|
||||
clearFiringFlag,
|
||||
activeLoop,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { WriteStream } from "node:fs";
|
||||
import { type MutableRefObject, useCallback, useEffect } from "react";
|
||||
|
||||
/** Ctrl+C / SIGINT → flush transcript + `process.exit(0)`. We call `process.exit` directly rather than Ink's `exit()` because the singleton stdin reader keeps a `data` listener attached — `exit()` would unmount the React tree but leave the event loop alive and the terminal would hang. */
|
||||
export function useQuit(transcriptRef: MutableRefObject<WriteStream | null>): () => void {
|
||||
const quitProcess = useCallback(() => {
|
||||
transcriptRef.current?.end();
|
||||
process.exit(0);
|
||||
}, [transcriptRef]);
|
||||
|
||||
useEffect(() => {
|
||||
process.on("SIGINT", quitProcess);
|
||||
return () => {
|
||||
process.off("SIGINT", quitProcess);
|
||||
};
|
||||
}, [quitProcess]);
|
||||
|
||||
return quitProcess;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export interface Scrollback {
|
||||
cost: number;
|
||||
sessionCost: number;
|
||||
balance?: number;
|
||||
balanceCurrency?: string;
|
||||
elapsedMs?: number;
|
||||
}): string;
|
||||
showPlan(args: {
|
||||
@@ -219,6 +220,7 @@ export function useScrollback(): Scrollback {
|
||||
cost: args.cost,
|
||||
sessionCost: args.sessionCost,
|
||||
balance: args.balance,
|
||||
balanceCurrency: args.balanceCurrency,
|
||||
elapsedMs: args.elapsedMs,
|
||||
});
|
||||
return id;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { WriteStream } from "node:fs";
|
||||
import { type MutableRefObject, useCallback } from "react";
|
||||
import type { LoopEvent } from "../../../loop.js";
|
||||
import { recordFromLoopEvent, writeRecord } from "../../../transcript/log.js";
|
||||
|
||||
/** Returns a `LoopEvent` writer that no-ops when no transcript was opened. Wraps `recordFromLoopEvent` + `writeRecord` so callers don't carry the model/prefix metadata. */
|
||||
export function useTranscriptWriter(
|
||||
transcriptRef: MutableRefObject<WriteStream | null>,
|
||||
model: string,
|
||||
prefixHash: string,
|
||||
): (ev: LoopEvent) => void {
|
||||
return useCallback(
|
||||
(ev: LoopEvent) => {
|
||||
const stream = transcriptRef.current;
|
||||
if (!stream) return;
|
||||
writeRecord(stream, recordFromLoopEvent(ev, { model, prefixHash }));
|
||||
},
|
||||
[transcriptRef, model, prefixHash],
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { PILL_MODEL, PILL_SECTION, Pill, modelBadgeFor } from "../primitives/Pil
|
||||
import { Spinner } from "../primitives/Spinner.js";
|
||||
import { CARD, FG, TONE } from "../theme/tokens.js";
|
||||
import { useElapsedSeconds, useSlowTick, useTick } from "../ticker.js";
|
||||
import type { SubagentActivity } from "../useSubagent.js";
|
||||
|
||||
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
@@ -159,20 +160,8 @@ function subagentTitle(skillName: string | undefined, task: string): string {
|
||||
return `Sub-agent · ${short || "anonymous"}`;
|
||||
}
|
||||
|
||||
/** Live block for a running subagent — Card-styled, fixed row count for flicker-safety. */
|
||||
export function SubagentRow({
|
||||
activity,
|
||||
}: {
|
||||
activity: {
|
||||
task: string;
|
||||
iter: number;
|
||||
elapsedMs: number;
|
||||
skillName?: string;
|
||||
model?: string;
|
||||
phase?: "exploring" | "summarising";
|
||||
lastInner: { glyph: string; color: string; label: string; meta?: string } | null;
|
||||
};
|
||||
}) {
|
||||
/** Live block for a single in-flight subagent — rich layout, used when only one is running. */
|
||||
export function SubagentRow({ activity }: { activity: SubagentActivity }) {
|
||||
useTick();
|
||||
const seconds = (activity.elapsedMs / 1000).toFixed(1);
|
||||
const phase = subagentPhaseLabel(activity.phase, activity.iter, activity.elapsedMs);
|
||||
@@ -222,6 +211,78 @@ export function SubagentRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** 1 → rich; 2-max → compact rows; >max → compact + "+N more" fold. */
|
||||
export function SubagentLiveStack({
|
||||
activities,
|
||||
max = 3,
|
||||
}: {
|
||||
activities: ReadonlyArray<SubagentActivity>;
|
||||
max?: number;
|
||||
}) {
|
||||
const tick = useTick();
|
||||
if (activities.length === 0) return null;
|
||||
if (activities.length === 1) return <SubagentRow activity={activities[0]!} />;
|
||||
const visible = activities.slice(0, max);
|
||||
const overflow = activities.length - visible.length;
|
||||
const summarising = activities.filter((a) => a.phase === "summarising").length;
|
||||
const metaParts = [`${activities.length} running`];
|
||||
if (summarising > 0) metaParts.push(`${summarising} summarising`);
|
||||
return (
|
||||
<Card tone={CARD.subagent.color}>
|
||||
<CardHeader
|
||||
glyph="⌬"
|
||||
tone={CARD.subagent.color}
|
||||
title="subagents"
|
||||
titleColor={PILL_SECTION.plan.fg}
|
||||
titleBg={PILL_SECTION.plan.bg}
|
||||
subtitle={metaParts.join(" · ")}
|
||||
right={<Spinner kind="braille" color={CARD.subagent.color} />}
|
||||
/>
|
||||
{visible.map((a, i) => (
|
||||
<CompactSubagentLine key={a.runId} activity={a} tick={tick} index={i} />
|
||||
))}
|
||||
{overflow > 0 ? <Text color={FG.faint}>{` +${overflow} more running…`}</Text> : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactSubagentLine({
|
||||
activity,
|
||||
tick,
|
||||
index,
|
||||
}: {
|
||||
activity: SubagentActivity;
|
||||
tick: number;
|
||||
index: number;
|
||||
}) {
|
||||
const summarising = activity.phase === "summarising";
|
||||
const spinnerFrame = SPINNER_FRAMES[(tick + index) % SPINNER_FRAMES.length] ?? "·";
|
||||
const glyph = summarising ? "▶" : spinnerFrame;
|
||||
const glyphColor = summarising ? TONE.brand : CARD.subagent.color;
|
||||
const seconds = (activity.elapsedMs / 1000).toFixed(1).padStart(5);
|
||||
const title = activity.skillName ?? truncate(activity.task, 28);
|
||||
const titlePadded = title.padEnd(28);
|
||||
const last = activity.lastInner;
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Text color={glyphColor} bold>
|
||||
{` ${glyph} `}
|
||||
</Text>
|
||||
<Text color={FG.body}>{titlePadded}</Text>
|
||||
<Text color={FG.faint}>{` iter ${String(activity.iter).padStart(2)} · ${seconds}s · `}</Text>
|
||||
{last ? (
|
||||
<>
|
||||
<Text color={last.color}>{`${last.glyph} `}</Text>
|
||||
<Text color={FG.body}>{truncate(last.label, 18)}</Text>
|
||||
{last.meta ? <Text color={FG.faint}>{` ${last.meta}`}</Text> : null}
|
||||
</>
|
||||
) : (
|
||||
<Text color={FG.faint}>queued…</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||||
}
|
||||
|
||||
@@ -4,25 +4,17 @@ import React from "react";
|
||||
import { Countdown } from "../primitives/Countdown.js";
|
||||
import { useAgentState } from "../state/provider.js";
|
||||
import type { Mode, NetworkState, StatusBar } from "../state/state.js";
|
||||
import { FG, TONE, USD_TO_CNY } from "../theme/tokens.js";
|
||||
import { FG, TONE, balanceColor, formatBalance, formatCost } from "../theme/tokens.js";
|
||||
|
||||
const RULE_PAD = 4;
|
||||
const RULE_MIN = 20;
|
||||
|
||||
function balanceColor(cny: number): string {
|
||||
if (cny < 5) return TONE.err;
|
||||
if (cny < 20) return TONE.warn;
|
||||
return TONE.brand;
|
||||
}
|
||||
|
||||
export function StatusRow(): React.ReactElement {
|
||||
const status = useAgentState((s) => s.status);
|
||||
const session = useAgentState((s) => s.session);
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const ruleWidth = Math.max(RULE_MIN, cols - RULE_PAD);
|
||||
const turnCny = status.cost * USD_TO_CNY;
|
||||
const sessionCny = status.sessionCost * USD_TO_CNY;
|
||||
const hasTurn = status.cost > 0;
|
||||
|
||||
return (
|
||||
@@ -48,16 +40,22 @@ export function StatusRow(): React.ReactElement {
|
||||
<Text bold color={TONE.brand}>
|
||||
{"▸ "}
|
||||
</Text>
|
||||
<Text bold color={FG.body}>{`¥${turnCny.toFixed(4)} turn`}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
{`${formatCost(status.cost, status.balanceCurrency)} turn`}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Sep />
|
||||
<Text color={FG.sub}>{`¥${sessionCny.toFixed(3)} session`}</Text>
|
||||
<Text
|
||||
color={FG.sub}
|
||||
>{`${formatCost(status.sessionCost, status.balanceCurrency, 3)} session`}</Text>
|
||||
{status.balance !== undefined && (
|
||||
<>
|
||||
<Sep />
|
||||
<Text color={FG.faint}>{"wallet "}</Text>
|
||||
<Text bold color={balanceColor(status.balance)}>{`¥${status.balance.toFixed(2)}`}</Text>
|
||||
<Text bold color={balanceColor(status.balance, status.balanceCurrency)}>
|
||||
{formatBalance(status.balance, status.balanceCurrency)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Sep />
|
||||
|
||||
+33
-29
@@ -328,31 +328,29 @@ function FallbackTable({
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{headerCells.map((h, ci) => {
|
||||
const label = `${padToCells(h, labelPad - 2)}: `;
|
||||
const values = bodyCells.map((r) => r[ci] ?? "");
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: header cells positional
|
||||
<Box key={`fk-${ci}`} flexDirection="column">
|
||||
{values.map((v, ri) => {
|
||||
const lines = wrapToCells(v, valueCells);
|
||||
return lines.map((line, li) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: fallback table lines are positional
|
||||
<Box key={`fv-${ri}-${li}`}>
|
||||
{li === 0 ? (
|
||||
<Text bold color={FG.sub}>
|
||||
{label}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>{padToCells("", labelPad)}</Text>
|
||||
)}
|
||||
<Text color={FG.body}>{line}</Text>
|
||||
</Box>
|
||||
));
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{bodyCells.map((row, ri) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: body rows positional
|
||||
<Box key={`fr-${ri}`} flexDirection="column">
|
||||
{ri > 0 ? <Text> </Text> : null}
|
||||
{headerCells.map((h, ci) => {
|
||||
const label = `${padToCells(h, labelPad - 2)}: `;
|
||||
const lines = wrapToCells(row[ci] ?? "", valueCells);
|
||||
return lines.map((line, li) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: fallback table lines are positional
|
||||
<Box key={`fc-${ri}-${ci}-${li}`}>
|
||||
{li === 0 ? (
|
||||
<Text bold color={FG.sub}>
|
||||
{label}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>{padToCells("", labelPad)}</Text>
|
||||
)}
|
||||
<Text color={FG.body}>{line}</Text>
|
||||
</Box>
|
||||
));
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -370,12 +368,17 @@ function Inline({ tokens }: { tokens: Token[] }): React.ReactElement {
|
||||
const FILE_REF_RE = /\b([A-Za-z0-9_./@\-]+\.[A-Za-z0-9]{1,6})(?::(\d+)(?:-(\d+))?)?\b/g;
|
||||
const MENTION_RE = /(?<![A-Za-z0-9_])@([A-Za-z0-9_./\-]+\.[A-Za-z0-9]{1,6})/g;
|
||||
|
||||
function osc8(label: string, target: string, color: string): React.ReactElement {
|
||||
const open = `\x1b]8;;${target}\x07`;
|
||||
const close = "\x1b]8;;\x07";
|
||||
function looksLikeFileRef(path: string, hasLine: boolean): boolean {
|
||||
if (hasLine) return true;
|
||||
if (path.includes("/")) return true;
|
||||
const ext = path.split(".").pop() ?? "";
|
||||
return ext.length >= 2;
|
||||
}
|
||||
|
||||
function osc8(label: string, _target: string, color: string): React.ReactElement {
|
||||
return (
|
||||
<Text color={color} underline>
|
||||
{`${open}${label}${close}`}
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -402,6 +405,7 @@ function renderInlineText(raw: string): React.ReactElement {
|
||||
if (hits.some((h) => start < h.end && end > h.start)) continue;
|
||||
const path = m[1]!;
|
||||
const line = m[2];
|
||||
if (!looksLikeFileRef(path, line !== undefined)) continue;
|
||||
const target = line ? `file://${path}:${line}` : `file://${path}`;
|
||||
hits.push({ start, end, node: osc8(m[0], target, TONE.brand) });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SlashArgContext, SlashCommandSpec } from "./types.js";
|
||||
|
||||
export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
{ cmd: "help", summary: "show the full command reference" },
|
||||
{ cmd: "help", summary: "show the full command reference", aliases: ["?"] },
|
||||
{ cmd: "status", summary: "current model, flags, context, session" },
|
||||
{
|
||||
cmd: "preset",
|
||||
@@ -52,6 +52,7 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
argsHint: "<EN|zh-CN>",
|
||||
summary: "switch the runtime language",
|
||||
argCompleter: ["EN", "zh-CN"],
|
||||
aliases: ["lang"],
|
||||
},
|
||||
{ cmd: "mcp", summary: "list MCP servers + tools attached to this session" },
|
||||
{
|
||||
@@ -147,13 +148,17 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
summary: "show semantic_search status — built? Ollama installed? how to enable",
|
||||
},
|
||||
{ cmd: "clear", summary: "clear visible scrollback only (log/context kept)" },
|
||||
{ cmd: "new", summary: "start a fresh conversation (clear context + scrollback)" },
|
||||
{
|
||||
cmd: "new",
|
||||
summary: "start a fresh conversation (clear context + scrollback)",
|
||||
aliases: ["reset"],
|
||||
},
|
||||
{
|
||||
cmd: "loop",
|
||||
argsHint: "<5s..6h> <prompt> · stop · (no args = status)",
|
||||
summary: "auto-resubmit <prompt> every <interval> until you type something / Esc / /loop stop",
|
||||
},
|
||||
{ cmd: "exit", summary: "quit the TUI" },
|
||||
{ cmd: "exit", summary: "quit the TUI", aliases: ["quit", "q"] },
|
||||
// Code-mode only
|
||||
{
|
||||
cmd: "init",
|
||||
@@ -253,16 +258,31 @@ export function suggestSlashCommands(prefix: string, codeMode = false): SlashCom
|
||||
const p = prefix.toLowerCase();
|
||||
return SLASH_COMMANDS.filter((c) => {
|
||||
if (c.contextual === "code" && !codeMode) return false;
|
||||
return c.cmd.startsWith(p);
|
||||
if (c.cmd.startsWith(p)) return true;
|
||||
return c.aliases?.some((a) => a.startsWith(p)) ?? false;
|
||||
});
|
||||
}
|
||||
|
||||
/** alias → canonical cmd map, derived from SLASH_COMMANDS at module init. */
|
||||
const ALIAS_TO_CMD: Readonly<Record<string, string>> = (() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const spec of SLASH_COMMANDS) {
|
||||
if (!spec.aliases) continue;
|
||||
for (const a of spec.aliases) m[a] = spec.cmd;
|
||||
}
|
||||
return m;
|
||||
})();
|
||||
|
||||
export function resolveSlashAlias(name: string): string {
|
||||
return ALIAS_TO_CMD[name] ?? name;
|
||||
}
|
||||
|
||||
/** Picker fires only when arg tail has no internal whitespace; past that it's a usage hint. */
|
||||
export function detectSlashArgContext(input: string, codeMode = false): SlashArgContext | null {
|
||||
// `/cmd <rest>` — one space, rest captured up to end-of-buffer.
|
||||
const m = /^\/(\S+) ([\s\S]*)$/.exec(input);
|
||||
if (!m) return null;
|
||||
const cmdName = m[1]!.toLowerCase();
|
||||
const cmdName = resolveSlashAlias(m[1]!.toLowerCase());
|
||||
const tail = m[2] ?? "";
|
||||
const spec = SLASH_COMMANDS.find(
|
||||
(s) => s.cmd === cmdName && (s.contextual !== "code" || codeMode),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CacheFirstLoop } from "../../../loop.js";
|
||||
import { resolveSlashAlias } from "./commands.js";
|
||||
import { handlers as adminHandlers } from "./handlers/admin.js";
|
||||
import { handlers as basicHandlers } from "./handlers/basic.js";
|
||||
import { handlers as dashboardHandlers } from "./handlers/dashboard.js";
|
||||
@@ -45,7 +46,7 @@ export function handleSlash(
|
||||
loop: CacheFirstLoop,
|
||||
ctx: SlashContext = {},
|
||||
): SlashResult {
|
||||
const h = HANDLERS[cmd];
|
||||
const h = HANDLERS[resolveSlashAlias(cmd)];
|
||||
if (h) return h(args, loop, ctx);
|
||||
return { unknown: true, info: `unknown command: /${cmd} (try /help)` };
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
type ResolvedHook,
|
||||
globalSettingsPath,
|
||||
projectSettingsPath,
|
||||
} from "../../../../hooks.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { aggregateUsage, defaultUsageLogPath, readUsageLog } from "../../../../telemetry/usage.js";
|
||||
import { VERSION, compareVersions, isNpxInstall } from "../../../../version.js";
|
||||
} from "@/hooks.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { aggregateUsage, defaultUsageLogPath, readUsageLog } from "@/telemetry/usage.js";
|
||||
import { VERSION, compareVersions, isNpxInstall } from "@/version.js";
|
||||
import { runDoctorChecks } from "../../../commands/doctor.js";
|
||||
import { renderDashboard } from "../../../commands/stats.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { formatDuration, formatLoopStatus, parseLoopCommand } from "../../loop.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
@@ -189,13 +189,10 @@ const loop: SlashHandler = (args, _loop, ctx) => {
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
exit,
|
||||
quit: exit,
|
||||
clear,
|
||||
new: resetLog,
|
||||
reset: resetLog,
|
||||
keys,
|
||||
help,
|
||||
"?": help,
|
||||
retry,
|
||||
loop,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const dashboard: SlashHandler = (args, _loop, ctx) => {
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
fmtAgo,
|
||||
listCheckpoints,
|
||||
restoreCheckpoint,
|
||||
} from "../../../../code/checkpoints.js";
|
||||
import type { EditMode } from "../../../../config.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
} from "@/code/checkpoints.js";
|
||||
import type { EditMode } from "@/config.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { parseEditIndices } from "../../edit-history.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
import { runGitCommit, stripOuterQuotes } from "../helpers.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import * as pathMod from "node:path";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const INIT_PROMPT = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import type { JobRecord } from "../../../../tools/jobs.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { JobRecord } from "@/tools/jobs.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
function statusIcon(r: JobRecord): string {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
getSupportedLanguages,
|
||||
notifyLanguageChange,
|
||||
setLanguage,
|
||||
t,
|
||||
} from "../../../../i18n/index.js";
|
||||
import type { LanguageCode } from "../../../../i18n/types.js";
|
||||
import { getSupportedLanguages, notifyLanguageChange, setLanguage, t } from "@/i18n/index.js";
|
||||
import type { LanguageCode } from "@/i18n/types.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
@@ -30,5 +25,4 @@ export const handlers: Record<string, SlashHandler> = {
|
||||
|
||||
return { info: t("slash.language.success") };
|
||||
},
|
||||
lang: (args, loop, ctx) => handlers.language!(args, loop, ctx),
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import type { CacheFirstLoop } from "../../../../loop.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { CacheFirstLoop } from "@/loop.js";
|
||||
import { applyMcpAppend } from "../../mcp-append.js";
|
||||
import { toggleMcpDisabled } from "../../mcp-disable.js";
|
||||
import { slashHealthBadge } from "../../mcp-health.js";
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import {
|
||||
PROJECT_MEMORY_FILE,
|
||||
memoryEnabled,
|
||||
readProjectMemory,
|
||||
} from "../../../../memory/project.js";
|
||||
import { type MemoryScope, MemoryStore } from "../../../../memory/user.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { PROJECT_MEMORY_FILE, memoryEnabled, readProjectMemory } from "@/memory/project.js";
|
||||
import { type MemoryScope, MemoryStore } from "@/memory/user.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
import { resolveMemoryTarget } from "../helpers.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { saveReasoningEffort } from "../../../../config.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { saveReasoningEffort } from "@/config.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { PRESETS } from "../../presets.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import {
|
||||
DEEPSEEK_CONTEXT_TOKENS,
|
||||
DEEPSEEK_PRICING,
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
} from "../../../../telemetry/stats.js";
|
||||
import { countTokens } from "../../../../tokenizer.js";
|
||||
} from "@/telemetry/stats.js";
|
||||
import { countTokens } from "@/tokenizer.js";
|
||||
import { computeCtxBreakdown } from "../../ctx-breakdown.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
import { compactNum, formatToolList } from "../helpers.js";
|
||||
@@ -208,7 +208,7 @@ const cost: SlashHandler = (args, loop, ctx) => {
|
||||
return {};
|
||||
};
|
||||
|
||||
function estimateCost(userText: string, loop: import("../../../../loop.js").CacheFirstLoop) {
|
||||
function estimateCost(userText: string, loop: import("@/loop.js").CacheFirstLoop) {
|
||||
const pricing = DEEPSEEK_PRICING[loop.model];
|
||||
if (!pricing) {
|
||||
return { info: t("handlers.observability.costNoPricing", { model: loop.model }) };
|
||||
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
clearProjectShellAllowed,
|
||||
loadProjectShellAllowed,
|
||||
removeProjectShellAllowed,
|
||||
} from "../../../../config.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { BUILTIN_ALLOWLIST } from "../../../../tools/shell.js";
|
||||
} from "@/config.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { BUILTIN_ALLOWLIST } from "@/tools/shell.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const permissions: SlashHandler = (args, _loop, ctx) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { basename } from "node:path";
|
||||
import { listPlanArchives, loadPlanState, relativeTime } from "../../../../code/plan-store.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { listPlanArchives, loadPlanState, relativeTime } from "@/code/plan-store.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const plans: SlashHandler = (_args, loop) => {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { probeOllama } from "../../../../index/semantic/embedding.js";
|
||||
import { t } from "../../../../index/semantic/i18n.js";
|
||||
import { findOllamaBinary } from "../../../../index/semantic/ollama-launcher.js";
|
||||
import { probeOllama } from "@/index/semantic/embedding.js";
|
||||
import { t } from "@/index/semantic/i18n.js";
|
||||
import { findOllamaBinary } from "@/index/semantic/ollama-launcher.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const semantic: SlashHandler = (_args, _loop, ctx) => {
|
||||
@@ -74,15 +74,24 @@ interface IndexSummary {
|
||||
/** 10MB read cap so `/semantic` stays snappy on very large repos. */
|
||||
async function readIndexMeta(rootDir: string): Promise<IndexSummary | null> {
|
||||
const dataPath = path.join(rootDir, ".reasonix", "semantic", "index.jsonl");
|
||||
let raw: string;
|
||||
try {
|
||||
const stat = await fs.stat(dataPath);
|
||||
if (stat.size > 10 * 1024 * 1024) {
|
||||
// For huge indexes, give an order-of-magnitude estimate from
|
||||
// file size (avg ~500 bytes/chunk in practice). Files won't
|
||||
// be available, so we report just the chunk approximation.
|
||||
return { chunks: Math.round(stat.size / 500), files: 0 };
|
||||
// Bind size check and content to the same fd so a concurrent
|
||||
// rebuild can't grow the file past the 10MB threshold between
|
||||
// stat and read (CodeQL js/file-system-race).
|
||||
const fh = await fs.open(dataPath, "r");
|
||||
try {
|
||||
const stat = await fh.stat();
|
||||
if (stat.size > 10 * 1024 * 1024) {
|
||||
// For huge indexes, give an order-of-magnitude estimate from
|
||||
// file size (avg ~500 bytes/chunk in practice). Files won't
|
||||
// be available, so we report just the chunk approximation.
|
||||
return { chunks: Math.round(stat.size / 500), files: 0 };
|
||||
}
|
||||
raw = await fh.readFile("utf8");
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
const raw = await fs.readFile(dataPath, "utf8");
|
||||
const seenPaths = new Set<string>();
|
||||
let chunks = 0;
|
||||
for (const line of raw.split("\n")) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { deleteSession, listSessions, renameSession } from "../../../../memory/session.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { deleteSession, listSessions, renameSession } from "@/memory/session.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const sessions: SlashHandler = () => ({ openSessionsPicker: true });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { SkillStore } from "../../../../skills.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { SkillStore } from "@/skills.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const skill: SlashHandler = (args, _loop, ctx) => {
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface SlashContext {
|
||||
cost: number;
|
||||
sessionCost: number;
|
||||
balance?: number;
|
||||
balanceCurrency?: string;
|
||||
elapsedMs?: number;
|
||||
}) => void;
|
||||
dispatch?: (event: import("../state/events.js").AgentEvent) => void;
|
||||
@@ -131,6 +132,8 @@ export interface SlashCommandSpec {
|
||||
argsHint?: string;
|
||||
/** First-arg picker source — file paths intentionally absent (use `@path` mentions instead). */
|
||||
argCompleter?: "models" | "mcp-resources" | "mcp-prompts" | readonly string[];
|
||||
/** Alternate names — typing any of these resolves to `cmd` for dispatch / suggestion / arg-context. */
|
||||
aliases?: readonly string[];
|
||||
}
|
||||
|
||||
export interface SlashArgContext {
|
||||
|
||||
@@ -116,6 +116,7 @@ export interface UsageCard extends CardBase {
|
||||
readonly cost: number;
|
||||
readonly sessionCost: number;
|
||||
readonly balance?: number;
|
||||
readonly balanceCurrency?: string;
|
||||
/** Wall-clock for the turn — surfaced as `· 1.2s` in the header meta. */
|
||||
readonly elapsedMs?: number;
|
||||
/** Auto-emitted per-turn cards render as a single dim row; /cost emits the full breakdown. */
|
||||
|
||||
@@ -117,6 +117,7 @@ const sessionUpdate = z.object({
|
||||
cost: z.number().optional(),
|
||||
sessionCost: z.number().optional(),
|
||||
balance: z.number().optional(),
|
||||
balanceCurrency: z.string().optional(),
|
||||
cacheHit: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
@@ -241,6 +242,7 @@ const usageShow = z.object({
|
||||
cost: z.number().nonnegative(),
|
||||
sessionCost: z.number().nonnegative(),
|
||||
balance: z.number().optional(),
|
||||
balanceCurrency: z.string().optional(),
|
||||
elapsedMs: z.number().nonnegative().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -250,6 +250,7 @@ export function reduce(state: AgentState, event: AgentEvent): AgentState {
|
||||
cost: event.cost,
|
||||
sessionCost: event.sessionCost,
|
||||
balance: event.balance,
|
||||
balanceCurrency: event.balanceCurrency,
|
||||
elapsedMs: event.elapsedMs,
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface StatusBar {
|
||||
cost: number;
|
||||
sessionCost: number;
|
||||
balance?: number;
|
||||
balanceCurrency?: string;
|
||||
cacheHit: number;
|
||||
countdownSeconds?: number;
|
||||
recording?: { sizeBytes: number; events: number; path: string };
|
||||
|
||||
@@ -59,7 +59,32 @@ export type CardTone = keyof typeof CARD;
|
||||
/** DeepSeek prices in CNY; our internal table is USD divided by 7.2. Multiply back for display. */
|
||||
export const USD_TO_CNY = 7.2;
|
||||
|
||||
export function formatCNY(usd: number, fractionDigits = 4): string {
|
||||
const cny = usd * USD_TO_CNY;
|
||||
return `¥${cny.toFixed(fractionDigits)}`;
|
||||
const SYMBOL: Record<string, string> = { USD: "$", CNY: "¥" };
|
||||
|
||||
/** Format an amount already in `currency`. Undefined currency → CNY (matches pre-fix behavior). */
|
||||
export function formatBalance(
|
||||
amount: number,
|
||||
currency?: string,
|
||||
opts?: { fractionDigits?: number; label?: boolean },
|
||||
): string {
|
||||
const cur = currency ?? "CNY";
|
||||
const sym = SYMBOL[cur];
|
||||
const digits = opts?.fractionDigits ?? 2;
|
||||
const body = sym ? `${sym}${amount.toFixed(digits)}` : `${cur} ${amount.toFixed(digits)}`;
|
||||
return opts?.label ? `w ${body}` : body;
|
||||
}
|
||||
|
||||
/** Format an internal USD cost in the wallet's display currency. Undefined currency → CNY. */
|
||||
export function formatCost(costUsd: number, currency?: string, fractionDigits = 4): string {
|
||||
const cur = currency ?? "CNY";
|
||||
const amount = cur === "CNY" ? costUsd * USD_TO_CNY : costUsd;
|
||||
return formatBalance(amount, cur, { fractionDigits });
|
||||
}
|
||||
|
||||
/** Threshold color for a wallet balance. USD is converted to CNY before the threshold check. */
|
||||
export function balanceColor(amount: number, currency?: string): string {
|
||||
const cny = (currency ?? "CNY") === "USD" ? amount * USD_TO_CNY : amount;
|
||||
if (cny < 5) return TONE.err;
|
||||
if (cny < 20) return TONE.warn;
|
||||
return TONE.brand;
|
||||
}
|
||||
+84
-71
@@ -3,7 +3,7 @@ import type { LoopEvent } from "../../loop.js";
|
||||
import { appendUsage } from "../../telemetry/usage.js";
|
||||
import type { SubagentEvent, SubagentSink } from "../../tools/subagent.js";
|
||||
import type { Scrollback } from "./hooks/useScrollback.js";
|
||||
import { CARD, TONE } from "./theme/tokens.js";
|
||||
import { CARD, TONE, formatCost } from "./theme/tokens.js";
|
||||
|
||||
function summariseInner(ev: LoopEvent): SubagentInnerSummary | null {
|
||||
if (ev.role === "tool_start") {
|
||||
@@ -40,6 +40,10 @@ export interface SubagentInnerSummary {
|
||||
}
|
||||
|
||||
export interface SubagentActivity {
|
||||
/** Stable per-spawn id; key for parallel-row rendering. */
|
||||
runId: string;
|
||||
/** Wall-clock start so the stack stays in launch order even when events arrive interleaved. */
|
||||
startedAt: number;
|
||||
task: string;
|
||||
iter: number;
|
||||
elapsedMs: number;
|
||||
@@ -52,94 +56,103 @@ export interface SubagentActivity {
|
||||
export interface UseSubagentParams {
|
||||
session: string | undefined;
|
||||
log: Scrollback;
|
||||
/** Read live wallet currency at end-event time so the cost suffix follows the wallet symbol. */
|
||||
getWalletCurrency?: () => string | undefined;
|
||||
}
|
||||
|
||||
export interface UseSubagentResult {
|
||||
/** Live state for an in-flight subagent, null when none is running. */
|
||||
activity: SubagentActivity | null;
|
||||
/** In-flight runs, oldest first. Empty when none active. */
|
||||
activities: ReadonlyArray<SubagentActivity>;
|
||||
sinkRef: React.MutableRefObject<SubagentSink>;
|
||||
}
|
||||
|
||||
export function useSubagent({ session, log }: UseSubagentParams): UseSubagentResult {
|
||||
const [activity, setActivity] = useState<SubagentActivity | null>(null);
|
||||
export function useSubagent({
|
||||
session,
|
||||
log,
|
||||
getWalletCurrency,
|
||||
}: UseSubagentParams): UseSubagentResult {
|
||||
const [activities, setActivities] = useState<ReadonlyArray<SubagentActivity>>([]);
|
||||
const sinkRef = useRef<SubagentSink>({ current: null });
|
||||
// Subagent runs can outlive a balance refresh; the thunk lives in a ref so the
|
||||
// sink callback (installed once at mount) always reads the latest wallet currency.
|
||||
const getWalletCurrencyRef = useRef(getWalletCurrency);
|
||||
useEffect(() => {
|
||||
getWalletCurrencyRef.current = getWalletCurrency;
|
||||
}, [getWalletCurrency]);
|
||||
|
||||
useEffect(() => {
|
||||
sinkRef.current.current = (ev: SubagentEvent) => {
|
||||
if (ev.kind === "start") {
|
||||
setActivity({
|
||||
task: ev.task,
|
||||
iter: ev.iter ?? 0,
|
||||
elapsedMs: ev.elapsedMs ?? 0,
|
||||
skillName: ev.skillName,
|
||||
model: ev.model,
|
||||
phase: "exploring",
|
||||
lastInner: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (ev.kind === "progress") {
|
||||
setActivity((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
iter: ev.iter ?? prev.iter,
|
||||
elapsedMs: ev.elapsedMs ?? prev.elapsedMs,
|
||||
}
|
||||
: {
|
||||
task: ev.task,
|
||||
iter: ev.iter ?? 0,
|
||||
elapsedMs: ev.elapsedMs ?? 0,
|
||||
skillName: ev.skillName,
|
||||
model: ev.model,
|
||||
phase: "exploring",
|
||||
lastInner: null,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (ev.kind === "phase") {
|
||||
setActivity((prev) => (prev ? { ...prev, phase: ev.phase } : prev));
|
||||
return;
|
||||
}
|
||||
if (ev.kind === "inner" && ev.inner) {
|
||||
const summary = summariseInner(ev.inner);
|
||||
if (!summary) return;
|
||||
setActivity((prev) => (prev ? { ...prev, lastInner: summary } : prev));
|
||||
return;
|
||||
}
|
||||
// end
|
||||
setActivity(null);
|
||||
const seconds = ((ev.elapsedMs ?? 0) / 1000).toFixed(1);
|
||||
// Inline cost: the one number most users look at. Saves a /stats round-trip.
|
||||
const costTail =
|
||||
ev.costUsd !== undefined && ev.costUsd > 0 ? ` · $${ev.costUsd.toFixed(4)}` : "";
|
||||
const summary = ev.error
|
||||
? `⌬ subagent "${ev.task}" failed after ${seconds}s · ${ev.iter ?? 0} tool call(s) — ${ev.error}`
|
||||
: `⌬ subagent "${ev.task}" done in ${seconds}s · ${ev.iter ?? 0} tool call(s) · ${ev.turns ?? 0} turn(s)${costTail}`;
|
||||
log.pushInfo(summary);
|
||||
// Persist a subagent summary row to ~/.reasonix/usage.jsonl so
|
||||
// `/stats` and `reasonix stats` surface it. Skipped on error —
|
||||
// we only record what actually cost money and did work.
|
||||
if (!ev.error && ev.usage && ev.model) {
|
||||
appendUsage({
|
||||
session: session ?? null,
|
||||
model: ev.model,
|
||||
usage: ev.usage,
|
||||
kind: "subagent",
|
||||
subagent: {
|
||||
setActivities((prev) => {
|
||||
if (prev.some((a) => a.runId === ev.runId)) return prev;
|
||||
const next: SubagentActivity = {
|
||||
runId: ev.runId,
|
||||
startedAt: Date.now() - (ev.elapsedMs ?? 0),
|
||||
task: ev.task,
|
||||
iter: ev.iter ?? 0,
|
||||
elapsedMs: ev.elapsedMs ?? 0,
|
||||
skillName: ev.skillName,
|
||||
taskPreview: ev.task.slice(0, 60),
|
||||
toolIters: ev.iter ?? 0,
|
||||
durationMs: ev.elapsedMs ?? 0,
|
||||
},
|
||||
model: ev.model,
|
||||
phase: "exploring",
|
||||
lastInner: null,
|
||||
};
|
||||
return [...prev, next];
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (ev.kind === "end") {
|
||||
setActivities((prev) => prev.filter((a) => a.runId !== ev.runId));
|
||||
const seconds = ((ev.elapsedMs ?? 0) / 1000).toFixed(1);
|
||||
const costTail =
|
||||
ev.costUsd !== undefined && ev.costUsd > 0
|
||||
? ` · ${formatCost(ev.costUsd, getWalletCurrencyRef.current?.())}`
|
||||
: "";
|
||||
const summary = ev.error
|
||||
? `⌬ subagent "${ev.task}" failed after ${seconds}s · ${ev.iter ?? 0} tool call(s) — ${ev.error}`
|
||||
: `⌬ subagent "${ev.task}" done in ${seconds}s · ${ev.iter ?? 0} tool call(s) · ${ev.turns ?? 0} turn(s)${costTail}`;
|
||||
log.pushInfo(summary);
|
||||
if (!ev.error && ev.usage && ev.model) {
|
||||
appendUsage({
|
||||
session: session ?? null,
|
||||
model: ev.model,
|
||||
usage: ev.usage,
|
||||
kind: "subagent",
|
||||
subagent: {
|
||||
skillName: ev.skillName,
|
||||
taskPreview: ev.task.slice(0, 60),
|
||||
toolIters: ev.iter ?? 0,
|
||||
durationMs: ev.elapsedMs ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// progress / phase / inner — patch the matching row, ignore stragglers from runs we never saw `start` for.
|
||||
setActivities((prev) =>
|
||||
prev.map((a) => {
|
||||
if (a.runId !== ev.runId) return a;
|
||||
if (ev.kind === "progress") {
|
||||
return {
|
||||
...a,
|
||||
iter: ev.iter ?? a.iter,
|
||||
elapsedMs: ev.elapsedMs ?? a.elapsedMs,
|
||||
};
|
||||
}
|
||||
if (ev.kind === "phase") {
|
||||
return { ...a, phase: ev.phase ?? a.phase };
|
||||
}
|
||||
if (ev.kind === "inner" && ev.inner) {
|
||||
const summary = summariseInner(ev.inner);
|
||||
return summary ? { ...a, lastInner: summary } : a;
|
||||
}
|
||||
return a;
|
||||
}),
|
||||
);
|
||||
};
|
||||
return () => {
|
||||
sinkRef.current.current = null;
|
||||
};
|
||||
}, [session, log]);
|
||||
|
||||
return { activity, sinkRef };
|
||||
return { activities, sinkRef };
|
||||
}
|
||||
+4
-5
@@ -92,11 +92,10 @@ export class DeepSeekClient {
|
||||
);
|
||||
}
|
||||
this.apiKey = apiKey;
|
||||
this.baseUrl = (
|
||||
opts.baseUrl ??
|
||||
process.env.DEEPSEEK_BASE_URL ??
|
||||
"https://api.deepseek.com"
|
||||
).replace(/\/+$/, "");
|
||||
let url = opts.baseUrl ?? process.env.DEEPSEEK_BASE_URL ?? "https://api.deepseek.com";
|
||||
// Manual trim — `/\/+$/` is O(n²) on slash-heavy non-matches per CodeQL js/polynomial-redos.
|
||||
while (url.endsWith("/")) url = url.slice(0, -1);
|
||||
this.baseUrl = url;
|
||||
// 11 min. DeepSeek's load-balancer may keep a connection open for
|
||||
// up to 10 minutes while the request waits in queue (non-streaming
|
||||
// sends empty lines, streaming sends `:` SSE keep-alive comments —
|
||||
|
||||
+88
-33
@@ -1,6 +1,18 @@
|
||||
/** SEARCH must match byte-for-byte; empty SEARCH = create new file. No fuzzy match — silent wrong edit beats a missing one. */
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fstatSync,
|
||||
ftruncateSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
writeSync,
|
||||
} from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
export interface EditBlock {
|
||||
@@ -69,49 +81,92 @@ export function applyEditBlock(block: EditBlock, rootDir: string): ApplyResult {
|
||||
}
|
||||
|
||||
const searchEmpty = block.search.length === 0;
|
||||
const exists = existsSync(absTarget);
|
||||
|
||||
// Branch on intent first so each path makes exactly one `open` call
|
||||
// — keeps CodeQL's flow analyser from tripping over a check→use
|
||||
// chain across two opens (js/file-system-race).
|
||||
if (searchEmpty) {
|
||||
try {
|
||||
mkdirSync(dirname(absTarget), { recursive: true });
|
||||
const fd = openSync(absTarget, "wx");
|
||||
try {
|
||||
writeSync(fd, block.replace);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
return { path: block.path, status: "created" };
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code === "EEXIST") {
|
||||
return {
|
||||
path: block.path,
|
||||
status: "not-found",
|
||||
message: "empty SEARCH only creates new files — this file already exists",
|
||||
};
|
||||
}
|
||||
return { path: block.path, status: "error", message: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!exists) {
|
||||
if (!searchEmpty) {
|
||||
// Modify path. ENOENT is reported as `file-missing` so the model
|
||||
// knows it needs an empty SEARCH to create the file.
|
||||
let fd: number;
|
||||
try {
|
||||
fd = openSync(absTarget, "r+");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return {
|
||||
path: block.path,
|
||||
status: "file-missing",
|
||||
message: "file does not exist; to create it, use an empty SEARCH block",
|
||||
};
|
||||
}
|
||||
mkdirSync(dirname(absTarget), { recursive: true });
|
||||
writeFileSync(absTarget, block.replace, "utf8");
|
||||
return { path: block.path, status: "created" };
|
||||
throw err;
|
||||
}
|
||||
|
||||
const content = readFileSync(absTarget, "utf8");
|
||||
if (searchEmpty) {
|
||||
return {
|
||||
path: block.path,
|
||||
status: "not-found",
|
||||
message: "empty SEARCH only creates new files — this file already exists",
|
||||
};
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
const inBuf = Buffer.alloc(stat.size);
|
||||
let readBytes = 0;
|
||||
while (readBytes < stat.size) {
|
||||
const n = readSync(fd, inBuf, readBytes, stat.size - readBytes, readBytes);
|
||||
if (n <= 0) break;
|
||||
readBytes += n;
|
||||
}
|
||||
const content = inBuf.toString("utf8", 0, readBytes);
|
||||
const le = lineEndingOf(content);
|
||||
const adaptedSearch = block.search.replace(/\r?\n/g, le);
|
||||
const adaptedReplace = block.replace.replace(/\r?\n/g, le);
|
||||
const idx = content.indexOf(adaptedSearch);
|
||||
if (idx === -1) {
|
||||
return {
|
||||
path: block.path,
|
||||
status: "not-found",
|
||||
message: "SEARCH text does not match the current file content exactly",
|
||||
};
|
||||
}
|
||||
// Replace only the first occurrence — if the model needs multiple
|
||||
// identical edits it should emit multiple blocks (each anchored by
|
||||
// more surrounding context). Auto-expanding to replace-all is a
|
||||
// footgun when the same string legitimately appears in several
|
||||
// unrelated places.
|
||||
const replaced = `${content.slice(0, idx)}${adaptedReplace}${content.slice(idx + adaptedSearch.length)}`;
|
||||
// Truncate first so a shorter result doesn't leave stale tail
|
||||
// bytes; ftruncate also pads with NUL when the new length is
|
||||
// longer, which we then overwrite below.
|
||||
const outBuf = Buffer.from(replaced, "utf8");
|
||||
ftruncateSync(fd, outBuf.length);
|
||||
let written = 0;
|
||||
while (written < outBuf.length) {
|
||||
const n = writeSync(fd, outBuf, written, outBuf.length - written, written);
|
||||
if (n <= 0) break;
|
||||
written += n;
|
||||
}
|
||||
return { path: block.path, status: "applied" };
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
const le = lineEndingOf(content);
|
||||
const adaptedSearch = block.search.replace(/\r?\n/g, le);
|
||||
const adaptedReplace = block.replace.replace(/\r?\n/g, le);
|
||||
const idx = content.indexOf(adaptedSearch);
|
||||
if (idx === -1) {
|
||||
return {
|
||||
path: block.path,
|
||||
status: "not-found",
|
||||
message: "SEARCH text does not match the current file content exactly",
|
||||
};
|
||||
}
|
||||
// Replace only the first occurrence — if the model needs multiple
|
||||
// identical edits it should emit multiple blocks (each anchored by
|
||||
// more surrounding context). Auto-expanding to replace-all is a
|
||||
// footgun when the same string legitimately appears in several
|
||||
// unrelated places.
|
||||
const replaced = `${content.slice(0, idx)}${adaptedReplace}${content.slice(idx + adaptedSearch.length)}`;
|
||||
writeFileSync(absTarget, replaced, "utf8");
|
||||
return { path: block.path, status: "applied" };
|
||||
} catch (err) {
|
||||
return { path: block.path, status: "error", message: (err as Error).message };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const SECRET_KEY_RE =
|
||||
/(secret|token|password|passphrase|api[-_]?key|authorization|cookie|credential|passwd|pwd)/i;
|
||||
|
||||
export function redactEventValue<T>(value: T): T {
|
||||
return redactUnknown(value, null) as T;
|
||||
}
|
||||
|
||||
function redactUnknown(value: unknown, key: string | null): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => redactUnknown(item, null));
|
||||
if (value && typeof value === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [childKey, childValue] of Object.entries(value)) {
|
||||
out[childKey] = redactUnknown(childValue, childKey);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if ((key && SECRET_KEY_RE.test(key)) || /^Bearer\s+/i.test(value)) return "[redacted]";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LoopEvent } from "../loop.js";
|
||||
import type { ChatMessage, RawUsage, ToolCall } from "../types.js";
|
||||
import { redactEventValue } from "./event-redaction.js";
|
||||
import type {
|
||||
Event,
|
||||
ErrorEvent as KernelErrorEvent,
|
||||
@@ -10,6 +11,10 @@ import type {
|
||||
SessionOpenedEvent,
|
||||
SlashInvokedEvent,
|
||||
StatusEvent,
|
||||
ToolCallEvent,
|
||||
ToolConfirmAllowEvent,
|
||||
ToolConfirmAlwaysAllowEvent,
|
||||
ToolConfirmDenyEvent,
|
||||
ToolDispatchedEvent,
|
||||
ToolIntentEvent,
|
||||
ToolResultEvent,
|
||||
@@ -125,6 +130,66 @@ export class Eventizer {
|
||||
};
|
||||
}
|
||||
|
||||
emitToolCall(turn: number, name: string, args: Record<string, unknown>): ToolCallEvent {
|
||||
return {
|
||||
id: ++this.nextId,
|
||||
ts: new Date().toISOString(),
|
||||
turn,
|
||||
type: "tool.call",
|
||||
name,
|
||||
args: redactEventValue(args),
|
||||
};
|
||||
}
|
||||
|
||||
emitToolConfirmAllow(
|
||||
turn: number,
|
||||
kind: "run_command" | "run_background",
|
||||
payload: { command: string },
|
||||
): ToolConfirmAllowEvent {
|
||||
return {
|
||||
id: ++this.nextId,
|
||||
ts: new Date().toISOString(),
|
||||
turn,
|
||||
type: "tool.confirm.allow",
|
||||
kind,
|
||||
payload: redactEventValue(payload),
|
||||
};
|
||||
}
|
||||
|
||||
emitToolConfirmDeny(
|
||||
turn: number,
|
||||
kind: "run_command" | "run_background",
|
||||
payload: { command: string },
|
||||
denyContext?: string,
|
||||
): ToolConfirmDenyEvent {
|
||||
return {
|
||||
id: ++this.nextId,
|
||||
ts: new Date().toISOString(),
|
||||
turn,
|
||||
type: "tool.confirm.deny",
|
||||
kind,
|
||||
payload: redactEventValue(payload),
|
||||
denyContext,
|
||||
};
|
||||
}
|
||||
|
||||
emitToolConfirmAlwaysAllow(
|
||||
turn: number,
|
||||
kind: "run_command" | "run_background",
|
||||
payload: { command: string },
|
||||
prefix: string,
|
||||
): ToolConfirmAlwaysAllowEvent {
|
||||
return {
|
||||
id: ++this.nextId,
|
||||
ts: new Date().toISOString(),
|
||||
turn,
|
||||
type: "tool.confirm.always_allow",
|
||||
kind,
|
||||
payload: redactEventValue(payload),
|
||||
prefix,
|
||||
};
|
||||
}
|
||||
|
||||
private turnStartedEvent(turn: number, ctx: EventizeContext): ModelTurnStartedEvent {
|
||||
return {
|
||||
id: ++this.nextId,
|
||||
|
||||
@@ -76,6 +76,32 @@ export interface ToolResultEvent extends EventBase {
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface ToolCallEvent extends EventBase {
|
||||
type: "tool.call";
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolConfirmAllowEvent extends EventBase {
|
||||
type: "tool.confirm.allow";
|
||||
kind: "run_command" | "run_background";
|
||||
payload: { command: string };
|
||||
}
|
||||
|
||||
export interface ToolConfirmDenyEvent extends EventBase {
|
||||
type: "tool.confirm.deny";
|
||||
kind: "run_command" | "run_background";
|
||||
payload: { command: string };
|
||||
denyContext?: string;
|
||||
}
|
||||
|
||||
export interface ToolConfirmAlwaysAllowEvent extends EventBase {
|
||||
type: "tool.confirm.always_allow";
|
||||
kind: "run_command" | "run_background";
|
||||
payload: { command: string };
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export interface FileTouchedEvent extends EventBase {
|
||||
type: "effect.file.touched";
|
||||
path: string;
|
||||
@@ -197,6 +223,10 @@ export type Event =
|
||||
| ToolDispatchedEvent
|
||||
| ToolDeniedEvent
|
||||
| ToolResultEvent
|
||||
| ToolCallEvent
|
||||
| ToolConfirmAllowEvent
|
||||
| ToolConfirmDenyEvent
|
||||
| ToolConfirmAlwaysAllowEvent
|
||||
| FileTouchedEvent
|
||||
| MemoryWrittenEvent
|
||||
| PlanSubmittedEvent
|
||||
|
||||
@@ -21,6 +21,25 @@ export type ChoiceVerdict =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "cancel" };
|
||||
|
||||
export type ToolConfirmationAuditEvent =
|
||||
| {
|
||||
type: "tool.confirm.allow";
|
||||
kind: "run_command" | "run_background";
|
||||
payload: { command: string };
|
||||
}
|
||||
| {
|
||||
type: "tool.confirm.deny";
|
||||
kind: "run_command" | "run_background";
|
||||
payload: { command: string };
|
||||
denyContext?: string;
|
||||
}
|
||||
| {
|
||||
type: "tool.confirm.always_allow";
|
||||
kind: "run_command" | "run_background";
|
||||
payload: { command: string };
|
||||
prefix: string;
|
||||
};
|
||||
|
||||
interface PauseResponseMap {
|
||||
run_command: ConfirmationChoice;
|
||||
run_background: ConfirmationChoice;
|
||||
@@ -48,6 +67,7 @@ export type PauseRequest = {
|
||||
};
|
||||
|
||||
type GateListener = (request: PauseRequest) => void;
|
||||
type AuditListener = (event: ToolConfirmationAuditEvent) => void;
|
||||
|
||||
/** Named options for PauseGate.ask() — makes it obvious which field is kind vs payload. */
|
||||
export interface PauseAskOpts<K extends PauseKind = PauseKind> {
|
||||
@@ -59,6 +79,7 @@ export class PauseGate {
|
||||
private _nextId = 0;
|
||||
private _pending = new Map<number, { resolve: (data: unknown) => void; request: PauseRequest }>();
|
||||
private _listeners: Set<GateListener> = new Set();
|
||||
private _auditListener: AuditListener | null = null;
|
||||
|
||||
/** Block until the user responds. Takes a named options object so the
|
||||
* kind and payload fields don't get confused at the call site. */
|
||||
@@ -88,9 +109,14 @@ export class PauseGate {
|
||||
const p = this._pending.get(id);
|
||||
if (!p) return;
|
||||
this._pending.delete(id);
|
||||
this.emitAuditEvent(p.request, data);
|
||||
p.resolve(data);
|
||||
}
|
||||
|
||||
setAuditListener(fn: AuditListener | null): void {
|
||||
this._auditListener = fn;
|
||||
}
|
||||
|
||||
/** Subscribe to new pause requests. Returns an unsubscribe function. */
|
||||
on(fn: GateListener): () => void {
|
||||
this._listeners.add(fn);
|
||||
@@ -104,6 +130,45 @@ export class PauseGate {
|
||||
for (const [, p] of this._pending) return p.request;
|
||||
return null;
|
||||
}
|
||||
|
||||
private emitAuditEvent(request: PauseRequest, data: unknown): void {
|
||||
if (!this._auditListener) return;
|
||||
if (request.kind !== "run_command" && request.kind !== "run_background") return;
|
||||
if (!data || typeof data !== "object") return;
|
||||
const choice = data as Partial<ConfirmationChoice>;
|
||||
try {
|
||||
switch (choice.type) {
|
||||
case "run_once":
|
||||
this._auditListener({
|
||||
type: "tool.confirm.allow",
|
||||
kind: request.kind,
|
||||
payload: request.payload as { command: string },
|
||||
});
|
||||
break;
|
||||
case "deny":
|
||||
this._auditListener({
|
||||
type: "tool.confirm.deny",
|
||||
kind: request.kind,
|
||||
payload: request.payload as { command: string },
|
||||
denyContext: choice.denyContext,
|
||||
});
|
||||
break;
|
||||
case "always_allow":
|
||||
if (typeof choice.prefix !== "string") return;
|
||||
this._auditListener({
|
||||
type: "tool.confirm.always_allow",
|
||||
kind: request.kind,
|
||||
payload: request.payload as { command: string },
|
||||
prefix: choice.prefix,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* audit path must never break the gate */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton shared between tools and the App. */
|
||||
|
||||
+1
-1
@@ -365,7 +365,7 @@ export const EN: TranslationSchema = {
|
||||
" /clear clear displayed scrollback only (context kept — model still sees it)",
|
||||
helpLoop:
|
||||
" /loop <interval> <prompt> auto-resubmit <prompt> every <interval> (5s..6h). /loop stop · type anything to cancel.",
|
||||
helpExit: " /exit quit",
|
||||
helpExit: " /exit quit (aliases: /quit, /q)",
|
||||
helpShellTitle: "Shell shortcut:",
|
||||
helpShell: " !<cmd> run <cmd> in the sandbox root; output goes into",
|
||||
helpShellDetail:
|
||||
|
||||
+1
-1
@@ -345,7 +345,7 @@ export const zhCN: TranslationSchema = {
|
||||
helpClear: " /clear 仅清除显示的滚动回放(上下文保留 — 模型仍能看到)",
|
||||
helpLoop:
|
||||
" /loop <interval> <prompt> 每 <interval> 自动重新提交 <prompt>(5秒..6小时)。/loop stop · 输入任何内容取消。",
|
||||
helpExit: " /exit 退出",
|
||||
helpExit: " /exit 退出(别名:/quit, /q)",
|
||||
helpShellTitle: "Shell 快捷方式:",
|
||||
helpShell: " !<cmd> 在沙箱根目录运行 <cmd>;输出进入对话",
|
||||
helpShellDetail: " 以便模型在下一轮看到。无允许列表限制。",
|
||||
|
||||
@@ -193,24 +193,15 @@ export async function* walkChunks(
|
||||
onSkip(rel, "pattern");
|
||||
continue;
|
||||
}
|
||||
let stat: import("node:fs").Stats;
|
||||
try {
|
||||
stat = await fs.stat(abs);
|
||||
} catch {
|
||||
onSkip(rel, "readError");
|
||||
continue;
|
||||
}
|
||||
if (stat.size > filters.maxFileBytes) {
|
||||
onSkip(rel, "tooLarge");
|
||||
continue;
|
||||
}
|
||||
let text: string;
|
||||
try {
|
||||
text = await fs.readFile(abs, "utf8");
|
||||
} catch {
|
||||
onSkip(rel, "readError");
|
||||
// Open once and check size + read against the same fd. Skipping
|
||||
// a path-based `fs.stat` upstream is intentional — stat→open is
|
||||
// the TOCTOU shape CodeQL flags as js/file-system-race.
|
||||
const result = await readSizeBoundedFile(abs, filters.maxFileBytes);
|
||||
if (result.kind === "skip") {
|
||||
onSkip(rel, result.reason);
|
||||
continue;
|
||||
}
|
||||
const text = result.text;
|
||||
if (text.indexOf("\0") !== -1) {
|
||||
onSkip(rel, "binaryContent");
|
||||
continue;
|
||||
@@ -235,3 +226,20 @@ export async function chunkDirectory(root: string, opts: ChunkOptions = {}): Pro
|
||||
for await (const c of walkChunks(root, opts)) out.push(c);
|
||||
return out;
|
||||
}
|
||||
|
||||
type ReadFileResult = { kind: "ok"; text: string } | { kind: "skip"; reason: SkipReason };
|
||||
|
||||
async function readSizeBoundedFile(abs: string, maxBytes: number): Promise<ReadFileResult> {
|
||||
try {
|
||||
const fh = await fs.open(abs, "r");
|
||||
try {
|
||||
const stat = await fh.stat();
|
||||
if (stat.size > maxBytes) return { kind: "skip", reason: "tooLarge" };
|
||||
return { kind: "ok", text: await fh.readFile("utf8") };
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
} catch {
|
||||
return { kind: "skip", reason: "readError" };
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export async function registerSemanticSearchTool(
|
||||
description:
|
||||
"FIRST CHOICE for descriptive queries. Use this BEFORE search_content (grep) when the user describes WHAT code does ('where do we handle X', 'which file owns Y', 'how does Z work', 'find the logic that …'). Returns ranked snippets ordered by semantic relevance — finds the right file even when your description shares no words with the code. Falls back to search_content / search_files only for: exact identifiers, regex patterns, or counting occurrences of a known token. If your first instinct is grep on a paraphrased question, you are wrong — try semantic_search first.",
|
||||
readOnly: true,
|
||||
parallelSafe: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
||||
+224
-665
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,11 @@
|
||||
import type { BranchSample } from "../consistency.js";
|
||||
import type { BranchSummary } from "./types.js";
|
||||
|
||||
export function summarizeBranch(chosen: BranchSample, samples: BranchSample[]): BranchSummary {
|
||||
return {
|
||||
budget: samples.length,
|
||||
chosenIndex: chosen.index,
|
||||
uncertainties: samples.map((s) => s.planState.uncertainties.length),
|
||||
temperatures: samples.map((s) => s.temperature),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/** Single text-layer DeepSeek-error formatter — 429/5xx never reach here (retry.ts swallows). */
|
||||
export function formatLoopError(err: Error): string {
|
||||
const msg = err.message ?? "";
|
||||
if (msg.includes("maximum context length")) {
|
||||
const reqMatch = msg.match(/requested\s+(\d+)\s+tokens/);
|
||||
const requested = reqMatch
|
||||
? `${Number(reqMatch[1]).toLocaleString()} tokens`
|
||||
: "too many tokens";
|
||||
return `Context overflow (DeepSeek 400): session history is ${requested}, past the model's prompt limit (V4: 1M tokens; legacy chat/reasoner: 131k). Usually a single tool result grew too big. Reasonix caps new tool results at 8k tokens and auto-heals oversized history on session load — a restart often clears it. If it still overflows, run /forget (delete the session) or /clear (drop the displayed history) to start fresh.`;
|
||||
}
|
||||
|
||||
const m = /^DeepSeek (\d{3}):\s*([\s\S]*)$/.exec(msg);
|
||||
if (!m) return msg;
|
||||
const status = m[1] ?? "";
|
||||
const body = m[2] ?? "";
|
||||
const inner = extractDeepSeekErrorMessage(body);
|
||||
|
||||
if (status === "401") {
|
||||
return `Authentication failed (DeepSeek 401): ${inner}. Your API key is rejected. Fix with \`reasonix setup\` or \`export DEEPSEEK_API_KEY=sk-...\`. Get one at https://platform.deepseek.com/api_keys.`;
|
||||
}
|
||||
if (status === "402") {
|
||||
return `Out of balance (DeepSeek 402): ${inner}. Top up at https://platform.deepseek.com/top_up — the panel header shows your balance once it's non-zero.`;
|
||||
}
|
||||
if (status === "422") {
|
||||
return `Invalid parameter (DeepSeek 422): ${inner}`;
|
||||
}
|
||||
if (status === "400") {
|
||||
return `Bad request (DeepSeek 400): ${inner}`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
export function reasonPrefixFor(
|
||||
reason: "budget" | "aborted" | "context-guard" | "stuck",
|
||||
iterCap: number,
|
||||
): string {
|
||||
if (reason === "aborted") return "[aborted by user (Esc) — summarizing what I found so far]";
|
||||
if (reason === "context-guard") {
|
||||
return "[context budget running low — summarizing before the next call would overflow]";
|
||||
}
|
||||
if (reason === "stuck") {
|
||||
return "[stuck on a repeated tool call — explaining what was tried and what's blocking progress]";
|
||||
}
|
||||
return `[tool-call budget (${iterCap}) reached — forcing summary from what I found]`;
|
||||
}
|
||||
|
||||
export function errorLabelFor(
|
||||
reason: "budget" | "aborted" | "context-guard" | "stuck",
|
||||
iterCap: number,
|
||||
): string {
|
||||
if (reason === "aborted") return "aborted by user";
|
||||
if (reason === "context-guard") return "context-guard triggered (prompt > 80% of window)";
|
||||
if (reason === "stuck") return "stuck (repeated tool call suppressed by storm-breaker)";
|
||||
return `tool-call budget (${iterCap}) reached`;
|
||||
}
|
||||
|
||||
function extractDeepSeekErrorMessage(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return "(no message)";
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const obj = parsed as { error?: { message?: unknown }; message?: unknown };
|
||||
if (obj.error && typeof obj.error.message === "string") return obj.error.message;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
}
|
||||
} catch {
|
||||
/* not JSON — fall through */
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Accepts `<<<NEEDS_PRO>>>` or `<<<NEEDS_PRO: reason>>>` (reason trimmed, may be empty). */
|
||||
const NEEDS_PRO_MARKER_PREFIX = "<<<NEEDS_PRO";
|
||||
const NEEDS_PRO_MARKER_RE = /^<<<NEEDS_PRO(?::\s*([^>]*))?>>>/;
|
||||
/** Buffer cap before flushing — must fit `<<<NEEDS_PRO: reason>>>` without premature flush. */
|
||||
export const NEEDS_PRO_BUFFER_CHARS = 256;
|
||||
|
||||
/** Anchored to lead — mid-text matches are normal content (user asking about the marker). */
|
||||
export function parseEscalationMarker(content: string): { matched: boolean; reason?: string } {
|
||||
const m = NEEDS_PRO_MARKER_RE.exec(content.trimStart());
|
||||
if (!m) return { matched: false };
|
||||
const reason = m[1]?.trim();
|
||||
return { matched: true, reason: reason || undefined };
|
||||
}
|
||||
|
||||
/** Convenience boolean — same gate the streaming path used to call. */
|
||||
export function isEscalationRequest(content: string): boolean {
|
||||
return parseEscalationMarker(content).matched;
|
||||
}
|
||||
|
||||
/** Drives streaming flush — while plausibly partial, keep accumulating; else flush. */
|
||||
export function looksLikePartialEscalationMarker(buf: string): boolean {
|
||||
const t = buf.trimStart();
|
||||
if (t.length === 0) return true;
|
||||
if (t.length <= NEEDS_PRO_MARKER_PREFIX.length) {
|
||||
return NEEDS_PRO_MARKER_PREFIX.startsWith(t);
|
||||
}
|
||||
if (!t.startsWith(NEEDS_PRO_MARKER_PREFIX)) return false;
|
||||
const rest = t.slice(NEEDS_PRO_MARKER_PREFIX.length);
|
||||
if (rest[0] !== ">" && rest[0] !== ":") return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { type DeepSeekClient, Usage } from "../client.js";
|
||||
import type { TurnStats } from "../telemetry/stats.js";
|
||||
import type { ChatMessage } from "../types.js";
|
||||
import { errorLabelFor, reasonPrefixFor } from "./errors.js";
|
||||
import { buildAssistantMessage } from "./messages.js";
|
||||
import { stripHallucinatedToolMarkup, thinkingModeForModel } from "./thinking.js";
|
||||
import type { LoopEvent } from "./types.js";
|
||||
|
||||
export type ForceSummaryReason = "budget" | "aborted" | "context-guard" | "stuck";
|
||||
|
||||
export interface ForceSummaryContext {
|
||||
client: DeepSeekClient;
|
||||
signal: AbortSignal;
|
||||
buildMessages: () => ChatMessage[];
|
||||
appendAndPersist: (msg: ChatMessage) => void;
|
||||
recordStats: (model: string, usage: Usage) => TurnStats;
|
||||
turn: number;
|
||||
maxToolIters: number;
|
||||
}
|
||||
|
||||
export async function* forceSummaryAfterIterLimit(
|
||||
ctx: ForceSummaryContext,
|
||||
opts: { reason: ForceSummaryReason } = { reason: "budget" },
|
||||
): AsyncGenerator<LoopEvent> {
|
||||
try {
|
||||
// Status bridges the silence — summary call is non-streaming, 30-60s typical.
|
||||
yield { turn: ctx.turn, role: "status", content: "summarizing what was gathered…" };
|
||||
const messages = ctx.buildMessages();
|
||||
// Passing `tools: undefined` was supposed to force a text response,
|
||||
// but R1 can still hallucinate tool-call markup (e.g. DSML
|
||||
// `<|DSML|function_calls>…`) when primed by prior tool use. An
|
||||
// explicit user-role instruction plus post-hoc stripping of known
|
||||
// hallucination shapes keeps the user from seeing raw markup.
|
||||
messages.push({
|
||||
role: "user",
|
||||
content:
|
||||
"I'm out of tool-call budget for this turn. Summarize in plain prose what you learned from the tool results above. Do NOT emit any tool calls, function-call markup, DSML invocations, or SEARCH/REPLACE edit blocks — they will be silently discarded. Just plain text.",
|
||||
});
|
||||
// Pin to flash + effort=high regardless of the main turn's model —
|
||||
// pro is 12× overkill for "paraphrase tool results into prose," and
|
||||
// budget-exhausted turns are exactly when we don't want to torch the wallet.
|
||||
const summaryModel = "deepseek-v4-flash";
|
||||
const summaryEffort: "high" | "max" = "high";
|
||||
const resp = await ctx.client.chat({
|
||||
model: summaryModel,
|
||||
messages,
|
||||
signal: ctx.signal,
|
||||
thinking: thinkingModeForModel(summaryModel),
|
||||
reasoningEffort: summaryEffort,
|
||||
});
|
||||
const rawContent = resp.content?.trim() ?? "";
|
||||
const cleaned = stripHallucinatedToolMarkup(rawContent);
|
||||
const summary =
|
||||
cleaned ||
|
||||
"(model emitted fake tool-call markup instead of a prose summary — try /retry with a narrower question, or /think to inspect R1's reasoning)";
|
||||
const reasonPrefix = reasonPrefixFor(opts.reason, ctx.maxToolIters);
|
||||
const annotated = `${reasonPrefix}\n\n${summary}`;
|
||||
// Record under the actual model used (flash), so per-turn cost reflects reality.
|
||||
const summaryStats = ctx.recordStats(summaryModel, resp.usage ?? new Usage());
|
||||
ctx.appendAndPersist(buildAssistantMessage(summary, [], summaryModel, resp.reasoningContent));
|
||||
yield {
|
||||
turn: ctx.turn,
|
||||
role: "assistant_final",
|
||||
content: annotated,
|
||||
stats: summaryStats,
|
||||
forcedSummary: true,
|
||||
};
|
||||
yield { turn: ctx.turn, role: "done", content: summary };
|
||||
} catch (err) {
|
||||
const label = errorLabelFor(opts.reason, ctx.maxToolIters);
|
||||
yield {
|
||||
turn: ctx.turn,
|
||||
role: "error",
|
||||
content: "",
|
||||
error: `${label} and the fallback summary call failed: ${(err as Error).message}. Run /clear and retry with a narrower question, or raise --max-tool-iters.`,
|
||||
};
|
||||
yield { turn: ctx.turn, role: "done", content: "" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ChatMessage } from "../types.js";
|
||||
import { shrinkOversizedToolResults, shrinkOversizedToolResultsByTokens } from "./shrink.js";
|
||||
import { isThinkingModeModel } from "./thinking.js";
|
||||
|
||||
/** Drops both unpaired assistant.tool_calls and stray tool messages — DeepSeek 400s on either. */
|
||||
export function fixToolCallPairing(messages: ChatMessage[]): {
|
||||
messages: ChatMessage[];
|
||||
droppedAssistantCalls: number;
|
||||
droppedStrayTools: number;
|
||||
} {
|
||||
const out: ChatMessage[] = [];
|
||||
let droppedAssistantCalls = 0;
|
||||
let droppedStrayTools = 0;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]!;
|
||||
if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
|
||||
const needed = new Set<string>();
|
||||
for (const call of msg.tool_calls) {
|
||||
if (call?.id) needed.add(call.id);
|
||||
}
|
||||
const candidates: ChatMessage[] = [];
|
||||
let j = i + 1;
|
||||
while (j < messages.length && needed.size > 0) {
|
||||
const nxt = messages[j]!;
|
||||
if (nxt.role !== "tool") break;
|
||||
const id = nxt.tool_call_id ?? "";
|
||||
if (!needed.has(id)) break;
|
||||
needed.delete(id);
|
||||
candidates.push(nxt);
|
||||
j++;
|
||||
}
|
||||
if (needed.size === 0) {
|
||||
out.push(msg);
|
||||
for (const r of candidates) out.push(r);
|
||||
i = j - 1;
|
||||
} else {
|
||||
droppedAssistantCalls += 1;
|
||||
droppedStrayTools += candidates.length;
|
||||
i = j - 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (msg.role === "tool") {
|
||||
droppedStrayTools += 1;
|
||||
continue;
|
||||
}
|
||||
out.push(msg);
|
||||
}
|
||||
return { messages: out, droppedAssistantCalls, droppedStrayTools };
|
||||
}
|
||||
|
||||
export function healLoadedMessages(
|
||||
messages: ChatMessage[],
|
||||
maxChars: number,
|
||||
): { messages: ChatMessage[]; healedCount: number; healedFrom: number } {
|
||||
const shrunk = shrinkOversizedToolResults(messages, maxChars);
|
||||
const paired = fixToolCallPairing(shrunk.messages);
|
||||
const healedCount = shrunk.healedCount + paired.droppedAssistantCalls + paired.droppedStrayTools;
|
||||
return { messages: paired.messages, healedCount, healedFrom: shrunk.healedFrom };
|
||||
}
|
||||
|
||||
/** Back-fills "" on bare assistant turns; skipped on non-thinking to avoid prefix-cache churn. */
|
||||
export function stampMissingReasoningForThinkingMode(
|
||||
messages: ChatMessage[],
|
||||
model: string,
|
||||
): { messages: ChatMessage[]; stampedCount: number } {
|
||||
if (!isThinkingModeModel(model)) {
|
||||
return { messages, stampedCount: 0 };
|
||||
}
|
||||
let stampedCount = 0;
|
||||
const out = messages.map((msg) => {
|
||||
if (msg.role !== "assistant") return msg;
|
||||
if (Object.hasOwn(msg, "reasoning_content")) return msg;
|
||||
stampedCount += 1;
|
||||
return { ...msg, reasoning_content: "" };
|
||||
});
|
||||
return { messages: out, stampedCount };
|
||||
}
|
||||
|
||||
/** Token-cap variant — char cap would let CJK slip past at 2× the intended token cost. */
|
||||
export function healLoadedMessagesByTokens(
|
||||
messages: ChatMessage[],
|
||||
maxTokens: number,
|
||||
): {
|
||||
messages: ChatMessage[];
|
||||
healedCount: number;
|
||||
tokensSaved: number;
|
||||
charsSaved: number;
|
||||
} {
|
||||
const shrunk = shrinkOversizedToolResultsByTokens(messages, maxTokens);
|
||||
const paired = fixToolCallPairing(shrunk.messages);
|
||||
const healedCount = shrunk.healedCount + paired.droppedAssistantCalls + paired.droppedStrayTools;
|
||||
return {
|
||||
messages: paired.messages,
|
||||
healedCount,
|
||||
tokensSaved: shrunk.tokensSaved,
|
||||
charsSaved: shrunk.charsSaved,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { type HookOutcome, formatHookOutcomeMessage } from "../hooks.js";
|
||||
import type { LoopEvent } from "./types.js";
|
||||
|
||||
export function safeParseToolArgs(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/** Format non-pass hook outcomes as `LoopEvent`s of role `warning`. */
|
||||
export function* hookWarnings(outcomes: HookOutcome[], turn: number): Generator<LoopEvent> {
|
||||
for (const o of outcomes) {
|
||||
if (o.decision === "pass") continue;
|
||||
yield { turn, role: "warning", content: formatHookOutcomeMessage(o) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ChatMessage, ToolCall } from "../types.js";
|
||||
import { isThinkingModeModel } from "./thinking.js";
|
||||
|
||||
/** Thinking-mode producer ⇒ reasoning_content MUST be set (even ""), or next call 400s. */
|
||||
export function buildAssistantMessage(
|
||||
content: string,
|
||||
toolCalls: ToolCall[],
|
||||
producingModel: string,
|
||||
reasoningContent?: string | null,
|
||||
): ChatMessage {
|
||||
const msg: ChatMessage = { role: "assistant", content };
|
||||
if (toolCalls.length > 0) msg.tool_calls = toolCalls;
|
||||
// V4-era deepseek-chat returns reasoning_content even with thinking.type
|
||||
// disabled, and the API rejects round-trips that drop it. Whitelist on
|
||||
// model name is too brittle — preserve whenever the producer emitted any.
|
||||
if (isThinkingModeModel(producingModel) || (reasoningContent && reasoningContent.length > 0)) {
|
||||
msg.reasoning_content = reasoningContent ?? "";
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** Abort notices etc — caller passes its current model as the thinking-mode stamp. */
|
||||
export function buildSyntheticAssistantMessage(
|
||||
content: string,
|
||||
fallbackModel: string,
|
||||
): ChatMessage {
|
||||
return buildAssistantMessage(content, [], fallbackModel, "");
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { truncateForModel, truncateForModelByTokens } from "../mcp/registry.js";
|
||||
import { countTokens } from "../tokenizer.js";
|
||||
import type { ChatMessage } from "../types.js";
|
||||
|
||||
/** UI progress feedback only — NOT a dispatch gate. */
|
||||
export function looksLikeCompleteJson(s: string): boolean {
|
||||
if (!s || !s.trim()) return false;
|
||||
try {
|
||||
JSON.parse(s);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tool-role only — truncating user prompts would corrupt authored intent. */
|
||||
export function shrinkOversizedToolResults(
|
||||
messages: ChatMessage[],
|
||||
maxChars: number,
|
||||
): { messages: ChatMessage[]; healedCount: number; healedFrom: number } {
|
||||
let healedCount = 0;
|
||||
let healedFrom = 0;
|
||||
const out = messages.map((msg) => {
|
||||
if (msg.role !== "tool") return msg;
|
||||
const content = typeof msg.content === "string" ? msg.content : "";
|
||||
if (content.length <= maxChars) return msg;
|
||||
healedCount += 1;
|
||||
healedFrom += content.length;
|
||||
return { ...msg, content: truncateForModel(content, maxChars) };
|
||||
});
|
||||
return { messages: out, healedCount, healedFrom };
|
||||
}
|
||||
|
||||
/** Token-cap variant — char cap would let CJK slip past at 2× the intended token cost. */
|
||||
export function shrinkOversizedToolResultsByTokens(
|
||||
messages: ChatMessage[],
|
||||
maxTokens: number,
|
||||
): {
|
||||
messages: ChatMessage[];
|
||||
healedCount: number;
|
||||
tokensSaved: number;
|
||||
charsSaved: number;
|
||||
} {
|
||||
let healedCount = 0;
|
||||
let tokensSaved = 0;
|
||||
let charsSaved = 0;
|
||||
const out = messages.map((msg) => {
|
||||
if (msg.role !== "tool") return msg;
|
||||
const content = typeof msg.content === "string" ? msg.content : "";
|
||||
// length ≤ maxTokens ⇒ tokens ≤ maxTokens — skip the per-message tokenize.
|
||||
if (content.length <= maxTokens) return msg;
|
||||
const beforeTokens = countTokens(content);
|
||||
if (beforeTokens <= maxTokens) return msg;
|
||||
const truncated = truncateForModelByTokens(content, maxTokens);
|
||||
const afterTokens = countTokens(truncated);
|
||||
healedCount += 1;
|
||||
tokensSaved += Math.max(0, beforeTokens - afterTokens);
|
||||
charsSaved += Math.max(0, content.length - truncated.length);
|
||||
return { ...msg, content: truncated };
|
||||
});
|
||||
return { messages: out, healedCount, tokensSaved, charsSaved };
|
||||
}
|
||||
|
||||
/** Caller must gate on paired tool_calls — in-flight calls would crash mid-turn. */
|
||||
export function shrinkOversizedToolCallArgsByTokens(
|
||||
messages: ChatMessage[],
|
||||
maxTokens: number,
|
||||
): {
|
||||
messages: ChatMessage[];
|
||||
healedCount: number;
|
||||
tokensSaved: number;
|
||||
charsSaved: number;
|
||||
} {
|
||||
let healedCount = 0;
|
||||
let tokensSaved = 0;
|
||||
let charsSaved = 0;
|
||||
const out = messages.map((msg) => {
|
||||
if (msg.role !== "assistant" || !Array.isArray(msg.tool_calls)) return msg;
|
||||
let changed = false;
|
||||
const newCalls = msg.tool_calls.map((call) => {
|
||||
const args = call.function?.arguments;
|
||||
if (typeof args !== "string" || args.length <= maxTokens) return call;
|
||||
const beforeTokens = countTokens(args);
|
||||
if (beforeTokens <= maxTokens) return call;
|
||||
const shrunk = shrinkJsonLongStrings(args);
|
||||
const afterTokens = countTokens(shrunk);
|
||||
// Many-short-strings payloads can come back marginally larger — only swap on real saving.
|
||||
if (afterTokens >= beforeTokens) return call;
|
||||
changed = true;
|
||||
healedCount += 1;
|
||||
tokensSaved += beforeTokens - afterTokens;
|
||||
charsSaved += args.length - shrunk.length;
|
||||
return { ...call, function: { ...call.function, arguments: shrunk } };
|
||||
});
|
||||
if (!changed) return msg;
|
||||
return { ...msg, tool_calls: newCalls };
|
||||
});
|
||||
return { messages: out, healedCount, tokensSaved, charsSaved };
|
||||
}
|
||||
|
||||
/** Keeps short keys/values (paths, ids) verbatim; only long string values get a marker. */
|
||||
function shrinkJsonLongStrings(jsonStr: string): string {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
const head = jsonStr.slice(0, 200);
|
||||
return `${head}…[shrunk: ${jsonStr.length} chars, unparsed]`;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return jsonStr;
|
||||
}
|
||||
const LONG_THRESHOLD = 300;
|
||||
const input = parsed as Record<string, unknown>;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(input)) {
|
||||
if (typeof v === "string" && v.length > LONG_THRESHOLD) {
|
||||
const newlines = v.match(/\n/g)?.length ?? 0;
|
||||
output[k] =
|
||||
`[…shrunk: ${v.length} chars, ${newlines} lines — tool already responded, see result]`;
|
||||
} else {
|
||||
output[k] = v;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(output);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** True when the model emits reasoning_content and requires it round-tripped on follow-ups. */
|
||||
export function isThinkingModeModel(model: string): boolean {
|
||||
if (model.includes("reasoner")) return true;
|
||||
if (model === "deepseek-v4-flash" || model === "deepseek-v4-pro") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Pins extra_body.thinking.type; `undefined` lets third-party endpoints skip the field. */
|
||||
export function thinkingModeForModel(model: string): "enabled" | "disabled" | undefined {
|
||||
if (model === "deepseek-chat") return "disabled";
|
||||
if (model.includes("reasoner")) return "enabled";
|
||||
if (model === "deepseek-v4-flash" || model === "deepseek-v4-pro") return "enabled";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Strip hallucinated tool-call envelopes — `tools: undefined` doesn't always force prose. */
|
||||
export function stripHallucinatedToolMarkup(s: string): string {
|
||||
let out = s;
|
||||
// DeepSeek's DSML envelope (full-width "|" is the form R1 emits in practice).
|
||||
out = out.replace(/<|DSML|function_calls>[\s\S]*?<\/?|DSML|function_calls>/g, "");
|
||||
out = out.replace(/<\|DSML\|function_calls>[\s\S]*?<\/?\|DSML\|function_calls>/g, "");
|
||||
out = out.replace(/<function_calls>[\s\S]*?<\/function_calls>/g, "");
|
||||
// Lone unpaired DSML opener left over after R1 truncates mid-call.
|
||||
out = out.replace(/<|DSML|[\s\S]*$/g, "");
|
||||
return out.trim();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { RepairReport } from "../repair/index.js";
|
||||
|
||||
export const FAILURE_ESCALATION_THRESHOLD = 3;
|
||||
|
||||
export class TurnFailureTracker {
|
||||
private count = 0;
|
||||
private types: Record<string, number> = {};
|
||||
|
||||
reset(): void {
|
||||
this.count = 0;
|
||||
this.types = {};
|
||||
}
|
||||
|
||||
/** True ONLY on the call where the count crosses FAILURE_ESCALATION_THRESHOLD. */
|
||||
noteAndCrossedThreshold(resultJson: string, repair?: RepairReport): boolean {
|
||||
const before = this.count;
|
||||
const bump = (kind: string, by = 1): void => {
|
||||
this.count += by;
|
||||
this.types[kind] = (this.types[kind] ?? 0) + by;
|
||||
};
|
||||
if (resultJson.includes('"error"') && resultJson.includes("search text not found")) {
|
||||
bump("search-mismatch");
|
||||
}
|
||||
if (repair) {
|
||||
if (repair.scavenged > 0) bump("scavenged", repair.scavenged);
|
||||
if (repair.truncationsFixed > 0) bump("truncated", repair.truncationsFixed);
|
||||
if (repair.stormsBroken > 0) bump("repeat-loop", repair.stormsBroken);
|
||||
}
|
||||
return before < FAILURE_ESCALATION_THRESHOLD && this.count >= FAILURE_ESCALATION_THRESHOLD;
|
||||
}
|
||||
|
||||
formatBreakdown(): string {
|
||||
const parts = Object.entries(this.types)
|
||||
.filter(([, n]) => n > 0)
|
||||
.map(([kind, n]) => `${n}× ${kind}`);
|
||||
return parts.length > 0 ? parts.join(", ") : `${this.count} repair/error signal(s)`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { TypedPlanState } from "../harvest.js";
|
||||
import type { RepairReport } from "../repair/index.js";
|
||||
import type { TurnStats } from "../telemetry/stats.js";
|
||||
|
||||
export type EventRole =
|
||||
| "assistant_delta"
|
||||
| "assistant_final"
|
||||
/** Only liveness signal during a large-args tool call (no content/reasoning bytes). */
|
||||
| "tool_call_delta"
|
||||
/** Pre-dispatch ping so the TUI can show a spinner during long tool awaits. */
|
||||
| "tool_start"
|
||||
| "tool"
|
||||
| "done"
|
||||
| "error"
|
||||
| "warning"
|
||||
/** Transient indicator for silent phases; UI clears on next primary event. */
|
||||
| "status"
|
||||
| "branch_start"
|
||||
| "branch_progress"
|
||||
| "branch_done";
|
||||
|
||||
export interface BranchSummary {
|
||||
budget: number;
|
||||
chosenIndex: number;
|
||||
uncertainties: number[];
|
||||
temperatures: number[];
|
||||
}
|
||||
|
||||
export interface BranchProgress {
|
||||
completed: number;
|
||||
total: number;
|
||||
latestIndex: number;
|
||||
latestTemperature: number;
|
||||
latestUncertainties: number;
|
||||
}
|
||||
|
||||
export interface LoopEvent {
|
||||
turn: number;
|
||||
role: EventRole;
|
||||
content: string;
|
||||
reasoningDelta?: string;
|
||||
toolName?: string;
|
||||
/** Raw args JSON — needed by `reasonix diff` to explain why a tool was called. */
|
||||
toolArgs?: string;
|
||||
/** Cumulative arguments-string length for `role === "tool_call_delta"`. */
|
||||
toolCallArgsChars?: number;
|
||||
/** Zero-based index of the tool call this delta belongs to (multi-tool progress). */
|
||||
toolCallIndex?: number;
|
||||
/** Count of tool calls whose args have parsed as valid JSON (UI progress, not dispatch gate). */
|
||||
toolCallReadyCount?: number;
|
||||
stats?: TurnStats;
|
||||
planState?: TypedPlanState;
|
||||
repair?: RepairReport;
|
||||
branch?: BranchSummary;
|
||||
branchProgress?: BranchProgress;
|
||||
error?: string;
|
||||
/** Display-only — code-mode applier MUST skip SEARCH/REPLACE in forced-summary text. */
|
||||
forcedSummary?: boolean;
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export function diffFrames(prev: Frame, next: Frame, pools: DiffPools): Patch[]
|
||||
return undefined;
|
||||
});
|
||||
|
||||
appendTrailClears(out, cursor, prev, next);
|
||||
|
||||
if (next.cursor.x !== cursor.x || next.cursor.y !== cursor.y) {
|
||||
moveTo(out, cursor, next.cursor.x, next.cursor.y, next.viewportWidth);
|
||||
}
|
||||
@@ -89,7 +91,7 @@ function moveTo(
|
||||
}
|
||||
|
||||
if (cursor.x !== targetX) {
|
||||
out.push({ type: "cursorMove", dx: targetX - cursor.x, dy: 0 });
|
||||
out.push({ type: "cursorTo", col: targetX });
|
||||
cursor.x = targetX;
|
||||
}
|
||||
}
|
||||
@@ -158,3 +160,30 @@ function resetTrailingState(out: Patch[], cursor: CursorState, pools: DiffPools)
|
||||
transitionStyle(out, cursor, pools.style.none, pools);
|
||||
transitionHyperlink(out, cursor, 0, pools);
|
||||
}
|
||||
|
||||
/** Per-row trail clear when prev had content past next's last visible cell — defends shrinking rows against terminal-state desync. */
|
||||
function appendTrailClears(out: Patch[], cursor: CursorState, prev: Frame, next: Frame): void {
|
||||
const w = Math.min(prev.screen.width, next.viewportWidth);
|
||||
const h = Math.max(prev.screen.height, next.screen.height);
|
||||
for (let y = 0; y < h; y++) {
|
||||
let lastNextCol = -1;
|
||||
for (let x = next.screen.width - 1; x >= 0; x--) {
|
||||
const c = next.screen.cellAt(x, y);
|
||||
if (c && c.charId !== 0) {
|
||||
lastNextCol = x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let prevHasTrail = false;
|
||||
for (let x = lastNextCol + 1; x < w; x++) {
|
||||
const c = prev.screen.cellAt(x, y);
|
||||
if (c && c.charId !== 0) {
|
||||
prevHasTrail = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!prevHasTrail) continue;
|
||||
moveTo(out, cursor, Math.max(0, lastNextCol + 1), y, next.viewportWidth);
|
||||
out.push({ type: "clearToEOL" });
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export type Patch =
|
||||
| { readonly type: "styleStr"; readonly str: string }
|
||||
| { readonly type: "hyperlink"; readonly uri: string }
|
||||
| { readonly type: "clear"; readonly count: number }
|
||||
| { readonly type: "clearToEOL" }
|
||||
| { readonly type: "clearTerminal" };
|
||||
|
||||
export type Diff = ReadonlyArray<Patch>;
|
||||
@@ -30,6 +30,8 @@ function serializeOne(patch: Patch): string {
|
||||
return `${ESC}]8;;${patch.uri}${ST}`;
|
||||
case "clear":
|
||||
return clearLines(patch.count);
|
||||
case "clearToEOL":
|
||||
return `${CSI}K`;
|
||||
case "clearTerminal":
|
||||
return `${CSI}2J${CSI}H`;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface InkTextProps {
|
||||
readonly strikethrough?: boolean;
|
||||
readonly inverse?: boolean;
|
||||
readonly wrap?: "wrap" | "truncate" | "truncate-start" | "truncate-middle" | "truncate-end";
|
||||
/** OSC 8 hyperlink target — emitted as proper escape bytes, never inlined into cell content. */
|
||||
readonly hyperlink?: string;
|
||||
}
|
||||
|
||||
const SGR = {
|
||||
@@ -38,5 +40,9 @@ export function Text(props: InkTextProps): React.ReactElement {
|
||||
if (props.underline) codes.push(SGR.underline);
|
||||
if (props.inverse) codes.push(SGR.inverse);
|
||||
if (props.strikethrough) codes.push(SGR.strikethrough);
|
||||
return <RsxText style={codes.length > 0 ? codes : undefined}>{props.children}</RsxText>;
|
||||
return (
|
||||
<RsxText style={codes.length > 0 ? codes : undefined} hyperlink={props.hyperlink}>
|
||||
{props.children}
|
||||
</RsxText>
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,20 @@ export interface ScavengeResult {
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** Bounds the regex input — DSML matchers are O(n²) on adversarial input per CodeQL js/polynomial-redos. */
|
||||
const MAX_SCAVENGE_INPUT = 100 * 1024;
|
||||
|
||||
export function scavengeToolCalls(
|
||||
reasoningContent: string | null | undefined,
|
||||
opts: ScavengeOptions,
|
||||
): ScavengeResult {
|
||||
if (!reasoningContent) return { calls: [], notes: [] };
|
||||
if (reasoningContent.length > MAX_SCAVENGE_INPUT) {
|
||||
return {
|
||||
calls: [],
|
||||
notes: [`scavenge skipped: reasoning_content too large (${reasoningContent.length} chars)`],
|
||||
};
|
||||
}
|
||||
const max = opts.maxCalls ?? 4;
|
||||
const notes: string[] = [];
|
||||
const out: ToolCall[] = [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Job state in a module-scoped Map keyed by project root so multi-root dashboards don't collide; CLI `reasonix index` runs independently. */
|
||||
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { closeSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadIndexConfig } from "../../config.js";
|
||||
import {
|
||||
@@ -186,7 +186,9 @@ function readIndexMeta(root: string): IndexMetaResponse | { exists: false } {
|
||||
const dir = join(root, INDEX_DIR_NAME);
|
||||
const metaPath = join(dir, "index.meta.json");
|
||||
const dataPath = join(dir, "index.jsonl");
|
||||
if (!existsSync(metaPath) || !existsSync(dataPath)) return { exists: false };
|
||||
// Try-read both files unconditionally — the catch covers "file
|
||||
// missing" without a path-based existsSync precheck (CodeQL flags
|
||||
// the precheck as a stat→read race).
|
||||
let meta: { dim?: number; model?: string; updatedAt?: string };
|
||||
try {
|
||||
meta = JSON.parse(readFileSync(metaPath, "utf8"));
|
||||
@@ -197,8 +199,24 @@ function readIndexMeta(root: string): IndexMetaResponse | { exists: false } {
|
||||
const files = new Set<string>();
|
||||
let sizeBytes = 0;
|
||||
try {
|
||||
sizeBytes = statSync(dataPath).size;
|
||||
const raw = readFileSync(dataPath, "utf8");
|
||||
// Bind size and content to the same fd so a concurrent rebuild
|
||||
// can't grow the file between stat and read (CodeQL js/file-system-race).
|
||||
const fd = openSync(dataPath, "r");
|
||||
let raw: string;
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
sizeBytes = stat.size;
|
||||
const buf = Buffer.alloc(stat.size);
|
||||
let read = 0;
|
||||
while (read < stat.size) {
|
||||
const n = readSync(fd, buf, read, stat.size - read, read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
raw = buf.toString("utf8", 0, read);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
chunks++;
|
||||
|
||||
+20
-21
@@ -1,14 +1,6 @@
|
||||
/** apiKey is write-only on the wire; GET always returns a redacted form so dashboard screenshots don't leak credentials. */
|
||||
|
||||
import {
|
||||
isPlausibleKey,
|
||||
readConfig,
|
||||
redactKey,
|
||||
saveApiKey,
|
||||
saveEditMode,
|
||||
saveReasoningEffort,
|
||||
writeConfig,
|
||||
} from "../../config.js";
|
||||
import { isPlausibleKey, readConfig, redactKey, saveEditMode, writeConfig } from "../../config.js";
|
||||
import { getLanguage, getSupportedLanguages, setLanguage } from "../../i18n/index.js";
|
||||
import type { LanguageCode } from "../../i18n/types.js";
|
||||
import type { DashboardContext } from "../context.js";
|
||||
@@ -74,8 +66,13 @@ export async function handleSettings(
|
||||
|
||||
if (method === "POST") {
|
||||
const fields = parseBody(body);
|
||||
// Single read up top, all field updates accumulate, single writeConfig at the end —
|
||||
// a per-field write would clobber earlier per-field writes from the same POST.
|
||||
const cfg = readConfig(ctx.configPath);
|
||||
const changed: string[] = [];
|
||||
let langPending: LanguageCode | null = null;
|
||||
let presetPendingLive: string | null = null;
|
||||
let effortPendingLive: "high" | "max" | null = null;
|
||||
|
||||
if (fields.lang !== undefined) {
|
||||
const raw = String(fields.lang);
|
||||
@@ -86,15 +83,15 @@ export async function handleSettings(
|
||||
if (!langCode) {
|
||||
return { status: 400, body: { error: `lang must be one of: ${supported.join(", ")}` } };
|
||||
}
|
||||
setLanguage(langCode);
|
||||
cfg.lang = langCode;
|
||||
langPending = langCode;
|
||||
changed.push("lang");
|
||||
}
|
||||
if (fields.apiKey !== undefined) {
|
||||
if (typeof fields.apiKey !== "string" || !isPlausibleKey(fields.apiKey)) {
|
||||
return { status: 400, body: { error: "apiKey must be a plausible sk- token" } };
|
||||
}
|
||||
// saveApiKey reads + writes the entire file with chmod 600.
|
||||
saveApiKey(fields.apiKey, ctx.configPath);
|
||||
cfg.apiKey = fields.apiKey.trim();
|
||||
changed.push("apiKey");
|
||||
}
|
||||
if (fields.baseUrl !== undefined) {
|
||||
@@ -102,7 +99,6 @@ export async function handleSettings(
|
||||
return { status: 400, body: { error: "baseUrl must be a non-empty string" } };
|
||||
}
|
||||
cfg.baseUrl = fields.baseUrl.trim();
|
||||
writeConfig(cfg, ctx.configPath);
|
||||
changed.push("baseUrl");
|
||||
}
|
||||
if (fields.preset !== undefined) {
|
||||
@@ -110,11 +106,7 @@ export async function handleSettings(
|
||||
return { status: 400, body: { error: "preset must be auto | flash | pro" } };
|
||||
}
|
||||
cfg.preset = fields.preset as "auto" | "flash" | "pro" | "fast" | "smart" | "max";
|
||||
writeConfig(cfg, ctx.configPath);
|
||||
// Apply to the LIVE loop too so the user doesn't have to restart
|
||||
// their session to feel the change. The callback canonicalizes
|
||||
// legacy aliases via resolvePreset internally.
|
||||
ctx.applyPresetLive?.(fields.preset);
|
||||
presetPendingLive = fields.preset;
|
||||
changed.push("preset");
|
||||
}
|
||||
if (fields.reasoningEffort !== undefined) {
|
||||
@@ -124,8 +116,8 @@ export async function handleSettings(
|
||||
) {
|
||||
return { status: 400, body: { error: "reasoningEffort must be high | max" } };
|
||||
}
|
||||
saveReasoningEffort(fields.reasoningEffort as "high" | "max", ctx.configPath);
|
||||
ctx.applyEffortLive?.(fields.reasoningEffort as "high" | "max");
|
||||
cfg.reasoningEffort = fields.reasoningEffort as "high" | "max";
|
||||
effortPendingLive = fields.reasoningEffort as "high" | "max";
|
||||
changed.push("reasoningEffort");
|
||||
}
|
||||
if (fields.search !== undefined) {
|
||||
@@ -133,11 +125,18 @@ export async function handleSettings(
|
||||
return { status: 400, body: { error: "search must be a boolean" } };
|
||||
}
|
||||
cfg.search = fields.search;
|
||||
writeConfig(cfg, ctx.configPath);
|
||||
changed.push("search");
|
||||
}
|
||||
|
||||
if (changed.length > 0) {
|
||||
writeConfig(cfg, ctx.configPath);
|
||||
// Runtime side-effects fire after the disk write succeeds —
|
||||
// prevents an i18n change from being visible while the on-disk
|
||||
// value still reflects the old setting (and vice-versa for
|
||||
// preset / reasoningEffort).
|
||||
if (langPending) setLanguage(langPending);
|
||||
if (presetPendingLive) ctx.applyPresetLive?.(presetPendingLive);
|
||||
if (effortPendingLive) ctx.applyEffortLive?.(effortPendingLive);
|
||||
ctx.audit?.({ ts: Date.now(), action: "set-settings", payload: { fields: changed } });
|
||||
}
|
||||
return { status: 200, body: { changed } };
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/** `/api/skills` — edits files only; loop reloads on /new or restart. `builtin` scope is read-only. */
|
||||
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
@@ -67,10 +70,25 @@ function listSkills(dir: string, scope: "project" | "global"): SkillListEntry[]
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (!SAFE_NAME.test(entry)) continue;
|
||||
const skillPath = join(dir, entry, SKILL_FILE);
|
||||
if (!existsSync(skillPath)) continue;
|
||||
try {
|
||||
const stat = statSync(skillPath);
|
||||
const raw = readFileSync(skillPath, "utf8");
|
||||
// Open once and reuse the fd so size/mtime/content all bind to
|
||||
// the same inode — closes the exists→stat→read TOCTOU races.
|
||||
const fd = openSync(skillPath, "r");
|
||||
let stat: ReturnType<typeof fstatSync>;
|
||||
let raw: string;
|
||||
try {
|
||||
stat = fstatSync(fd);
|
||||
const buf = Buffer.alloc(stat.size);
|
||||
let read = 0;
|
||||
while (read < stat.size) {
|
||||
const n = readSync(fd, buf, read, stat.size - read, read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
raw = buf.toString("utf8", 0, read);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
const item: SkillListEntry = {
|
||||
name: entry,
|
||||
scope,
|
||||
|
||||
@@ -15,6 +15,7 @@ export async function handleSlash(
|
||||
summary: c.summary,
|
||||
argsHint: c.argsHint,
|
||||
contextual: c.contextual,
|
||||
aliases: c.aliases,
|
||||
}));
|
||||
return { status: 200, body: { commands, codeMode } };
|
||||
}
|
||||
+21
-7
@@ -1,4 +1,4 @@
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { closeSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -33,12 +33,26 @@ const ASSET_DIR = resolveAssetDir();
|
||||
const fileCache = new Map<string, { body: string; mtimeMs: number }>();
|
||||
|
||||
function loadCachedFile(path: string): string {
|
||||
const stat = statSync(path);
|
||||
const cached = fileCache.get(path);
|
||||
if (cached && cached.mtimeMs === stat.mtimeMs) return cached.body;
|
||||
const body = readFileSync(path, "utf8");
|
||||
fileCache.set(path, { body, mtimeMs: stat.mtimeMs });
|
||||
return body;
|
||||
// Open once and reuse the fd so the mtime check and the read bind to
|
||||
// the same inode — closes the stat→read TOCTOU race.
|
||||
const fd = openSync(path, "r");
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
const cached = fileCache.get(path);
|
||||
if (cached && cached.mtimeMs === stat.mtimeMs) return cached.body;
|
||||
const buf = Buffer.alloc(stat.size);
|
||||
let read = 0;
|
||||
while (read < stat.size) {
|
||||
const n = readSync(fd, buf, read, stat.size - read, read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
const body = buf.toString("utf8", 0, read);
|
||||
fileCache.set(path, { body, mtimeMs: stat.mtimeMs });
|
||||
return body;
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function loadIndexTemplate(): string {
|
||||
|
||||
+12
-3
@@ -28,8 +28,8 @@ export interface Skill {
|
||||
scope: SkillScope;
|
||||
/** Absolute path to the SKILL.md (or {name}.md) file, or "(builtin)" for shipped defaults. */
|
||||
path: string;
|
||||
/** Raw `allowed-tools` field from frontmatter, if any. Unused in v1. */
|
||||
allowedTools?: string;
|
||||
/** Parsed `allowed-tools` frontmatter — when present, the spawned subagent's registry is scoped to these literal tool names. */
|
||||
allowedTools?: readonly string[];
|
||||
runAs: SkillRunAs;
|
||||
/** Subagent model override; only meaningful when `runAs === "subagent"`. */
|
||||
model?: string;
|
||||
@@ -69,6 +69,15 @@ function isValidSkillName(name: string): boolean {
|
||||
return VALID_SKILL_NAME.test(name);
|
||||
}
|
||||
|
||||
function parseAllowedTools(raw: string | undefined): readonly string[] | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const names = raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return names.length > 0 ? Object.freeze(names) : undefined;
|
||||
}
|
||||
|
||||
export class SkillStore {
|
||||
private readonly homeDir: string;
|
||||
private readonly projectRoot: string | undefined;
|
||||
@@ -176,7 +185,7 @@ export class SkillStore {
|
||||
body: body.trim(),
|
||||
scope,
|
||||
path,
|
||||
allowedTools: data["allowed-tools"],
|
||||
allowedTools: parseAllowedTools(data["allowed-tools"]),
|
||||
runAs: parseRunAs(data.runAs),
|
||||
model: data.model?.startsWith("deepseek-") ? data.model : undefined,
|
||||
};
|
||||
|
||||
+16
-2
@@ -96,6 +96,20 @@ export interface SessionSummary {
|
||||
|
||||
export class SessionStats {
|
||||
readonly turns: TurnStats[] = [];
|
||||
/** Cost from prior runs of a resumed session, restored from session meta. */
|
||||
private _carryoverCost = 0;
|
||||
/** Turn count from prior runs of a resumed session. */
|
||||
private _carryoverTurns = 0;
|
||||
|
||||
/** Seed totals from a resumed session's persisted meta — only call once at construction. */
|
||||
seedCarryover(opts: { totalCostUsd?: number; turnCount?: number }): void {
|
||||
if (typeof opts.totalCostUsd === "number" && opts.totalCostUsd > 0) {
|
||||
this._carryoverCost = opts.totalCostUsd;
|
||||
}
|
||||
if (typeof opts.turnCount === "number" && opts.turnCount > 0) {
|
||||
this._carryoverTurns = opts.turnCount;
|
||||
}
|
||||
}
|
||||
|
||||
record(turn: number, model: string, usage: Usage): TurnStats {
|
||||
const cost = costUsd(model, usage);
|
||||
@@ -111,7 +125,7 @@ export class SessionStats {
|
||||
}
|
||||
|
||||
get totalCost(): number {
|
||||
return this.turns.reduce((sum, t) => sum + t.cost, 0);
|
||||
return this._carryoverCost + this.turns.reduce((sum, t) => sum + t.cost, 0);
|
||||
}
|
||||
|
||||
get totalClaudeEquivalent(): number {
|
||||
@@ -145,7 +159,7 @@ export class SessionStats {
|
||||
summary(): SessionSummary {
|
||||
const last = this.turns[this.turns.length - 1];
|
||||
return {
|
||||
turns: this.turns.length,
|
||||
turns: this.turns.length + this._carryoverTurns,
|
||||
totalCostUsd: round(this.totalCost, 6),
|
||||
totalInputCostUsd: round(this.totalInputCost, 6),
|
||||
totalOutputCostUsd: round(this.totalOutputCost, 6),
|
||||
|
||||
+38
-11
@@ -2,10 +2,16 @@
|
||||
|
||||
import {
|
||||
appendFileSync,
|
||||
closeSync,
|
||||
existsSync,
|
||||
fstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
@@ -72,20 +78,31 @@ const USAGE_COMPACTION_THRESHOLD_BYTES = 5 * 1024 * 1024;
|
||||
const USAGE_RETENTION_DAYS = 365;
|
||||
|
||||
function compactUsageLogIfLarge(path: string, now: number): void {
|
||||
let size: number;
|
||||
try {
|
||||
size = statSync(path).size;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (size < USAGE_COMPACTION_THRESHOLD_BYTES) return;
|
||||
const cutoff = now - USAGE_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
// Open once for the size check + read so they bind to the same fd
|
||||
// (CodeQL js/file-system-race). Concurrent appenders that grow the
|
||||
// log between check and read can no longer cause us to act on a
|
||||
// stale size and rewrite based on partial content.
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(path, "utf8");
|
||||
const fd = openSync(path, "r");
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
if (stat.size < USAGE_COMPACTION_THRESHOLD_BYTES) return;
|
||||
const buf = Buffer.alloc(stat.size);
|
||||
let read = 0;
|
||||
while (read < stat.size) {
|
||||
const n = readSync(fd, buf, read, stat.size - read, read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
raw = buf.toString("utf8", 0, read);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const cutoff = now - USAGE_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const kept: string[] = [];
|
||||
for (const line of lines) {
|
||||
@@ -99,10 +116,20 @@ function compactUsageLogIfLarge(path: string, now: number): void {
|
||||
}
|
||||
// No-op when nothing aged out — avoids rewrite storms on fresh logs.
|
||||
if (kept.length === lines.filter((l) => l.trim()).length) return;
|
||||
// Write to a sibling tmp path then rename — atomic from a reader's
|
||||
// POV and severs CodeQL's stat→write taint chain. Concurrent
|
||||
// appenders during the compaction window lose their entries; we
|
||||
// accept that for a best-effort usage log.
|
||||
const tmp = `${path}.compacting`;
|
||||
try {
|
||||
writeFileSync(path, kept.length > 0 ? `${kept.join("\n")}\n` : "", "utf8");
|
||||
writeFileSync(tmp, kept.length > 0 ? `${kept.join("\n")}\n` : "", "utf8");
|
||||
renameSync(tmp, path);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
try {
|
||||
unlinkSync(tmp);
|
||||
} catch {
|
||||
/* tmp may not exist — ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface ToolDefinition<A = any, R = any> {
|
||||
readOnly?: boolean;
|
||||
/** Per-args check; takes precedence over `readOnly`. e.g. `run_command` + allowlisted argv. */
|
||||
readOnlyCheck?: (args: A) => boolean;
|
||||
/** Safe to dispatch concurrently with other parallel-safe calls in the same turn. Default false — opt-in only. */
|
||||
parallelSafe?: boolean;
|
||||
fn: (args: A, ctx?: ToolCallContext) => R | Promise<R>;
|
||||
}
|
||||
|
||||
@@ -30,6 +32,13 @@ export interface ToolRegistryOptions {
|
||||
autoFlatten?: boolean;
|
||||
}
|
||||
|
||||
export type ToolCallAuditEvent = {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ToolCallAuditListener = (event: ToolCallAuditEvent) => void;
|
||||
|
||||
/** String return short-circuits dispatch; null/undefined falls through to the tool fn. */
|
||||
export type ToolInterceptor = (
|
||||
name: string,
|
||||
@@ -41,6 +50,7 @@ export class ToolRegistry {
|
||||
private readonly _autoFlatten: boolean;
|
||||
private _planMode = false;
|
||||
private _interceptor: ToolInterceptor | null = null;
|
||||
private _auditListener: ToolCallAuditListener | null = null;
|
||||
|
||||
constructor(opts: ToolRegistryOptions = {}) {
|
||||
this._autoFlatten = opts.autoFlatten !== false;
|
||||
@@ -61,6 +71,10 @@ export class ToolRegistry {
|
||||
this._interceptor = fn;
|
||||
}
|
||||
|
||||
setAuditListener(fn: ToolCallAuditListener | null): void {
|
||||
this._auditListener = fn;
|
||||
}
|
||||
|
||||
register<A, R>(def: ToolDefinition<A, R>): this {
|
||||
if (!def.name) throw new Error("tool requires a name");
|
||||
const internal: InternalTool = { ...(def as ToolDefinition) };
|
||||
@@ -96,6 +110,11 @@ export class ToolRegistry {
|
||||
return Boolean(this._tools.get(name)?.flatSchema);
|
||||
}
|
||||
|
||||
/** Unknown / unannotated tools default to false — third-party MCP tools must opt in. */
|
||||
isParallelSafe(name: string): boolean {
|
||||
return this._tools.get(name)?.parallelSafe === true;
|
||||
}
|
||||
|
||||
specs(): ToolSpec[] {
|
||||
return [...this._tools.values()].map((t) => ({
|
||||
type: "function",
|
||||
@@ -172,6 +191,11 @@ export class ToolRegistry {
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
this._auditListener?.({ name, args });
|
||||
} catch {
|
||||
/* audit path must never break tool execution */
|
||||
}
|
||||
const result = await tool.fn(args, {
|
||||
signal: opts.signal,
|
||||
confirmationGate: opts.confirmationGate,
|
||||
|
||||
+73
-224
@@ -2,8 +2,13 @@
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as pathMod from "node:path";
|
||||
import picomatch from "picomatch";
|
||||
import { DEFAULT_INDEX_EXCLUDES } from "../index/config.js";
|
||||
import type { ToolRegistry } from "../tools.js";
|
||||
import { applyEdit } from "./fs/edit.js";
|
||||
import { searchContent, searchFiles } from "./fs/search.js";
|
||||
|
||||
export { lineDiff } from "./fs/edit.js";
|
||||
|
||||
export interface FilesystemToolsOptions {
|
||||
/** Absolute directory the tools may read/write. Paths outside this are refused. */
|
||||
@@ -30,6 +35,26 @@ const SKIP_DIR_NAMES: ReadonlySet<string> = new Set(DEFAULT_INDEX_EXCLUDES.dirs)
|
||||
/** First line of binary defense; NUL-byte sniff is the second (catches mislabeled `.txt`). */
|
||||
const BINARY_EXTENSIONS: ReadonlySet<string> = new Set(DEFAULT_INDEX_EXCLUDES.exts);
|
||||
|
||||
export function displayRel(rootDir: string, full: string): string {
|
||||
return pathMod.relative(rootDir, full).replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
const GLOB_METACHARS = /[*?{[]/;
|
||||
|
||||
/** Glob via picomatch when metachars present, else case-insensitive substring — keeps `.ts` / `test` callers working. Slash in pattern → match rel-path; otherwise basename. */
|
||||
export function compileNameFilter(
|
||||
filter: string | null | undefined,
|
||||
): ((name: string, rel: string) => boolean) | null {
|
||||
if (!filter) return null;
|
||||
if (!GLOB_METACHARS.test(filter)) {
|
||||
const needle = filter.toLowerCase();
|
||||
return (name) => name.toLowerCase().includes(needle);
|
||||
}
|
||||
const matchPath = filter.includes("/");
|
||||
const isMatch = picomatch(filter, { dot: true, nocase: true });
|
||||
return matchPath ? (_n, rel) => isMatch(rel) : (name) => isMatch(name);
|
||||
}
|
||||
|
||||
function isLikelyBinaryByName(name: string): boolean {
|
||||
const dot = name.lastIndexOf(".");
|
||||
if (dot < 0) return false;
|
||||
@@ -77,6 +102,7 @@ export function registerFilesystemTools(
|
||||
|
||||
registry.register({
|
||||
name: "read_file",
|
||||
parallelSafe: true,
|
||||
description: `Read a file under the sandbox root. To save context, PREFER to scope the read instead of pulling the whole file:
|
||||
- head: N → first N lines (imports, public API, small configs)
|
||||
- tail: N → last N lines (recently-added code, log tails)
|
||||
@@ -99,11 +125,19 @@ When none of these is given AND the file is longer than ${DEFAULT_AUTO_PREVIEW_L
|
||||
},
|
||||
fn: async (args: { path: string; head?: number; tail?: number; range?: string }) => {
|
||||
const abs = safePath(args.path);
|
||||
const stat = await fs.stat(abs);
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(`not a file: ${args.path} (it's a directory)`);
|
||||
// Open once and reuse the fd so the directory check and the read
|
||||
// bind to the same inode — closes the stat→read TOCTOU race.
|
||||
const fh = await fs.open(abs, "r");
|
||||
let raw: Buffer;
|
||||
try {
|
||||
const stat = await fh.stat();
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(`not a file: ${args.path} (it's a directory)`);
|
||||
}
|
||||
raw = await fh.readFile();
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
const raw = await fs.readFile(abs);
|
||||
if (raw.length > maxReadBytes) {
|
||||
const headBytes = raw.slice(0, maxReadBytes).toString("utf8");
|
||||
return `${headBytes}\n\n[…truncated ${raw.length - maxReadBytes} bytes — file is ${raw.length} B, cap ${maxReadBytes} B. Retry with head/tail/range for targeted view.]`;
|
||||
@@ -168,6 +202,7 @@ When none of these is given AND the file is longer than ${DEFAULT_AUTO_PREVIEW_L
|
||||
|
||||
registry.register({
|
||||
name: "list_directory",
|
||||
parallelSafe: true,
|
||||
description:
|
||||
"List entries in a directory under the sandbox root. Returns one line per entry, marking directories with a trailing slash. Not recursive — use directory_tree for that.",
|
||||
readOnly: true,
|
||||
@@ -190,6 +225,7 @@ When none of these is given AND the file is longer than ${DEFAULT_AUTO_PREVIEW_L
|
||||
|
||||
registry.register({
|
||||
name: "directory_tree",
|
||||
parallelSafe: true,
|
||||
description: `Recursively list entries in a directory. Shows indented tree structure with directories marked '/'. Budget-aware by default:
|
||||
- maxDepth defaults to 2 (root + one level). A depth-4 tree on a real repo blew ~5K tokens in one call. If you truly need deeper, pass maxDepth:N explicitly.
|
||||
- Skips ${[...SKIP_DIR_NAMES].sort().join(", ")} unless include_deps:true. Traversing into node_modules / .git / dist is almost always token-waste.
|
||||
@@ -279,8 +315,9 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
|
||||
registry.register({
|
||||
name: "search_files",
|
||||
parallelSafe: true,
|
||||
description:
|
||||
"Find files whose NAME matches a substring or regex. Case-insensitive. Walks the directory recursively under the sandbox root. Returns one path per line.",
|
||||
"Find files whose NAME matches a substring or regex. Case-insensitive. Walks the directory recursively under the sandbox root. Returns one path per line. Skips dependency / VCS / build directories (node_modules, .git, dist, build, .next, target, .venv) by default.",
|
||||
readOnly: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
@@ -290,53 +327,25 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
type: "string",
|
||||
description: "Substring (or regex) to match against filenames.",
|
||||
},
|
||||
include_deps: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"When true, also walk node_modules / .git / dist / build / etc. Off by default — most filename searches are about the user's own code.",
|
||||
},
|
||||
},
|
||||
required: ["pattern"],
|
||||
},
|
||||
fn: async (args: { path?: string; pattern: string }) => {
|
||||
const startAbs = safePath(args.path ?? ".");
|
||||
const needle = args.pattern.toLowerCase();
|
||||
// Try as regex first (permits users who want patterns); fall
|
||||
// back to plain substring when it's not a valid regex. Flag `i`
|
||||
// so matching is case-insensitive regardless of path.
|
||||
let re: RegExp | null = null;
|
||||
try {
|
||||
re = new RegExp(args.pattern, "i");
|
||||
} catch {
|
||||
re = null;
|
||||
}
|
||||
const matches: string[] = [];
|
||||
let totalBytes = 0;
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = pathMod.join(dir, e.name);
|
||||
const lower = e.name.toLowerCase();
|
||||
const hit = re ? re.test(e.name) : lower.includes(needle);
|
||||
if (hit) {
|
||||
const rel = pathMod.relative(rootDir, full);
|
||||
if (totalBytes + rel.length + 1 > maxListBytes) {
|
||||
matches.push("[… search truncated — refine pattern …]");
|
||||
return;
|
||||
}
|
||||
matches.push(rel);
|
||||
totalBytes += rel.length + 1;
|
||||
}
|
||||
if (e.isDirectory()) await walk(full);
|
||||
}
|
||||
};
|
||||
await walk(startAbs);
|
||||
return matches.length === 0 ? "(no matches)" : matches.join("\n");
|
||||
},
|
||||
fn: async (args: { path?: string; pattern: string; include_deps?: boolean }) =>
|
||||
searchFiles(
|
||||
{ rootDir, maxListBytes, skipDirNames: SKIP_DIR_NAMES },
|
||||
safePath(args.path ?? "."),
|
||||
args,
|
||||
),
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "search_content",
|
||||
parallelSafe: true,
|
||||
description:
|
||||
"Recursively grep file CONTENTS for a substring or regex. This is the right tool for 'find all places that call X', 'where is Y referenced', 'what files contain Z'. Different from search_files (which matches FILE NAMES). Returns one match per line in 'path:line: text' format. Skips dependency / VCS / build directories (node_modules, .git, dist, build, .next, target, .venv) and binary files by default.",
|
||||
readOnly: true,
|
||||
@@ -354,7 +363,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
glob: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional file-name suffix or substring filter. Examples: '.ts' (only TypeScript), 'test' (any file with 'test' in the name). Reduces noise when you know the file shape.",
|
||||
"Optional filename filter. Real glob when the value contains `*`, `?`, `{`, or `[` — e.g. '*.ts', '**/*.tsx', 'src/**/*.{ts,tsx}'. Plain substring otherwise — e.g. '.ts' (suffix), 'test' (anywhere in the name). Patterns containing `/` match against the path relative to the search root; otherwise just the basename.",
|
||||
},
|
||||
case_sensitive: {
|
||||
type: "boolean",
|
||||
@@ -374,102 +383,23 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
glob?: string;
|
||||
case_sensitive?: boolean;
|
||||
include_deps?: boolean;
|
||||
}) => {
|
||||
const startAbs = safePath(args.path ?? ".");
|
||||
const caseSensitive = args.case_sensitive === true;
|
||||
const includeDeps = args.include_deps === true;
|
||||
const nameFilter = typeof args.glob === "string" ? args.glob.toLowerCase() : null;
|
||||
// Try the pattern as a regex first (lets the model say `\bdispatch\(`
|
||||
// for a word-bounded match); fall back to literal substring on
|
||||
// invalid regex. No `g` flag — we test once per line, so global
|
||||
// statefulness (lastIndex tracking) would just be noise.
|
||||
let re: RegExp | null = null;
|
||||
try {
|
||||
re = new RegExp(args.pattern, caseSensitive ? "" : "i");
|
||||
} catch {
|
||||
re = null;
|
||||
}
|
||||
const needle = caseSensitive ? args.pattern : args.pattern.toLowerCase();
|
||||
const matches: string[] = [];
|
||||
let totalBytes = 0;
|
||||
let scanned = 0;
|
||||
let truncated = false;
|
||||
|
||||
const walk = async (dir: string): Promise<void> => {
|
||||
if (truncated) return;
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (truncated) return;
|
||||
if (e.isDirectory()) {
|
||||
if (!includeDeps && SKIP_DIR_NAMES.has(e.name)) continue;
|
||||
await walk(pathMod.join(dir, e.name));
|
||||
continue;
|
||||
}
|
||||
if (!e.isFile()) continue;
|
||||
if (nameFilter && !e.name.toLowerCase().includes(nameFilter)) continue;
|
||||
if (isLikelyBinaryByName(e.name)) continue;
|
||||
const full = pathMod.join(dir, e.name);
|
||||
let stat: import("node:fs").Stats;
|
||||
try {
|
||||
stat = await fs.stat(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// Per-file size cap so a 50MB log doesn't dominate the search.
|
||||
// Anything legitimately interesting fits in 2 MB; bigger files
|
||||
// are usually data dumps or generated bundles.
|
||||
if (stat.size > 2 * 1024 * 1024) continue;
|
||||
let raw: Buffer;
|
||||
try {
|
||||
raw = await fs.readFile(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// Content-based binary sniff: a NUL byte in the first 8KB is
|
||||
// a strong indicator. Catches binaries with .json or .txt
|
||||
// extensions (yes, this happens).
|
||||
const firstNul = raw.indexOf(0);
|
||||
if (firstNul !== -1 && firstNul < 8 * 1024) continue;
|
||||
const text = raw.toString("utf8");
|
||||
const rel = pathMod.relative(rootDir, full);
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
const line = lines[li]!;
|
||||
const lineForCheck = caseSensitive ? line : line.toLowerCase();
|
||||
const hit = re ? re.test(line) : lineForCheck.includes(needle);
|
||||
if (!hit) continue;
|
||||
// Truncate very long lines so one giant minified file
|
||||
// doesn't blow the budget on a single match.
|
||||
const display = line.length > 200 ? `${line.slice(0, 200)}…` : line;
|
||||
const out = `${rel}:${li + 1}: ${display}`;
|
||||
if (totalBytes + out.length + 1 > maxListBytes) {
|
||||
matches.push(`[… truncated at ${maxListBytes} bytes — refine pattern or path …]`);
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
matches.push(out);
|
||||
totalBytes += out.length + 1;
|
||||
}
|
||||
scanned++;
|
||||
}
|
||||
};
|
||||
await walk(startAbs);
|
||||
if (matches.length === 0) {
|
||||
return scanned === 0
|
||||
? "(no files scanned — path empty or all files filtered out)"
|
||||
: `(no matches across ${scanned} file${scanned === 1 ? "" : "s"})`;
|
||||
}
|
||||
return matches.join("\n");
|
||||
},
|
||||
}) =>
|
||||
searchContent(
|
||||
{
|
||||
rootDir,
|
||||
maxListBytes,
|
||||
skipDirNames: SKIP_DIR_NAMES,
|
||||
isBinaryByName: isLikelyBinaryByName,
|
||||
nameMatch: compileNameFilter(typeof args.glob === "string" ? args.glob : null),
|
||||
},
|
||||
safePath(args.path ?? "."),
|
||||
args,
|
||||
),
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "get_file_info",
|
||||
parallelSafe: true,
|
||||
description:
|
||||
"Stat a path under the sandbox root. Returns type (file|directory|symlink), size in bytes, mtime in ISO-8601.",
|
||||
readOnly: true,
|
||||
@@ -510,7 +440,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
const abs = safePath(args.path);
|
||||
await fs.mkdir(pathMod.dirname(abs), { recursive: true });
|
||||
await fs.writeFile(abs, args.content, "utf8");
|
||||
return `wrote ${args.content.length} chars to ${pathMod.relative(rootDir, abs)}`;
|
||||
return `wrote ${args.content.length} chars to ${displayRel(rootDir, abs)}`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -527,34 +457,8 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
},
|
||||
required: ["path", "search", "replace"],
|
||||
},
|
||||
fn: async (args: { path: string; search: string; replace: string }) => {
|
||||
const abs = safePath(args.path);
|
||||
const before = await fs.readFile(abs, "utf8");
|
||||
if (args.search.length === 0) {
|
||||
throw new Error("edit_file: search cannot be empty");
|
||||
}
|
||||
const le = before.includes("\r\n") ? "\r\n" : "\n";
|
||||
const adaptedSearch = args.search.replace(/\r?\n/g, le);
|
||||
const adaptedReplace = args.replace.replace(/\r?\n/g, le);
|
||||
const firstIdx = before.indexOf(adaptedSearch);
|
||||
if (firstIdx < 0) {
|
||||
throw new Error(`edit_file: search text not found in ${pathMod.relative(rootDir, abs)}`);
|
||||
}
|
||||
const nextIdx = before.indexOf(adaptedSearch, firstIdx + 1);
|
||||
if (nextIdx >= 0) {
|
||||
throw new Error(
|
||||
`edit_file: search text appears multiple times in ${pathMod.relative(rootDir, abs)} — include more context to disambiguate`,
|
||||
);
|
||||
}
|
||||
const after =
|
||||
before.slice(0, firstIdx) + adaptedReplace + before.slice(firstIdx + adaptedSearch.length);
|
||||
await fs.writeFile(abs, after, "utf8");
|
||||
const rel = pathMod.relative(rootDir, abs);
|
||||
const header = `edited ${rel} (${adaptedSearch.length}→${adaptedReplace.length} chars)`;
|
||||
const startLine = before.slice(0, firstIdx).split(/\r?\n/).length;
|
||||
const diff = renderEditDiff(adaptedSearch, adaptedReplace, startLine);
|
||||
return `${header}\n${diff}`;
|
||||
},
|
||||
fn: async (args: { path: string; search: string; replace: string }) =>
|
||||
applyEdit(rootDir, safePath(args.path), args),
|
||||
});
|
||||
|
||||
registry.register({
|
||||
@@ -568,7 +472,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
fn: async (args: { path: string }) => {
|
||||
const abs = safePath(args.path);
|
||||
await fs.mkdir(abs, { recursive: true });
|
||||
return `created ${pathMod.relative(rootDir, abs)}/`;
|
||||
return `created ${displayRel(rootDir, abs)}/`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -588,64 +492,9 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
|
||||
const dst = safePath(args.destination);
|
||||
await fs.mkdir(pathMod.dirname(dst), { recursive: true });
|
||||
await fs.rename(src, dst);
|
||||
return `moved ${pathMod.relative(rootDir, src)} → ${pathMod.relative(rootDir, dst)}`;
|
||||
return `moved ${displayRel(rootDir, src)} → ${displayRel(rootDir, dst)}`;
|
||||
},
|
||||
});
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
function renderEditDiff(search: string, replace: string, startLine: number): string {
|
||||
const a = search.split(/\r?\n/);
|
||||
const b = replace.split(/\r?\n/);
|
||||
const diff = lineDiff(a, b);
|
||||
const hunk = `@@ -${startLine},${a.length} +${startLine},${b.length} @@`;
|
||||
const body = diff.map((d) => `${d.op === " " ? " " : d.op} ${d.line}`).join("\n");
|
||||
return `${hunk}\n${body}`;
|
||||
}
|
||||
|
||||
export function lineDiff(
|
||||
a: readonly string[],
|
||||
b: readonly string[],
|
||||
): Array<{ op: "-" | "+" | " "; line: string }> {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
// dp[i][j] = LCS length of a[0..i) and b[0..j).
|
||||
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||||
for (let i = 1; i <= n; i++) {
|
||||
for (let j = 1; j <= m; j++) {
|
||||
if (a[i - 1] === b[j - 1]) dp[i]![j] = dp[i - 1]![j - 1]! + 1;
|
||||
else dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!);
|
||||
}
|
||||
}
|
||||
// Backtrack to recover the op sequence.
|
||||
const out: Array<{ op: "-" | "+" | " "; line: string }> = [];
|
||||
let i = n;
|
||||
let j = m;
|
||||
while (i > 0 && j > 0) {
|
||||
if (a[i - 1] === b[j - 1]) {
|
||||
out.unshift({ op: " ", line: a[i - 1]! });
|
||||
i--;
|
||||
j--;
|
||||
} else if ((dp[i - 1]![j] ?? 0) > (dp[i]![j - 1] ?? 0)) {
|
||||
out.unshift({ op: "-", line: a[i - 1]! });
|
||||
i--;
|
||||
} else {
|
||||
// Tie-break goes here (strictly less or equal): take the
|
||||
// insertion first during backtrack so the final forward order
|
||||
// renders removals BEFORE additions for a substitution —
|
||||
// matches git-diff convention of `- old / + new`.
|
||||
out.unshift({ op: "+", line: b[j - 1]! });
|
||||
j--;
|
||||
}
|
||||
}
|
||||
while (i > 0) {
|
||||
out.unshift({ op: "-", line: a[i - 1]! });
|
||||
i--;
|
||||
}
|
||||
while (j > 0) {
|
||||
out.unshift({ op: "+", line: b[j - 1]! });
|
||||
j--;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user