atom-agent 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/README.md +220 -224
- package/dist/App.js +922 -341
- package/dist/adapters.js +127 -14
- package/dist/agent/goal-evaluator.js +3 -0
- package/dist/agent/loop.js +211 -430
- package/dist/agent/tool-pipeline.js +398 -0
- package/dist/agent/turn-events.js +12 -0
- package/dist/cli.js +57 -8
- package/dist/compact.js +72 -8
- package/dist/config.js +19 -0
- package/dist/context-manager.js +6 -2
- package/dist/extensions.js +6 -0
- package/dist/file-diffs.js +108 -0
- package/dist/kilo.js +1 -1
- package/dist/local-discovery.js +2 -2
- package/dist/media.js +276 -0
- package/dist/overflow.js +140 -0
- package/dist/policy.js +8 -0
- package/dist/scheduler.js +38 -9
- package/dist/session-revert.js +125 -0
- package/dist/sessions.js +101 -0
- package/dist/snapshots.js +69 -0
- package/dist/system.js +2 -89
- package/dist/telemetry.js +26 -1
- package/dist/todos.js +241 -0
- package/dist/tools/filesystem.js +102 -22
- package/dist/tools/registry.js +184 -45
- package/dist/tools/ripgrep.js +7 -6
- package/dist/tools/search.js +172 -17
- package/dist/tools/shared.js +6 -0
- package/dist/tools.js +7 -39
- package/dist/ui/diff-panel.js +5 -5
- package/dist/ui/diff-view.js +16 -7
- package/dist/ui/diff.js +73 -51
- package/dist/ui/errors.js +20 -6
- package/dist/ui/input.js +24 -20
- package/dist/ui/live-tail.js +36 -1
- package/dist/ui/markdown.js +9 -4
- package/dist/ui/modals.js +6 -4
- package/dist/ui/paint-scheduler.js +120 -0
- package/dist/ui/palette.js +4 -2
- package/dist/ui/pickers.js +4 -1
- package/dist/ui/side-by-side.js +88 -27
- package/dist/ui/status-bar.js +63 -8
- package/dist/ui/stream-store.js +7 -0
- package/dist/ui/theme.js +23 -1
- package/dist/ui/todo-panel.js +5 -2
- package/dist/ui/tool-inspector.js +33 -4
- package/dist/ui/transcript.js +9 -6
- package/dist/web/events.js +93 -0
- package/dist/web/runtime.js +790 -0
- package/dist/web/server.js +570 -0
- package/dist/web/ui/app.js +1925 -0
- package/dist/web/ui/index.html +135 -0
- package/dist/web/ui/styles.css +515 -0
- package/dist/zen.js +115 -4
- package/documentation/cli.md +5 -5
- package/documentation/configuration.md +11 -6
- package/documentation/development.md +4 -3
- package/documentation/extensions.md +1 -1
- package/documentation/goals.md +1 -1
- package/documentation/index.md +4 -4
- package/documentation/providers.md +2 -3
- package/documentation/skills.md +3 -3
- package/documentation/tools.md +8 -3
- package/documentation/troubleshooting.md +1 -1
- package/examples/extensions/01-audit-gate.js +2 -2
- package/examples/extensions/02-notes-tool.js +2 -2
- package/examples/extensions/03-custom-command.js +2 -2
- package/package.json +3 -2
package/dist/App.js
CHANGED
|
@@ -9,16 +9,20 @@ import { Box, Text, useApp, useInput, usePaste } from "ink";
|
|
|
9
9
|
import { DEFAULT_MODEL, EFFORT_OPTIONS, FALLBACK_MODELS, LoopCancelledError, buildSystemPrompt, fetchModelsForProviderWithStatus, fetchModelsWithStatus, historyChars, isEffortSupported, messageChars, normalizeEffort, runAgenticLoopForProvider, } from "./zen.js";
|
|
10
10
|
import { createContextManager, trackHistory, } from "./context-manager.js";
|
|
11
11
|
import { assemblePrefix, providerCacheSupport, } from "./prompt-cache.js";
|
|
12
|
-
import { TOOL_DEFINITIONS, TOOL_ONE_LINERS, APPROVAL_PREVIEW_MAX_BYTES, clearTodos, describeToolCall, executeTool, getTodos, needsApproval, previewDiffForApproval, providerSecrets } from "./tools.js";
|
|
12
|
+
import { TOOL_DEFINITIONS, TOOL_ONE_LINERS, APPROVAL_PREVIEW_MAX_BYTES, clearTodos, describeToolCall, executeTool, getTodos, needsApproval, previewDiffForApproval, providerSecrets, todowriteTool } from "./tools.js";
|
|
13
13
|
import { classifyTurnOutcome, createTelemetryRecorder, loadTelemetrySessions, resolveTelemetryEnabled, summarizeTelemetry, telemetryDir, } from "./telemetry.js";
|
|
14
14
|
import { writeTelemetryDashboard } from "./telemetry-dashboard.js";
|
|
15
15
|
import { formatRules, parseRuleInput, } from "./permissions.js";
|
|
16
|
-
import {
|
|
16
|
+
import { decideApproval, skillGrantsFor } from "./policy.js";
|
|
17
17
|
import { capSkillBodyForAuto, createSkillRegistry, loadSkillBody, matchSkills, resolveSkills, } from "./skills.js";
|
|
18
18
|
import { contextWindowFor } from "./context-windows.js";
|
|
19
19
|
import { emptyGoalStats, formatGoalForCompact, goalClearNotice, goalFollowUp, goalPauseNotice, goalResumeNotice, goalSetNotice, goalStatusText, goalTokensForUsage, parseGoalCommand, restoreGoalFromPersist, serializeGoalForPersist, } from "./goal.js";
|
|
20
20
|
import { requestGoalVerdict } from "./agent/goal-evaluator.js";
|
|
21
|
+
import { readSessionTodos, withSessionTodos, } from "./todos.js";
|
|
22
|
+
import { collectTurnFileDiffs, emptyFileDiffs, FILE_DIFFS_METADATA_KEY, mergeFileDiffs, readFileDiffs, serializeFileDiffs, } from "./file-diffs.js";
|
|
23
|
+
import { revertSessionToCheckpoint, } from "./session-revert.js";
|
|
21
24
|
import { COMPACT_PCT_DEFAULT, buildCompactedHistory, collectStoredTouchedFiles, collectTouchedFiles, compactBoundaryLine, compactPct, countUserTurns, estimateTokensForChars, fitSummaryWithFilesAndGoal, isThrashDisabled, requestCompactSummary, splitHistoryForCompaction, } from "./compact.js";
|
|
25
|
+
import { shouldAutoCompactReal } from "./overflow.js";
|
|
22
26
|
import { DEFAULT_PROVIDER, PROVIDERS, chatEndpointFor, getProvider, isLocalProviderId, isProviderId, localBaseURLFor, maskKey, openaiCompatibleChatEndpoint, providerNeedsKey, validateBaseURL, } from "./providers.js";
|
|
23
27
|
import { createLocalDiscovery, summarizeLocalSnapshot, } from "./local-discovery.js";
|
|
24
28
|
import { getStoredBaseURL, loadAuth, resolveApiKey, saveAuth, setStoredKey, } from "./auth.js";
|
|
@@ -26,7 +30,7 @@ import { validateProviderKey } from "./adapters.js";
|
|
|
26
30
|
import { clearKiloModelsCache, isFreeKiloModel, preferFreeKiloModel, } from "./kilo.js";
|
|
27
31
|
import { getGitInfo, withEnvBlock } from "./env-block.js";
|
|
28
32
|
import { loadPrefs, loadSession, saveSession, sessionExists, } from "./session.js";
|
|
29
|
-
import { createSession, ensureActiveSession, getActiveSession, getActiveSessionId, getSession, listSessions, renameSession, setActiveSession, updateSession, } from "./sessions.js";
|
|
33
|
+
import { createSession, ensureActiveSession, forkSession, getActiveSession, getActiveSessionId, getSession, listSessions, renameSession, setActiveSession, updateSession, } from "./sessions.js";
|
|
30
34
|
import { discoverExtensionEntries, loadExtensions, resolveExtensionName, } from "./extensions.js";
|
|
31
35
|
import { formatExtensionStatusText } from "./extension-ui.js";
|
|
32
36
|
import { getExtensionCommand, listExtensionCommands, parseExtensionCommandInput, runExtensionCommand, } from "./extension-commands.js";
|
|
@@ -47,21 +51,20 @@ import { PickerMoreAbove, PickerMoreBelow, PickerRow, PickerShell, pickerWindow
|
|
|
47
51
|
import { shortenCwd } from "./ui/status-bar.js";
|
|
48
52
|
import { StatusBarHost } from "./ui/status-host.js";
|
|
49
53
|
import { createStreamStore } from "./ui/stream-store.js";
|
|
54
|
+
import { createPaintScheduler } from "./ui/paint-scheduler.js";
|
|
50
55
|
import { theme } from "./ui/theme.js";
|
|
51
56
|
import { TodoPanel } from "./ui/todo-panel.js";
|
|
52
57
|
import { TranscriptView, applyScrollAction } from "./ui/transcript.js";
|
|
53
58
|
// Single registry for the "/" autocomplete menu and the exact-command path.
|
|
54
59
|
export const SLASH_COMMANDS = [
|
|
55
|
-
{ name: "/model", description: "Open the model picker." },
|
|
56
|
-
{ name: "/models", description: "Refresh local model discovery (Ollama, LM Studio, llama.cpp)." },
|
|
60
|
+
{ name: "/model", description: "Open the model picker (/model <text> filters, /model refresh re-probes servers)." },
|
|
57
61
|
{ name: "/provider", description: "Pick AI provider, paste API key once, chat." },
|
|
58
62
|
{
|
|
59
63
|
name: "/effort",
|
|
60
64
|
description: "Open the reasoning-effort picker (Auto/Low/Medium/High/Max; Auto lets the model decide).",
|
|
61
65
|
},
|
|
62
66
|
{ name: "/tools", description: "List the tools with one-line descriptions." },
|
|
63
|
-
{ name: "/
|
|
64
|
-
{ name: "/skill", description: "Invoke a skill by name (/skill:name; /skills lists)." },
|
|
67
|
+
{ name: "/skill", description: "List skills in a picker, or invoke (/skill:name, /skill <name>)." },
|
|
65
68
|
{ name: "/mode", description: "Print the current permission mode (Tab cycles normal → yolo → plan)." },
|
|
66
69
|
{ name: "/trust", description: "Toggle session trust: auto-approve write/edit/bash without full yolo (/trust again revokes)." },
|
|
67
70
|
{ name: "/allow", description: "Pre-approve a tool pattern this session (e.g. /allow bash:npm test*)." },
|
|
@@ -75,10 +78,12 @@ export const SLASH_COMMANDS = [
|
|
|
75
78
|
{ name: "/queue", description: "List queued follow-ups (/queue clear wipes them)." },
|
|
76
79
|
{ name: "/steer", description: "Steer the running turn, or send when idle (/steer <text>)." },
|
|
77
80
|
{ name: "/autoscroll", description: "Toggle following new output (on by default; bare toggles, on|off sets it; off freezes the view mid-turn)." },
|
|
78
|
-
{ name: "/goal", description: "Set, show, pause, resume, or clear the session goal (/goal <objective>; bare shows it; /goal pause|resume; /goal clear ends it)." },
|
|
81
|
+
{ name: "/goal", description: "Set (and start working, like a normal message), show, pause, resume, or clear the session goal (/goal <objective>; bare shows it; /goal pause|resume; /goal clear ends it)." },
|
|
79
82
|
{ name: "/thinking", description: "Show or hide model thinking in the TUI (rendering only; the turn is untouched)." },
|
|
80
83
|
{ name: "/resume", description: "Restore the last saved session (turns, history, settings, usage)." },
|
|
81
84
|
{ name: "/session", description: "Switch the active session (interactive picker, most recent first)." },
|
|
85
|
+
{ name: "/fork", description: "Fork this session into a new one and switch to it (/fork [n] drops the last n messages first)." },
|
|
86
|
+
{ name: "/revert", description: "Undo to a checkpoint — restores conversation + files (/revert [n] goes n checkpoints back)." },
|
|
82
87
|
{ name: "/telemetry", description: "Show the local observability summary (sessions, tokens, tools)." },
|
|
83
88
|
{ name: "/dashboard", description: "Write the local observability dashboard page and show its path." },
|
|
84
89
|
{ name: "/rewind", description: "Restore files to a session checkpoint (files only; shell side effects are never snapshotted)." },
|
|
@@ -114,14 +119,17 @@ export const SUBMIT_PIPELINE_STAGES = [
|
|
|
114
119
|
// Shared usage strings: the exact texts the commands print, hoisted to
|
|
115
120
|
// module scope so the slash-menu argument hints reuse them (no second
|
|
116
121
|
// implementation).
|
|
117
|
-
export const SKILL_USAGE = "usage: /skill
|
|
122
|
+
export const SKILL_USAGE = "usage: /skill (list) · /skill:name or /skill <name> (invoke, e.g. /skill:code-review)";
|
|
123
|
+
export const MODEL_USAGE = "usage: /model [filter text] — open the picker; /model refresh re-probes local servers (Ollama, LM Studio, llama.cpp)";
|
|
118
124
|
export const RULE_USAGE = "usage: /allow <tool[:glob]> · /deny <tool[:glob]> · /rules · /rules clear (e.g. /allow bash:npm test*, /deny bash:rm *)";
|
|
119
125
|
export const QUEUE_USAGE = "usage: /queue (list) · /queue clear (wipe) · /steer <text> (steer the running turn, or send when idle)";
|
|
120
126
|
export const STEER_USAGE = "usage: /steer <text> — while busy, injects into the running turn at the next step boundary (the current action finishes first); when idle, sends as a normal turn";
|
|
121
127
|
export const AUTOSCROLL_USAGE = "usage: /autoscroll [on|off] — on (default) follows new output as it arrives; off freezes the view while a turn runs (a `↓ N new` indicator offers the jump back). Bare /autoscroll toggles between the two.";
|
|
122
128
|
export const THINKING_USAGE = "usage: /thinking — toggles model-thinking visibility in the TUI (rendering only: the live block and future rounds show or hide; already-printed blocks stay as printed; the turn, history, and telemetry are untouched).";
|
|
123
129
|
export const RENAME_USAGE = 'usage: /rename <name> — rename the current session (e.g. /rename Build authentication; quotes optional: /rename "name with spaces"). Bare /rename prints this usage.';
|
|
124
|
-
export const
|
|
130
|
+
export const FORK_USAGE = "usage: /fork [n] — fork this session into a new one and switch to it (optional n drops the last n messages first, snapped to a turn boundary). Bare /fork clones the full conversation.";
|
|
131
|
+
export const REVERT_USAGE = "usage: /revert [n] — undo to a checkpoint (n checkpoints back, default 0 = latest). Restores conversation + files; other sessions and forks untouched. Bare /revert undoes the last bad turn.";
|
|
132
|
+
export const GOAL_USAGE = "usage: /goal <objective> (set and start working, just like a normal message; replacing resets counters; mid-turn set replaces quietly) · /goal (show with cumulative stats) · /goal pause · /goal resume (re-arms; idle starts a turn, busy resumes at turn end) · /goal clear (ends it)";
|
|
125
133
|
// Pure arg parser for /rename (unit-tested): strips the command, trims,
|
|
126
134
|
// then strips one layer of matching outer quotes (single or double) so
|
|
127
135
|
// quoted names work even though the command line has no real parser.
|
|
@@ -142,8 +150,8 @@ export function parseRenameArg(raw) {
|
|
|
142
150
|
export function filterSlashCommands(prefix) {
|
|
143
151
|
const q = prefix.startsWith("/") ? prefix.slice(1) : prefix;
|
|
144
152
|
// Exact match wins outright: a fully-typed command collapses the menu
|
|
145
|
-
// to itself, so prefix-siblings (/
|
|
146
|
-
// never read as duplicates and Enter stays deterministic. Partial
|
|
153
|
+
// to itself, so prefix-siblings (/mode vs /model, /skill vs /skill:name
|
|
154
|
+
// rows) never read as duplicates and Enter stays deterministic. Partial
|
|
147
155
|
// input keeps the prefix-then-fuzzy tiers below untouched.
|
|
148
156
|
const full = `/${q}`;
|
|
149
157
|
const exact = SLASH_COMMANDS.find((c) => c.name === full);
|
|
@@ -273,7 +281,7 @@ export function fuzzyScore(query, target) {
|
|
|
273
281
|
// Max skill rows in the menu: the command list always renders whole, skills
|
|
274
282
|
// narrow as you type — the menu can never take over the screen.
|
|
275
283
|
export const SLASH_MENU_SKILL_CAP = 8;
|
|
276
|
-
// Pure filter for the /
|
|
284
|
+
// Pure filter for the /skill picker (unit-tested): case-insensitive
|
|
277
285
|
// substring over the skill name (a search popup narrows harder than the
|
|
278
286
|
// prefix-only slash menu). Empty query returns everything as-is.
|
|
279
287
|
export function filterSkillPicker(entries, query) {
|
|
@@ -380,10 +388,16 @@ export function commandUsage(name) {
|
|
|
380
388
|
return STEER_USAGE;
|
|
381
389
|
case "/skill":
|
|
382
390
|
return SKILL_USAGE;
|
|
391
|
+
case "/model":
|
|
392
|
+
return MODEL_USAGE;
|
|
383
393
|
case "/compact":
|
|
384
394
|
return "Usage: /compact [focus text] — summarize older turns (works while busy; drains at turn end).";
|
|
385
395
|
case "/rename":
|
|
386
396
|
return RENAME_USAGE;
|
|
397
|
+
case "/fork":
|
|
398
|
+
return FORK_USAGE;
|
|
399
|
+
case "/revert":
|
|
400
|
+
return REVERT_USAGE;
|
|
387
401
|
case "/goal":
|
|
388
402
|
return GOAL_USAGE;
|
|
389
403
|
default:
|
|
@@ -796,7 +810,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
796
810
|
modelFilterRef.current = next;
|
|
797
811
|
setModelFilter(next);
|
|
798
812
|
}
|
|
799
|
-
// /
|
|
813
|
+
// /skill picker (opencode-style searchable popup): type-to-filter over the
|
|
800
814
|
// resolved registry, ↑/↓ + Enter to load, Esc cancels, windowed like the
|
|
801
815
|
// model picker so any library size stays navigable. Snapshot state (the
|
|
802
816
|
// registry always renders — names only, never descriptions).
|
|
@@ -817,7 +831,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
817
831
|
// /session picker (interactive switcher): snapshot state loaded ONCE per
|
|
818
832
|
// open (listSessions reads each record a single time), filtered in memory
|
|
819
833
|
// per keystroke. ↑/↓ + Enter switches, Esc cancels with the live session
|
|
820
|
-
// untouched. Same keyboard/window pattern as the /
|
|
834
|
+
// untouched. Same keyboard/window pattern as the /skill picker.
|
|
821
835
|
const [selectingSession, setSelectingSession] = useState(false);
|
|
822
836
|
const [sessionItems, setSessionItems] = useState([]);
|
|
823
837
|
const [sessionIndex, setSessionIndex] = useState(0);
|
|
@@ -980,10 +994,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
980
994
|
setScrollEnd(next);
|
|
981
995
|
}
|
|
982
996
|
// Thinking visibility (the /thinking toggle, rendering-only, default
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
const [showThinking, setShowThinking] = useState(
|
|
986
|
-
const showThinkingRef = useRef(
|
|
997
|
+
// shown): committed thinking turns + the live thinking block show while
|
|
998
|
+
// on. Never touches the turn, history, or telemetry — purely paint.
|
|
999
|
+
const [showThinking, setShowThinking] = useState(true);
|
|
1000
|
+
const showThinkingRef = useRef(true);
|
|
987
1001
|
function setShowThinkingBoth(next) {
|
|
988
1002
|
showThinkingRef.current = next;
|
|
989
1003
|
setShowThinking(next);
|
|
@@ -1019,6 +1033,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1019
1033
|
// accounting never breaks the turn
|
|
1020
1034
|
}
|
|
1021
1035
|
}
|
|
1036
|
+
// Goal-resume staged while busy (ticket 07): `/goal resume` mid-turn
|
|
1037
|
+
// re-arms the flag for turn-end pickup (see runGoalCommand) — the running
|
|
1038
|
+
// loop usually consumes the re-arm live at its next continuation check,
|
|
1039
|
+
// but a resume that raced the loop's final check (or a failed turn, which
|
|
1040
|
+
// never continues) leaves the goal active with no continuation. The
|
|
1041
|
+
// turn-boundary drain consumes this flag exactly once (see
|
|
1042
|
+
// drainTurnBoundary stage 5). Never set when idle (idle resume submits
|
|
1043
|
+
// directly); cleared on every turn end even when it kicks nothing.
|
|
1044
|
+
const goalResumePendingRef = useRef(false);
|
|
1022
1045
|
// Usage accumulator (session totals + goal slice, real reports only): the
|
|
1023
1046
|
// turn's onUsage below and the goal-judge runner share it so judge spend
|
|
1024
1047
|
// bills exactly like model spend. Every reporting POST accumulates
|
|
@@ -1038,6 +1061,16 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1038
1061
|
// the per-POST value, NOT the accumulated total.
|
|
1039
1062
|
if (updateLoad)
|
|
1040
1063
|
lastPromptTokensRef.current = u.prompt_tokens;
|
|
1064
|
+
// A real main-loop report just arrived: the load is exact again, so
|
|
1065
|
+
// the estimate latch clears (reset paths set it; summary/judge POSTs
|
|
1066
|
+
// never touch it — same rule as the latch above).
|
|
1067
|
+
if (updateLoad)
|
|
1068
|
+
setLoadEstimatedBoth(false);
|
|
1069
|
+
// Overflow-trigger source: the full last report (total, else parts).
|
|
1070
|
+
// Stashed only for main-loop POSTs — summary/judge spend must not
|
|
1071
|
+
// move the trigger (same rule as the load latch above).
|
|
1072
|
+
if (updateLoad)
|
|
1073
|
+
lastUsageRef.current = u;
|
|
1041
1074
|
}
|
|
1042
1075
|
if (u.completion_tokens !== undefined) {
|
|
1043
1076
|
next.completion_tokens = (next.completion_tokens ?? 0) + u.completion_tokens;
|
|
@@ -1088,7 +1121,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1088
1121
|
const [skillRegistry] = useState(() => createSkillRegistry({ projectDir: skillDirs?.projectDir, homeDir: skillDirs?.homeDir }));
|
|
1089
1122
|
// Skill entries for the slash menu (namespaced `/skill:name` commands):
|
|
1090
1123
|
// a snapshot of user-invocable skills (name + description), refreshed on
|
|
1091
|
-
// mount, /
|
|
1124
|
+
// mount, /skill, /clear, and /new — never per keystroke (disk I/O stays
|
|
1092
1125
|
// out of the typing path). Empty until the first refresh lands.
|
|
1093
1126
|
const [skillMenu, setSkillMenu] = useState([]);
|
|
1094
1127
|
async function refreshSkillMenu() {
|
|
@@ -1135,6 +1168,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1135
1168
|
return null;
|
|
1136
1169
|
}
|
|
1137
1170
|
}
|
|
1171
|
+
// Approval provenance slot (display-only, ticket 04): the verdict's via
|
|
1172
|
+
// token for the in-flight approval-gated call, held for the matching
|
|
1173
|
+
// onToolActivity commit. Same single-slot discipline as pendingDiffRef —
|
|
1174
|
+
// the scheduler never parallel-batches conflicting writes, and every
|
|
1175
|
+
// execution commits exactly one activity entry in call order. Lifetime ⊆
|
|
1176
|
+
// one turn: set in approve(), consumed-or-cleared by the matching
|
|
1177
|
+
// activity (same name-match predicate as the diff slot), and cleared on
|
|
1178
|
+
// deny/cancel/turn boundaries so a stale token can never attribute to a
|
|
1179
|
+
// later call. Read-only tools never consult approval, so they never set
|
|
1180
|
+
// this (their audit lines stay exactly as before).
|
|
1181
|
+
const pendingViaRef = useRef(null);
|
|
1138
1182
|
// Tool approval prompt (normal mode, write/edit/bash): the loop waits on
|
|
1139
1183
|
// the resolver until the user presses y/a/n. Ctrl+C aborts the whole turn
|
|
1140
1184
|
// (LoopCancelledError) instead of denying one call.
|
|
@@ -1176,12 +1220,27 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1176
1220
|
// Consumed by onToolActivity into Turn.ms and by the live running line;
|
|
1177
1221
|
// parallel batches share it (last start wins — approximate, display-only).
|
|
1178
1222
|
const toolStartRef = useRef(null);
|
|
1223
|
+
// Structured tool identity (ticket 02 sink consumer): FIFO of started
|
|
1224
|
+
// tools in commit order ({toolCallId, name, startedAt}). The loop's
|
|
1225
|
+
// onToolStarted fires before each commit and onToolFinished right after
|
|
1226
|
+
// the matching onToolActivity, so the queue head at activity time IS the
|
|
1227
|
+
// committing call's stable identity — no label parsing. Lifetime ⊆ one
|
|
1228
|
+
// turn like toolStartRef/pendingDiffRef: cleared at turn start and in the
|
|
1229
|
+
// turn-end finally so a cancelled/vetoed start can never leak sideways.
|
|
1230
|
+
const toolIdentityQueueRef = useRef([]);
|
|
1179
1231
|
// Latest streamed answer text (display bookkeeping only): if the turn
|
|
1180
1232
|
// FAILS after streaming (rate limits, dead network), the catch path
|
|
1181
1233
|
// commits this as a marked partial turn so the output never vanishes.
|
|
1182
1234
|
// History still rolls back (the model never sees it); the transcript
|
|
1183
1235
|
// keeps what the user already read. Cleared at every turn start.
|
|
1184
1236
|
const lastPartialRef = useRef("");
|
|
1237
|
+
// Streamed answer text already placed in the transcript this turn.
|
|
1238
|
+
// Multi-POST turns stream inter-tool chatter the loop keeps only in
|
|
1239
|
+
// history — without this the commit (final `reply` only) drops what the
|
|
1240
|
+
// user already read, and an empty final reply commits a blank turn that
|
|
1241
|
+
// reads as vanished output. Drained at tool commits and turn end; reset
|
|
1242
|
+
// at every turn start alongside lastPartialRef.
|
|
1243
|
+
const committedStreamRef = useRef("");
|
|
1185
1244
|
const [error, setError] = useState(null);
|
|
1186
1245
|
// Local observability recorder (src/telemetry.ts): one telemetry session
|
|
1187
1246
|
// per App mount. Best-effort and never throwing; off via ATOM_TELEMETRY=0
|
|
@@ -1205,6 +1264,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1205
1264
|
// completes. NK stays cumulative; P must NOT use the cumulative total.
|
|
1206
1265
|
const [contextLoad, setContextLoad] = useState(null);
|
|
1207
1266
|
const contextLoadRef = useRef(null);
|
|
1267
|
+
// Estimate latch (ticket 07 closes the ticket-06 follow-up): true when the
|
|
1268
|
+
// load behind P% is the chars/token heuristic rather than provider-reported
|
|
1269
|
+
// input tokens (post-compaction / /clear / resume / switch resets, or a
|
|
1270
|
+
// provider that never reports prompt_tokens). False once a main-loop POST
|
|
1271
|
+
// reports prompt_tokens; null when there is no load to qualify. Passed to
|
|
1272
|
+
// the status bar's `loadEstimated` prop — the bar's own heuristic covers
|
|
1273
|
+
// only the never-reported case, this latch covers the reset paths.
|
|
1274
|
+
const [loadEstimated, setLoadEstimated] = useState(null);
|
|
1275
|
+
const loadEstimatedRef = useRef(null);
|
|
1208
1276
|
// Git identity for the status bar (branch only, no status porcelain):
|
|
1209
1277
|
// refreshed at turn boundaries (a turn's bash may switch branches), read
|
|
1210
1278
|
// from render. Null outside git repos — the bar then shows cwd alone.
|
|
@@ -1221,10 +1289,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1221
1289
|
// parse time to include exclusive cache counters) — the load metric source.
|
|
1222
1290
|
// Summary-request usage never touches this — only main-loop POSTs do.
|
|
1223
1291
|
const lastPromptTokensRef = useRef(undefined);
|
|
1292
|
+
// Last main-loop POST's full reported usage (the real-total source for the
|
|
1293
|
+
// overflow trigger below). Summary/judge POSTs never touch this — only
|
|
1294
|
+
// main-loop POSTs do (same rule as lastPromptTokensRef). Reset everywhere
|
|
1295
|
+
// the load latch resets: the old report no longer measures this context.
|
|
1296
|
+
const lastUsageRef = useRef(undefined);
|
|
1224
1297
|
// Thrash guard: consecutive auto-compactions without the load dropping
|
|
1225
1298
|
// below threshold. At 3, auto disables for the session (manual still
|
|
1226
1299
|
// works and resets the counter on success).
|
|
1227
1300
|
const autoStreakRef = useRef(0);
|
|
1301
|
+
// Per-turn file-diff watermark (ticket 06): index into historyRef.current
|
|
1302
|
+
// up to which committed tool_calls have been collected into the session's
|
|
1303
|
+
// metadata.filediffs record. Replacements (compact/switch/resume/new/
|
|
1304
|
+
// clear) swap the array, so the read site guards a stale watermark into
|
|
1305
|
+
// a rescan — merge dedupes, so records are never lost, only re-scanned.
|
|
1306
|
+
const fileDiffsWatermarkRef = useRef(0);
|
|
1228
1307
|
const [autoDisabled, setAutoDisabled] = useState(false);
|
|
1229
1308
|
const autoDisabledRef = useRef(false);
|
|
1230
1309
|
// /compact typed while busy: focus text ("" = no focus) runs at turn end,
|
|
@@ -1262,6 +1341,40 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1262
1341
|
// `phase`/`phaseDetail`/`toolHint` stay in App state: they change at most a
|
|
1263
1342
|
// few times per turn (low frequency, and StatusBar legitimately needs them).
|
|
1264
1343
|
const [streamStore] = useState(() => createStreamStore());
|
|
1344
|
+
// Centralized streaming paint scheduler (render-stability): ONE trailing
|
|
1345
|
+
// timer for the answer draft + thinking lanes. onToken/onThinking push
|
|
1346
|
+
// every partial (activity/stall tracking stays per-token); paints coalesce
|
|
1347
|
+
// to one per DRAFT_THROTTLE_MS window, delivered in a single store update
|
|
1348
|
+
// so both lanes land in the same React render. Flushed on done/turn-end
|
|
1349
|
+
// and on tool transitions, errors, and cancellation (no stale trailing
|
|
1350
|
+
// paint may outlive the state it depicts).
|
|
1351
|
+
const paintSchedulerRef = useRef(null);
|
|
1352
|
+
function paintScheduler() {
|
|
1353
|
+
let ps = paintSchedulerRef.current;
|
|
1354
|
+
if (!ps) {
|
|
1355
|
+
ps = createPaintScheduler({
|
|
1356
|
+
intervalMs: DRAFT_THROTTLE_MS,
|
|
1357
|
+
now,
|
|
1358
|
+
setTimeoutFn,
|
|
1359
|
+
clearTimeoutFn,
|
|
1360
|
+
onFlush: (lanes) => {
|
|
1361
|
+
// Paint path only: the commit carries the byte-exact full text.
|
|
1362
|
+
// One store update notifies LiveTailHost alone — never App.
|
|
1363
|
+
streamStore.set(lanes);
|
|
1364
|
+
},
|
|
1365
|
+
});
|
|
1366
|
+
paintSchedulerRef.current = ps;
|
|
1367
|
+
}
|
|
1368
|
+
return ps;
|
|
1369
|
+
}
|
|
1370
|
+
function flushDraft() {
|
|
1371
|
+
try {
|
|
1372
|
+
paintScheduler().flush();
|
|
1373
|
+
}
|
|
1374
|
+
catch {
|
|
1375
|
+
// ignore (draft stays as-is; the commit carries the full text)
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1265
1378
|
// Thinking channel (onThinking): reasoning text streamed apart from the
|
|
1266
1379
|
// answer, rendered in its own dim block below. The live value is transient
|
|
1267
1380
|
// like the draft — cleared on every turn boundary below — but each completed
|
|
@@ -1278,7 +1391,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1278
1391
|
try {
|
|
1279
1392
|
// Drop any trailing paint: the commit carries the full text, and a
|
|
1280
1393
|
// late flush must never resurrect stale reasoning after the clear.
|
|
1281
|
-
|
|
1394
|
+
paintScheduler().cancel("thinking");
|
|
1282
1395
|
}
|
|
1283
1396
|
catch {
|
|
1284
1397
|
// ignore (the store clear below still wins)
|
|
@@ -1291,13 +1404,24 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1291
1404
|
function clearThinking() {
|
|
1292
1405
|
thinkingRef.current = null;
|
|
1293
1406
|
try {
|
|
1294
|
-
|
|
1407
|
+
paintScheduler().cancel("thinking");
|
|
1295
1408
|
}
|
|
1296
1409
|
catch {
|
|
1297
1410
|
// ignore (the store clear below still wins)
|
|
1298
1411
|
}
|
|
1299
1412
|
streamStore.setThinking(null);
|
|
1300
1413
|
}
|
|
1414
|
+
// Take streamed answer text not yet in the transcript (null when none or
|
|
1415
|
+
// already committed). Marks the take so later drains never duplicate it.
|
|
1416
|
+
function takeUncommittedStream() {
|
|
1417
|
+
const text = lastPartialRef.current;
|
|
1418
|
+
if (typeof text !== "string" || text.trim().length === 0)
|
|
1419
|
+
return null;
|
|
1420
|
+
if (text === committedStreamRef.current)
|
|
1421
|
+
return null;
|
|
1422
|
+
committedStreamRef.current = text;
|
|
1423
|
+
return { role: "assistant", content: text };
|
|
1424
|
+
}
|
|
1301
1425
|
const [phase, setPhase] = useState("idle");
|
|
1302
1426
|
const [phaseDetail, setPhaseDetail] = useState("");
|
|
1303
1427
|
const [toolHint, setToolHint] = useState(null);
|
|
@@ -1318,55 +1442,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1318
1442
|
setPhaseDetail(detail);
|
|
1319
1443
|
}
|
|
1320
1444
|
}
|
|
1321
|
-
//
|
|
1322
|
-
//
|
|
1323
|
-
//
|
|
1324
|
-
const draftThrottleRef = useRef(null);
|
|
1325
|
-
function draftThrottler() {
|
|
1326
|
-
let th = draftThrottleRef.current;
|
|
1327
|
-
if (!th) {
|
|
1328
|
-
th = createDraftThrottler({
|
|
1329
|
-
now,
|
|
1330
|
-
setTimeoutFn,
|
|
1331
|
-
clearTimeoutFn,
|
|
1332
|
-
onFlush: (text) => {
|
|
1333
|
-
// Paint path only: the commit carries the byte-exact full text.
|
|
1334
|
-
// Writing the store notifies LiveTailHost alone — never App.
|
|
1335
|
-
streamStore.setDraft(text);
|
|
1336
|
-
},
|
|
1337
|
-
});
|
|
1338
|
-
draftThrottleRef.current = th;
|
|
1339
|
-
}
|
|
1340
|
-
return th;
|
|
1341
|
-
}
|
|
1342
|
-
function flushDraft() {
|
|
1343
|
-
try {
|
|
1344
|
-
draftThrottler().flush();
|
|
1345
|
-
}
|
|
1346
|
-
catch {
|
|
1347
|
-
// ignore (draft stays as-is; the commit carries the full text)
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
// Thinking paint coalescing: reasoning chunks arrive at token rate but
|
|
1351
|
-
// paint through the same trailing window into the store (producer/consumer
|
|
1352
|
-
// symmetry with the draft). thinkingRef stays synchronous per chunk so
|
|
1353
|
-
// commitThinking can never lose reasoning to a pending trailing paint.
|
|
1354
|
-
const thinkingThrottleRef = useRef(null);
|
|
1355
|
-
function thinkingThrottler() {
|
|
1356
|
-
let th = thinkingThrottleRef.current;
|
|
1357
|
-
if (!th) {
|
|
1358
|
-
th = createDraftThrottler({
|
|
1359
|
-
now,
|
|
1360
|
-
setTimeoutFn,
|
|
1361
|
-
clearTimeoutFn,
|
|
1362
|
-
onFlush: (text) => {
|
|
1363
|
-
streamStore.setThinking(text);
|
|
1364
|
-
},
|
|
1365
|
-
});
|
|
1366
|
-
thinkingThrottleRef.current = th;
|
|
1367
|
-
}
|
|
1368
|
-
return th;
|
|
1369
|
-
}
|
|
1445
|
+
// Production paint path uses paintScheduler() above (single trailing
|
|
1446
|
+
// timer for both lanes). createDraftThrottler further below is retained
|
|
1447
|
+
// for its unit tests and as the documented single-lane primitive.
|
|
1370
1448
|
// Phase 5: models-list session cache (successful live lists only, keyed
|
|
1371
1449
|
// by modelsCacheKey). Failures fall back uncached, exactly as before.
|
|
1372
1450
|
const modelsCacheRef = useRef(new Map());
|
|
@@ -1398,7 +1476,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1398
1476
|
setLocalSnapBoth(snap);
|
|
1399
1477
|
}
|
|
1400
1478
|
// Non-blocking refresh kick: shared in-flight promise dedupes overlapping
|
|
1401
|
-
// calls (mount + picker-open + /
|
|
1479
|
+
// calls (mount + picker-open + /model refresh), so servers are never probed twice.
|
|
1402
1480
|
function kickLocalDiscovery() {
|
|
1403
1481
|
if (initialModels)
|
|
1404
1482
|
return;
|
|
@@ -1423,7 +1501,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1423
1501
|
if (r.ok)
|
|
1424
1502
|
return null;
|
|
1425
1503
|
const name = getProvider(id)?.name ?? id;
|
|
1426
|
-
return `${name} is unreachable at ${r.baseURL} — start the server, then run /
|
|
1504
|
+
return `${name} is unreachable at ${r.baseURL} — start the server, then run /model refresh.`;
|
|
1427
1505
|
}
|
|
1428
1506
|
// Loopback baseURL for chat/submit paths (env override wins, else the
|
|
1429
1507
|
// probed snapshot base, else the compiled default).
|
|
@@ -1533,7 +1611,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1533
1611
|
};
|
|
1534
1612
|
}, [endpoint, apiKey, initialModels]);
|
|
1535
1613
|
// Slash-menu skill snapshot once on mount (local disk reads only —
|
|
1536
|
-
// zero fetches; refreshed on /
|
|
1614
|
+
// zero fetches; refreshed on /skill, /clear, /new below).
|
|
1537
1615
|
useEffect(() => {
|
|
1538
1616
|
void refreshSkillMenu();
|
|
1539
1617
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -1669,8 +1747,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1669
1747
|
turnTimerRef.current = null;
|
|
1670
1748
|
}
|
|
1671
1749
|
try {
|
|
1672
|
-
|
|
1673
|
-
thinkingThrottleRef.current?.cancel();
|
|
1750
|
+
paintSchedulerRef.current?.cancel();
|
|
1674
1751
|
}
|
|
1675
1752
|
catch {
|
|
1676
1753
|
// ignore
|
|
@@ -1863,7 +1940,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
1863
1940
|
setSelectingRewindScope(false);
|
|
1864
1941
|
pendingRewindRef.current = null;
|
|
1865
1942
|
}
|
|
1866
|
-
// /
|
|
1943
|
+
// /skill picker (opencode-style searchable popup): opens on the fresh
|
|
1867
1944
|
// registry (names only), filters as you type, loads on Enter. Local disk
|
|
1868
1945
|
// reads only — zero fetches. Idle-only (history injection mid-turn would
|
|
1869
1946
|
// break the loop's assistant/tool pairing).
|
|
@@ -2056,6 +2133,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2056
2133
|
contextLoadRef.current = next;
|
|
2057
2134
|
setContextLoad(next);
|
|
2058
2135
|
}
|
|
2136
|
+
function setLoadEstimatedBoth(next) {
|
|
2137
|
+
loadEstimatedRef.current = next;
|
|
2138
|
+
setLoadEstimated(next);
|
|
2139
|
+
}
|
|
2059
2140
|
function setAutoDisabledBoth(next) {
|
|
2060
2141
|
autoDisabledRef.current = next;
|
|
2061
2142
|
setAutoDisabled(next);
|
|
@@ -2078,6 +2159,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2078
2159
|
// the chars/4 estimate applies until the next report arrives.
|
|
2079
2160
|
function resetContextLoadToEstimate() {
|
|
2080
2161
|
lastPromptTokensRef.current = undefined;
|
|
2162
|
+
lastUsageRef.current = undefined;
|
|
2163
|
+
// The estimate applies until the next report arrives: the latch marks
|
|
2164
|
+
// P% estimated so the bar reads `(~P%)`, never an exact fact.
|
|
2165
|
+
setLoadEstimatedBoth(true);
|
|
2081
2166
|
if (!usageRef.current) {
|
|
2082
2167
|
setContextLoadBoth(null);
|
|
2083
2168
|
}
|
|
@@ -2363,10 +2448,35 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2363
2448
|
const id = ensureStoreSession();
|
|
2364
2449
|
if (!id)
|
|
2365
2450
|
return;
|
|
2451
|
+
// The live checklist rides every store persist (same call as every
|
|
2452
|
+
// completed turn — no new save cadence). Other metadata keys pass
|
|
2453
|
+
// through untouched; only metadata.todos is set (never filediffs).
|
|
2454
|
+
let diskMetadata;
|
|
2455
|
+
try {
|
|
2456
|
+
diskMetadata = getSession(id, authHome)?.metadata;
|
|
2457
|
+
}
|
|
2458
|
+
catch {
|
|
2459
|
+
diskMetadata = undefined;
|
|
2460
|
+
}
|
|
2461
|
+
// Per-turn file diffs (ticket 06): collect the tool_calls committed
|
|
2462
|
+
// since the last persist and merge them into the session-scoped
|
|
2463
|
+
// metadata.filediffs record. This site runs on completed turns only
|
|
2464
|
+
// (failed/cancelled turns roll back and never persist), so failed
|
|
2465
|
+
// work is never recorded. Delta-only scan; a stale watermark after
|
|
2466
|
+
// an array replacement degrades to a rescan, and merge dedupes.
|
|
2467
|
+
const liveHistory = historyRef.current;
|
|
2468
|
+
const diffsStart = fileDiffsWatermarkRef.current <= liveHistory.length
|
|
2469
|
+
? fileDiffsWatermarkRef.current
|
|
2470
|
+
: 0;
|
|
2471
|
+
const diskRecord = typeof diskMetadata === "object" && diskMetadata !== null && !Array.isArray(diskMetadata)
|
|
2472
|
+
? diskMetadata
|
|
2473
|
+
: undefined;
|
|
2474
|
+
const mergedDiffs = mergeFileDiffs(readFileDiffs(diskRecord?.[FILE_DIFFS_METADATA_KEY]), collectTurnFileDiffs(liveHistory.slice(diffsStart)));
|
|
2475
|
+
fileDiffsWatermarkRef.current = liveHistory.length;
|
|
2366
2476
|
updateSession(id, {
|
|
2367
2477
|
history: historyRef.current,
|
|
2368
2478
|
turns: turnsRef.current.map((t) => {
|
|
2369
|
-
const { diff: _dropped, ...rest } = t;
|
|
2479
|
+
const { diff: _dropped, approvalVia: _viaDropped, ...rest } = t;
|
|
2370
2480
|
return rest;
|
|
2371
2481
|
}),
|
|
2372
2482
|
usageTotals: usageRef.current,
|
|
@@ -2375,6 +2485,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2375
2485
|
// snapshots this session's goal into its own record first, so a
|
|
2376
2486
|
// switch back restores it and sessions never leak goals.
|
|
2377
2487
|
goal: serializeGoalForPersist(goalRef.current),
|
|
2488
|
+
metadata: {
|
|
2489
|
+
...withSessionTodos(diskMetadata, getTodos()),
|
|
2490
|
+
[FILE_DIFFS_METADATA_KEY]: serializeFileDiffs(mergedDiffs),
|
|
2491
|
+
},
|
|
2378
2492
|
provider: providerRef.current,
|
|
2379
2493
|
model: modelRef.current,
|
|
2380
2494
|
effort: effortRef.current,
|
|
@@ -2525,7 +2639,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2525
2639
|
goal: goalRef.current,
|
|
2526
2640
|
history: historyRef.current,
|
|
2527
2641
|
turns: turnsRef.current.map((t) => {
|
|
2528
|
-
const { diff: _dropped, ...rest } = t;
|
|
2642
|
+
const { diff: _dropped, approvalVia: _viaDropped, ...rest } = t;
|
|
2529
2643
|
return rest;
|
|
2530
2644
|
}),
|
|
2531
2645
|
}, authHome);
|
|
@@ -2611,7 +2725,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2611
2725
|
pushInfo("(nothing to compact)");
|
|
2612
2726
|
return false;
|
|
2613
2727
|
}
|
|
2614
|
-
const split = splitHistoryForCompaction(historyRef.current);
|
|
2728
|
+
const split = splitHistoryForCompaction(historyRef.current, undefined, modelRef.current);
|
|
2615
2729
|
if (split.olderTurnCount <= 0 || split.head.length === 0) {
|
|
2616
2730
|
if (!isAuto)
|
|
2617
2731
|
pushInfo("(nothing to compact)");
|
|
@@ -2736,7 +2850,23 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2736
2850
|
// the same goal without re-exploring; over-budget lists shrink instead
|
|
2737
2851
|
// of failing compaction (model text + goal block are never cut).
|
|
2738
2852
|
const goalBlock = formatGoalForCompact(goalRef.current, getTodos().map((t) => ({ content: t.content, status: t.status })));
|
|
2739
|
-
|
|
2853
|
+
// Ticket 06: the session-scoped accumulated record (files from earlier
|
|
2854
|
+
// turns and prior compactions) merges with this head's touches, so
|
|
2855
|
+
// Relevant Files names exactly what the session touched — not just
|
|
2856
|
+
// the head being summarized now. Disk is the source of truth; a
|
|
2857
|
+
// failed read falls back to the head alone (compaction never fails
|
|
2858
|
+
// for a files feed).
|
|
2859
|
+
let recordedDiffs = emptyFileDiffs();
|
|
2860
|
+
try {
|
|
2861
|
+
const compactId = ensureStoreSession();
|
|
2862
|
+
if (compactId) {
|
|
2863
|
+
recordedDiffs = readFileDiffs(getSession(compactId, authHome)?.metadata?.[FILE_DIFFS_METADATA_KEY]);
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
catch {
|
|
2867
|
+
// ignore — head touches alone still summarize
|
|
2868
|
+
}
|
|
2869
|
+
const fitted = fitSummaryWithFilesAndGoal(summary, mergeFileDiffs(recordedDiffs, collectTouchedFiles(split.head)), goalBlock);
|
|
2740
2870
|
const next = buildCompactedHistory(systemMsg, fitted.text, split.tail, split.olderTurnCount);
|
|
2741
2871
|
// Replacement: re-wrap so the ledger restarts from the compacted array
|
|
2742
2872
|
// (the old ledger is discarded with the old array).
|
|
@@ -2750,14 +2880,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2750
2880
|
pushInfo(`(/compact — discarded ${compactDrops} file checkpoint(s); undos do not cross a compaction)`);
|
|
2751
2881
|
}
|
|
2752
2882
|
// P% must drop immediately: the old lastPromptTokens reflects the
|
|
2753
|
-
// pre-compact context (and its cache counters), so
|
|
2754
|
-
// the new
|
|
2755
|
-
|
|
2756
|
-
const newLoad = estimateTokensForChars(historyChars(historyRef.current));
|
|
2757
|
-
setContextLoadBoth(newLoad);
|
|
2883
|
+
// pre-compact context (and its cache counters), so the estimate latch
|
|
2884
|
+
// resets onto the new history (marks P% `(~P%)` until next report).
|
|
2885
|
+
resetContextLoadToEstimate();
|
|
2758
2886
|
if (isAuto) {
|
|
2759
2887
|
const pct = compactPct();
|
|
2760
2888
|
const window = contextWindowFor(modelRef.current);
|
|
2889
|
+
const newLoad = contextLoadRef.current ?? 0;
|
|
2761
2890
|
if (window !== undefined && newLoad / window < pct) {
|
|
2762
2891
|
autoStreakRef.current = 0;
|
|
2763
2892
|
}
|
|
@@ -2778,22 +2907,23 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2778
2907
|
return false;
|
|
2779
2908
|
}
|
|
2780
2909
|
}
|
|
2781
|
-
// After a completed main turn: refresh load, reset the streak when
|
|
2782
|
-
//
|
|
2783
|
-
//
|
|
2910
|
+
// After a completed main turn: refresh load, reset the streak when the
|
|
2911
|
+
// real-usage overflow trigger is quiet, else auto-compact (known-window
|
|
2912
|
+
// models with a reported real total at/above the usable limit only,
|
|
2913
|
+
// unless thrash-disabled). Called while still busy, before the next turn.
|
|
2784
2914
|
async function maybeAutoCompact() {
|
|
2785
2915
|
const load = refreshContextLoad();
|
|
2786
2916
|
if (load === null) {
|
|
2787
2917
|
autoStreakRef.current = 0;
|
|
2788
2918
|
return;
|
|
2789
2919
|
}
|
|
2790
|
-
// Unknown window → no auto trigger (never
|
|
2791
|
-
//
|
|
2792
|
-
//
|
|
2793
|
-
|
|
2920
|
+
// Unknown window or no real usage reported → no auto trigger (never
|
|
2921
|
+
// invent a window, never estimate); below the usable limit → streak
|
|
2922
|
+
// resets. shouldAutoCompactReal is false for all three, so re-check
|
|
2923
|
+
// the window for the reset.
|
|
2924
|
+
if (!shouldAutoCompactReal(modelRef.current, lastUsageRef.current)) {
|
|
2794
2925
|
// Distinguish unknown-window (streak untouched — irrelevant) from
|
|
2795
|
-
// below-
|
|
2796
|
-
// both, so re-check the window for the reset.
|
|
2926
|
+
// below-limit (streak resets).
|
|
2797
2927
|
if (contextWindowFor(modelRef.current) !== undefined) {
|
|
2798
2928
|
autoStreakRef.current = 0;
|
|
2799
2929
|
}
|
|
@@ -2846,13 +2976,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2846
2976
|
refreshSystemEnv();
|
|
2847
2977
|
// Restored load is the estimate (no prompt_tokens survived the save);
|
|
2848
2978
|
// thrash state restarts fresh on resume.
|
|
2849
|
-
|
|
2850
|
-
if (!usageRef.current) {
|
|
2851
|
-
setContextLoadBoth(null);
|
|
2852
|
-
}
|
|
2853
|
-
else {
|
|
2854
|
-
setContextLoadBoth(estimateTokensForChars(historyChars(historyRef.current)));
|
|
2855
|
-
}
|
|
2979
|
+
resetContextLoadToEstimate();
|
|
2856
2980
|
autoStreakRef.current = 0;
|
|
2857
2981
|
setAutoDisabledBoth(false);
|
|
2858
2982
|
pendingCompactRef.current = null;
|
|
@@ -2995,13 +3119,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
2995
3119
|
if (target.history.length > 0) {
|
|
2996
3120
|
refreshSystemEnv();
|
|
2997
3121
|
}
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
}
|
|
3002
|
-
else {
|
|
3003
|
-
setContextLoadBoth(estimateTokensForChars(historyChars(historyRef.current)));
|
|
3004
|
-
}
|
|
3122
|
+
// Switched sessions measure a different context: the estimate applies
|
|
3123
|
+
// until the next report (same latch as resume above).
|
|
3124
|
+
resetContextLoadToEstimate();
|
|
3005
3125
|
autoStreakRef.current = 0;
|
|
3006
3126
|
setAutoDisabledBoth(false);
|
|
3007
3127
|
pendingCompactRef.current = null;
|
|
@@ -3015,22 +3135,19 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3015
3135
|
content: `(/session — discarded ${switchDrops} live file checkpoint(s); undos do not cross a session switch)`,
|
|
3016
3136
|
});
|
|
3017
3137
|
}
|
|
3018
|
-
//
|
|
3019
|
-
//
|
|
3020
|
-
//
|
|
3021
|
-
//
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
});
|
|
3029
|
-
}
|
|
3030
|
-
|
|
3031
|
-
clearTodos();
|
|
3032
|
-
setTodoSnap([]);
|
|
3033
|
-
}
|
|
3138
|
+
// Todo restore (mirrors the goal restore above): the target's checklist
|
|
3139
|
+
// replaces the live one wholesale (never merged) — one session's plan
|
|
3140
|
+
// can never leak into another. The outgoing list was snapshotted into
|
|
3141
|
+
// its own record by persistStoreSession above, so switching back
|
|
3142
|
+
// restores it. Absent/corrupt data lands on an empty list. Restored
|
|
3143
|
+
// through todowriteTool so the live invariants still hold; the record
|
|
3144
|
+
// always replays cleanly because it was valid when saved.
|
|
3145
|
+
clearTodos();
|
|
3146
|
+
const restoredTodos = readSessionTodos(target.metadata);
|
|
3147
|
+
if (restoredTodos.length > 0) {
|
|
3148
|
+
await todowriteTool({ todos: restoredTodos });
|
|
3149
|
+
}
|
|
3150
|
+
setTodoSnap(getTodos());
|
|
3034
3151
|
// The target's history/turns REPLACE the live arrays wholesale — used
|
|
3035
3152
|
// whole, never trimmed.
|
|
3036
3153
|
for (const section of collectStoredTouchedFiles(historyRef.current)) {
|
|
@@ -3288,6 +3405,132 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3288
3405
|
setSessionTitleBoth(renamed.title);
|
|
3289
3406
|
pushInfo(`(renamed session to "${renamed.title}")`);
|
|
3290
3407
|
}
|
|
3408
|
+
// /fork [n]: clone the active session into a brand-new session and switch
|
|
3409
|
+
// to it ("try another approach from here"). Bare forks at the tip; /fork
|
|
3410
|
+
// <n> keeps all but the last n messages (forkSession snaps the cut to a
|
|
3411
|
+
// turn boundary, so assistant/tool pairing never splits). Idle-only: the
|
|
3412
|
+
// wholesale live-array swap would race a running turn.
|
|
3413
|
+
async function runForkCommand(raw) {
|
|
3414
|
+
const arg = raw.trim() === "/fork" ? "" : raw.trim().slice("/fork".length).trim();
|
|
3415
|
+
let drop = 0;
|
|
3416
|
+
if (arg !== "") {
|
|
3417
|
+
if (!/^\d+$/.test(arg)) {
|
|
3418
|
+
pushInfo(FORK_USAGE);
|
|
3419
|
+
return;
|
|
3420
|
+
}
|
|
3421
|
+
drop = Number(arg);
|
|
3422
|
+
}
|
|
3423
|
+
let id = null;
|
|
3424
|
+
try {
|
|
3425
|
+
id = ensureStoreSession();
|
|
3426
|
+
}
|
|
3427
|
+
catch {
|
|
3428
|
+
id = null;
|
|
3429
|
+
}
|
|
3430
|
+
if (!id) {
|
|
3431
|
+
pushInfo("(fork failed — session store unavailable; staying put)");
|
|
3432
|
+
return;
|
|
3433
|
+
}
|
|
3434
|
+
// Snapshot live state first so unpersisted turns ride into the fork;
|
|
3435
|
+
// forkSession only reads the disk record.
|
|
3436
|
+
persistStoreSession();
|
|
3437
|
+
const keep = drop === 0 ? undefined : Math.max(0, historyRef.current.length - drop);
|
|
3438
|
+
let forked = null;
|
|
3439
|
+
try {
|
|
3440
|
+
forked = forkSession(id, keep, authHome);
|
|
3441
|
+
}
|
|
3442
|
+
catch {
|
|
3443
|
+
forked = null;
|
|
3444
|
+
}
|
|
3445
|
+
if (!forked) {
|
|
3446
|
+
pushInfo("(fork failed — source session unreadable; staying put)");
|
|
3447
|
+
return;
|
|
3448
|
+
}
|
|
3449
|
+
await switchToSession(forked.id);
|
|
3450
|
+
pushInfo(`(forked into "${forked.title}")`);
|
|
3451
|
+
}
|
|
3452
|
+
// /revert [n]: undo to a checkpoint via revertSessionToCheckpoint —
|
|
3453
|
+
// restores the session's conversation AND files, then swaps the live
|
|
3454
|
+
// arrays wholesale (switch precedent). Bare reverts to the latest
|
|
3455
|
+
// checkpoint; /revert <n> goes n checkpoints back. Idle-only: the swap
|
|
3456
|
+
// would race a running turn. Checkpoints are live-lineage (in-memory,
|
|
3457
|
+
// dropped by compact/switch like /rewind's) — nothing older is offered.
|
|
3458
|
+
async function runRevertCommand(raw) {
|
|
3459
|
+
const arg = raw.trim() === "/revert" ? "" : raw.trim().slice("/revert".length).trim();
|
|
3460
|
+
let back = 0;
|
|
3461
|
+
if (arg !== "") {
|
|
3462
|
+
if (!/^\d+$/.test(arg)) {
|
|
3463
|
+
pushInfo(REVERT_USAGE);
|
|
3464
|
+
return;
|
|
3465
|
+
}
|
|
3466
|
+
back = Number(arg);
|
|
3467
|
+
}
|
|
3468
|
+
const cps = listCheckpoints();
|
|
3469
|
+
if (cps.length === 0) {
|
|
3470
|
+
pushInfo("(no snapshots recorded — every write/edit auto-snapshots; nothing to revert)");
|
|
3471
|
+
return;
|
|
3472
|
+
}
|
|
3473
|
+
if (back >= cps.length) {
|
|
3474
|
+
pushInfo(`(only ${cps.length} checkpoint(s) — nothing was changed)`);
|
|
3475
|
+
return;
|
|
3476
|
+
}
|
|
3477
|
+
const cp = cps[cps.length - 1 - back];
|
|
3478
|
+
let id = null;
|
|
3479
|
+
try {
|
|
3480
|
+
id = ensureStoreSession();
|
|
3481
|
+
}
|
|
3482
|
+
catch {
|
|
3483
|
+
id = null;
|
|
3484
|
+
}
|
|
3485
|
+
if (!id) {
|
|
3486
|
+
pushInfo("(revert failed — session store unavailable; nothing was changed)");
|
|
3487
|
+
return;
|
|
3488
|
+
}
|
|
3489
|
+
// Snapshot live state first: the revert reads the disk record, so
|
|
3490
|
+
// unpersisted turns must land there before the cut.
|
|
3491
|
+
persistStoreSession();
|
|
3492
|
+
let result;
|
|
3493
|
+
try {
|
|
3494
|
+
result = await revertSessionToCheckpoint(id, cp.id, authHome);
|
|
3495
|
+
}
|
|
3496
|
+
catch {
|
|
3497
|
+
result = { ok: false, error: "revert failed unexpectedly — session left exactly as it was" };
|
|
3498
|
+
}
|
|
3499
|
+
if (!result.ok) {
|
|
3500
|
+
pushInfo(`(${result.error})`);
|
|
3501
|
+
return;
|
|
3502
|
+
}
|
|
3503
|
+
// Wholesale live swap: the persisted record is truth (goal/todos live
|
|
3504
|
+
// on unchanged — the revert only rewrote history+turns).
|
|
3505
|
+
historyRef.current = trackHistory(result.session.history.length > 0
|
|
3506
|
+
? [...result.session.history]
|
|
3507
|
+
: [{ role: "system", content: withEnvBlock(systemPrompt) }]);
|
|
3508
|
+
setTurnsBoth(result.session.turns);
|
|
3509
|
+
// Restored bytes invalidate stale-read fingerprints (runRewind
|
|
3510
|
+
// precedent): forget them so later edits re-capture instead of
|
|
3511
|
+
// false-refusing.
|
|
3512
|
+
for (const f of cp.files) {
|
|
3513
|
+
try {
|
|
3514
|
+
forgetReadFingerprint(f.abs);
|
|
3515
|
+
}
|
|
3516
|
+
catch {
|
|
3517
|
+
// ignore — fingerprint refresh never breaks a revert
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
// Same remount + load refresh as /clear and /resume: the cut tail
|
|
3521
|
+
// leaves the test frame, and the old load no longer measures this
|
|
3522
|
+
// context.
|
|
3523
|
+
setScrollEndBoth(null);
|
|
3524
|
+
setClearGen((g) => g + 1);
|
|
3525
|
+
refreshContextLoad();
|
|
3526
|
+
lastPromptTokensRef.current = undefined;
|
|
3527
|
+
lastUsageRef.current = undefined;
|
|
3528
|
+
// The cut tail leaves the test frame: the refreshed load above was built
|
|
3529
|
+
// on the pre-cut report, so the estimate latch marks it until next turn.
|
|
3530
|
+
setLoadEstimatedBoth(true);
|
|
3531
|
+
pushInfo(result.message);
|
|
3532
|
+
persistSession();
|
|
3533
|
+
}
|
|
3291
3534
|
// /autoscroll [on|off]: follow switch for the scrollback viewport. View-
|
|
3292
3535
|
// only state — safe while busy (never touches the turn, like /queue).
|
|
3293
3536
|
// Bare toggles off ⇄ on; on jumps to the latest; off freezes a following
|
|
@@ -3365,6 +3608,12 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3365
3608
|
setGoalBoth({ ...g, active: true });
|
|
3366
3609
|
persistSession();
|
|
3367
3610
|
if (busyRef.current) {
|
|
3611
|
+
// Re-arm for turn-end pickup: the running loop reads the live flag
|
|
3612
|
+
// (usually consuming this immediately), and the turn-boundary drain
|
|
3613
|
+
// kicks one continuation turn when the ended turn left it stranded
|
|
3614
|
+
// (see drainTurnBoundary stage 5). Staged only on a real re-arm —
|
|
3615
|
+
// absent/already-active goals return above with no flag.
|
|
3616
|
+
goalResumePendingRef.current = true;
|
|
3368
3617
|
pushInfo("(goal resumes when the current turn ends — no new turn started while busy)");
|
|
3369
3618
|
return;
|
|
3370
3619
|
}
|
|
@@ -3374,16 +3623,46 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3374
3623
|
pushInfo(goalSetNotice(cmd.objective, goalRef.current));
|
|
3375
3624
|
setGoalBoth({ objective: cmd.objective, active: true, stats: emptyGoalStats() });
|
|
3376
3625
|
persistSession();
|
|
3626
|
+
if (busyRef.current) {
|
|
3627
|
+
// Mid-turn set replaces the live goal quietly (pre-existing
|
|
3628
|
+
// behavior): the running loop reads the live goal at its
|
|
3629
|
+
// continuation checks. No turn is ever injected while busy.
|
|
3630
|
+
pushInfo("(goal set — the running turn picks it up; nothing new started while busy)");
|
|
3631
|
+
return;
|
|
3632
|
+
}
|
|
3633
|
+
// A goal is just like a normal message: setting it starts a turn with
|
|
3634
|
+
// the objective as the user message, so the live-goal loop engages
|
|
3635
|
+
// (resume already kicks this way; set was the quiet outlier).
|
|
3636
|
+
void submit(cmd.objective);
|
|
3637
|
+
}
|
|
3638
|
+
// runModelsCommand (the `/model refresh` backend): reports the last
|
|
3639
|
+
// snapshot (kicking a first probe when discovery never ran) on a bare
|
|
3640
|
+
// call; `refresh` re-probes all three runtimes first. Results merge into
|
|
3641
|
+
// the models cache, so the /model picker serves them with no extra
|
|
3642
|
+
// fetches — one dim summary line, never transcript spam.
|
|
3643
|
+
// Unified /model picker open: unfiltered with the highlight on the
|
|
3644
|
+
// current model, or pre-filtered when the command carried text
|
|
3645
|
+
// (/model <text>). Active provider's section first, so same-provider
|
|
3646
|
+
// rises stay index-stable when other keyed providers add sections below.
|
|
3647
|
+
// Late lifecycle: if discovery never ran (slow/no startup probe),
|
|
3648
|
+
// kick it now so local sections fill in behind the open picker.
|
|
3649
|
+
function openModelPicker(initialFilter) {
|
|
3650
|
+
if (localSnapRef.current.version === 0)
|
|
3651
|
+
kickLocalDiscovery();
|
|
3652
|
+
setModelFilterBoth(initialFilter);
|
|
3653
|
+
const entries = buildModelEntries();
|
|
3654
|
+
const at = entries.findIndex((e) => e.providerId === providerRef.current && e.model === modelRef.current);
|
|
3655
|
+
setSelIndexBoth(Math.max(0, at));
|
|
3656
|
+
setSelecting(true);
|
|
3657
|
+
setSelectingEffort(false);
|
|
3658
|
+
setSelectingProvider(false);
|
|
3659
|
+
setKeyPromptBoth(null);
|
|
3660
|
+
setBaseURLPromptBoth(null);
|
|
3377
3661
|
}
|
|
3378
|
-
// /models: local-discovery status + refresh. Bare `/models` reports the
|
|
3379
|
-
// last snapshot (kicking a first probe when discovery never ran);
|
|
3380
|
-
// `/models refresh` re-probes all three runtimes, then reports. Results
|
|
3381
|
-
// merge into the models cache, so the /model picker serves them with no
|
|
3382
|
-
// extra fetches — one dim summary line, never transcript spam.
|
|
3383
3662
|
async function runModelsCommand(arg) {
|
|
3384
3663
|
const a = arg.trim().toLowerCase();
|
|
3385
3664
|
if (a !== "" && a !== "refresh") {
|
|
3386
|
-
pushInfo("usage: /
|
|
3665
|
+
pushInfo("usage: /model [filter|refresh] — pick a model, or probe local model servers (Ollama, LM Studio, llama.cpp).");
|
|
3387
3666
|
return;
|
|
3388
3667
|
}
|
|
3389
3668
|
// Manual Kilo refresh: clear the gateway catalog cache and re-fetch.
|
|
@@ -3445,6 +3724,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3445
3724
|
setClearGen((g) => g + 1);
|
|
3446
3725
|
setError(null);
|
|
3447
3726
|
streamStore.setDraft(null);
|
|
3727
|
+
lastPartialRef.current = "";
|
|
3728
|
+
committedStreamRef.current = "";
|
|
3448
3729
|
clearThinking();
|
|
3449
3730
|
setToolHint(null);
|
|
3450
3731
|
setPhaseBoth("idle", "");
|
|
@@ -3473,7 +3754,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3473
3754
|
}
|
|
3474
3755
|
// autoDisabled stays for the session (thrash guard is session-wide).
|
|
3475
3756
|
lastPromptTokensRef.current = undefined;
|
|
3757
|
+
lastUsageRef.current = undefined;
|
|
3476
3758
|
setContextLoadBoth(null);
|
|
3759
|
+
// No load left to qualify: the latch clears with it.
|
|
3760
|
+
setLoadEstimatedBoth(null);
|
|
3477
3761
|
autoStreakRef.current = 0;
|
|
3478
3762
|
pendingCompactRef.current = null;
|
|
3479
3763
|
telemetry.recordEvent("clear", "conversation cleared (token totals kept)");
|
|
@@ -3536,6 +3820,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3536
3820
|
setClearGen((g) => g + 1);
|
|
3537
3821
|
setError(null);
|
|
3538
3822
|
streamStore.setDraft(null);
|
|
3823
|
+
lastPartialRef.current = "";
|
|
3824
|
+
committedStreamRef.current = "";
|
|
3539
3825
|
clearThinking();
|
|
3540
3826
|
setToolHint(null);
|
|
3541
3827
|
setPhaseBoth("idle", "");
|
|
@@ -3545,7 +3831,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3545
3831
|
// conversation + counters reset.
|
|
3546
3832
|
setUsageBoth(null);
|
|
3547
3833
|
lastPromptTokensRef.current = undefined;
|
|
3834
|
+
lastUsageRef.current = undefined;
|
|
3548
3835
|
setContextLoadBoth(null);
|
|
3836
|
+
// Fresh conversation with no usage and no load: nothing to qualify.
|
|
3837
|
+
setLoadEstimatedBoth(null);
|
|
3549
3838
|
// Fresh conversation: the session checklist restarts too.
|
|
3550
3839
|
clearTodos();
|
|
3551
3840
|
setTodoSnap([]);
|
|
@@ -3573,22 +3862,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3573
3862
|
void runCompactCommand("");
|
|
3574
3863
|
return;
|
|
3575
3864
|
case "/model": {
|
|
3576
|
-
|
|
3577
|
-
// model (active provider's section first, so same-provider rises
|
|
3578
|
-
// stay index-stable when other keyed providers add sections below).
|
|
3579
|
-
// Late lifecycle: if discovery never ran (slow/no startup probe),
|
|
3580
|
-
// kick it now so local sections fill in behind the open picker.
|
|
3581
|
-
if (localSnapRef.current.version === 0)
|
|
3582
|
-
kickLocalDiscovery();
|
|
3583
|
-
setModelFilterBoth("");
|
|
3584
|
-
const entries = buildModelEntries();
|
|
3585
|
-
const at = entries.findIndex((e) => e.providerId === providerRef.current && e.model === modelRef.current);
|
|
3586
|
-
setSelIndexBoth(Math.max(0, at));
|
|
3587
|
-
setSelecting(true);
|
|
3588
|
-
setSelectingEffort(false);
|
|
3589
|
-
setSelectingProvider(false);
|
|
3590
|
-
setKeyPromptBoth(null);
|
|
3591
|
-
setBaseURLPromptBoth(null);
|
|
3865
|
+
openModelPicker("");
|
|
3592
3866
|
return;
|
|
3593
3867
|
}
|
|
3594
3868
|
case "/provider":
|
|
@@ -3605,13 +3879,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3605
3879
|
case "/tools":
|
|
3606
3880
|
pushInfo(toolsListText());
|
|
3607
3881
|
return;
|
|
3608
|
-
case "/skills":
|
|
3609
|
-
// Searchable picker (names only, type to filter, Enter loads).
|
|
3610
|
-
// Local reads only — zero fetches, like the model picker.
|
|
3611
|
-
openSkillPicker();
|
|
3612
|
-
return;
|
|
3613
3882
|
case "/skill":
|
|
3614
|
-
|
|
3883
|
+
// Unified skill command: bare opens the picker (what /skills did).
|
|
3884
|
+
openSkillPicker();
|
|
3615
3885
|
return;
|
|
3616
3886
|
case "/context":
|
|
3617
3887
|
pushInfo(buildContextText());
|
|
@@ -3678,6 +3948,18 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3678
3948
|
case "/session":
|
|
3679
3949
|
openSessionPicker("");
|
|
3680
3950
|
return;
|
|
3951
|
+
case "/fork":
|
|
3952
|
+
// Bare exact match (slash-menu Enter on the highlighted name):
|
|
3953
|
+
// full-conversation fork — the typed-args form is preserved by the
|
|
3954
|
+
// menu branch and the submit prefix route below.
|
|
3955
|
+
void runForkCommand("/fork");
|
|
3956
|
+
return;
|
|
3957
|
+
case "/revert":
|
|
3958
|
+
// Bare exact match: revert to the latest checkpoint — the typed
|
|
3959
|
+
// form is preserved by the menu branch and the submit prefix
|
|
3960
|
+
// route below.
|
|
3961
|
+
void runRevertCommand("/revert");
|
|
3962
|
+
return;
|
|
3681
3963
|
case "/rename":
|
|
3682
3964
|
// Bare exact match (slash-menu Enter on the highlighted name):
|
|
3683
3965
|
// usage — the typed-args form is preserved by the menu branch and
|
|
@@ -3727,14 +4009,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3727
4009
|
}
|
|
3728
4010
|
}
|
|
3729
4011
|
}
|
|
3730
|
-
// approve hook for runAgenticLoop:
|
|
4012
|
+
// approve hook for runAgenticLoop: one verdict per call (deny refuses as a
|
|
3731
4013
|
// standard "no" — pre-execution, model-visible denial result, audit line
|
|
3732
4014
|
// via the untouched onToolActivity path — and wins over everything below,
|
|
3733
|
-
// including plan mode); plan mode
|
|
3734
|
-
// gate, which refuses with a replan note
|
|
3735
|
-
//
|
|
3736
|
-
//
|
|
3737
|
-
//
|
|
4015
|
+
// including plan mode); plan mode never prompts — its mutations flow to
|
|
4016
|
+
// the execute gate, which refuses with a replan note (allow/yolo/trust/
|
|
4017
|
+
// always/skill grants cannot punch through); then allow, yolo, session
|
|
4018
|
+
// trust (/trust or [t]), and always-allowed tools run without prompting;
|
|
4019
|
+
// otherwise an Ink y/a/t/n prompt resolves the promise.
|
|
4020
|
+
// The verdict (decision + provenance + preview) is computed ONCE here via
|
|
4021
|
+
// decideApproval: the modal renders verdict.preview and the stashed
|
|
4022
|
+
// description (never recomputing either — no filesystem reads in render),
|
|
4023
|
+
// and execution consumes the recorded via without re-deciding. The only
|
|
4024
|
+
// other mode consultation is the execute gate below (guardedExecute),
|
|
4025
|
+
// which enforces plan-mode at execution time — it consults no rules and
|
|
4026
|
+
// prompts nothing, so approval still decides exactly once.
|
|
3738
4027
|
// The promise also rejects with LoopCancelledError when the turn is
|
|
3739
4028
|
// cancelled (Ctrl+C aborts the controller), so a cancel unblocks the loop
|
|
3740
4029
|
// as a whole-turn cancel — never as a one-call denial.
|
|
@@ -3760,38 +4049,49 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3760
4049
|
: null;
|
|
3761
4050
|
pendingDiffRef.current = { name, path: toolPath, beforeFull, afterArg, diff: stagedDiff };
|
|
3762
4051
|
}
|
|
3763
|
-
//
|
|
3764
|
-
//
|
|
3765
|
-
//
|
|
3766
|
-
|
|
4052
|
+
// One verdict for this call (deny → plan → allow → yolo → trust →
|
|
4053
|
+
// always → skill grants → prompt, owned by the policy layer). The
|
|
4054
|
+
// modal description is computed here too (same single pass — the label
|
|
4055
|
+
// may resolve symlinks, so render must not recompute it).
|
|
4056
|
+
const verdict = decideApproval(name, args, {
|
|
3767
4057
|
mode: modeRef.current,
|
|
3768
4058
|
trustAll: trustAllRef.current,
|
|
3769
4059
|
rules: rulesRef.current,
|
|
3770
4060
|
alwaysAllowed: alwaysAllowedRef.current,
|
|
3771
4061
|
skillGrants: skillGrantsRef.current,
|
|
3772
4062
|
approvalGated: needsApproval(name),
|
|
3773
|
-
});
|
|
3774
|
-
|
|
4063
|
+
}, stagedDiff);
|
|
4064
|
+
// Provenance for the transcript: approval-gated calls record their via
|
|
4065
|
+
// for the matching activity commit (deny clears immediately below —
|
|
4066
|
+
// its ↳ line already names the denial — and prompt-denials clear in
|
|
4067
|
+
// resolveApproval, so only executed calls ever render it).
|
|
4068
|
+
if (needsApproval(name))
|
|
4069
|
+
pendingViaRef.current = { name, via: verdict.via };
|
|
4070
|
+
if (verdict.decision === "deny") {
|
|
3775
4071
|
pendingDiffRef.current = null;
|
|
4072
|
+
pendingViaRef.current = null;
|
|
3776
4073
|
return "no";
|
|
3777
4074
|
}
|
|
3778
|
-
if (
|
|
4075
|
+
if (verdict.decision === "allow")
|
|
3779
4076
|
return "once";
|
|
4077
|
+
const description = describeToolCall(name, args);
|
|
3780
4078
|
const signal = turnCancelRef.current?.signal ?? null;
|
|
3781
4079
|
if (signal?.aborted) {
|
|
3782
4080
|
pendingDiffRef.current = null;
|
|
4081
|
+
pendingViaRef.current = null;
|
|
3783
4082
|
throw new LoopCancelledError();
|
|
3784
4083
|
}
|
|
3785
4084
|
return new Promise((resolve, reject) => {
|
|
3786
4085
|
approvalResolveRef.current = { resolve, reject };
|
|
3787
4086
|
setApproveIndexBoth(0);
|
|
3788
|
-
setPendingApproval({ name, args, diff:
|
|
4087
|
+
setPendingApproval({ name, args, diff: verdict.preview, description });
|
|
3789
4088
|
if (signal) {
|
|
3790
4089
|
const onAbort = () => {
|
|
3791
4090
|
const h = approvalResolveRef.current;
|
|
3792
4091
|
approvalResolveRef.current = null;
|
|
3793
4092
|
setPendingApproval(null);
|
|
3794
4093
|
pendingDiffRef.current = null;
|
|
4094
|
+
pendingViaRef.current = null;
|
|
3795
4095
|
h?.reject(new LoopCancelledError());
|
|
3796
4096
|
};
|
|
3797
4097
|
if (signal.aborted)
|
|
@@ -3805,8 +4105,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3805
4105
|
if (decision === "always" && pendingApproval) {
|
|
3806
4106
|
alwaysAllowedRef.current.add(pendingApproval.name);
|
|
3807
4107
|
}
|
|
3808
|
-
if (decision === "no")
|
|
4108
|
+
if (decision === "no") {
|
|
3809
4109
|
pendingDiffRef.current = null;
|
|
4110
|
+
pendingViaRef.current = null;
|
|
4111
|
+
}
|
|
3810
4112
|
const h = approvalResolveRef.current;
|
|
3811
4113
|
approvalResolveRef.current = null;
|
|
3812
4114
|
setPendingApproval(null);
|
|
@@ -3879,6 +4181,175 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3879
4181
|
setAskCustomBoth("");
|
|
3880
4182
|
h?.reject(new Error("question cancelled by user"));
|
|
3881
4183
|
}
|
|
4184
|
+
// Turn-boundary drain (ticket 07): the ONE explicitly ordered routine for
|
|
4185
|
+
// everything pending at a turn boundary. Called once per turn from
|
|
4186
|
+
// submit()'s turn-end finally (the single turn boundary — the compact
|
|
4187
|
+
// drains are extracted from submit's success path (try) and failure path
|
|
4188
|
+
// (catch) here); submit-time handlers only STAGE pending state (glue that
|
|
4189
|
+
// stays at those call sites, documented where it stages: /compact-while-
|
|
4190
|
+
// busy sets pendingCompactRef, busy follow-ups enqueue, busy /steer sets
|
|
4191
|
+
// steerRef, busy /goal resume re-arms plus stages goalResumePendingRef).
|
|
4192
|
+
//
|
|
4193
|
+
// Effective order (verified against the pre-ticket code + tests — every
|
|
4194
|
+
// stage keeps today's semantics; do not reorder):
|
|
4195
|
+
// 1. compact — manual /compact first (pending flag; a /compact arriving
|
|
4196
|
+
// mid-compaction re-arms the flag → nested second drain on clean
|
|
4197
|
+
// turns), else auto-compact on clean turns only. Failed/cancelled
|
|
4198
|
+
// turns drain a pending manual only (single, guarded); auto never
|
|
4199
|
+
// fires there (load is meaningless for a rolled-back turn — just
|
|
4200
|
+
// refresh it). Runs while still busy, so a /compact now re-arms the
|
|
4201
|
+
// pending flag instead of running a concurrent compaction.
|
|
4202
|
+
// 2. turn teardown (pinned here, not a drain stage: the goal work-time
|
|
4203
|
+
// accrual must follow compaction — both old paths ran compact-then-
|
|
4204
|
+
// accrue — and the busy reset plus modal/slot cleanup must precede
|
|
4205
|
+
// any chained submit so the next turn starts clean).
|
|
4206
|
+
// 3. steer — a steer stranded by a failed/cancelled turn rejoins the
|
|
4207
|
+
// queue FRONT (all outcomes; never dropped, never run inline).
|
|
4208
|
+
// 4. queue — the next queued follow-up auto-sends on non-cancelled turns
|
|
4209
|
+
// only (cancelled turns keep the queue visible but never auto-send;
|
|
4210
|
+
// failed turns auto-send like clean ones — the gate is the
|
|
4211
|
+
// cancellation latch, same `!turnCancelledRef` as before). The
|
|
4212
|
+
// chained submit re-enters submit() → a clean turn → this drain
|
|
4213
|
+
// again at its end.
|
|
4214
|
+
// 5. goal-resume — a staged busy-resume whose goal is still active with
|
|
4215
|
+
// no turn just chained starts exactly one continuation turn (covers
|
|
4216
|
+
// failed turns, which never continue inside the loop, and resumes
|
|
4217
|
+
// that raced the loop's final continuation check). A chained queue
|
|
4218
|
+
// turn consumes the stage instead (its loop reads the live flag — no
|
|
4219
|
+
// second turn starts); cancelled turns never kick (the loop paused
|
|
4220
|
+
// the goal, and the queue stays put for the user).
|
|
4221
|
+
// Re-entrancy: chained submits re-enter this routine per turn; every flag
|
|
4222
|
+
// is consumed (nulled) before the await that acts on it, so a nested
|
|
4223
|
+
// drain can never double-run a stage.
|
|
4224
|
+
async function drainTurnBoundary(outcome, goalWorkStartMs, goalWorkObjective) {
|
|
4225
|
+
// STAGE 1 — compact (verbatim from the old try/catch drains).
|
|
4226
|
+
if (outcome === "clean") {
|
|
4227
|
+
// Drain boundary (still busy, never mid-turn): pending manual /compact
|
|
4228
|
+
// first (it resets the thrash counter), else auto-compact when the
|
|
4229
|
+
// load is over threshold. Compaction persists via the normal save path.
|
|
4230
|
+
if (pendingCompactRef.current !== null) {
|
|
4231
|
+
const focus = pendingCompactRef.current;
|
|
4232
|
+
pendingCompactRef.current = null;
|
|
4233
|
+
await doCompact(focus, false);
|
|
4234
|
+
// A /compact that arrived during the compaction above drains now.
|
|
4235
|
+
if (pendingCompactRef.current !== null) {
|
|
4236
|
+
const focus2 = pendingCompactRef.current;
|
|
4237
|
+
pendingCompactRef.current = null;
|
|
4238
|
+
await doCompact(focus2, false);
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4241
|
+
else {
|
|
4242
|
+
await maybeAutoCompact();
|
|
4243
|
+
if (pendingCompactRef.current !== null) {
|
|
4244
|
+
const focus = pendingCompactRef.current;
|
|
4245
|
+
pendingCompactRef.current = null;
|
|
4246
|
+
await doCompact(focus, false);
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
else {
|
|
4251
|
+
// Turn-end drain even after failure/rollback: pending manual still
|
|
4252
|
+
// runs (it applies to the surviving history); auto never fires here
|
|
4253
|
+
// (load is meaningless for a rolled-back turn — just refresh it).
|
|
4254
|
+
if (pendingCompactRef.current !== null) {
|
|
4255
|
+
const focus = pendingCompactRef.current;
|
|
4256
|
+
pendingCompactRef.current = null;
|
|
4257
|
+
try {
|
|
4258
|
+
await doCompact(focus, false);
|
|
4259
|
+
}
|
|
4260
|
+
catch {
|
|
4261
|
+
// doCompact never throws (it reports inline), but stay safe.
|
|
4262
|
+
}
|
|
4263
|
+
}
|
|
4264
|
+
else {
|
|
4265
|
+
refreshContextLoad();
|
|
4266
|
+
}
|
|
4267
|
+
}
|
|
4268
|
+
// STAGE 2 — turn teardown (verbatim from the old finally, position
|
|
4269
|
+
// pinned: accrual after compaction, busy reset before any chained turn).
|
|
4270
|
+
// Goal work time (ticket 02): this submit's wall clock accrues once,
|
|
4271
|
+
// for every outcome (success, failure, and cancel all did work), when
|
|
4272
|
+
// the same goal is still live. A mid-turn replacement keeps its own
|
|
4273
|
+
// stats — we accrue only while the objective still matches.
|
|
4274
|
+
try {
|
|
4275
|
+
if (goalWorkStartMs !== null &&
|
|
4276
|
+
goalRef.current !== null &&
|
|
4277
|
+
goalRef.current.objective === goalWorkObjective) {
|
|
4278
|
+
const workedMs = Math.max(0, Date.now() - goalWorkStartMs);
|
|
4279
|
+
patchGoalStats((s) => ({ ...s, workMs: s.workMs + workedMs }));
|
|
4280
|
+
}
|
|
4281
|
+
}
|
|
4282
|
+
catch {
|
|
4283
|
+
// accounting never breaks turn teardown
|
|
4284
|
+
}
|
|
4285
|
+
turnCancelRef.current = null;
|
|
4286
|
+
approvalResolveRef.current = null;
|
|
4287
|
+
setPendingApproval(null);
|
|
4288
|
+
// Safety net: the slot is normally consumed by onToolActivity or
|
|
4289
|
+
// cleared on deny/cancel — never let it cross a turn boundary.
|
|
4290
|
+
// Same for the structured-identity queue (cancelled/vetoed starts)
|
|
4291
|
+
// and the provenance slot (same lifetime as the diff slot).
|
|
4292
|
+
pendingDiffRef.current = null;
|
|
4293
|
+
pendingViaRef.current = null;
|
|
4294
|
+
toolIdentityQueueRef.current = [];
|
|
4295
|
+
askResolveRef.current = null;
|
|
4296
|
+
setPendingQuestion(null);
|
|
4297
|
+
setAskCustomBoth("");
|
|
4298
|
+
// Turn-scoped skill grants expire here: armed-while-idle and auto
|
|
4299
|
+
// skills cover exactly the turn that just ended (success, failure,
|
|
4300
|
+
// or cancel) — the next user message starts clean (ticket 06).
|
|
4301
|
+
skillGrantsRef.current = new Set();
|
|
4302
|
+
busyRef.current = false;
|
|
4303
|
+
setBusy(false);
|
|
4304
|
+
refreshGitInfo();
|
|
4305
|
+
try {
|
|
4306
|
+
paintSchedulerRef.current?.cancel();
|
|
4307
|
+
}
|
|
4308
|
+
catch {
|
|
4309
|
+
// ignore
|
|
4310
|
+
}
|
|
4311
|
+
streamStore.setDraft(null);
|
|
4312
|
+
clearThinking();
|
|
4313
|
+
setToolHint(null);
|
|
4314
|
+
clearTurnTimer();
|
|
4315
|
+
setStalledBoth(false);
|
|
4316
|
+
setElapsedSecs(0);
|
|
4317
|
+
setPhaseBoth("idle", "");
|
|
4318
|
+
// STAGE 3 — steer (verbatim from the old finally): a steer stranded
|
|
4319
|
+
// by a failed/cancelled turn rejoins the queue front — the thought is
|
|
4320
|
+
// preserved, the user decides when it runs.
|
|
4321
|
+
const stranded = steerRef.current;
|
|
4322
|
+
if (stranded) {
|
|
4323
|
+
steerRef.current = null;
|
|
4324
|
+
setSteerPending(null);
|
|
4325
|
+
setQueueBoth([stranded, ...queueRef.current]);
|
|
4326
|
+
}
|
|
4327
|
+
// STAGE 4 — queue (verbatim from the old finally): a clean turn
|
|
4328
|
+
// auto-sends the next queued follow-up (chaining while the queue is
|
|
4329
|
+
// non-empty); a cancelled turn keeps its queue visible but never
|
|
4330
|
+
// auto-sends. Runs after the busy reset above so the chained submit
|
|
4331
|
+
// enters a clean turn.
|
|
4332
|
+
if (!turnCancelledRef.current && queueRef.current.length > 0) {
|
|
4333
|
+
const next = queueRef.current[0];
|
|
4334
|
+
setQueueBoth(queueRef.current.slice(1));
|
|
4335
|
+
void submit(next);
|
|
4336
|
+
}
|
|
4337
|
+
// STAGE 5 — goal-resume: consume the staged busy-resume (always —
|
|
4338
|
+
// even when it kicks nothing, so the flag never leaks across turns),
|
|
4339
|
+
// then kick exactly one continuation turn when the ended turn left the
|
|
4340
|
+
// goal active without chaining (failed turns and raced resumes — the
|
|
4341
|
+
// running loop consumes live re-arms itself, and a chained queue turn
|
|
4342
|
+
// above already carries the live goal). Cancelled turns never kick.
|
|
4343
|
+
const resumeStaged = goalResumePendingRef.current;
|
|
4344
|
+
goalResumePendingRef.current = false;
|
|
4345
|
+
if (resumeStaged &&
|
|
4346
|
+
outcome !== "cancelled" &&
|
|
4347
|
+
!busyRef.current &&
|
|
4348
|
+
goalRef.current !== null &&
|
|
4349
|
+
goalRef.current.active === true) {
|
|
4350
|
+
void submit(goalFollowUp(goalRef.current.objective));
|
|
4351
|
+
}
|
|
4352
|
+
}
|
|
3882
4353
|
// Submit-time pipeline (ticket 02 — stage order is SUBMIT_PIPELINE_STAGES
|
|
3883
4354
|
// above; each `SUBMIT STAGE n/3` marker below names its stage plus its
|
|
3884
4355
|
// rollback-scope rule). Local "/" routing precedes the pipeline: exact
|
|
@@ -3989,11 +4460,24 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
3989
4460
|
runRulesCommand(text);
|
|
3990
4461
|
return;
|
|
3991
4462
|
}
|
|
3992
|
-
// /
|
|
3993
|
-
//
|
|
4463
|
+
// /model takes an optional filter or subcommand: bare opens the picker,
|
|
4464
|
+
// `/model refresh` re-probes local servers (and the Kilo catalog when
|
|
4465
|
+
// Kilo is active), `/model <text>` opens the picker pre-filtered.
|
|
4466
|
+
// SLASH_NAMES only holds the exact command.
|
|
4467
|
+
if (text === "/model" || text.startsWith("/model ")) {
|
|
4468
|
+
const arg = text === "/model" ? "" : text.slice("/model ".length).trim();
|
|
4469
|
+
if (arg.toLowerCase() === "refresh") {
|
|
4470
|
+
void runModelsCommand("refresh");
|
|
4471
|
+
return;
|
|
4472
|
+
}
|
|
4473
|
+
openModelPicker(arg);
|
|
4474
|
+
return;
|
|
4475
|
+
}
|
|
4476
|
+
// Retired: /models merged into /model (see above). Explicit branch so
|
|
4477
|
+
// the input explains instead of hitting skill lookup — the name stays
|
|
4478
|
+
// reserved against extension shadowing.
|
|
3994
4479
|
if (text === "/models" || text.startsWith("/models ")) {
|
|
3995
|
-
|
|
3996
|
-
void runModelsCommand(arg);
|
|
4480
|
+
pushInfo("(merged — use /model to pick, /model refresh to re-probe local servers)");
|
|
3997
4481
|
return;
|
|
3998
4482
|
}
|
|
3999
4483
|
// /session takes an optional initial filter ("/session auth" opens the
|
|
@@ -4003,6 +4487,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4003
4487
|
openSessionPicker(initial);
|
|
4004
4488
|
return;
|
|
4005
4489
|
}
|
|
4490
|
+
// /fork takes an optional drop count ("/fork 5" drops the last 5
|
|
4491
|
+
// messages first) — SLASH_NAMES only holds the exact command. Idle-only
|
|
4492
|
+
// like /session: the live-array swap would race a running turn, and the
|
|
4493
|
+
// busy guard above already drops other "/" input while busy.
|
|
4494
|
+
if (text === "/fork" || text.startsWith("/fork ")) {
|
|
4495
|
+
await runForkCommand(text);
|
|
4496
|
+
return;
|
|
4497
|
+
}
|
|
4498
|
+
// /revert takes an optional checkpoint index ("/revert 1" goes one
|
|
4499
|
+
// checkpoint back) — SLASH_NAMES only holds the exact command.
|
|
4500
|
+
// Idle-only like /fork: the live-array swap would race a running turn.
|
|
4501
|
+
if (text === "/revert" || text.startsWith("/revert ")) {
|
|
4502
|
+
await runRevertCommand(text);
|
|
4503
|
+
return;
|
|
4504
|
+
}
|
|
4006
4505
|
// Exact full-command + Enter runs it. A single-token "/name" not in
|
|
4007
4506
|
// SLASH_NAMES resolves through the skill registry (ticket 03, legacy
|
|
4008
4507
|
// form — the namespaced `/skill:name` below is canonical); anything
|
|
@@ -4016,13 +4515,28 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4016
4515
|
runSlashCommand(text);
|
|
4017
4516
|
return;
|
|
4018
4517
|
}
|
|
4019
|
-
// Namespaced skill invocation: `/skill:name` (bare `/skill`
|
|
4020
|
-
// via the registry path above
|
|
4021
|
-
// the legacy `/name` form and
|
|
4518
|
+
// Namespaced skill invocation: `/skill:name` (bare `/skill` opens the
|
|
4519
|
+
// picker via the registry path above; `/skill <name>` below invokes).
|
|
4520
|
+
// Resolves through the same registry as the legacy `/name` form and
|
|
4521
|
+
// the slash menu.
|
|
4022
4522
|
if (text === "/skill" || text === "/skill:") {
|
|
4023
4523
|
pushInfo(SKILL_USAGE);
|
|
4024
4524
|
return;
|
|
4025
4525
|
}
|
|
4526
|
+
// Space form for the unified command: `/skill deploy` invokes exactly
|
|
4527
|
+
// like `/skill:deploy`.
|
|
4528
|
+
const spacedSkill = /^\/skill\s+([A-Za-z0-9_-]+)\s*$/.exec(text)?.[1];
|
|
4529
|
+
if (spacedSkill !== undefined) {
|
|
4530
|
+
void invokeSkillByName(spacedSkill);
|
|
4531
|
+
return;
|
|
4532
|
+
}
|
|
4533
|
+
// Retired: /skills merged into /skill (bare opens the picker).
|
|
4534
|
+
// Explicit branch so the input explains instead of hitting skill
|
|
4535
|
+
// lookup — the name stays reserved against extension shadowing.
|
|
4536
|
+
if (text === "/skills" || text.startsWith("/skills ")) {
|
|
4537
|
+
pushInfo("(merged — /skill lists and picks, /skill:name invokes)");
|
|
4538
|
+
return;
|
|
4539
|
+
}
|
|
4026
4540
|
const namespaced = /^\/skill:([A-Za-z0-9_-]+)$/.exec(text)?.[1];
|
|
4027
4541
|
if (namespaced !== undefined) {
|
|
4028
4542
|
void invokeSkillByName(namespaced);
|
|
@@ -4069,8 +4583,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4069
4583
|
// (loaded while idle) must survive into the turn it was armed for.
|
|
4070
4584
|
// Expiry happens in the turn-end finally below, plus /clear + /new.
|
|
4071
4585
|
try {
|
|
4072
|
-
|
|
4073
|
-
thinkingThrottler().reset();
|
|
4586
|
+
paintScheduler().reset();
|
|
4074
4587
|
}
|
|
4075
4588
|
catch {
|
|
4076
4589
|
// ignore (first token still paints; at worst one window late)
|
|
@@ -4082,11 +4595,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4082
4595
|
startTurnTimer();
|
|
4083
4596
|
// Display-only tool clock: no tool is running at turn start, so any
|
|
4084
4597
|
// stale timestamp from a previous turn must not leak into this one.
|
|
4598
|
+
// Same for the structured-identity queue (a cancelled turn's unconsumed
|
|
4599
|
+
// starts must never attribute to this turn).
|
|
4085
4600
|
toolStartRef.current = null;
|
|
4601
|
+
toolIdentityQueueRef.current = [];
|
|
4086
4602
|
// Same for the transcript-diff slot: a previous turn's unconsumed
|
|
4087
4603
|
// preview (cancelled mid-execution) must never attach to this turn.
|
|
4604
|
+
// Same for the provenance slot (same lifetime, same reason).
|
|
4088
4605
|
pendingDiffRef.current = null;
|
|
4606
|
+
pendingViaRef.current = null;
|
|
4089
4607
|
lastPartialRef.current = "";
|
|
4608
|
+
committedStreamRef.current = "";
|
|
4090
4609
|
refreshGitInfo();
|
|
4091
4610
|
// SUBMIT STAGE 2/3 — context-assembly (rollback scope: pre-rollbackTo,
|
|
4092
4611
|
// survives failure). Refresh the pinned env block ONCE per turn (not per
|
|
@@ -4104,6 +4623,12 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4104
4623
|
// catch). Cancellation (LoopCancelledError) shares the same splice
|
|
4105
4624
|
// contract.
|
|
4106
4625
|
const rollbackTo = historyRef.current.length;
|
|
4626
|
+
// Turn-boundary outcome glue (ticket 07): the try end marks clean, the
|
|
4627
|
+
// catch marks failed/cancelled — the turn-end finally passes it to the
|
|
4628
|
+
// single drain routine (see drainTurnBoundary). Dead default is the most
|
|
4629
|
+
// conservative ("cancelled" never auto-sends); every path below
|
|
4630
|
+
// overwrites it before the finally reads it.
|
|
4631
|
+
let turnOutcome = "cancelled";
|
|
4107
4632
|
// Goal work time (ticket 02): wall clock for this submit accrues to the
|
|
4108
4633
|
// live goal in the turn finally (all outcomes — success, failure, and
|
|
4109
4634
|
// cancel all did work). Pinned to the starting objective so a mid-turn
|
|
@@ -4218,6 +4743,37 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4218
4743
|
// Local observability sink: the loop reports completed model/tool
|
|
4219
4744
|
// calls (iterations, durations, usage) into the open turn trace.
|
|
4220
4745
|
telemetry: telemetrySink,
|
|
4746
|
+
// Structured tool identity (ticket 02 sink consumer): every tool
|
|
4747
|
+
// start/finish arrives here with its stable toolCallId + name, in
|
|
4748
|
+
// commit order. onToolActivity below consumes the queue head (the
|
|
4749
|
+
// start fired before the commit); onToolFinished reconciles by id
|
|
4750
|
+
// for paths whose activity never fired. Guarded: observer errors
|
|
4751
|
+
// degrade to the label-matching fallback, never break the turn.
|
|
4752
|
+
turnEvents: {
|
|
4753
|
+
onToolStarted: (info) => {
|
|
4754
|
+
try {
|
|
4755
|
+
toolIdentityQueueRef.current.push({
|
|
4756
|
+
toolCallId: info.toolCallId,
|
|
4757
|
+
name: info.name,
|
|
4758
|
+
startedAt: clockNow(),
|
|
4759
|
+
});
|
|
4760
|
+
}
|
|
4761
|
+
catch {
|
|
4762
|
+
// ignore (that call falls back to the phase-timing channel)
|
|
4763
|
+
}
|
|
4764
|
+
},
|
|
4765
|
+
onToolFinished: (info) => {
|
|
4766
|
+
try {
|
|
4767
|
+
const q = toolIdentityQueueRef.current;
|
|
4768
|
+
const at = q.findIndex((e) => e.toolCallId === info.toolCallId);
|
|
4769
|
+
if (at >= 0)
|
|
4770
|
+
q.splice(at, 1);
|
|
4771
|
+
}
|
|
4772
|
+
catch {
|
|
4773
|
+
// ignore (queue hygiene only; the activity already consumed)
|
|
4774
|
+
}
|
|
4775
|
+
},
|
|
4776
|
+
},
|
|
4221
4777
|
// Loop-harness rollup: per-turn LoopStats (cache hits, guard hits,
|
|
4222
4778
|
// bottleneck, context growth) attach to the same open turn trace.
|
|
4223
4779
|
// Fires once per turn — including failed/cancelled turns, whose
|
|
@@ -4231,7 +4787,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4231
4787
|
endpointOverride: activeEndpoint,
|
|
4232
4788
|
onToken: (partial) => {
|
|
4233
4789
|
try {
|
|
4234
|
-
|
|
4790
|
+
paintScheduler().push("draft", partial);
|
|
4235
4791
|
}
|
|
4236
4792
|
catch {
|
|
4237
4793
|
// Never lose tokens: paint now rather than drop the partial.
|
|
@@ -4243,7 +4799,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4243
4799
|
onThinking: (partial) => {
|
|
4244
4800
|
thinkingRef.current = partial;
|
|
4245
4801
|
try {
|
|
4246
|
-
|
|
4802
|
+
paintScheduler().push("thinking", partial);
|
|
4247
4803
|
}
|
|
4248
4804
|
catch {
|
|
4249
4805
|
// Never lose reasoning: paint now rather than drop the partial.
|
|
@@ -4265,8 +4821,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4265
4821
|
else if (p === "tool" && detail) {
|
|
4266
4822
|
setToolHint(detail);
|
|
4267
4823
|
toolStartRef.current = clockNow();
|
|
4824
|
+
// Paint any coalesced stream text NOW so the tool transition
|
|
4825
|
+
// never shows a stale draft for up to a window behind.
|
|
4826
|
+
flushDraft();
|
|
4268
4827
|
}
|
|
4269
4828
|
else if (p === "retry") {
|
|
4829
|
+
// Same ordering as tool start: pending paint lands before the
|
|
4830
|
+
// retry line commits, so the transcript never reorders.
|
|
4831
|
+
flushDraft();
|
|
4270
4832
|
const msg = detail ? `↻ retrying… ${detail}` : "↻ retrying…";
|
|
4271
4833
|
appendTurns({ role: "tool", content: msg });
|
|
4272
4834
|
// Local observability: transport retries attach to the model call
|
|
@@ -4281,6 +4843,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4281
4843
|
onToolDelta: (name) => {
|
|
4282
4844
|
setToolHint(name);
|
|
4283
4845
|
toolStartRef.current = clockNow();
|
|
4846
|
+
// Tool calls can start mid-stream: paint the pending draft now so
|
|
4847
|
+
// the running line and the latest text arrive in the same frame.
|
|
4848
|
+
flushDraft();
|
|
4284
4849
|
},
|
|
4285
4850
|
onUsage: (u) => {
|
|
4286
4851
|
// Local observability: per-turn usage accumulates inside the
|
|
@@ -4312,13 +4877,34 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4312
4877
|
appendTurns({ role: "user", content: s });
|
|
4313
4878
|
},
|
|
4314
4879
|
onToolActivity: (label, result, isError) => {
|
|
4315
|
-
//
|
|
4316
|
-
//
|
|
4317
|
-
//
|
|
4318
|
-
|
|
4880
|
+
// Structured identity first (ticket 02 sink): the queue head is
|
|
4881
|
+
// this commit's stable identity (toolCallId + name, commit order)
|
|
4882
|
+
// — never parsed out of the label. Null head = identity-less call
|
|
4883
|
+
// (old paths, tests driving the callback directly) → the legacy
|
|
4884
|
+
// label-matching fallback for that call only, so no line is ever
|
|
4885
|
+
// dropped. The label text itself stays byte-identical either way.
|
|
4886
|
+
const identity = toolIdentityQueueRef.current.length > 0
|
|
4887
|
+
? toolIdentityQueueRef.current.shift()
|
|
4888
|
+
: null;
|
|
4889
|
+
// Display-only duration: wall time since the tool started. On the
|
|
4890
|
+
// sink path the start comes from the structured identity (never
|
|
4891
|
+
// the phase-timing side channel); the fallback keeps toolStartRef.
|
|
4892
|
+
// Attached as Turn.ms for the `· Ns` suffix.
|
|
4893
|
+
const phaseStarted = toolStartRef.current;
|
|
4319
4894
|
toolStartRef.current = null;
|
|
4320
|
-
const ms =
|
|
4321
|
-
|
|
4895
|
+
const ms = identity !== null
|
|
4896
|
+
? Math.max(0, clockNow() - identity.startedAt)
|
|
4897
|
+
: phaseStarted !== null
|
|
4898
|
+
? Math.max(0, clockNow() - phaseStarted)
|
|
4899
|
+
: 0;
|
|
4900
|
+
const items = [];
|
|
4901
|
+
// Inter-tool chatter streamed before this result would otherwise
|
|
4902
|
+
// vanish (the turn commit carries the final reply only). Pin it
|
|
4903
|
+
// above the tool line in commit order.
|
|
4904
|
+
const pendingStream = takeUncommittedStream();
|
|
4905
|
+
if (pendingStream !== null)
|
|
4906
|
+
items.push(pendingStream);
|
|
4907
|
+
items.push({ role: "tool", content: label, ms });
|
|
4322
4908
|
// Inspector retention (display-only): keep the full result for
|
|
4323
4909
|
// later browsing. Capped count; stored text char-capped inside
|
|
4324
4910
|
// the record with an explicit truncation flag.
|
|
@@ -4330,12 +4916,22 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4330
4916
|
// are short checklists, so successful ones join the transcript
|
|
4331
4917
|
// (history fidelity — what did the list look like when?) and
|
|
4332
4918
|
// refresh the live <TodoPanel> snapshot below the transcript.
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4919
|
+
// The membership test reads the structured tool name on the sink
|
|
4920
|
+
// path; the label-prefix match survives only for identity-less
|
|
4921
|
+
// fallback calls.
|
|
4922
|
+
const isTodo = identity !== null
|
|
4923
|
+
? identity.name === "todo_get" ||
|
|
4924
|
+
identity.name === "todowrite" ||
|
|
4925
|
+
identity.name === "todo_update"
|
|
4926
|
+
: label === "⚙ todo_get" ||
|
|
4927
|
+
label.startsWith("⚙ todowrite ") ||
|
|
4928
|
+
label.startsWith("⚙ todo_update ");
|
|
4336
4929
|
if (isTodo)
|
|
4337
4930
|
setTodoSnap(getTodos());
|
|
4338
4931
|
if (isError) {
|
|
4932
|
+
// Errors commit immediately: paint any coalesced stream text
|
|
4933
|
+
// first so the failure line never overtakes the text it follows.
|
|
4934
|
+
flushDraft();
|
|
4339
4935
|
const firstLine = result.split("\n", 1)[0] ?? result;
|
|
4340
4936
|
items.push({ role: "tool", content: ` ↳ ${firstLine}`, error: true });
|
|
4341
4937
|
}
|
|
@@ -4347,12 +4943,34 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4347
4943
|
// every matching activity (success or failure) so a stale
|
|
4348
4944
|
// capture can never leak onto a later call; render only on
|
|
4349
4945
|
// success with a real payload (failures keep the ↳ line only).
|
|
4946
|
+
// The match reads the structured tool name on the sink path
|
|
4947
|
+
// (never parsed out of the label); the label-prefix match
|
|
4948
|
+
// survives only for identity-less fallback calls.
|
|
4350
4949
|
// Full-file BEFORE→AFTER is preferred (aligned panes with
|
|
4351
4950
|
// context); when either side is unavailable (unreadable file,
|
|
4352
4951
|
// oversize), fall back to the arg-block preview pair.
|
|
4353
4952
|
const slot = pendingDiffRef.current;
|
|
4354
|
-
|
|
4355
|
-
(
|
|
4953
|
+
const slotMatch = slot !== null &&
|
|
4954
|
+
(identity !== null
|
|
4955
|
+
? identity.name === slot.name
|
|
4956
|
+
: label === `⚙ ${slot.name}` || label.startsWith(`⚙ ${slot.name} `));
|
|
4957
|
+
// Approval provenance rides the same pairing: the verdict's via
|
|
4958
|
+
// token recorded in approve() attributes to this exact execution.
|
|
4959
|
+
// Consume-or-clear on match (same predicate as the diff slot), so
|
|
4960
|
+
// a stale token can never leak onto a later call; denied calls
|
|
4961
|
+
// cleared their slot in approve()/resolveApproval and render no
|
|
4962
|
+
// suffix. Attached to the label turn for success and error alike
|
|
4963
|
+
// (a plan-passthrough refusal names its provenance too).
|
|
4964
|
+
const viaSlot = pendingViaRef.current;
|
|
4965
|
+
const viaMatch = viaSlot !== null &&
|
|
4966
|
+
(identity !== null
|
|
4967
|
+
? identity.name === viaSlot.name
|
|
4968
|
+
: label === `⚙ ${viaSlot.name}` || label.startsWith(`⚙ ${viaSlot.name} `));
|
|
4969
|
+
if (viaMatch) {
|
|
4970
|
+
pendingViaRef.current = null;
|
|
4971
|
+
items[0].approvalVia = viaSlot.via;
|
|
4972
|
+
}
|
|
4973
|
+
if (slotMatch) {
|
|
4356
4974
|
pendingDiffRef.current = null;
|
|
4357
4975
|
if (!isError) {
|
|
4358
4976
|
let afterFull = null;
|
|
@@ -4363,7 +4981,18 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4363
4981
|
afterFull = readFileForDiff(path.resolve(process.cwd(), slot.path));
|
|
4364
4982
|
}
|
|
4365
4983
|
const beforeFull = slot.beforeFull;
|
|
4366
|
-
|
|
4984
|
+
// Retention bound (OOM defense): full file texts stay on the
|
|
4985
|
+
// committed turn for the whole session. Past 1MB a side the
|
|
4986
|
+
// diff engine would only render its "file over 1MB — diff
|
|
4987
|
+
// skipped" notice anyway, so attach nothing and keep the
|
|
4988
|
+
// label-only turn instead of retaining megabytes to paint one
|
|
4989
|
+
// line. The modal preview (transient) is unaffected.
|
|
4990
|
+
const oversize = (beforeFull !== null && beforeFull.length > APPROVAL_PREVIEW_MAX_BYTES) ||
|
|
4991
|
+
(afterFull !== null && afterFull.length > APPROVAL_PREVIEW_MAX_BYTES) ||
|
|
4992
|
+
(slot.diff !== null &&
|
|
4993
|
+
((slot.diff.oldText !== null && slot.diff.oldText.length > APPROVAL_PREVIEW_MAX_BYTES) ||
|
|
4994
|
+
slot.diff.newText.length > APPROVAL_PREVIEW_MAX_BYTES));
|
|
4995
|
+
if (!oversize && beforeFull !== null && afterFull !== null) {
|
|
4367
4996
|
items[0].diff = {
|
|
4368
4997
|
oldText: beforeFull,
|
|
4369
4998
|
newText: afterFull,
|
|
@@ -4371,7 +5000,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4371
5000
|
path: slot.path,
|
|
4372
5001
|
};
|
|
4373
5002
|
}
|
|
4374
|
-
else if (slot.diff !== null) {
|
|
5003
|
+
else if (!oversize && slot.diff !== null) {
|
|
4375
5004
|
items[0].diff = slot.diff;
|
|
4376
5005
|
}
|
|
4377
5006
|
}
|
|
@@ -4387,7 +5016,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4387
5016
|
// the answer it produced).
|
|
4388
5017
|
flushDraft();
|
|
4389
5018
|
commitThinking();
|
|
4390
|
-
|
|
5019
|
+
// The loop returns the FINAL post's text only: inter-tool chatter was
|
|
5020
|
+
// pinned above at each tool commit, so commit just the remainder — an
|
|
5021
|
+
// empty final reply falls back to uncommitted stream text, and a turn
|
|
5022
|
+
// with nothing streamed commits nothing (never a blank vanishing turn).
|
|
5023
|
+
if (reply.trim().length > 0) {
|
|
5024
|
+
if (reply !== committedStreamRef.current) {
|
|
5025
|
+
committedStreamRef.current = reply;
|
|
5026
|
+
appendTurns({ role: "assistant", content: reply });
|
|
5027
|
+
}
|
|
5028
|
+
}
|
|
5029
|
+
else {
|
|
5030
|
+
const pendingReply = takeUncommittedStream();
|
|
5031
|
+
if (pendingReply !== null)
|
|
5032
|
+
appendTurns(pendingReply);
|
|
5033
|
+
}
|
|
4391
5034
|
// The turn committed to history (final text, denial-as-result, or
|
|
4392
5035
|
// stop-notice) — persist the kill-safe save. Rolled-back turns (catch
|
|
4393
5036
|
// below) never reach here, so a failure can't clobber the last good save.
|
|
@@ -4396,28 +5039,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4396
5039
|
// labels (completed / blocked / unverified / budget-exceeded) and flush.
|
|
4397
5040
|
telemetry.endTurn(telemetryTurnId, classifyTurnOutcome(reply), reply);
|
|
4398
5041
|
persistTelemetry();
|
|
4399
|
-
//
|
|
4400
|
-
//
|
|
4401
|
-
|
|
4402
|
-
if (pendingCompactRef.current !== null) {
|
|
4403
|
-
const focus = pendingCompactRef.current;
|
|
4404
|
-
pendingCompactRef.current = null;
|
|
4405
|
-
await doCompact(focus, false);
|
|
4406
|
-
// A /compact that arrived during the compaction above drains now.
|
|
4407
|
-
if (pendingCompactRef.current !== null) {
|
|
4408
|
-
const focus2 = pendingCompactRef.current;
|
|
4409
|
-
pendingCompactRef.current = null;
|
|
4410
|
-
await doCompact(focus2, false);
|
|
4411
|
-
}
|
|
4412
|
-
}
|
|
4413
|
-
else {
|
|
4414
|
-
await maybeAutoCompact();
|
|
4415
|
-
if (pendingCompactRef.current !== null) {
|
|
4416
|
-
const focus = pendingCompactRef.current;
|
|
4417
|
-
pendingCompactRef.current = null;
|
|
4418
|
-
await doCompact(focus, false);
|
|
4419
|
-
}
|
|
4420
|
-
}
|
|
5042
|
+
// Success path reached turn end — the compact drain plus everything
|
|
5043
|
+
// after it runs in the single turn-boundary drain (see the finally).
|
|
5044
|
+
turnOutcome = "clean";
|
|
4421
5045
|
}
|
|
4422
5046
|
catch (err) {
|
|
4423
5047
|
const cancelled = err instanceof LoopCancelledError ||
|
|
@@ -4425,6 +5049,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4425
5049
|
controller.signal.aborted;
|
|
4426
5050
|
historyRef.current.splice(rollbackTo); // don't keep the failed/cancelled turn
|
|
4427
5051
|
turnCancelledRef.current = cancelled;
|
|
5052
|
+
// The failure path reached turn end — compact drain plus the rest runs
|
|
5053
|
+
// in the single turn-boundary drain (see the finally).
|
|
5054
|
+
turnOutcome = cancelled ? "cancelled" : "failed";
|
|
4428
5055
|
// The turn never happened: drop live thinking with it (a failed turn
|
|
4429
5056
|
// commits nothing — same scope as the history rollback above).
|
|
4430
5057
|
clearThinking();
|
|
@@ -4448,95 +5075,26 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4448
5075
|
// limit or dead network after 30s of streaming wipes everything the
|
|
4449
5076
|
// user already read. History stays rolled back (model never sees
|
|
4450
5077
|
// it); only the display transcript keeps the partial.
|
|
4451
|
-
const
|
|
5078
|
+
const pendingPartial = takeUncommittedStream();
|
|
4452
5079
|
lastPartialRef.current = "";
|
|
4453
|
-
if (
|
|
5080
|
+
if (pendingPartial !== null) {
|
|
4454
5081
|
appendTurns({
|
|
4455
5082
|
role: "assistant",
|
|
4456
|
-
content: `${
|
|
5083
|
+
content: `${pendingPartial.content}\n\n(request failed before completing — partial output preserved)`,
|
|
4457
5084
|
});
|
|
4458
5085
|
}
|
|
4459
5086
|
setError(err instanceof Error ? err.message : String(err));
|
|
4460
5087
|
}
|
|
4461
|
-
//
|
|
4462
|
-
//
|
|
4463
|
-
// (load is meaningless for a rolled-back turn — just refresh it).
|
|
4464
|
-
if (pendingCompactRef.current !== null) {
|
|
4465
|
-
const focus = pendingCompactRef.current;
|
|
4466
|
-
pendingCompactRef.current = null;
|
|
4467
|
-
try {
|
|
4468
|
-
await doCompact(focus, false);
|
|
4469
|
-
}
|
|
4470
|
-
catch {
|
|
4471
|
-
// doCompact never throws (it reports inline), but stay safe.
|
|
4472
|
-
}
|
|
4473
|
-
}
|
|
4474
|
-
else {
|
|
4475
|
-
refreshContextLoad();
|
|
4476
|
-
}
|
|
5088
|
+
// (Compact drain for this path lives in the single turn-boundary
|
|
5089
|
+
// drain below — no inline drain logic remains here.)
|
|
4477
5090
|
}
|
|
4478
5091
|
finally {
|
|
4479
|
-
//
|
|
4480
|
-
//
|
|
4481
|
-
//
|
|
4482
|
-
//
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
goalRef.current !== null &&
|
|
4486
|
-
goalRef.current.objective === goalWorkObjective) {
|
|
4487
|
-
const workedMs = Math.max(0, Date.now() - goalWorkStartMs);
|
|
4488
|
-
patchGoalStats((s) => ({ ...s, workMs: s.workMs + workedMs }));
|
|
4489
|
-
}
|
|
4490
|
-
}
|
|
4491
|
-
catch {
|
|
4492
|
-
// accounting never breaks turn teardown
|
|
4493
|
-
}
|
|
4494
|
-
turnCancelRef.current = null;
|
|
4495
|
-
approvalResolveRef.current = null;
|
|
4496
|
-
setPendingApproval(null);
|
|
4497
|
-
// Safety net: the slot is normally consumed by onToolActivity or
|
|
4498
|
-
// cleared on deny/cancel — never let it cross a turn boundary.
|
|
4499
|
-
pendingDiffRef.current = null;
|
|
4500
|
-
askResolveRef.current = null;
|
|
4501
|
-
setPendingQuestion(null);
|
|
4502
|
-
setAskCustomBoth("");
|
|
4503
|
-
// Turn-scoped skill grants expire here: armed-while-idle and auto
|
|
4504
|
-
// skills cover exactly the turn that just ended (success, failure,
|
|
4505
|
-
// or cancel) — the next user message starts clean (ticket 06).
|
|
4506
|
-
skillGrantsRef.current = new Set();
|
|
4507
|
-
busyRef.current = false;
|
|
4508
|
-
setBusy(false);
|
|
4509
|
-
refreshGitInfo();
|
|
4510
|
-
try {
|
|
4511
|
-
draftThrottleRef.current?.cancel();
|
|
4512
|
-
}
|
|
4513
|
-
catch {
|
|
4514
|
-
// ignore
|
|
4515
|
-
}
|
|
4516
|
-
streamStore.setDraft(null);
|
|
4517
|
-
clearThinking();
|
|
4518
|
-
setToolHint(null);
|
|
4519
|
-
clearTurnTimer();
|
|
4520
|
-
setStalledBoth(false);
|
|
4521
|
-
setElapsedSecs(0);
|
|
4522
|
-
setPhaseBoth("idle", "");
|
|
4523
|
-
// Queue drain (Claude-Code-style): a clean turn auto-sends the next
|
|
4524
|
-
// queued follow-up (chaining while the queue is non-empty); a cancelled
|
|
4525
|
-
// turn keeps its queue visible but never auto-sends. A steer stranded
|
|
4526
|
-
// by a failed/cancelled turn rejoins the queue front — the thought is
|
|
4527
|
-
// preserved, the user decides when it runs. Runs after busy resets
|
|
4528
|
-
// above so the chained submit enters a clean turn.
|
|
4529
|
-
const stranded = steerRef.current;
|
|
4530
|
-
if (stranded) {
|
|
4531
|
-
steerRef.current = null;
|
|
4532
|
-
setSteerPending(null);
|
|
4533
|
-
setQueueBoth([stranded, ...queueRef.current]);
|
|
4534
|
-
}
|
|
4535
|
-
if (!turnCancelledRef.current && queueRef.current.length > 0) {
|
|
4536
|
-
const next = queueRef.current[0];
|
|
4537
|
-
setQueueBoth(queueRef.current.slice(1));
|
|
4538
|
-
void submit(next);
|
|
4539
|
-
}
|
|
5092
|
+
// The single turn-boundary drain (ticket 07): compact → steer →
|
|
5093
|
+
// queue → goal-resume, in that order (see drainTurnBoundary). The
|
|
5094
|
+
// outcome glue above selects the clean vs failed/cancelled compact
|
|
5095
|
+
// variant; everything else (teardown position, steer-to-front,
|
|
5096
|
+
// cancel-keeps-queue) is owned by the routine.
|
|
5097
|
+
await drainTurnBoundary(turnOutcome, goalWorkStartMs, goalWorkObjective);
|
|
4540
5098
|
}
|
|
4541
5099
|
}
|
|
4542
5100
|
function cancelTurn() {
|
|
@@ -4964,7 +5522,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
4964
5522
|
}
|
|
4965
5523
|
return;
|
|
4966
5524
|
}
|
|
4967
|
-
// 3a2. Session picker (interactive switcher, same pattern as /
|
|
5525
|
+
// 3a2. Session picker (interactive switcher, same pattern as /skill:
|
|
4968
5526
|
// type to filter, ↑/↓ + Enter switches, Esc cancels with the live
|
|
4969
5527
|
// session completely unchanged). The list is the open-time snapshot —
|
|
4970
5528
|
// filtering never touches disk. Enter on an empty filtered list only
|
|
@@ -5170,6 +5728,22 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
5170
5728
|
setInputBoth("");
|
|
5171
5729
|
runRenameCommand(raw);
|
|
5172
5730
|
}
|
|
5731
|
+
else if (pick.name === "/fork" &&
|
|
5732
|
+
(inputRef.current === "/fork" || inputRef.current.startsWith("/fork "))) {
|
|
5733
|
+
// Preserve the typed drop count (e.g. "/fork 5"); a bare
|
|
5734
|
+
// highlighted name forks at the tip.
|
|
5735
|
+
const raw = inputRef.current;
|
|
5736
|
+
setInputBoth("");
|
|
5737
|
+
void runForkCommand(raw);
|
|
5738
|
+
}
|
|
5739
|
+
else if (pick.name === "/revert" &&
|
|
5740
|
+
(inputRef.current === "/revert" || inputRef.current.startsWith("/revert "))) {
|
|
5741
|
+
// Preserve the typed checkpoint index (e.g. "/revert 1"); a
|
|
5742
|
+
// bare highlighted name reverts to the latest checkpoint.
|
|
5743
|
+
const raw = inputRef.current;
|
|
5744
|
+
setInputBoth("");
|
|
5745
|
+
void runRevertCommand(raw);
|
|
5746
|
+
}
|
|
5173
5747
|
else if (!pick.skill &&
|
|
5174
5748
|
getExtensionCommand(pick.name.slice(1)) &&
|
|
5175
5749
|
(inputRef.current === pick.name || inputRef.current.startsWith(`${pick.name} `))) {
|
|
@@ -5563,7 +6137,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
5563
6137
|
const modelTitle = `Atom — Select model (${modelEntries.length}` +
|
|
5564
6138
|
(modelFilter ? ` of ${modelEntriesAll.length}, filter: "${modelFilter}"` : "") +
|
|
5565
6139
|
`) — type to filter, up/down + Enter, Esc cancels:`;
|
|
5566
|
-
// /
|
|
6140
|
+
// /skill picker derived for render (mirrors the useInput computation
|
|
5567
6141
|
// above): names only, filtered, clamped highlight, visible window. The
|
|
5568
6142
|
// title keeps the `Skills (` prefix the registry header always had.
|
|
5569
6143
|
// Memoized for the same tick/append reason as the model picker above.
|
|
@@ -5603,10 +6177,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
5603
6177
|
const checkpointListMemo = useMemo(() => (selectingRewind ? listCheckpoints() : []),
|
|
5604
6178
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5605
6179
|
[selectingRewind]);
|
|
5606
|
-
// Approval description: the tool-call one-liner for the modal
|
|
5607
|
-
// the pending approval itself —
|
|
5608
|
-
//
|
|
5609
|
-
|
|
6180
|
+
// Approval description: the tool-call one-liner for the modal, stashed on
|
|
6181
|
+
// the pending approval by approve() itself — render never recomputes it
|
|
6182
|
+
// (describeToolCall may resolve symlinks, so rebuilding it here would put
|
|
6183
|
+
// filesystem reads back into the 1s busy-tick render path).
|
|
6184
|
+
const approvalDescription = pendingApproval?.description ?? "";
|
|
5610
6185
|
// (The cursor clamp lives inside the memoized InputBox now, next to its
|
|
5611
6186
|
// only use — App body no longer reads cursor state for paint.)
|
|
5612
6187
|
// Status-line reasoning segment wired to the effort session state: a
|
|
@@ -5627,44 +6202,50 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
|
|
|
5627
6202
|
const toolElapsedSecs = busy && toolHint && toolStartRef.current !== null
|
|
5628
6203
|
? elapsedSecsSince(toolStartRef.current, turnStartRef.current + elapsedSecs * 1000)
|
|
5629
6204
|
: null;
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
6205
|
+
// Memoized goal slice for the status bar (render-stability): the inline
|
|
6206
|
+
// literal used to defeat StatusBarHost's memo on every App render
|
|
6207
|
+
// (keystrokes, 1s ticks) whenever a goal was active — a new object
|
|
6208
|
+
// identity per render meant the whole status subtree reconciled for
|
|
6209
|
+
// nothing. Identity now tracks the goal, not the render.
|
|
6210
|
+
const goalStatus = useMemo(() => (goal ? { objective: goal.objective, active: goal.active === true } : null), [goal]);
|
|
6211
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen, end: scrollEnd, held: scrollEnd !== null, showThinking: showThinking }), _jsx(LiveTailHost, { store: streamStore, isEmpty: turns.length === 0, sessionHint: sessionHint, emptySessionTitle: turns.length === 0 ? sessionTitle : null, busy: busy, held: scrollEnd !== null, toolHint: toolHint, toolElapsedSecs: toolElapsedSecs, elapsedSecs: elapsedSecs, showThinking: showThinking }), error ? _jsxs(Text, { color: theme.color.error, children: ["error> ", error] }) : null, pendingApproval ? (_jsx(ApprovalBox, { toolName: pendingApproval.name, description: approvalDescription, selected: approveIndex, diff: pendingApproval.diff ?? null })) : null, pendingQuestion ? (_jsx(QuestionBox, { question: pendingQuestion.question, options: pendingQuestion.options, allowCustom: pendingQuestion.allowCustom, askCustom: askCustom, askSelIndex: askSelIndex })) : null, extPendingDialog && !pendingApproval && !pendingQuestion ? (_jsx(QuestionBox, { question: `[${extPendingDialog.owner}] ${extPendingDialog.question}`, options: extPendingDialog.options, allowCustom: extPendingDialog.allowCustom, askCustom: extDlgCustom, askSelIndex: extDlgSel }, `ext-dialog-${extPendingDialog.id}`)) : null, _jsx(TodoPanel, { items: todoSnap }), extWidgets.map((w) => (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.panel, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, children: ["[", w.owner, "] ", w.title] }), _jsx(Text, { children: w.text })] }, `${w.owner}-${w.id}`))), steerPending ? _jsxs(Text, { dimColor: true, children: ["Steering: ", steerPending] }) : null, queue.length > 0 ? (_jsxs(Text, { dimColor: true, children: ["Queued (", queue.length, "): ", queue[0], queue.length > 1 ? ` +${queue.length - 1} more (/queue)` : ""] })) : null, _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [paletteOpen ? (_jsx(PalettePanel, { entries: paletteEntriesMemo, index: paletteIndex, filter: paletteFilter })) : inspecting ? (_jsx(InspectorPanel, { records: toolLogRef.current, index: inspectIndex, expanded: inspectExpanded, scroll: inspectScroll })) : selecting ? (_jsxs(PickerShell, { title: modelTitle, children: [_jsx(PickerMoreAbove, { count: modelWin.start }), modelEntries.slice(modelWin.start, modelWin.end).map((e, k) => {
|
|
6212
|
+
const i = modelWin.start + k;
|
|
6213
|
+
const entryLocal = e.local === true;
|
|
6214
|
+
const prevLocal = i === 0 ? null : modelEntries[i - 1]?.local === true;
|
|
6215
|
+
const showGroup = i === 0 || prevLocal !== entryLocal;
|
|
6216
|
+
const showHeader = showGroup || modelEntries[i - 1]?.providerId !== e.providerId;
|
|
6217
|
+
const def = getProvider(e.providerId);
|
|
6218
|
+
return (_jsxs(React.Fragment, { children: [showGroup ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.descSeparator, " ", entryLocal ? "Local" : "Remote"] })) : null, showHeader ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.descSeparator, " ", def?.name ?? e.providerId, entryLocal && isLocalProviderId(e.providerId)
|
|
6219
|
+
? ` ${theme.symbol.separator} ${localBaseURLFor(e.providerId)}`
|
|
6220
|
+
: null, e.providerId === provider ? " (current)" : ""] })) : null, _jsxs(PickerRow, { highlighted: i === modelHi, children: [e.model, e.free === true ? _jsx(Text, { dimColor: true, children: " (free)" }) : null, e.providerId === provider && e.model === model ? " (current)" : ""] })] }, `${e.providerId}-${e.model}-${i}`));
|
|
6221
|
+
}), _jsx(PickerMoreBelow, { count: modelEntries.length - modelWin.end }), modelEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No models match \u2014 backspace to widen the filter." })) : null] })) : selectingSkills ? (_jsxs(PickerShell, { title: skillTitle, children: [_jsx(PickerMoreAbove, { count: skillWin.start }), skillEntries.slice(skillWin.start, skillWin.end).map((e, k) => {
|
|
6222
|
+
const i = skillWin.start + k;
|
|
6223
|
+
return (_jsxs(PickerRow, { highlighted: i === skillHi, children: ["/skill:", e.name, !e.userInvocable ? _jsx(Text, { dimColor: true, children: " [auto-only]" }) : null] }, `${e.name}-${i}`));
|
|
6224
|
+
}), _jsx(PickerMoreBelow, { count: skillEntries.length - skillWin.end }), skillEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: skillEntriesAll.length === 0
|
|
6225
|
+
? "No skills installed — add SKILL.md skills under .claude/skills/, .agents/skills/, or the ~/. counterparts."
|
|
6226
|
+
: "No skills match — backspace to widen the filter." })) : null] })) : selectingSession ? (_jsxs(PickerShell, { title: sessionPickerTitle, children: [_jsx(PickerMoreAbove, { count: sessionWin.start }), sessionEntries.slice(sessionWin.start, sessionWin.end).map((e, k) => {
|
|
6227
|
+
const i = sessionWin.start + k;
|
|
6228
|
+
const age = formatSessionAge(Date.now(), e.updatedAt);
|
|
6229
|
+
return (_jsxs(PickerRow, { highlighted: i === sessionHi, children: [e.title, e.active ? " (current)" : "", _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", e.turnCount, " turn", e.turnCount === 1 ? "" : "s", " \u00B7 ", age] })] }, `${e.id}-${i}`));
|
|
6230
|
+
}), _jsx(PickerMoreBelow, { count: sessionEntries.length - sessionWin.end }), sessionEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: sessionEntriesAll.length === 0
|
|
6231
|
+
? "No sessions yet — your current conversation is saved automatically."
|
|
6232
|
+
: "No sessions match — backspace to widen the filter." })) : null] })) : selectingProvider ? (_jsx(PickerShell, { title: "Atom \u2014 Select provider (up/down + Enter, Esc cancels):", children: PROVIDERS.map((p, i) => {
|
|
6233
|
+
const has = keyForProvider(p.id).length > 0;
|
|
6234
|
+
const keyMark = isLocalProviderId(p.id)
|
|
6235
|
+
? `local ${theme.symbol.descSeparator} no key needed`
|
|
6236
|
+
: has
|
|
6237
|
+
? `${theme.symbol.keyPresent} key`
|
|
6238
|
+
: p.id === "kilo"
|
|
6239
|
+
? `${theme.symbol.descSeparator} key optional — free models need none`
|
|
6240
|
+
: `${theme.symbol.descSeparator} no key`;
|
|
6241
|
+
return (_jsxs(PickerRow, { highlighted: i === providerIndex, children: [p.name, " (", p.id, ") ", keyMark, p.id === provider ? " (current)" : ""] }, p.id));
|
|
6242
|
+
}) })) : keyPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsxs(Text, { bold: true, children: ["Atom \u2014 API key for ", keyPrompt.providerId, " (paste + Enter, Esc cancels):"] }), keyPrompt.consoleURL ? (_jsxs(Text, { dimColor: true, children: ["Get a key: ", keyPrompt.consoleURL] })) : null, keyPrompt.existingMasked ? (_jsxs(Text, { dimColor: true, children: ["key on file (", keyPrompt.existingMasked, ") \u2014 type a new key to replace, Esc keeps + switches"] })) : (_jsx(Text, { dimColor: true, children: "No key on file \u2014 paste once, validated then stored in ~/.atom/auth.json" })), keyPrompt.providerId === "kilo" ? (_jsx(Text, { dimColor: true, children: "Optional: free models work without a key \u2014 empty Enter continues anonymously" })) : null, _jsxs(Text, { children: ["key: ", theme.symbol.keyMask.repeat(keyPrompt.draft.length), _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), keyPrompt.validating ? _jsxs(Text, { dimColor: true, children: ["validating", theme.symbol.ellipsis] }) : null, keyPrompt.error ? _jsx(Text, { color: theme.color.error, children: keyPrompt.error }) : null] })) : baseURLPrompt ? (_jsxs(Box, { flexDirection: "column", borderStyle: theme.border.style, borderColor: theme.border.picker, paddingX: theme.spacing.pickerPadX, children: [_jsx(Text, { bold: true, children: "Atom \u2014 baseURL for openai-compatible (http(s) URL + Enter, Esc cancels):" }), _jsxs(Text, { children: ["baseURL: ", baseURLPrompt.draft, _jsx(Text, { color: theme.color.mutedPaint, children: theme.symbol.cursorBlock })] }), baseURLPrompt.error ? _jsx(Text, { color: theme.color.error, children: baseURLPrompt.error }) : null] })) : selectingEffort ? (_jsxs(PickerShell, { title: "Atom \u2014 Select reasoning effort (up/down + Enter, Esc cancels):", children: [EFFORT_OPTIONS.map((o, i) => (_jsxs(PickerRow, { highlighted: i === effortIndex, children: [o === "auto" ? "Auto" : o === "max" ? "Max" : o[0]?.toUpperCase() + o.slice(1), o === effort ? " (current)" : ""] }, `${o}-${i}`))), _jsx(Text, { dimColor: true, children: "Auto lets the model decide; Low\u2192Max raise reasoning depth on every model." })] })) : selectingRewind ? (_jsxs(PickerShell, { title: "Atom \u2014 Rewind to checkpoint (up/down + Enter, Esc cancels):", children: [checkpointListMemo.map((c, i) => (_jsxs(PickerRow, { highlighted: i === rewindIndex, children: ["#", c.seq, " ", theme.symbol.separator, " ", c.label, " ", theme.symbol.separator, " ", c.files.length, " file(s)"] }, c.id))), _jsx(Text, { dimColor: true, children: "Restores exact bytes (hash-verified). Shell side effects (bash) are never snapshotted and cannot be undone." })] })) : selectingRewindScope ? (_jsxs(PickerShell, { title: "Atom \u2014 Rewind scope (up/down + Enter, Esc cancels):", children: [REWIND_SCOPES.map((s, i) => (_jsx(PickerRow, { highlighted: i === rewindScopeIndex, children: s }, s))), _jsx(Text, { dimColor: true, children: "Shell side effects (bash) are explicitly out of scope and cannot be undone." })] })) : (
|
|
6243
|
+
// The input is the one boxed, prominent surface (see the memoized
|
|
6244
|
+
// InputBox above): a quiet gray frame sets it apart from the
|
|
6245
|
+
// transcript above and the status line below. Pickers and modals
|
|
6246
|
+
// replace it (never stack with it), each carrying their own semantic
|
|
6247
|
+
// border color.
|
|
6248
|
+
_jsx(InputBox, { input: input, cursor: cursor, busy: busy })), slashVisible && !inspecting && !paletteOpen ? (_jsxs(PickerShell, { title: slashHasSkills
|
|
6249
|
+
? `Atom commands + skills (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`
|
|
6250
|
+
: `Atom commands (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`, borderColor: theme.border.menu, children: [_jsx(PickerMoreAbove, { count: slashWin.start }), filteredSlash.slice(slashWin.start, slashWin.end).map((c) => (_jsxs(PickerRow, { highlighted: c.name === slashHighlight, highlightColor: theme.color.menuSelection, children: [c.name, c.description ? ` ${theme.symbol.descSeparator} ${c.description}` : ""] }, c.name))), _jsx(PickerMoreBelow, { count: filteredSlash.length - slashWin.end }), slashUsage ? _jsx(Text, { dimColor: true, children: slashUsage }) : null, slashMenu.moreSkills > 0 ? (_jsxs(Text, { dimColor: true, children: [theme.symbol.ellipsis, "and ", slashMenu.moreSkills, " more skill", slashMenu.moreSkills === 1 ? "" : "s", " \u2014 keep typing to narrow"] })) : null] })) : null, _jsx(StatusBarHost, { provider: provider, model: model, usageTotals: usageTotals, contextLoad: contextLoad, loadEstimated: loadEstimated, reasoningDisplay: reasoningDisplay, mode: mode, trustAll: trustAll, busy: busy, activity: toolHint ? activityText(toolHint) : null, phaseLabel: phaseLabel, elapsedSecs: elapsedSecs, stalled: stalled, approvalPending: pendingApproval !== null, cwd: shortenCwd(process.cwd(), os.homedir()), branch: gitInfo?.branch ?? null, extensionStatus: extStatusText, goal: goalStatus })] })] }));
|
|
5670
6251
|
}
|