atom-agent 1.1.0 → 1.3.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/README.md +18 -8
  3. package/atom.example.json +11 -0
  4. package/dist/App.js +1637 -255
  5. package/dist/adapters.js +112 -21
  6. package/dist/agent/gates.js +14 -1
  7. package/dist/agent/goal-evaluator.js +69 -0
  8. package/dist/agent/loop-guard.js +11 -13
  9. package/dist/agent/loop.js +716 -132
  10. package/dist/agent/normalize.js +9 -2
  11. package/dist/cli.js +25 -3
  12. package/dist/compact.js +169 -17
  13. package/dist/config.js +43 -7
  14. package/dist/context-manager.js +16 -198
  15. package/dist/context-windows.js +4 -2
  16. package/dist/env-block.js +46 -8
  17. package/dist/extension-commands.js +196 -0
  18. package/dist/extension-ui.js +153 -0
  19. package/dist/extensions.js +1571 -0
  20. package/dist/goal.js +583 -0
  21. package/dist/project-trust.js +96 -0
  22. package/dist/providers.js +6 -6
  23. package/dist/scheduler.js +159 -41
  24. package/dist/session.js +23 -5
  25. package/dist/sessions.js +543 -0
  26. package/dist/system.js +89 -13
  27. package/dist/telemetry-dashboard.js +28 -0
  28. package/dist/telemetry.js +39 -0
  29. package/dist/tools/compaction-hooks.js +165 -0
  30. package/dist/tools/custom.js +189 -0
  31. package/dist/tools/dir-cache.js +7 -0
  32. package/dist/tools/filesystem.js +3 -2
  33. package/dist/tools/intercept.js +145 -0
  34. package/dist/tools/overrides.js +105 -0
  35. package/dist/tools/provider-hooks.js +224 -0
  36. package/dist/tools/registry.js +247 -17
  37. package/dist/tools/ripgrep.js +256 -0
  38. package/dist/tools/search.js +119 -58
  39. package/dist/tools/shared.js +39 -0
  40. package/dist/tools/shell.js +7 -5
  41. package/dist/tools/web.js +6 -6
  42. package/dist/tools.js +45 -0
  43. package/dist/ui/diff-view.js +7 -2
  44. package/dist/ui/live-host.js +18 -0
  45. package/dist/ui/live-tail.js +9 -3
  46. package/dist/ui/markdown.js +26 -2
  47. package/dist/ui/palette.js +3 -1
  48. package/dist/ui/side-by-side.js +2 -2
  49. package/dist/ui/status-bar.js +80 -5
  50. package/dist/ui/status-host.js +22 -0
  51. package/dist/ui/stream-store.js +48 -0
  52. package/dist/ui/tool-inspector.js +7 -1
  53. package/dist/ui/transcript.js +92 -38
  54. package/dist/zen.js +370 -87
  55. package/documentation/architecture.md +114 -0
  56. package/documentation/cli.md +82 -0
  57. package/documentation/compaction.md +50 -0
  58. package/documentation/configuration.md +111 -0
  59. package/documentation/development.md +62 -0
  60. package/documentation/extensions.md +160 -0
  61. package/documentation/getting-started.md +63 -0
  62. package/documentation/goals.md +41 -0
  63. package/documentation/index.md +41 -0
  64. package/documentation/observability.md +70 -0
  65. package/documentation/permissions.md +66 -0
  66. package/documentation/providers.md +78 -0
  67. package/documentation/sessions.md +92 -0
  68. package/documentation/skills.md +57 -0
  69. package/documentation/tools.md +94 -0
  70. package/documentation/troubleshooting.md +54 -0
  71. package/examples/extensions/01-audit-gate.js +24 -0
  72. package/examples/extensions/02-notes-tool.js +32 -0
  73. package/examples/extensions/03-custom-command.js +32 -0
  74. package/package.json +6 -2
package/dist/App.js CHANGED
@@ -5,8 +5,8 @@ import * as fs from "node:fs";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import React, { useEffect, useMemo, useRef, useState } from "react";
8
- import { Box, Text, useApp, useInput, usePaste, useStdout } from "ink";
9
- import { DEFAULT_MODEL, EFFORT_OPTIONS, FALLBACK_MODELS, LoopCancelledError, REASONING_EFFORT_SUPPORTED_MODELS, buildSystemPrompt, fetchModelsForProviderWithStatus, fetchModelsWithStatus, historyChars, isEffortSupported, messageChars, openTodoNeedles, runAgenticLoopForProvider, } from "./zen.js";
8
+ import { Box, Text, useApp, useInput, usePaste } from "ink";
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
12
  import { TOOL_DEFINITIONS, TOOL_ONE_LINERS, APPROVAL_PREVIEW_MAX_BYTES, clearTodos, describeToolCall, executeTool, getTodos, needsApproval, previewDiffForApproval, providerSecrets } from "./tools.js";
@@ -16,7 +16,9 @@ import { formatRules, parseRuleInput, } from "./permissions.js";
16
16
  import { decidePolicy, skillGrantsFor } from "./policy.js";
17
17
  import { capSkillBodyForAuto, createSkillRegistry, loadSkillBody, matchSkills, resolveSkills, } from "./skills.js";
18
18
  import { contextWindowFor } from "./context-windows.js";
19
- import { COMPACT_PCT_DEFAULT, buildCompactedHistory, compactBoundaryLine, compactPct, countUserTurns, estimateTokensForChars, isThrashDisabled, requestCompactSummary, splitHistoryForCompaction, } from "./compact.js";
19
+ import { emptyGoalStats, formatGoalForCompact, goalClearNotice, goalFollowUp, goalPauseNotice, goalResumeNotice, goalSetNotice, goalStatusText, goalTokensForUsage, parseGoalCommand, restoreGoalFromPersist, serializeGoalForPersist, } from "./goal.js";
20
+ import { requestGoalVerdict } from "./agent/goal-evaluator.js";
21
+ import { COMPACT_PCT_DEFAULT, buildCompactedHistory, collectStoredTouchedFiles, collectTouchedFiles, compactBoundaryLine, compactPct, countUserTurns, estimateTokensForChars, fitSummaryWithFilesAndGoal, isThrashDisabled, requestCompactSummary, splitHistoryForCompaction, } from "./compact.js";
20
22
  import { DEFAULT_PROVIDER, PROVIDERS, chatEndpointFor, getProvider, isLocalProviderId, isProviderId, localBaseURLFor, maskKey, openaiCompatibleChatEndpoint, providerNeedsKey, validateBaseURL, } from "./providers.js";
21
23
  import { createLocalDiscovery, summarizeLocalSnapshot, } from "./local-discovery.js";
22
24
  import { getStoredBaseURL, loadAuth, resolveApiKey, saveAuth, setStoredKey, } from "./auth.js";
@@ -24,20 +26,27 @@ import { validateProviderKey } from "./adapters.js";
24
26
  import { clearKiloModelsCache, isFreeKiloModel, preferFreeKiloModel, } from "./kilo.js";
25
27
  import { getGitInfo, withEnvBlock } from "./env-block.js";
26
28
  import { loadPrefs, loadSession, saveSession, sessionExists, } from "./session.js";
29
+ import { createSession, ensureActiveSession, getActiveSession, getActiveSessionId, getSession, listSessions, renameSession, setActiveSession, updateSession, } from "./sessions.js";
30
+ import { discoverExtensionEntries, loadExtensions, resolveExtensionName, } from "./extensions.js";
31
+ import { formatExtensionStatusText } from "./extension-ui.js";
32
+ import { getExtensionCommand, listExtensionCommands, parseExtensionCommandInput, runExtensionCommand, } from "./extension-commands.js";
27
33
  import { loadAtomConfig } from "./config.js";
34
+ import { grantProjectTrust, isProjectTrusted, projectTrustQuestion, } from "./project-trust.js";
28
35
  import { cancelledTurnLine } from "./rollback.js";
29
36
  import { clearSnapshots, conversationCutIndex, getCheckpoint, listCheckpoints, registerHistoryProbe, restoreCheckpointFiles, } from "./snapshots.js";
30
- import { forgetReadFingerprint, refreshReadFingerprint } from "./tools.js";
37
+ import { applyBeforeCompact, beforeCompactInterceptors, forgetReadFingerprint, refreshReadFingerprint } from "./tools.js";
31
38
  import { InputBox } from "./ui/input.js";
32
39
  import { historyNewerIndex, historyOlderIndex, killToLineEnd, killToLineStart, killWordBefore, lineColOf, moveVertically, normalizePaste, offsetOfLines, pushInputHistory as pushInputHistoryList, splitInputLines, } from "./ui/input-model.js";
33
- import { LiveTail } from "./ui/live-tail.js";
40
+ import { LiveTailHost } from "./ui/live-host.js";
34
41
  import { InspectorPanel, MAX_TOOL_RECORDS, VIEWPORT_LINES, createToolRecord, } from "./ui/tool-inspector.js";
35
42
  import { activityText } from "./ui/activity.js";
36
43
  import { ApprovalBox, QuestionBox } from "./ui/modals.js";
37
44
  import { PalettePanel } from "./ui/palette.js";
38
45
  import { PALETTE_CATEGORY_ORDER, PALETTE_HINTS, paletteCategory } from "./ui/palette.js";
39
46
  import { PickerMoreAbove, PickerMoreBelow, PickerRow, PickerShell, pickerWindow } from "./ui/pickers.js";
40
- import { StatusBar, shortenCwd } from "./ui/status-bar.js";
47
+ import { shortenCwd } from "./ui/status-bar.js";
48
+ import { StatusBarHost } from "./ui/status-host.js";
49
+ import { createStreamStore } from "./ui/stream-store.js";
41
50
  import { theme } from "./ui/theme.js";
42
51
  import { TodoPanel } from "./ui/todo-panel.js";
43
52
  import { TranscriptView, applyScrollAction } from "./ui/transcript.js";
@@ -48,7 +57,7 @@ export const SLASH_COMMANDS = [
48
57
  { name: "/provider", description: "Pick AI provider, paste API key once, chat." },
49
58
  {
50
59
  name: "/effort",
51
- description: "Open the reasoning-effort picker (Default/Low/Medium/High/Max; top is Max, sent as max).",
60
+ description: "Open the reasoning-effort picker (Auto/Low/Medium/High/Max; Auto lets the model decide).",
52
61
  },
53
62
  { name: "/tools", description: "List the tools with one-line descriptions." },
54
63
  { name: "/skills", description: "List installed skills (project + global)." },
@@ -60,13 +69,16 @@ export const SLASH_COMMANDS = [
60
69
  { name: "/rules", description: "List session allow/deny rules (/rules clear wipes them)." },
61
70
  { name: "/clear", description: "Clear the conversation history (keeps session token totals; drops file checkpoints)." },
62
71
  { name: "/new", description: "Start a brand-new session (full fresh conversation + counters reset, previous kept for /resume)." },
72
+ { name: "/rename", description: "Rename the current session (/rename <name>)." },
63
73
  { name: "/compact", description: "Summarize older turns into one summary (optional focus text: /compact focus…)." },
64
74
  { name: "/context", description: "Show context usage by source (system, tools, history, skills)." },
65
75
  { name: "/queue", description: "List queued follow-ups (/queue clear wipes them)." },
66
76
  { name: "/steer", description: "Steer the running turn, or send when idle (/steer <text>)." },
67
- { name: "/autoscroll", description: "Follow new output as it arrives (/autoscroll on|off; off freezes the view mid-turn)." },
77
+ { 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)." },
68
79
  { name: "/thinking", description: "Show or hide model thinking in the TUI (rendering only; the turn is untouched)." },
69
80
  { name: "/resume", description: "Restore the last saved session (turns, history, settings, usage)." },
81
+ { name: "/session", description: "Switch the active session (interactive picker, most recent first)." },
70
82
  { name: "/telemetry", description: "Show the local observability summary (sessions, tokens, tools)." },
71
83
  { name: "/dashboard", description: "Write the local observability dashboard page and show its path." },
72
84
  { name: "/rewind", description: "Restore files to a session checkpoint (files only; shell side effects are never snapshotted)." },
@@ -79,12 +91,12 @@ const SLASH_NAMES = new Set(SLASH_COMMANDS.map((c) => c.name));
79
91
  // conversation only. Files-only is the default highlight (safest).
80
92
  export const REWIND_SCOPES = ["files only", "files + conversation", "conversation only"];
81
93
  // Submit-time pipeline order (ticket 02): submit() below reads as one
82
- // ordered sequence — permissions → context assembly → budget check → loop
94
+ // ordered sequence — permissions → context assembly → loop
83
95
  // entry — so future submit-time work has exactly one home stage. The
84
96
  // rollback-scope rule per stage states what a failed turn keeps vs drops.
85
97
  // This descriptor is the order test's source of truth:
86
98
  // tests/submit-order.test.ts pins both this order and the matching
87
- // `SUBMIT STAGE n/4` markers inside submit().
99
+ // `SUBMIT STAGE n/3` markers inside submit().
88
100
  export const SUBMIT_PIPELINE_STAGES = [
89
101
  {
90
102
  name: "permissions",
@@ -94,10 +106,6 @@ export const SUBMIT_PIPELINE_STAGES = [
94
106
  name: "context-assembly",
95
107
  rollbackScope: "pre-rollbackTo: the env-block refresh survives a failed turn (it is not part of the user turn)",
96
108
  },
97
- {
98
- name: "budget-check",
99
- rollbackScope: "pre-rollbackTo: the budget trim survives a failed turn (rollback indices are captured after it)",
100
- },
101
109
  {
102
110
  name: "loop-entry",
103
111
  rollbackScope: "post-rollbackTo: the user message, skill context, and loop entries roll back on failure",
@@ -110,8 +118,27 @@ export const SKILL_USAGE = "usage: /skill:<name> — invoke a skill directly (li
110
118
  export const RULE_USAGE = "usage: /allow <tool[:glob]> · /deny <tool[:glob]> · /rules · /rules clear (e.g. /allow bash:npm test*, /deny bash:rm *)";
111
119
  export const QUEUE_USAGE = "usage: /queue (list) · /queue clear (wipe) · /steer <text> (steer the running turn, or send when idle)";
112
120
  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";
113
- 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 prints the current state.";
114
- export const THINKING_USAGE = "usage: /thinking — toggles model-thinking visibility in the TUI (rendering only: shows or hides the committed thinking blocks; the turn, history, and telemetry are untouched).";
121
+ 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
+ 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
+ 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 GOAL_USAGE = "usage: /goal <objective> (set; replacing resets counters) · /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
+ // Pure arg parser for /rename (unit-tested): strips the command, trims,
126
+ // then strips one layer of matching outer quotes (single or double) so
127
+ // quoted names work even though the command line has no real parser.
128
+ // Unquoted multi-word names work as-is (everything after /rename is the
129
+ // name). Empty/whitespace-only input yields "" (caller prints usage).
130
+ export function parseRenameArg(raw) {
131
+ const text = raw.trim();
132
+ const arg = text === "/rename" ? "" : text.slice("/rename".length).trim();
133
+ if (arg.length >= 2) {
134
+ const first = arg[0];
135
+ const last = arg[arg.length - 1];
136
+ if ((first === '"' || first === "'") && last === first) {
137
+ return arg.slice(1, -1).trim();
138
+ }
139
+ }
140
+ return arg;
141
+ }
115
142
  export function filterSlashCommands(prefix) {
116
143
  const q = prefix.startsWith("/") ? prefix.slice(1) : prefix;
117
144
  // Exact match wins outright: a fully-typed command collapses the menu
@@ -164,6 +191,32 @@ export function paletteEntries(query) {
164
191
  if (c.description.toLowerCase().includes(q))
165
192
  out.push({ c, tier: 3, score: 0, idx });
166
193
  });
194
+ // Extension slash commands (ticket 04): the same prefix/fuzzy/
195
+ // description tiers over the live registry — a colliding name can never
196
+ // reach here (activation rejects it), and dispatch routes builtins first
197
+ // as backstop, so builtins are never shadowed.
198
+ const extCmds = listExtensionCommands();
199
+ const extNames = new Set(extCmds.map((c) => `/${c.name}`));
200
+ extCmds.forEach((cmd, extIdx) => {
201
+ const c = { name: `/${cmd.name}`, description: cmd.description };
202
+ const idx = SLASH_COMMANDS.length + extIdx;
203
+ const name = cmd.name.toLowerCase();
204
+ if (!q) {
205
+ out.push({ c, tier: 0, score: 0, idx });
206
+ return;
207
+ }
208
+ if (name.startsWith(q)) {
209
+ out.push({ c, tier: 1, score: 0, idx });
210
+ return;
211
+ }
212
+ const s = fuzzyScore(q, name);
213
+ if (s !== null) {
214
+ out.push({ c, tier: 2, score: s, idx });
215
+ return;
216
+ }
217
+ if (cmd.description.toLowerCase().includes(q))
218
+ out.push({ c, tier: 3, score: 0, idx });
219
+ });
167
220
  const catOrder = (n) => PALETTE_CATEGORY_ORDER.indexOf(paletteCategory(n));
168
221
  out.sort((a, b) => a.tier - b.tier ||
169
222
  (a.tier === 0
@@ -172,20 +225,25 @@ export function paletteEntries(query) {
172
225
  return out.map(({ c }) => ({
173
226
  name: c.name,
174
227
  description: c.description,
175
- category: paletteCategory(c.name),
228
+ category: extNames.has(c.name) ? "Extensions" : paletteCategory(c.name),
176
229
  hint: PALETTE_HINTS[c.name] ?? null,
177
230
  }));
178
231
  }
179
232
  // Busy-gate shared by the slash menu and the palette: /compact sets the
180
233
  // pending flag for turn-end drain; /queue + /steer manage the running turn;
181
- // /autoscroll and /thinking only flip view flags (never touch the turn).
234
+ // /autoscroll and /thinking only flip view flags (never touch the turn);
235
+ // /goal only flips session goal state (never touches the turn);
236
+ // /rename only renames the store record + title state (the later turn-end
237
+ // persist preserves the title, so it never races the turn).
182
238
  // Every other command waits idle.
183
239
  export function slashRunsWhileBusy(name) {
184
240
  return (name === "/compact" ||
185
241
  name === "/queue" ||
186
242
  name === "/steer" ||
187
243
  name === "/autoscroll" ||
188
- name === "/thinking");
244
+ name === "/thinking" ||
245
+ name === "/goal" ||
246
+ name === "/rename");
189
247
  }
190
248
  // Fuzzy subsequence match with gap/start/word-boundary scoring (lower is
191
249
  // better; null = no match). Pure — shared by the command filter, the skill
@@ -224,6 +282,15 @@ export function filterSkillPicker(entries, query) {
224
282
  return entries;
225
283
  return entries.filter((e) => e.name.toLowerCase().includes(q));
226
284
  }
285
+ export function sameSkillMenuSnapshot(a, b) {
286
+ if (a.length !== b.length)
287
+ return false;
288
+ for (let i = 0; i < a.length; i++) {
289
+ if (a[i].name !== b[i].name || a[i].description !== b[i].description)
290
+ return false;
291
+ }
292
+ return true;
293
+ }
227
294
  // Pure menu builder (unit-tested): matching commands first (prefix tier in
228
295
  // stable order, then fuzzy by score), then matching skills as `/skill:name`
229
296
  // entries (prefix tier stable, then fuzzy). Skills join only once the query
@@ -232,14 +299,37 @@ export function filterSkillPicker(entries, query) {
232
299
  // name. Skill rows carry a truncated description for discovery. Pure — the
233
300
  // App feeds it the cached registry snapshot.
234
301
  export const SKILL_MENU_DESC_CHARS = 60;
235
- export function buildSlashMenu(input, skills) {
302
+ export function buildSlashMenu(input, skills, extensions = []) {
236
303
  const items = filterSlashCommands(input).map((c) => ({
237
304
  name: c.name,
238
305
  description: c.description,
239
306
  }));
240
307
  if (input.length < 2)
241
308
  return { items, moreSkills: 0 };
309
+ // Extension slash commands (ticket 04): prefix tier in registration
310
+ // order (registration order is deterministic), then fuzzy by score —
311
+ // listed after builtins (exact dispatch routes builtins first, so an
312
+ // extension never shadows) and before skills.
242
313
  const q = input.slice(1);
314
+ const extPrefix = [];
315
+ const extFuzzy = [];
316
+ for (const e of extensions) {
317
+ const entry = `/${e.name}`;
318
+ if (e.name.startsWith(q) || entry.startsWith(input)) {
319
+ extPrefix.push(e);
320
+ continue;
321
+ }
322
+ const score = fuzzyScore(q, e.name);
323
+ if (score !== null)
324
+ extFuzzy.push({ e, score });
325
+ }
326
+ extFuzzy.sort((a, b) => a.score - b.score || (a.e.name < b.e.name ? -1 : 1));
327
+ for (const e of [...extPrefix, ...extFuzzy.map((f) => f.e)]) {
328
+ const desc = e.description.length > SKILL_MENU_DESC_CHARS
329
+ ? `${e.description.slice(0, SKILL_MENU_DESC_CHARS)}…`
330
+ : e.description;
331
+ items.push({ name: `/${e.name}`, description: desc });
332
+ }
243
333
  const skillQ = q.startsWith("skill:") ? q.slice("skill:".length) : q;
244
334
  const pushSkill = (s, shown, more) => {
245
335
  const entry = `/skill:${s.name}`;
@@ -292,6 +382,10 @@ export function commandUsage(name) {
292
382
  return SKILL_USAGE;
293
383
  case "/compact":
294
384
  return "Usage: /compact [focus text] — summarize older turns (works while busy; drains at turn end).";
385
+ case "/rename":
386
+ return RENAME_USAGE;
387
+ case "/goal":
388
+ return GOAL_USAGE;
295
389
  default:
296
390
  return null;
297
391
  }
@@ -311,6 +405,41 @@ export function modelsCacheKey(providerId, baseURL) {
311
405
  }
312
406
  return providerId;
313
407
  }
408
+ export function filterSessionEntries(entries, query) {
409
+ const q = query.trim().toLowerCase();
410
+ if (!q)
411
+ return entries;
412
+ const scored = [];
413
+ entries.forEach((e, idx) => {
414
+ const titleScore = fuzzyScore(q, e.title);
415
+ const idScore = fuzzyScore(q, e.id);
416
+ let best = titleScore;
417
+ if (idScore !== null && (best === null || idScore + 5 < best)) {
418
+ best = idScore + 5;
419
+ }
420
+ if (best !== null)
421
+ scored.push({ e, score: best, idx });
422
+ });
423
+ scored.sort((a, b) => a.score - b.score || a.idx - b.idx);
424
+ return scored.map((s) => s.e);
425
+ }
426
+ // Relative age for the picker secondary line (unit-tested). Invalid dates
427
+ // say "unknown" (never throw, never invent).
428
+ export function formatSessionAge(nowMs, updatedAt) {
429
+ const t = Date.parse(updatedAt);
430
+ if (!Number.isFinite(t))
431
+ return "unknown";
432
+ const secs = Math.max(0, Math.floor((nowMs - t) / 1000));
433
+ if (secs < 60)
434
+ return "just now";
435
+ const mins = Math.floor(secs / 60);
436
+ if (mins < 60)
437
+ return `${mins}m ago`;
438
+ const hours = Math.floor(mins / 60);
439
+ if (hours < 24)
440
+ return `${hours}h ago`;
441
+ return `${Math.floor(hours / 24)}d ago`;
442
+ }
314
443
  // Pure helpers for the elapsed/stall indicator (injectable now for tests).
315
444
  export function elapsedSecsSince(startMs, nowMs) {
316
445
  return Math.max(0, Math.floor((nowMs - startMs) / 1000));
@@ -471,14 +600,14 @@ export function helpListText() {
471
600
  `\nPlan mode is the read-only mode for risky work: explore with read/grep/glob/webfetch/websearch/todos/ask_question (all run free) while write/edit/bash are blocked pre-execution with a replan note (never a prompt, never silent — the ⚙ audit line still renders). Scoped /deny rules still win in plan mode; /allow, /trust, yolo, [a]lways, and skill grants cannot punch through it (/trust while in plan stays read-only with a notice — Tab out first). Exiting plan is the human approval: Tab from plan mode returns to normal (never yolo) and the todowrite checklist recorded while planning carries into implementation.` +
472
601
  `\n/trust toggles the session trust tier: with trust on, write/edit/bash auto-approve (one approval covers the whole task) without global yolo. Default off, normal mode stays the default; in-memory only, never saved. Every auto-approved call still renders its ⚙ line. The approval prompt also offers [t]rust-all mid-run; [n]/Esc still denies one call, Ctrl+C (or Esc while busy) still cancels the whole turn.` +
473
602
  `\n/allow <tool[:glob]> pre-approves matching write/edit/bash calls this session (no prompt; e.g. /allow bash:npm test*, /allow write:src/**; bare /allow bash matches any args). /deny <tool[:glob]> refuses matching calls before execution — the model sees the standard denial result and replans. Deny wins over /trust, yolo, [a]lways, and skill grants. Every auto-approved call still renders its ⚙ line. Rules are in-memory only (like /trust, never saved); /rules lists them, /rules clear wipes them.` +
474
- `\nToken totals accumulate per session from API-reported usage only: the status line shows \`token: n/a\` until the API reports usage (never estimated, never 0-by-default); with usage it shows \`token: (P%) NK\` — NK is the cumulative session spend in K, P% is the CURRENT context load over the model's verified window (last POST prompt_tokens, else the 4ch/token estimate; models with no verified window show a bare \`token: NK\`, never an invented percent). /clear keeps the totals; /new resets them.` +
475
- `\n/compact [focus text]: summarize older turns into one \`[Compacted context …]\` summary + keep the newest tail (~8000 estimated tokens, tool outputs capped at 2000 chars). Tiny history (≤1 user turn) reports \`(nothing to compact)\`. Works for unknown-window models (estimate only for the tail split).` +
603
+ `\nToken totals accumulate per session from API-reported usage only: the status line shows \`token: n/a\` until the API reports usage (never estimated, never 0-by-default); with usage it shows \`token: (P%) NK\` — NK is the cumulative session spend in K, P% is the CURRENT context load over the model's verified window (last POST input tokens incl. prefix cache, else the 4ch/token estimate; models with no verified window show a bare \`token: NK\`, never an invented percent). /clear keeps the totals; /new resets them.` +
604
+ `\n/compact [focus text]: summarize older turns into one \`[Compacted context …]\` summary + keep the newest tail (~20000 estimated tokens, tool outputs capped at 2000 chars). Tiny history (≤1 user turn) reports \`(nothing to compact)\`. Works for unknown-window models (estimate only for the tail split).` +
605
+ `\n/goal <objective>: pin one session goal (setting one replaces any live goal and resets its counters). Bare /goal shows it with cumulative stats (turns · requests · tokens · work). /goal pause halts the run but keeps the objective and stats; /goal resume re-arms it — idle starts a turn with the continuation text, busy resumes when the current turn ends. /goal clear ends it. The run has no turn cap: it continues turn-to-turn until paused, cleared, a complete/blocked verdict, or a thrown failure. Cancel and spent step/tool-call budgets pause (never clear). The model reports each turn via update_goal (continue with the next action, or complete/blocked with a reason); a report-less turn gets one bounded judge call when configured, otherwise continues — an unclear or failed judge pauses with the goal preserved. Three consecutive repeated tool results redirect with a replan nudge (the goal stays active). A complete with unverified code or open todos continues instead of stopping; blocked stops unconditionally (declared-unverifiable checks print openly in the verdict, never gate). /clear and /new end the goal; the live goal rides every session save with its stats intact (resume and session switches restore it; corrupt data loads as no goal). Compaction appends a Goal: line (text, state, stats, open todos) to the summary as the model's context backstop.` +
476
606
  `\nAuto-compact: after every completed turn the load is checked; on known-window models with load/window ≥ ${Math.round(COMPACT_PCT_DEFAULT * 100)}% (env ATOM_COMPACT_PCT percent, clamped 50–95, invalid→default) history auto-compacts before the next turn. Unknown-window models never auto-compact — use /compact manually.` +
477
607
  `\nThrash guard: 3 auto-compactions without the load dropping below threshold disables auto for the session with \`(auto-compact thrashing — disabled, use /compact or /clear)\`; manual /compact still works and resets the counter on success.` +
478
608
  `\n/provider: pick kilo|opencode-zen|openai|anthropic|deepseek|mistral|google-gemini|openai-compatible, paste a key once (stored in ~/.atom/auth.json, env wins). Kilo is the default: its free :free models (e.g. kilo-auto/free) work with no key; a Kilo key unlocks the full catalog. Switching provider keeps session history text; system prompt stays.` +
479
- `\n/effort options: Default/Low/Medium/High/Max (wire: default/low/medium/high/max; Default omits reasoning_effort).` +
480
- `\nNote: xhigh was requested but only Max is verified, so the top setting is Max, sent as max.` +
481
- `\nGating: reasoning_effort is sent ONLY when effort != Default AND the model is one of ${[...REASONING_EFFORT_SUPPORTED_MODELS].join(", ")} AND the provider is opencode-zen; otherwise omitted (setting kept, warning shown, status shows (unsupported)). Effort persists across /model switches.` +
609
+ `\n/effort options: Auto/Low/Medium/High/Max. Auto omits the knob (the model decides); anything else sends it — reasoning_effort on OpenAI-chat providers (every provider, every model), a thinking budget on Anthropic, a thinkingLevel on Gemini (Max rides high).` +
610
+ `\nUnsupported is server-authoritative, never preemptive: a model that truly lacks the knob fails the POST with a 400 naming it, and the turn retries once without it (warning shown, setting kept, status never invents "(unsupported)"). Effort persists across /model switches.` +
482
611
  `\n/resume: restores the last saved session (turns, history, provider/model/effort/mode, usage totals). The conversation never auto-restores — sending a message without /resume starts fresh, and the next completed turn overwrites the save. Your provider/model/effort picks DO persist across restarts automatically (saved on every completed turn and on clean exit; explicit OPENCODE_ZEN_MODEL wins over the saved model). /clear clears the live session only (the save keeps the pre-clear state until the next completed turn overwrites it). /new saves first, then starts a brand-new session (conversation + counters reset, settings kept) — so /resume right after /new restores the pre-/new conversation. Split: /clear = wipe transcript, keep counters; /new = full fresh conversation + counters reset, previous kept for /resume.` +
483
612
  `\nSession autosave: every completed turn (and clean exit, plus after each successful compaction) writes ~/.atom/session.json (0600 POSIX, may contain pasted secrets — never commit it); failed/cancelled turns never touch it; a corrupt save loads as "(saved session unreadable — starting fresh)".` +
484
613
  `\nBusy status shows the live phase plus elapsed seconds in the status line (· thinking… 4s); >3s without token/tool/phase activity adds a dim waiting… hint (status-bar only, never saved). ` +
@@ -494,8 +623,8 @@ export function helpListText() {
494
623
  // as `token: n/a` — never 0, which would imply measurement). The segment
495
624
  // itself lives in ./context-windows.js (single source for the exact
496
625
  // `token: (P%) NK` format); StatusBar (./ui/status-bar.js) is its only
497
- // surface. P% tracks CURRENT context load (last prompt_tokens, else
498
- // 4ch/token estimate); NK tracks cumulative session spend.
626
+ // surface. P% tracks CURRENT context load (last POST input tokens incl.
627
+ // cache, else 4ch/token estimate); NK tracks cumulative session spend.
499
628
  function formatKEst(chars) {
500
629
  return `~${(estimateTokensForChars(chars) / 1000).toFixed(1)}K`;
501
630
  }
@@ -506,17 +635,13 @@ export function MissingKey() {
506
635
  // Measured once: TOOL_DEFINITIONS never changes at runtime, so the schema
507
636
  // size is a constant (avoids re-serializing ~15KB on every manager build).
508
637
  const TOOLS_SCHEMA_CHARS = JSON.stringify(TOOL_DEFINITIONS).length;
509
- export function App({ apiKey, endpoint, initialModel, initialModels, initialProvider, restorePrefs, authHome, skillDirs, configDirs, now, setIntervalFn, clearIntervalFn, setTimeoutFn, clearTimeoutFn, localDiscovery }) {
638
+ // Render-count probe for propagation audits: incremented on every App body
639
+ // execution (hostile-perf suite asserts token paints and ticks stay out of
640
+ // here — only real state transitions may run the orchestrator).
641
+ export const appRenderProbe = { count: 0 };
642
+ export function App({ apiKey, endpoint, initialModel, initialModels, initialProvider, restorePrefs, authHome, skillDirs, configDirs, extensionsLockdown, enableExtensions, disableExtensions, now, setIntervalFn, clearIntervalFn, setTimeoutFn, clearTimeoutFn, localDiscovery }) {
643
+ appRenderProbe.count += 1;
510
644
  const { exit } = useApp();
511
- // Measured terminal width for the status bar's fit-or-drop branch logic.
512
- // Unknown (piped output) falls back to the bar's own default.
513
- let termColumns;
514
- try {
515
- termColumns = useStdout()?.stdout?.columns;
516
- }
517
- catch {
518
- termColumns = undefined;
519
- }
520
645
  // Saved preferences (provider/model/effort + resolved key/endpoint), loaded
521
646
  // once when restorePrefs is on (prod). Explicit props always win; without
522
647
  // prefs the CLI defaults apply. Null in tests (flag off) and on any
@@ -689,14 +814,33 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
689
814
  skillFilterRef.current = next;
690
815
  setSkillFilter(next);
691
816
  }
817
+ // /session picker (interactive switcher): snapshot state loaded ONCE per
818
+ // open (listSessions reads each record a single time), filtered in memory
819
+ // per keystroke. ↑/↓ + Enter switches, Esc cancels with the live session
820
+ // untouched. Same keyboard/window pattern as the /skills picker.
821
+ const [selectingSession, setSelectingSession] = useState(false);
822
+ const [sessionItems, setSessionItems] = useState([]);
823
+ const [sessionIndex, setSessionIndex] = useState(0);
824
+ const sessionIndexRef = useRef(0);
825
+ const [sessionFilter, setSessionFilter] = useState("");
826
+ const sessionFilterRef = useRef("");
827
+ function setSessionIndexBoth(next) {
828
+ sessionIndexRef.current = next;
829
+ setSessionIndex(next);
830
+ }
831
+ function setSessionFilterBoth(next) {
832
+ sessionFilterRef.current = next;
833
+ setSessionFilter(next);
834
+ }
692
835
  // Reasoning-effort picker (/effort): same pattern as the /model picker
693
836
  // (↑/↓ + Enter, Esc cancels). Saved effort restores with restorePrefs,
694
- // else the atom.json default, else Default.
837
+ // else the atom.json default, else Auto. normalizeEffort keeps pre-auto
838
+ // "default" values (old saves/configs) working.
695
839
  const [selectingEffort, setSelectingEffort] = useState(false);
696
840
  const [effortIndex, setEffortIndex] = useState(0);
697
841
  const effortIndexRef = useRef(0);
698
- const [effort, setEffort] = useState(prefs?.effort ?? atomConfig.reasoningEffort ?? "default");
699
- const effortRef = useRef(prefs?.effort ?? atomConfig.reasoningEffort ?? "default");
842
+ const [effort, setEffort] = useState(normalizeEffort(prefs?.effort ?? atomConfig.reasoningEffort ?? "auto"));
843
+ const effortRef = useRef(normalizeEffort(prefs?.effort ?? atomConfig.reasoningEffort ?? "auto"));
700
844
  // /provider picker + key/baseURL prompts (same keyboard pattern).
701
845
  const [selectingProvider, setSelectingProvider] = useState(false);
702
846
  const [providerIndex, setProviderIndex] = useState(0);
@@ -844,17 +988,97 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
844
988
  showThinkingRef.current = next;
845
989
  setShowThinking(next);
846
990
  }
847
- // /autoscroll (session-only, default on). On = today's behavior: a
848
- // following view extends with every appended turn. Off = appends during a
849
- // busy turn freeze a following view at its current end instead of yanking
850
- // it (the `↓ N new` indicator offers the jump back; End resumes). Idle
851
- // appends always follow — freezing only matters while output streams.
991
+ // /autoscroll (session-only, default on). On follows new output as it
992
+ // arrives; off freezes the view at the first busy append (the `↓ N new`
993
+ // indicator offers the jump back). Bare /autoscroll toggles.
852
994
  const [autoScroll, setAutoScroll] = useState(true);
853
995
  const autoScrollRef = useRef(true);
854
996
  function setAutoScrollBoth(next) {
855
997
  autoScrollRef.current = next;
856
998
  setAutoScroll(next);
857
999
  }
1000
+ // Session goal (ticket 01, in-memory only): at most one active goal per
1001
+ // session. Ordinary chat messages never touch it — only /goal does.
1002
+ const [goal, setGoal] = useState(null);
1003
+ const goalRef = useRef(null);
1004
+ function setGoalBoth(next) {
1005
+ goalRef.current = next;
1006
+ setGoal(next);
1007
+ }
1008
+ // Goal stat patch (ticket 02): accumulates into the LIVE goal's counters,
1009
+ // preserving objective/active. Drops when no goal exists (e.g. cleared
1010
+ // mid-turn — the slice belongs to no goal anymore). Never throws.
1011
+ function patchGoalStats(patch) {
1012
+ try {
1013
+ const g = goalRef.current;
1014
+ if (!g)
1015
+ return;
1016
+ setGoalBoth({ ...g, stats: patch(g.stats ?? emptyGoalStats()) });
1017
+ }
1018
+ catch {
1019
+ // accounting never breaks the turn
1020
+ }
1021
+ }
1022
+ // Usage accumulator (session totals + goal slice, real reports only): the
1023
+ // turn's onUsage below and the goal-judge runner share it so judge spend
1024
+ // bills exactly like model spend. Every reporting POST accumulates
1025
+ // (tool-round POSTs and successful retries each count once — each was
1026
+ // billed; failed attempts report nothing, so nothing is deduped).
1027
+ // usageTotals drives NK only, never P%.
1028
+ // updateLoad pins the load metric (P% source) to main-context POSTs: the
1029
+ // judge's summary-sized request must not move it (same rule as the
1030
+ // compaction summary POST — load tracks the main context).
1031
+ function accumulateUsage(u, updateLoad = true) {
1032
+ const prev = usageRef.current ?? {};
1033
+ const next = { ...prev };
1034
+ if (u.prompt_tokens !== undefined) {
1035
+ next.prompt_tokens = (next.prompt_tokens ?? 0) + u.prompt_tokens;
1036
+ // Load metric source: last POST's reported input-side tokens
1037
+ // (prompt_tokens, cache-inclusive for exclusive-cache providers) —
1038
+ // the per-POST value, NOT the accumulated total.
1039
+ if (updateLoad)
1040
+ lastPromptTokensRef.current = u.prompt_tokens;
1041
+ }
1042
+ if (u.completion_tokens !== undefined) {
1043
+ next.completion_tokens = (next.completion_tokens ?? 0) + u.completion_tokens;
1044
+ }
1045
+ if (u.total_tokens !== undefined) {
1046
+ next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
1047
+ }
1048
+ // Prefix-cache counters accumulate like spend (real reports only;
1049
+ // absent fields mean "not reported", never zero).
1050
+ if (u.cacheReadTokens !== undefined) {
1051
+ next.cacheReadTokens = (next.cacheReadTokens ?? 0) + u.cacheReadTokens;
1052
+ }
1053
+ if (u.cacheWriteTokens !== undefined) {
1054
+ next.cacheWriteTokens = (next.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
1055
+ }
1056
+ setUsageBoth(next);
1057
+ // Goal token slice (ticket 02): every reporting POST also accrues
1058
+ // to the live goal's spend — real reports only, and the session
1059
+ // totals above are untouched.
1060
+ try {
1061
+ if (goalRef.current) {
1062
+ const slice = goalTokensForUsage(u);
1063
+ if (slice > 0)
1064
+ patchGoalStats((s) => ({ ...s, tokens: s.tokens + slice }));
1065
+ }
1066
+ }
1067
+ catch {
1068
+ // accounting never breaks the turn
1069
+ }
1070
+ }
1071
+ // Loop-owned pause (ticket 02): cancel and spent budgets pause with a
1072
+ // visible notice — the objective and stats survive, so /goal resume
1073
+ // continues where the run stopped. No-op when absent/already paused
1074
+ // (pause fires exactly once per run).
1075
+ function pauseGoalWithNotice(notice) {
1076
+ const g = goalRef.current;
1077
+ if (!g || !g.active)
1078
+ return;
1079
+ setGoalBoth({ ...g, active: false });
1080
+ pushInfo(notice);
1081
+ }
858
1082
  // Skill registry (cached metadata): one instance per App, scoped to the
859
1083
  // same dirs the suite injects via skillDirs. Every discovery path below
860
1084
  // reads through it — refresh() revalidates by stat (mtime+size) and only
@@ -871,9 +1095,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
871
1095
  try {
872
1096
  const found = await skillRegistry.refresh();
873
1097
  const { skills } = resolveSkills(found.skills);
874
- setSkillMenu(skills
1098
+ const next = skills
875
1099
  .filter((s) => s.userInvocable)
876
- .map((s) => ({ name: s.name, description: s.description })));
1100
+ .map((s) => ({ name: s.name, description: s.description }));
1101
+ // Install only on change: the mount refresh routinely rediscovers the
1102
+ // identical set mid-turn, and a fresh array identity would schedule a
1103
+ // full App render for zero new information.
1104
+ setSkillMenu((prev) => (sameSkillMenuSnapshot(prev, next) ? prev : next));
877
1105
  }
878
1106
  catch {
879
1107
  // menu keeps its previous snapshot (a hiccup must never break input)
@@ -929,6 +1157,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
929
1157
  const askSelIndexRef = useRef(0);
930
1158
  const [askCustom, setAskCustom] = useState("");
931
1159
  const askCustomRef = useRef("");
1160
+ // Extension UI surface (ticket 10): the version bump re-renders on every
1161
+ // runtime UI mutation (segment/widget/notice/dialog); the dialog owns its
1162
+ // own select/custom state mirroring the question modal above.
1163
+ const [, bumpExtUI] = useState(0);
1164
+ const [extDlgSel, setExtDlgSel] = useState(0);
1165
+ const extDlgSelRef = useRef(0);
1166
+ const [extDlgCustom, setExtDlgCustom] = useState("");
1167
+ const extDlgCustomRef = useRef("");
932
1168
  const [busy, setBusy] = useState(false);
933
1169
  const busyRef = useRef(false);
934
1170
  // Per-turn cancellation (Ctrl+C mid-loop): abort stops after the current
@@ -964,8 +1200,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
964
1200
  const [usageTotals, setUsageTotals] = useState(null);
965
1201
  // Synchronous mirror of `usageTotals` (same save-time reason as turnsRef).
966
1202
  const usageRef = useRef(null);
967
- // Current context load driving status P% (last POST prompt_tokens when
968
- // available, else the 4ch/token estimate). Null until the first turn
1203
+ // Current context load driving status P% (last POST input tokens incl.
1204
+ // cache when available, else the 4ch/token estimate). Null until the first turn
969
1205
  // completes. NK stays cumulative; P must NOT use the cumulative total.
970
1206
  const [contextLoad, setContextLoad] = useState(null);
971
1207
  const contextLoadRef = useRef(null);
@@ -981,8 +1217,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
981
1217
  setGitInfo(null);
982
1218
  }
983
1219
  }
984
- // Last POST's reported prompt_tokens (load metric source). Summary-request
985
- // usage never touches thisonly main-loop POSTs do.
1220
+ // Last POST's reported input-side tokens (prompt_tokens, normalized at
1221
+ // parse time to include exclusive cache counters) the load metric source.
1222
+ // Summary-request usage never touches this — only main-loop POSTs do.
986
1223
  const lastPromptTokensRef = useRef(undefined);
987
1224
  // Thrash guard: consecutive auto-compactions without the load dropping
988
1225
  // below threshold. At 3, auto disables for the session (manual still
@@ -997,22 +1234,39 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
997
1234
  // as a dim line while the transcript is empty; the conversation itself
998
1235
  // never auto-restores (only provider/model/effort do, via restorePrefs).
999
1236
  const [sessionHint] = useState(() => sessionExists(authHome));
1237
+ // Active session display title (rename feedback + failure messages).
1238
+ // Initialized from the store record when one already exists (sync disk
1239
+ // read, no write — creation stays in the mount effect); thereafter the
1240
+ // store is the source of truth and every sync point below refreshes it.
1241
+ // Deliberately NOT in the status bar: the sole info bar has a fixed width
1242
+ // budget and a ~30-char title wraps `mode: X` onto its own line. Session
1243
+ // identity surfaces instead in the /session picker rows, the rename
1244
+ // confirmation, and the switch/new notices.
1245
+ const [sessionTitle, setSessionTitle] = useState(() => {
1246
+ try {
1247
+ return getActiveSession(authHome)?.title ?? "";
1248
+ }
1249
+ catch {
1250
+ return "";
1251
+ }
1252
+ });
1253
+ const sessionTitleRef = useRef(sessionTitle);
1000
1254
  // Reasoning label from response metadata (via onReasoning). The status
1001
- // line shows the session effort when non-Default (plus " (unsupported)"
1002
- // when the model is outside the verified-support set); when effort is
1003
- // Default it shows this label, falling back to `default`.
1255
+ // line shows the session effort when non-Auto; when effort is Auto it
1256
+ // shows this label, falling back to `auto`.
1004
1257
  const [reasoning, setReasoning] = useState(null);
1005
- // Live streaming state: `draft` is the growing assistant text (onToken),
1006
- // `phase`/`phaseDetail` track the observe→act→inspect→adjust loop
1007
- // (thinking|streaming|tool|retry|done), and `toolHint` shows a streamed
1008
- // tool name before its execution line lands.
1009
- const [draft, setDraft] = useState(null);
1258
+ // Live streaming state: the growing assistant text (onToken) and the
1259
+ // thinking channel (onThinking) live in a per-mount StreamStore, NOT in App
1260
+ // useState. Token paints (up to ~15/sec) notify only the subscribed
1261
+ // LiveTailHost App's body and every other leaf skip them entirely.
1262
+ // `phase`/`phaseDetail`/`toolHint` stay in App state: they change at most a
1263
+ // few times per turn (low frequency, and StatusBar legitimately needs them).
1264
+ const [streamStore] = useState(() => createStreamStore());
1010
1265
  // Thinking channel (onThinking): reasoning text streamed apart from the
1011
1266
  // answer, rendered in its own dim block below. The live value is transient
1012
- // like `draft` — cleared on every turn boundary below — but each completed
1267
+ // like the draft — cleared on every turn boundary below — but each completed
1013
1268
  // round commits to the transcript via commitThinking (stays in the TUI,
1014
1269
  // never the model history) instead of being replaced and lost.
1015
- const [thinking, setThinking] = useState(null);
1016
1270
  const thinkingRef = useRef(null);
1017
1271
  // Move the accumulated round thinking into the transcript as a quiet
1018
1272
  // annotation turn (no-op when empty). Called when a new POST starts and at
@@ -1021,18 +1275,49 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1021
1275
  function commitThinking() {
1022
1276
  const text = thinkingRef.current;
1023
1277
  thinkingRef.current = null;
1024
- setThinking(null);
1278
+ try {
1279
+ // Drop any trailing paint: the commit carries the full text, and a
1280
+ // late flush must never resurrect stale reasoning after the clear.
1281
+ thinkingThrottleRef.current?.cancel();
1282
+ }
1283
+ catch {
1284
+ // ignore (the store clear below still wins)
1285
+ }
1286
+ streamStore.setThinking(null);
1025
1287
  if (typeof text === "string" && text.length > 0) {
1026
1288
  appendTurns({ role: "assistant", content: text, thinking: true });
1027
1289
  }
1028
1290
  }
1029
1291
  function clearThinking() {
1030
1292
  thinkingRef.current = null;
1031
- setThinking(null);
1293
+ try {
1294
+ thinkingThrottleRef.current?.cancel();
1295
+ }
1296
+ catch {
1297
+ // ignore (the store clear below still wins)
1298
+ }
1299
+ streamStore.setThinking(null);
1032
1300
  }
1033
1301
  const [phase, setPhase] = useState("idle");
1034
1302
  const [phaseDetail, setPhaseDetail] = useState("");
1035
1303
  const [toolHint, setToolHint] = useState(null);
1304
+ // Synchronous mirrors for the hot loop callbacks: zen fires
1305
+ // onPhase("streaming") on EVERY content chunk, so the handler must not
1306
+ // issue same-value setStates per token (React re-invokes the component
1307
+ // before bailing out — 15 App-body executions/sec for nothing).
1308
+ // setPhaseBoth is the only writer; equal values skip setState entirely.
1309
+ const phaseRef = useRef("idle");
1310
+ const phaseDetailRef = useRef("");
1311
+ function setPhaseBoth(next, detail) {
1312
+ if (phaseRef.current !== next) {
1313
+ phaseRef.current = next;
1314
+ setPhase(next);
1315
+ }
1316
+ if (phaseDetailRef.current !== detail) {
1317
+ phaseDetailRef.current = detail;
1318
+ setPhaseDetail(detail);
1319
+ }
1320
+ }
1036
1321
  // Task B smoothness (a): throttled streaming draft. onToken pushes every
1037
1322
  // partial (activity/stall tracking stays per-token); paints coalesce to one
1038
1323
  // per DRAFT_THROTTLE_MS trailing window, flushed on done/turn-end.
@@ -1045,7 +1330,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1045
1330
  setTimeoutFn,
1046
1331
  clearTimeoutFn,
1047
1332
  onFlush: (text) => {
1048
- setDraft(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);
1049
1336
  },
1050
1337
  });
1051
1338
  draftThrottleRef.current = th;
@@ -1060,6 +1347,26 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1060
1347
  // ignore (draft stays as-is; the commit carries the full text)
1061
1348
  }
1062
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
+ }
1063
1370
  // Phase 5: models-list session cache (successful live lists only, keyed
1064
1371
  // by modelsCacheKey). Failures fall back uncached, exactly as before.
1065
1372
  const modelsCacheRef = useRef(new Map());
@@ -1135,6 +1442,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1135
1442
  // on the next activity.
1136
1443
  const [elapsedSecs, setElapsedSecs] = useState(0);
1137
1444
  const [stalled, setStalled] = useState(false);
1445
+ // Mirror for the per-token stall reset (same same-value-setState hazard
1446
+ // as phase above — noteTurnActivity runs on every chunk).
1447
+ const stalledRef = useRef(false);
1448
+ function setStalledBoth(next) {
1449
+ if (stalledRef.current !== next) {
1450
+ stalledRef.current = next;
1451
+ setStalled(next);
1452
+ }
1453
+ }
1138
1454
  const turnStartRef = useRef(0);
1139
1455
  const lastActivityRef = useRef(0);
1140
1456
  const turnTimerRef = useRef(null);
@@ -1150,7 +1466,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1150
1466
  // below re-wrap via trackHistory (never assign a raw array here).
1151
1467
  const historyRef = useRef(trackHistory([{ role: "system", content: withEnvBlock(systemPrompt) }]));
1152
1468
  // Task 6 per-turn env block (cwd, git branch/status, node, timestamp):
1153
- // pinned to history[0] (the only slot truncateHistory never drops), NEVER
1469
+ // pinned to history[0] (the system prompt), NEVER
1154
1470
  // to user content. Refreshed once per turn in submit() + after doResume, so
1155
1471
  // the loop's many POSTs reuse one block (no per-POST shell-outs).
1156
1472
  // Failure-silent via withEnvBlock (missing git → block shrinks).
@@ -1260,7 +1576,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1260
1576
  catch {
1261
1577
  // ignore clock errors (stall hint just won't trigger)
1262
1578
  }
1263
- setStalled(false);
1579
+ // Guarded: runs on every token/thinking/phase event, but only a
1580
+ // true→false transition may schedule a render.
1581
+ setStalledBoth(false);
1264
1582
  }
1265
1583
  function startTurnTimer() {
1266
1584
  clearTurnTimer();
@@ -1274,7 +1592,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1274
1592
  turnStartRef.current = start;
1275
1593
  lastActivityRef.current = start;
1276
1594
  setElapsedSecs(0);
1277
- setStalled(false);
1595
+ setStalledBoth(false);
1278
1596
  try {
1279
1597
  turnTimerRef.current = (setIntervalFn ?? setInterval)(() => {
1280
1598
  let t;
@@ -1286,7 +1604,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1286
1604
  }
1287
1605
  setElapsedSecs(elapsedSecsSince(turnStartRef.current, t));
1288
1606
  if (isStalledSince(lastActivityRef.current, t)) {
1289
- setStalled(true);
1607
+ // Guarded: the stalled flag flips false→true once per silence
1608
+ // window, not on every tick within it.
1609
+ if (!stalledRef.current)
1610
+ setStalledBoth(true);
1290
1611
  }
1291
1612
  }, TURN_TICK_MS);
1292
1613
  }
@@ -1301,6 +1622,26 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1301
1622
  // in that gap; runLoopWithChat checks the signal before the first POST).
1302
1623
  useEffect(() => {
1303
1624
  return () => {
1625
+ // Extension host teardown: subscribers observe the shutdown, then the
1626
+ // runtime is dropped. Best-effort and synchronous from React's side
1627
+ // (emit records its own errors, never rejects).
1628
+ // Extension UI teardown (ticket 10): remove every contribution with
1629
+ // zero residue first, then let subscribers observe the shutdown —
1630
+ // anything a shutdown handler re-registers lands in a dropped
1631
+ // runtime (ref nulled below) and never paints.
1632
+ try {
1633
+ extRuntimeRef.current?.disposeUI();
1634
+ }
1635
+ catch {
1636
+ // ignore
1637
+ }
1638
+ try {
1639
+ void extRuntimeRef.current?.emit("session_shutdown", { reason: "quit" });
1640
+ }
1641
+ catch {
1642
+ // ignore
1643
+ }
1644
+ extRuntimeRef.current = null;
1304
1645
  // Local observability: close the session trace on unmount (covers
1305
1646
  // every exit path — /exit, Ctrl+C idle, test teardown) and flush.
1306
1647
  // Best-effort, never throws; idempotent with closeTelemetry callers.
@@ -1329,6 +1670,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1329
1670
  }
1330
1671
  try {
1331
1672
  draftThrottleRef.current?.cancel();
1673
+ thinkingThrottleRef.current?.cancel();
1332
1674
  }
1333
1675
  catch {
1334
1676
  // ignore
@@ -1432,9 +1774,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1432
1774
  askCustomRef.current = next;
1433
1775
  setAskCustom(next);
1434
1776
  }
1777
+ function setExtDlgSelBoth(next) {
1778
+ extDlgSelRef.current = next;
1779
+ setExtDlgSel(next);
1780
+ }
1781
+ function setExtDlgCustomBoth(next) {
1782
+ extDlgCustomRef.current = next;
1783
+ setExtDlgCustom(next);
1784
+ }
1435
1785
  function setEffortBoth(next) {
1436
- effortRef.current = next;
1437
- setEffort(next);
1786
+ // Central normalization point: every restore path (saved prefs, session
1787
+ // switch, picker) funnels through here, so a legacy "default" can never
1788
+ // linger in live state.
1789
+ const canonical = normalizeEffort(next);
1790
+ effortRef.current = canonical;
1791
+ setEffort(canonical);
1438
1792
  }
1439
1793
  function setEffortIndexBoth(next) {
1440
1794
  effortIndexRef.current = next;
@@ -1499,6 +1853,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1499
1853
  setModelFilterBoth("");
1500
1854
  setSelectingSkills(false);
1501
1855
  setSkillFilterBoth("");
1856
+ setSelectingSession(false);
1857
+ setSessionFilterBoth("");
1502
1858
  setSelectingEffort(false);
1503
1859
  setSelectingProvider(false);
1504
1860
  setKeyPromptBoth(null);
@@ -1531,6 +1887,46 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1531
1887
  pushInfo("(skill discovery failed — no skills listed)");
1532
1888
  });
1533
1889
  }
1890
+ // /session picker open: snapshot the store list ONCE (most-recent-first
1891
+ // from listSessions) with the active record marked, then filter in memory
1892
+ // per keystroke (no disk reads while typing). Idle-only (callers gate on
1893
+ // busy like every picker — a mid-turn switch would race the loop's own
1894
+ // history writes). Empty store degrades to a notice (unreachable while
1895
+ // the mount bootstrap holds, but never a blank popup).
1896
+ function openSessionPicker(initialFilter) {
1897
+ setInputBoth("");
1898
+ closeAllPickers();
1899
+ let records;
1900
+ try {
1901
+ records = listSessions(authHome);
1902
+ }
1903
+ catch {
1904
+ pushInfo("(could not list sessions — store unreadable)");
1905
+ return;
1906
+ }
1907
+ if (records.length === 0) {
1908
+ pushInfo("(no sessions yet — your current conversation is saved automatically)");
1909
+ return;
1910
+ }
1911
+ let activeId = null;
1912
+ try {
1913
+ activeId = getActiveSessionId(authHome);
1914
+ }
1915
+ catch {
1916
+ activeId = null;
1917
+ }
1918
+ setSessionItems(records.map((s) => ({
1919
+ id: s.id,
1920
+ title: s.title,
1921
+ updatedAt: s.updatedAt,
1922
+ createdAt: s.createdAt,
1923
+ turnCount: s.turns.length,
1924
+ active: s.id === activeId,
1925
+ })));
1926
+ setSessionFilterBoth(initialFilter);
1927
+ setSessionIndexBoth(0);
1928
+ setSelectingSession(true);
1929
+ }
1534
1930
  // Unified /model entries for this render: active provider's current list
1535
1931
  // first, then every other keyed provider's cached-or-fallback list (pure,
1536
1932
  // local-only — see modelPickerEntries). Called from the /model open path,
@@ -1614,7 +2010,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1614
2010
  : def.defaultModel);
1615
2011
  setModelBoth(nextModel);
1616
2012
  setProviderBoth(pickedId);
1617
- // Provider switch resets the load latch: the last reported prompt_tokens
2013
+ // Provider switch resets the load latch: the last reported input tokens
1618
2014
  // belonged to the old provider/model tokenizer, so the estimate applies
1619
2015
  // until the new provider reports (usageTotals spend is untouched).
1620
2016
  resetContextLoadToEstimate();
@@ -1652,6 +2048,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1652
2048
  usageRef.current = next;
1653
2049
  setUsageTotals(next);
1654
2050
  }
2051
+ function setSessionTitleBoth(next) {
2052
+ sessionTitleRef.current = next;
2053
+ setSessionTitle(next);
2054
+ }
1655
2055
  function setContextLoadBoth(next) {
1656
2056
  contextLoadRef.current = next;
1657
2057
  setContextLoad(next);
@@ -1661,7 +2061,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1661
2061
  setAutoDisabled(next);
1662
2062
  }
1663
2063
  // Recompute contextLoad after a committed turn (or compaction): last
1664
- // POST prompt_tokens when available, else the 4ch/token estimate.
2064
+ // POST input tokens (incl. cache) when available, else the 4ch/token estimate.
1665
2065
  function refreshContextLoad() {
1666
2066
  // No usage yet → no load (status keeps `token: n/a`).
1667
2067
  if (!usageRef.current) {
@@ -1672,7 +2072,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1672
2072
  setContextLoadBoth(load);
1673
2073
  return load;
1674
2074
  }
1675
- // Load-reset contract (hold-last-known): the reported prompt_tokens survive
2075
+ // Load-reset contract (hold-last-known): the reported input tokens survive
1676
2076
  // silent POSTs — estimates never override a fresher report — and reset ONLY
1677
2077
  // here: compaction, /clear, resume, and model/provider switch. After a reset
1678
2078
  // the chars/4 estimate applies until the next report arrives.
@@ -1688,9 +2088,49 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1688
2088
  function pushInfo(content) {
1689
2089
  appendTurns({ role: "tool", content });
1690
2090
  }
2091
+ // Extension slash-command execution (ticket 04): handlers run outside
2092
+ // the model turn loop — no history writes, no telemetry turn, no busy
2093
+ // flag. say() posts transcript turns only (pushInfo), so a throwing
2094
+ // handler surfaces one clean error line and leaves the model session
2095
+ // untouched. The context is bound to the runtime generation at launch:
2096
+ // a session replacement mid-command makes further ctx use throw into
2097
+ // the same clean-error path.
2098
+ async function runExtensionCommandFromApp(name, args) {
2099
+ if (extCommandRunningRef.current) {
2100
+ pushInfo("(an extension command is already running — wait for its prompt)");
2101
+ return;
2102
+ }
2103
+ extCommandRunningRef.current = true;
2104
+ try {
2105
+ const runtime = extRuntimeRef.current;
2106
+ const gen = runtime?.generation ?? 0;
2107
+ const result = await runExtensionCommand(name, args, {
2108
+ cwd: storeCwd(),
2109
+ askUser,
2110
+ getSession: () => ({
2111
+ id: activeSessionIdRef.current,
2112
+ title: sessionTitleRef.current,
2113
+ turnCount: turnsRef.current.length,
2114
+ }),
2115
+ say: (message) => {
2116
+ pushInfo(message);
2117
+ },
2118
+ checkStale: () => {
2119
+ if (runtime && runtime.generation !== gen) {
2120
+ throw new Error("extension context is stale after a session replacement — rerun the command for fresh state");
2121
+ }
2122
+ },
2123
+ });
2124
+ if (!result.ok)
2125
+ pushInfo(result.error);
2126
+ }
2127
+ finally {
2128
+ extCommandRunningRef.current = false;
2129
+ }
2130
+ }
1691
2131
  // The session's ContextManager: window-derived budgets for the active
1692
- // model, measured tool schemas, configured ceilings. Built fresh per call
1693
- // (pure math, no I/O beyond the resolved ceiling sources) so it always sees
2132
+ // model plus measured tool schemas. Built fresh per call
2133
+ // (pure math, no I/O beyond compactPct) so it always sees
1694
2134
  // the current model; history is measured live on every use. The schema size
1695
2135
  // is memoized once — TOOL_DEFINITIONS never changes at runtime, so every
1696
2136
  // turn must not re-serialize 15KB to ask.
@@ -1721,6 +2161,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1721
2161
  const loadLine = u.loadPct !== undefined && b.windowTokens !== undefined
1722
2162
  ? `load: ${formatKEst(u.historyChars)} (${u.loadPct}% of ${(b.windowTokens / 1000).toFixed(0)}K verified window)`
1723
2163
  : `load: ${formatKEst(u.historyChars)} (no verified window — auto-compact off, use /compact manually)`;
2164
+ // Window-derived allowance is informational only: history is never
2165
+ // truncated, compaction is the only pressure valve.
2166
+ const allowanceLine = b.historyChars !== undefined && b.windowTokens !== undefined
2167
+ ? `allowance: ~${(b.historyChars / 1000).toFixed(0)}K chars of history fit the ${(b.windowTokens / 1000).toFixed(0)}K verified window`
2168
+ : `allowance: no verified window — history uncapped, use /compact manually`;
1724
2169
  const cfg = atomConfigLoad;
1725
2170
  const cfgSources = cfg.sources.project && cfg.sources.global
1726
2171
  ? "project + global"
@@ -1756,7 +2201,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1756
2201
  `skill injections live in history: ${skillLoads}\n` +
1757
2202
  `${cfgLine}\n` +
1758
2203
  `${cacheLine}\n` +
1759
- `${loadLine} · budget: ${b.effectiveMaxMessages} msgs / ${(b.effectiveMaxChars / 1000).toFixed(0)}K chars`);
2204
+ `${loadLine}\n` +
2205
+ `${allowanceLine}`);
1760
2206
  }
1761
2207
  // Load a resolved skill into the session (tickets 03/06) with
1762
2208
  // progressive-disclosure tiers (Claude-Code-style):
@@ -1825,6 +2271,247 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1825
2271
  // committed diff previews (Turn.diff) are display-only and never saved:
1826
2272
  // they can hold whole file contents (bloat) and would render stale
1827
2273
  // after later edits, so /resume restores label-only turns.
2274
+ //
2275
+ // Multi-session mirror (src/sessions.ts): every conversation auto-belongs
2276
+ // to a durable session record (~/.atom/sessions/<id>.json + active
2277
+ // pointer). persistSession() also mirrors the same committed snapshot
2278
+ // into the active record via persistStoreSession() below, so completed
2279
+ // turns, clean exits, and successful compactions bump updatedAt there too.
2280
+ // Failures/cancels never reach here (same rollback rule as legacy).
2281
+ // Active id lives in a ref only — never a session list in runtime state.
2282
+ const activeSessionIdRef = useRef(null);
2283
+ // Extension host (ticket: extension system 01): the loaded runtime lives
2284
+ // in a ref so session replacements can invalidate it without re-render.
2285
+ // Null until the mount-time load completes (or when nothing is installed).
2286
+ const extRuntimeRef = useRef(null);
2287
+ // An extension slash command in flight owns the question modal while
2288
+ // prompting (single slot shared with ask_question): submit routes new
2289
+ // model turns and nested extension commands aside with a notice until
2290
+ // this clears, so resolvers can never clobber each other.
2291
+ const extCommandRunningRef = useRef(false);
2292
+ /**
2293
+ * Session-replacement boundary for extensions: previously handed-out API
2294
+ * objects go stale (loud on use), then session_start fires for the new
2295
+ * lineage. This is the sanctioned post-replacement continuation — work
2296
+ * that must continue after a replacement runs in session_start handlers
2297
+ * via their fresh API, never via a captured pre-replacement handle (which
2298
+ * throws). Never throws; a missing runtime is a no-op. Callers bind the
2299
+ * new session id via runtime.setSessionId BEFORE calling, so start
2300
+ * handlers observe the new session's extension state.
2301
+ */
2302
+ function replaceExtensionContext(reason) {
2303
+ const runtime = extRuntimeRef.current;
2304
+ if (!runtime)
2305
+ return;
2306
+ try {
2307
+ runtime.invalidate(`extension context is stale after session ${reason} — use the fresh API passed to your session_start handler`);
2308
+ }
2309
+ catch {
2310
+ // invalidate never throws by contract; defensive only.
2311
+ }
2312
+ void runtime.emit("session_start", { reason });
2313
+ }
2314
+ function storeCwd() {
2315
+ try {
2316
+ return process.cwd();
2317
+ }
2318
+ catch {
2319
+ return "";
2320
+ }
2321
+ }
2322
+ // Ensure exactly one active persistent session exists (fresh mount creates
2323
+ // one with the current provider/model/effort/mode + cwd). Never throws,
2324
+ // never blocks render — disk errors leave the ref null and the next
2325
+ // persist retries.
2326
+ function ensureStoreSession() {
2327
+ try {
2328
+ const existing = activeSessionIdRef.current;
2329
+ // The record may vanish under a running process (external delete,
2330
+ // corrupted file): a stale cached id must re-ensure instead of
2331
+ // persisting into the void (every later turn would silently skip).
2332
+ if (existing) {
2333
+ try {
2334
+ if (getSession(existing, authHome))
2335
+ return existing;
2336
+ }
2337
+ catch {
2338
+ // fall through to re-ensure below
2339
+ }
2340
+ }
2341
+ const s = ensureActiveSession({
2342
+ cwd: storeCwd(),
2343
+ provider: providerRef.current,
2344
+ model: modelRef.current,
2345
+ effort: effortRef.current,
2346
+ mode: modeRef.current,
2347
+ }, authHome);
2348
+ activeSessionIdRef.current = s.id;
2349
+ // The store owns the title (a /rename from an earlier mount must show
2350
+ // after restart); sync the display state on every ensure.
2351
+ setSessionTitleBoth(s.title);
2352
+ return s.id;
2353
+ }
2354
+ catch {
2355
+ return null;
2356
+ }
2357
+ }
2358
+ // Mirror the committed snapshot into the active multi-session record
2359
+ // (single updateSession so updatedAt bumps on every committed turn).
2360
+ // Disk errors ignored, like the legacy save above.
2361
+ function persistStoreSession() {
2362
+ try {
2363
+ const id = ensureStoreSession();
2364
+ if (!id)
2365
+ return;
2366
+ updateSession(id, {
2367
+ history: historyRef.current,
2368
+ turns: turnsRef.current.map((t) => {
2369
+ const { diff: _dropped, ...rest } = t;
2370
+ return rest;
2371
+ }),
2372
+ usageTotals: usageRef.current,
2373
+ // Piggyback: the live goal rides every store persist (same call
2374
+ // as every completed turn — no new save cadence). Switching away
2375
+ // snapshots this session's goal into its own record first, so a
2376
+ // switch back restores it and sessions never leak goals.
2377
+ goal: serializeGoalForPersist(goalRef.current),
2378
+ provider: providerRef.current,
2379
+ model: modelRef.current,
2380
+ effort: effortRef.current,
2381
+ mode: modeRef.current,
2382
+ cwd: storeCwd(),
2383
+ }, authHome);
2384
+ }
2385
+ catch {
2386
+ // ignore disk errors (in-memory session still applies)
2387
+ }
2388
+ }
2389
+ // One boot notice for the extension host (ticket 07): the existing
2390
+ // loaded/failed line, plus one line per skip reason so declined/untrusted/
2391
+ // locked-down/disabled extensions stay visible with how to enable them.
2392
+ // Silent when nothing loaded, failed, or skipped.
2393
+ function announceExtensionRuntime(runtime) {
2394
+ if (runtime.loaded.length > 0 || runtime.errors.length > 0) {
2395
+ const names = runtime.loaded.map((e) => e.name).join(", ");
2396
+ const problems = runtime.errors.map((e) => `${e.path}: ${e.error}`).join("; ");
2397
+ pushInfo(`(extensions: ${runtime.loaded.length} loaded${names ? ` (${names})` : ""}` +
2398
+ `${problems ? `; ${runtime.errors.length} failed: ${problems}` : ""})`);
2399
+ }
2400
+ const byReason = new Map();
2401
+ for (const s of runtime.skipped) {
2402
+ const list = byReason.get(s.reason);
2403
+ if (list)
2404
+ list.push(s.name);
2405
+ else
2406
+ byReason.set(s.reason, [s.name]);
2407
+ }
2408
+ const whyFor = (reason) => {
2409
+ switch (reason) {
2410
+ case "lockdown":
2411
+ return "lockdown is on (--no-extensions)";
2412
+ case "untrusted-project":
2413
+ return "the project is not trusted";
2414
+ case "disabled":
2415
+ return "they match a disable pattern";
2416
+ case "not-enabled":
2417
+ return "they match no enable pattern";
2418
+ }
2419
+ };
2420
+ const hintFor = (reason) => {
2421
+ switch (reason) {
2422
+ case "lockdown":
2423
+ return "start without --no-extensions to load them";
2424
+ case "untrusted-project":
2425
+ return "trust the project when asked on next startup to load them";
2426
+ case "disabled":
2427
+ return 'remove the --disable-extension / atom.json "extensions.disabled" pattern to load them';
2428
+ case "not-enabled":
2429
+ return 'match them with --enable-extension / atom.json "extensions.enabled" to load them';
2430
+ }
2431
+ };
2432
+ for (const [reason, names] of byReason) {
2433
+ const unique = [...new Set(names)];
2434
+ pushInfo(`(extensions: ${unique.length} skipped (${unique.join(", ")}) — ${whyFor(reason)}; ${hintFor(reason)})`);
2435
+ }
2436
+ }
2437
+ // Mount bootstrap: claim/create the active session before any turn can
2438
+ // persist, so every normal conversation belongs to a durable session.
2439
+ // Startup never auto-restores conversation state (fresh + legacy hint,
2440
+ // matching current UX) — this only ensures the record exists.
2441
+ useEffect(() => {
2442
+ ensureStoreSession();
2443
+ // Extension host boot (best-effort, never blocks render): trust-gated
2444
+ // (ticket 07). Global-scope extensions are user-owned (implicitly
2445
+ // trusted, like the user's own config); project-scope + explicit-path
2446
+ // extensions never execute until the project is trusted — the user is
2447
+ // asked once via the question modal (declining, or Esc, leaves them fully
2448
+ // inert with a visible notice; the grant persists per project dir, so a
2449
+ // decline simply asks again next boot). Lockdown (--no-extensions) skips
2450
+ // the prompt and boots with zero third-party extensions. A loaded runtime
2451
+ // still records per-extension errors — loadExtensions never throws here.
2452
+ void (async () => {
2453
+ const cwd = storeCwd();
2454
+ const cfgExtensions = atomConfig.extensions;
2455
+ // CLI patterns win over atom.json when set (same CLI-over-config
2456
+ // layering as every other value); the project/global config merge
2457
+ // already applied inside loadAtomConfig.
2458
+ const enabled = enableExtensions !== undefined && enableExtensions.length > 0
2459
+ ? enableExtensions
2460
+ : (cfgExtensions?.enabled ?? []);
2461
+ const disabled = disableExtensions !== undefined && disableExtensions.length > 0
2462
+ ? disableExtensions
2463
+ : (cfgExtensions?.disabled ?? []);
2464
+ const lockdown = extensionsLockdown === true;
2465
+ const finish = async (trusted) => {
2466
+ const runtime = await loadExtensions({
2467
+ home: authHome,
2468
+ cwd,
2469
+ builtinSlashCommands: SLASH_COMMANDS.map((c) => c.name),
2470
+ projectTrusted: trusted,
2471
+ lockdown,
2472
+ enabledPatterns: enabled,
2473
+ disabledPatterns: disabled,
2474
+ // The TUI fulfills extension dialogs (ticket 10); headless modes
2475
+ // never load extensions, so they stay inert by construction.
2476
+ interactive: true,
2477
+ });
2478
+ extRuntimeRef.current = runtime;
2479
+ // Live UI surface: every segment/widget/notice/dialog mutation
2480
+ // re-renders (segments update across turns with no other trigger).
2481
+ runtime.subscribeUI(() => {
2482
+ bumpExtUI((v) => v + 1);
2483
+ });
2484
+ // Bind the store session BEFORE the startup emit, so session_start
2485
+ // handlers observe the reloaded session's extension state (ticket 05:
2486
+ // per-session state restores on reload through the record metadata).
2487
+ runtime.setSessionId(activeSessionIdRef.current);
2488
+ announceExtensionRuntime(runtime);
2489
+ return runtime.emit("session_start", { reason: "startup" });
2490
+ };
2491
+ if (!lockdown) {
2492
+ const gated = discoverExtensionEntries({ home: authHome, cwd }).filter((e) => e.scope !== "global");
2493
+ if (gated.length > 0 && !isProjectTrusted(cwd, authHome)) {
2494
+ const names = [...new Set(gated.map((e) => resolveExtensionName(e.path)))];
2495
+ let answer;
2496
+ try {
2497
+ answer = await askUser(projectTrustQuestion(names), ["Trust and load", "Keep disabled"]);
2498
+ }
2499
+ catch {
2500
+ answer = "Keep disabled"; // Esc declines: inert + visible, asked again next boot
2501
+ }
2502
+ const trusted = answer === "Trust and load";
2503
+ if (trusted)
2504
+ grantProjectTrust(cwd, authHome);
2505
+ await finish(trusted);
2506
+ return;
2507
+ }
2508
+ }
2509
+ await finish(isProjectTrusted(cwd, authHome));
2510
+ })().catch(() => {
2511
+ // loadExtensions never rejects by contract; defensive only.
2512
+ });
2513
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2514
+ }, []);
1828
2515
  function persistSession() {
1829
2516
  try {
1830
2517
  saveSession({
@@ -1833,6 +2520,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1833
2520
  effort: effortRef.current,
1834
2521
  mode: modeRef.current,
1835
2522
  usageTotals: usageRef.current,
2523
+ // Piggyback: the live goal rides the legacy save too, so /resume
2524
+ // and restarts bring it back with its cumulative stats intact.
2525
+ goal: goalRef.current,
1836
2526
  history: historyRef.current,
1837
2527
  turns: turnsRef.current.map((t) => {
1838
2528
  const { diff: _dropped, ...rest } = t;
@@ -1843,6 +2533,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1843
2533
  catch {
1844
2534
  // ignore disk errors (in-memory session still applies)
1845
2535
  }
2536
+ persistStoreSession();
1846
2537
  }
1847
2538
  // Local observability persistence: flush the current telemetry session
1848
2539
  // file (atomic, best-effort). Called on turn boundaries and session events —
@@ -1900,6 +2591,20 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1900
2591
  // failure: old history untouched + inline error (oversize retry-once
1901
2592
  // already handled inside requestCompactSummary, which appends /clear).
1902
2593
  // isAuto drives the thrash guard; manual resets the counter on success.
2594
+ // A non-reducing auto outcome (load still at/above threshold, or an
2595
+ // extension veto that changed nothing) counts toward the guard via
2596
+ // bumpAutoStreak, so a standing veto disables auto with the standard
2597
+ // notice instead of re-firing (and re-notifying) after every turn.
2598
+ function bumpAutoStreak() {
2599
+ autoStreakRef.current += 1;
2600
+ if (isThrashDisabled(autoStreakRef.current)) {
2601
+ setAutoDisabledBoth(true);
2602
+ appendTurns({
2603
+ role: "tool",
2604
+ content: "(auto-compact thrashing — disabled, use /compact or /clear)",
2605
+ });
2606
+ }
2607
+ }
1903
2608
  async function doCompact(focusText, isAuto) {
1904
2609
  if (countUserTurns(historyRef.current) <= 1) {
1905
2610
  if (!isAuto)
@@ -1930,52 +2635,109 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1930
2635
  }
1931
2636
  const baseURL = chatBaseURL(providerRef.current);
1932
2637
  try {
1933
- const summary = await requestCompactSummary({
1934
- provider: providerRef.current,
1935
- apiKey: submitKey,
1936
- model: modelRef.current,
1937
- systemContent,
1938
- head: split.head,
1939
- focusText,
1940
- baseURL,
1941
- endpointOverride: activeEndpoint,
1942
- onUsage: (u) => {
1943
- // Totals keep accumulating (real summary spend); load source
1944
- // untouched (summary prompt reflects head size, not new context).
1945
- const prev = usageRef.current ?? {};
1946
- const next = { ...prev };
1947
- if (u.prompt_tokens !== undefined) {
1948
- next.prompt_tokens = (next.prompt_tokens ?? 0) + u.prompt_tokens;
1949
- }
1950
- if (u.completion_tokens !== undefined) {
1951
- next.completion_tokens = (next.completion_tokens ?? 0) + u.completion_tokens;
1952
- }
1953
- if (u.total_tokens !== undefined) {
1954
- next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
1955
- }
1956
- if (u.cacheReadTokens !== undefined) {
1957
- next.cacheReadTokens = (next.cacheReadTokens ?? 0) + u.cacheReadTokens;
1958
- }
1959
- if (u.cacheWriteTokens !== undefined) {
1960
- next.cacheWriteTokens = (next.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
1961
- }
1962
- // Only accumulate when the summary actually reported usage;
1963
- // an empty onUsage keeps totals byte-identical.
1964
- if (u.prompt_tokens !== undefined ||
1965
- u.completion_tokens !== undefined ||
1966
- u.total_tokens !== undefined ||
1967
- u.cacheReadTokens !== undefined ||
1968
- u.cacheWriteTokens !== undefined) {
1969
- setUsageBoth(next);
1970
- }
1971
- // Local observability: compaction spend is session-level (it
1972
- // summarizes many turns and often lands after its turn ended), so
1973
- // it is kept separate from per-turn usage. No-op when empty.
1974
- telemetry.recordCompactionUsage(u, isAuto ? "auto" : "manual");
1975
- },
1976
- });
1977
- // Atomic swap: build the new history first, then replace.
1978
- const next = buildCompactedHistory(systemMsg, summary, split.tail, split.olderTurnCount);
2638
+ // Extension gate (ticket 09): consulted BEFORE the builtin summary
2639
+ // POST with the reason and the pending head/tail split as read-only
2640
+ // deep copies — a mutating hook cannot corrupt the split below, and a
2641
+ // cancelled attempt returns before EVERY write (history, snapshots,
2642
+ // totals, ledger stay byte-identical). Zero-cost when no hooks are
2643
+ // registered: the snapshot is a single spread and no await runs, so
2644
+ // hook-free auto-compact keeps its timing (the zen.ts context-hook
2645
+ // precedent). Fail-open: a throwing hook records a visible error and
2646
+ // compaction falls back to the builtin summary, never half-compacted.
2647
+ let customSummary = null;
2648
+ const compactHooks = beforeCompactInterceptors();
2649
+ if (compactHooks.length > 0) {
2650
+ const verdict = await applyBeforeCompact(compactHooks, {
2651
+ reason: isAuto ? "auto" : "manual",
2652
+ focusText,
2653
+ head: split.head,
2654
+ tail: split.tail,
2655
+ olderTurnCount: split.olderTurnCount,
2656
+ });
2657
+ if (verdict.cancelled) {
2658
+ pushInfo(verdict.cancelReason
2659
+ ? `(compaction cancelled: ${verdict.cancelReason})`
2660
+ : "(compaction cancelled by an extension)");
2661
+ // A vetoed auto-compaction changes nothing, so the load that
2662
+ // triggered it is still above threshold — count it toward the
2663
+ // thrash guard (manual cancels are deliberate one-shots, untouched).
2664
+ if (isAuto)
2665
+ bumpAutoStreak();
2666
+ return false;
2667
+ }
2668
+ if (verdict.errors.length > 0) {
2669
+ pushInfo(`(extension compact hook failed — using builtin summary: ${verdict.errors.join("; ")})`);
2670
+ }
2671
+ if (verdict.summary !== null)
2672
+ customSummary = verdict.summary;
2673
+ }
2674
+ // Single injection point: a custom summary replaces the builtin text
2675
+ // here and flows through the SAME post-processing below (goal block +
2676
+ // touched-files append/fit, boundary marker, atomic swap, save,
2677
+ // snapshot clearing) exactly like builtin output never a parallel
2678
+ // pipeline.
2679
+ const summary = customSummary ??
2680
+ (await requestCompactSummary({
2681
+ provider: providerRef.current,
2682
+ apiKey: submitKey,
2683
+ model: modelRef.current,
2684
+ systemContent,
2685
+ head: split.head,
2686
+ focusText,
2687
+ // Ticket 08: the live goal objective (active or paused) hints the
2688
+ // summarizer to preserve goal-relevant content; undefined keeps
2689
+ // the legacy instruction byte-identical. goalRef survives the
2690
+ // swap below untouched, so post-compact turns read the same live
2691
+ // goal through the loop's getGoal seam.
2692
+ goalObjective: goalRef.current?.objective,
2693
+ baseURL,
2694
+ endpointOverride: activeEndpoint,
2695
+ onUsage: (u) => {
2696
+ // Totals keep accumulating (real summary spend); load source
2697
+ // untouched (summary prompt reflects head size, not new context).
2698
+ const prev = usageRef.current ?? {};
2699
+ const next = { ...prev };
2700
+ if (u.prompt_tokens !== undefined) {
2701
+ next.prompt_tokens = (next.prompt_tokens ?? 0) + u.prompt_tokens;
2702
+ }
2703
+ if (u.completion_tokens !== undefined) {
2704
+ next.completion_tokens = (next.completion_tokens ?? 0) + u.completion_tokens;
2705
+ }
2706
+ if (u.total_tokens !== undefined) {
2707
+ next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
2708
+ }
2709
+ if (u.cacheReadTokens !== undefined) {
2710
+ next.cacheReadTokens = (next.cacheReadTokens ?? 0) + u.cacheReadTokens;
2711
+ }
2712
+ if (u.cacheWriteTokens !== undefined) {
2713
+ next.cacheWriteTokens = (next.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
2714
+ }
2715
+ // Only accumulate when the summary actually reported usage;
2716
+ // an empty onUsage keeps totals byte-identical.
2717
+ if (u.prompt_tokens !== undefined ||
2718
+ u.completion_tokens !== undefined ||
2719
+ u.total_tokens !== undefined ||
2720
+ u.cacheReadTokens !== undefined ||
2721
+ u.cacheWriteTokens !== undefined) {
2722
+ setUsageBoth(next);
2723
+ }
2724
+ // Local observability: compaction spend is session-level (it
2725
+ // summarizes many turns and often lands after its turn ended), so
2726
+ // it is kept separate from per-turn usage. No-op when empty.
2727
+ telemetry.recordCompactionUsage(u, isAuto ? "auto" : "manual");
2728
+ },
2729
+ }));
2730
+ // Atomic swap: build the new history first, then replace. The head's
2731
+ // touched files (collected from the committed tool_calls the loop
2732
+ // already recorded — no new tracking) plus the canonical `Goal:` block
2733
+ // (live text, state, cumulative stats, open checklist — the model's
2734
+ // context backstop; record restore stays the resume path) ride inside
2735
+ // the summary within budget, so compacted and resumed sessions continue
2736
+ // the same goal without re-exploring; over-budget lists shrink instead
2737
+ // of failing compaction (model text + goal block are never cut).
2738
+ const goalBlock = formatGoalForCompact(goalRef.current, getTodos().map((t) => ({ content: t.content, status: t.status })));
2739
+ const fitted = fitSummaryWithFilesAndGoal(summary, collectTouchedFiles(split.head), goalBlock);
2740
+ const next = buildCompactedHistory(systemMsg, fitted.text, split.tail, split.olderTurnCount);
1979
2741
  // Replacement: re-wrap so the ledger restarts from the compacted array
1980
2742
  // (the old ledger is discarded with the old array).
1981
2743
  historyRef.current = trackHistory(next);
@@ -1988,7 +2750,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1988
2750
  pushInfo(`(/compact — discarded ${compactDrops} file checkpoint(s); undos do not cross a compaction)`);
1989
2751
  }
1990
2752
  // P% must drop immediately: the old lastPromptTokens reflects the
1991
- // pre-compact context, so clear it and use the new-history estimate.
2753
+ // pre-compact context (and its cache counters), so clear it and use
2754
+ // the new-history estimate.
1992
2755
  lastPromptTokensRef.current = undefined;
1993
2756
  const newLoad = estimateTokensForChars(historyChars(historyRef.current));
1994
2757
  setContextLoadBoth(newLoad);
@@ -1999,14 +2762,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1999
2762
  autoStreakRef.current = 0;
2000
2763
  }
2001
2764
  else {
2002
- autoStreakRef.current += 1;
2003
- if (isThrashDisabled(autoStreakRef.current)) {
2004
- setAutoDisabledBoth(true);
2005
- appendTurns({
2006
- role: "tool",
2007
- content: "(auto-compact thrashing — disabled, use /compact or /clear)",
2008
- });
2009
- }
2765
+ bumpAutoStreak();
2010
2766
  }
2011
2767
  }
2012
2768
  else {
@@ -2077,6 +2833,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2077
2833
  setEffortBoth(s.effort);
2078
2834
  setModeBoth(s.mode);
2079
2835
  setUsageBoth(s.usageTotals);
2836
+ // Ticket 07: the saved goal restores verbatim (text, flag, cumulative
2837
+ // stats — never reset) and mirrors into the store record below, so the
2838
+ // resumed session continues the goal on its next turn. Corrupt/absent
2839
+ // goal data restores as no-goal without touching the conversation.
2840
+ setGoalBoth(restoreGoalFromPersist(s.goal));
2080
2841
  // Replacement: wrap the restored array (see the init comment).
2081
2842
  historyRef.current = trackHistory([...s.history]);
2082
2843
  // Task 6: refresh the pinned env block on the restored system line
@@ -2106,11 +2867,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2106
2867
  content: `(/resume — discarded ${resumedDrops} live file checkpoint(s); undos do not cross a resume)`,
2107
2868
  });
2108
2869
  }
2109
- // Same window-derived caps as the live path (the restored model is
2110
- // already in modelRef above).
2111
- contextManager().trimForSend(historyRef.current, (msg) => {
2112
- pendingNotices.push({ role: "tool", content: `⚠ ${msg}` });
2113
- }, undefined, openTodoNeedles());
2870
+ // Restored history is used whole: no caps, no trimming. A resumed
2871
+ // session knows what was touched without re-exploring the tree.
2872
+ for (const section of collectStoredTouchedFiles(historyRef.current)) {
2873
+ pendingNotices.push({ role: "tool", content: section });
2874
+ }
2114
2875
  // Remount the turns <Static> (same mechanism as /clear and /new): Ink's
2115
2876
  // Static only renders newly appended indices, so restoring a transcript
2116
2877
  // over a non-empty rendered buffer (e.g. the /new boundary line) would
@@ -2128,6 +2889,175 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2128
2889
  ]);
2129
2890
  telemetry.recordEvent("resume", `restored session saved at ${s.savedAt} (${s.turns.length} turns)`);
2130
2891
  persistTelemetry();
2892
+ // Mirror the restored legacy state into the active multi-session record
2893
+ // (create+activate when none exists). Startup itself never auto-restores
2894
+ // the store — only this explicit /resume does.
2895
+ persistStoreSession();
2896
+ // Same record stays active across a legacy resume — re-bind it so start
2897
+ // handlers observe the restored session's extension state (ticket 05).
2898
+ extRuntimeRef.current?.setSessionId(activeSessionIdRef.current);
2899
+ replaceExtensionContext("resume");
2900
+ }
2901
+ // /session switch: make the picked record the live conversation. Exactly
2902
+ // one history replacement (never merge, never duplicate): the target's
2903
+ // history/turns REPLACE the live arrays wholesale, following the doResume
2904
+ // precedent (endpoint recompute, env-block refresh, budget trim, lineage
2905
+ // drop, Static remount, title sync). Differences from /resume:
2906
+ // - the outgoing live state snapshots into the OLD record first, but only
2907
+ // when it holds turns (a fresh mount's system-only live state is NOT the
2908
+ // old record's content — persisting it would wipe that record);
2909
+ // - re-picking the current session is a no-op (reloading from disk would
2910
+ // drop unpersisted live turns);
2911
+ // - updatedAt is NOT bumped (switching is navigation, not a mutation —
2912
+ // listings keep true recency order);
2913
+ // - a missing/unreadable target errors WITHOUT touching the live session.
2914
+ // The legacy session.json follows the switch (same persistSession path as
2915
+ // every completed turn) so /resume stays coherent with the live view.
2916
+ async function switchToSession(id) {
2917
+ let target = null;
2918
+ try {
2919
+ target = getSession(id, authHome);
2920
+ }
2921
+ catch {
2922
+ target = null;
2923
+ }
2924
+ if (!target) {
2925
+ pushInfo("(session no longer available — staying on the current session)");
2926
+ return;
2927
+ }
2928
+ const currentId = activeSessionIdRef.current;
2929
+ if (currentId !== null && currentId === target.id) {
2930
+ pushInfo(`(already on "${target.title}")`);
2931
+ return;
2932
+ }
2933
+ // Cancellable gate FIRST (ticket 05): before_switch handlers run before
2934
+ // ANY snapshot/persist/mutate step (outgoing persist, active-pointer
2935
+ // write, ref swaps, legacy save), so a cancelled switch is a pure no-op
2936
+ // — the live session is byte-identical to before the call. Everything
2937
+ // below this point mutates, so nothing above it may.
2938
+ const gateRuntime = extRuntimeRef.current;
2939
+ if (gateRuntime) {
2940
+ let verdict;
2941
+ try {
2942
+ verdict = await gateRuntime.requestSwitch({ fromSessionId: currentId, toSessionId: target.id, reason: "switch" });
2943
+ }
2944
+ catch {
2945
+ // requestSwitch never rejects by contract; defensive only.
2946
+ verdict = { cancelled: false };
2947
+ }
2948
+ if (verdict.cancelled) {
2949
+ pushInfo(verdict.reason
2950
+ ? `(session switch cancelled: ${verdict.reason})`
2951
+ : "(session switch cancelled by an extension — staying on the current session)");
2952
+ return;
2953
+ }
2954
+ }
2955
+ // Snapshot the outgoing conversation into its own record first (same
2956
+ // rule as /new's pre-reset save). Guarded: only when the live state
2957
+ // actually holds turns of the outgoing record.
2958
+ if (currentId !== null && currentId !== target.id && turnsRef.current.length > 0) {
2959
+ persistStoreSession();
2960
+ }
2961
+ try {
2962
+ setActiveSession(target.id, authHome);
2963
+ }
2964
+ catch {
2965
+ // setActiveSession never throws by contract; defensive only.
2966
+ }
2967
+ activeSessionIdRef.current = target.id;
2968
+ setProviderBoth(target.provider);
2969
+ const baseURL = chatBaseURL(target.provider);
2970
+ if (target.provider === "openai-compatible") {
2971
+ setActiveEndpoint(openaiCompatibleChatEndpoint(baseURL));
2972
+ }
2973
+ else if (target.provider === "opencode-zen") {
2974
+ setActiveEndpoint(endpoint);
2975
+ }
2976
+ else {
2977
+ setActiveEndpoint(chatEndpointFor(target.provider, baseURL));
2978
+ }
2979
+ setModelBoth(target.model);
2980
+ setEffortBoth(target.effort);
2981
+ setModeBoth(target.mode);
2982
+ setUsageBoth(target.usageTotals);
2983
+ // Ticket 07: the target's goal replaces the live one wholesale (never
2984
+ // merged) — a session without a saved goal lands on no-goal, so one
2985
+ // session's goal can never leak into another. The outgoing goal was
2986
+ // snapshotted into its own record above, so switching back restores it.
2987
+ setGoalBoth(restoreGoalFromPersist(target.goal));
2988
+ // Replacement: the target's arrays replace the live ones wholesale (a
2989
+ // fresh-created record carries empty history — fall back to a fresh
2990
+ // system line so the system-first invariant always holds).
2991
+ historyRef.current =
2992
+ target.history.length > 0
2993
+ ? trackHistory([...target.history])
2994
+ : trackHistory([{ role: "system", content: withEnvBlock(systemPrompt) }]);
2995
+ if (target.history.length > 0) {
2996
+ refreshSystemEnv();
2997
+ }
2998
+ lastPromptTokensRef.current = undefined;
2999
+ if (!usageRef.current) {
3000
+ setContextLoadBoth(null);
3001
+ }
3002
+ else {
3003
+ setContextLoadBoth(estimateTokensForChars(historyChars(historyRef.current)));
3004
+ }
3005
+ autoStreakRef.current = 0;
3006
+ setAutoDisabledBoth(false);
3007
+ pendingCompactRef.current = null;
3008
+ const pendingNotices = [];
3009
+ // New lineage (see src/rollback.ts): checkpoint marks index the old
3010
+ // history — drop them, loudly when non-empty. Disk files untouched.
3011
+ const switchDrops = clearSnapshots();
3012
+ if (switchDrops > 0) {
3013
+ pendingNotices.push({
3014
+ role: "tool",
3015
+ content: `(/session — discarded ${switchDrops} live file checkpoint(s); undos do not cross a session switch)`,
3016
+ });
3017
+ }
3018
+ // TODO isolation (mirrors /new): the checklist is process-global memory
3019
+ // that is never persisted — carrying it across sessions would show the
3020
+ // new session the old session's plan, and the agent would act on it.
3021
+ // Reset it, loudly when non-empty.
3022
+ if (getTodos().length > 0) {
3023
+ clearTodos();
3024
+ setTodoSnap([]);
3025
+ pendingNotices.push({
3026
+ role: "tool",
3027
+ content: "(/session — checklist reset; TODOs are per-conversation and do not cross sessions)",
3028
+ });
3029
+ }
3030
+ else {
3031
+ clearTodos();
3032
+ setTodoSnap([]);
3033
+ }
3034
+ // The target's history/turns REPLACE the live arrays wholesale — used
3035
+ // whole, never trimmed.
3036
+ for (const section of collectStoredTouchedFiles(historyRef.current)) {
3037
+ pendingNotices.push({ role: "tool", content: section });
3038
+ }
3039
+ // Same Static remount as /clear, /resume, and /new: the replaced list
3040
+ // must not reuse the old buffer.
3041
+ setScrollEndBoth(null);
3042
+ setClearGen((g) => g + 1);
3043
+ setTurnsBoth([
3044
+ ...target.turns,
3045
+ {
3046
+ role: "tool",
3047
+ content: `(switched to session "${target.title}" — ${target.turns.length} turns)`,
3048
+ },
3049
+ ...pendingNotices,
3050
+ ]);
3051
+ setSessionTitleBoth(target.title);
3052
+ telemetry.recordEvent("info", `session switched to ${target.id} (${target.turns.length} turns)`);
3053
+ persistTelemetry();
3054
+ // Legacy single-file save follows the switch so /resume restores what
3055
+ // the live view shows (same path/format as every completed turn).
3056
+ // Bind the new record BEFORE the boundary emit, so session_start
3057
+ // handlers observe the new session's extension state (ticket 05).
3058
+ extRuntimeRef.current?.setSessionId(target.id);
3059
+ persistSession();
3060
+ replaceExtensionContext("switch");
2131
3061
  }
2132
3062
  // /rewind conversation scope (ticket 01): truncate history + transcript to
2133
3063
  // the checkpoint's turn. The cut drops the whole containing turn (submit's
@@ -2188,9 +3118,6 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2188
3118
  }
2189
3119
  pushInfo(filesMsg);
2190
3120
  }
2191
- function warnEffortUnsupported(modelName) {
2192
- pushInfo(`reasoning effort is not known to be supported by ${modelName} — setting kept, not sent`);
2193
- }
2194
3121
  // Manual /compact entry: busy → set pending flag, run at turn end (drain
2195
3122
  // boundary, never mid-turn); idle → run now under the busy guard so a
2196
3123
  // concurrent submit cannot interleave. Works for unknown-window models.
@@ -2322,19 +3249,54 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2322
3249
  ? "(thinking shown — model reasoning stays visible in the transcript)"
2323
3250
  : "(thinking hidden — reasoning still runs, it just isn't rendered)");
2324
3251
  }
3252
+ // /rename for the CURRENT session only (never creates one: renameSession
3253
+ // touches exactly the active record). Bare /rename prints usage — ATOM has
3254
+ // no generic text-prompt overlay (key/baseURL prompts are
3255
+ // provider-specific), so an interactive flow would be a new UI system.
3256
+ // Empty/whitespace-only names are rejected safely (previous name kept).
3257
+ // On failure the previous name is preserved (title state only changes on
3258
+ // success); history/turns are never part of the write.
3259
+ function runRenameCommand(raw) {
3260
+ const name = parseRenameArg(raw);
3261
+ if (!name) {
3262
+ pushInfo(RENAME_USAGE);
3263
+ return;
3264
+ }
3265
+ let id = null;
3266
+ try {
3267
+ id = ensureStoreSession();
3268
+ }
3269
+ catch {
3270
+ id = null;
3271
+ }
3272
+ if (!id) {
3273
+ pushInfo("(rename failed — session store unavailable; name unchanged)");
3274
+ return;
3275
+ }
3276
+ let renamed = null;
3277
+ try {
3278
+ renamed = renameSession(id, name, authHome);
3279
+ }
3280
+ catch {
3281
+ renamed = null;
3282
+ }
3283
+ if (!renamed) {
3284
+ const current = sessionTitleRef.current || "untitled";
3285
+ pushInfo(`(rename failed — still "${current}")`);
3286
+ return;
3287
+ }
3288
+ setSessionTitleBoth(renamed.title);
3289
+ pushInfo(`(renamed session to "${renamed.title}")`);
3290
+ }
2325
3291
  // /autoscroll [on|off]: follow switch for the scrollback viewport. View-
2326
3292
  // only state — safe while busy (never touches the turn, like /queue).
2327
- // Bare prints the state; on jumps to the latest; off freezes a following
3293
+ // Bare toggles off ⇄ on; on jumps to the latest; off freezes a following
2328
3294
  // view at its current end (mid-turn appends then accumulate below).
2329
3295
  function runAutoScrollCommand(raw) {
2330
3296
  const arg = raw.trim() === "/autoscroll" ? "" : raw.trim().slice("/autoscroll".length).trim().toLowerCase();
2331
- if (arg === "") {
2332
- pushInfo(autoScrollRef.current
2333
- ? "(autoscroll on — following new output as it arrives)"
2334
- : "(autoscroll off — the view freezes while a turn runs; End follows the latest)");
2335
- return;
2336
- }
2337
- if (arg === "on") {
3297
+ // Bare command toggles; explicit on|off sets directly.
3298
+ const effective = arg === "" ? (autoScrollRef.current ? "off" : "on") : arg;
3299
+ if (effective === "on") {
2338
3300
  if (autoScrollRef.current) {
2339
3301
  pushInfo("(autoscroll already on)");
2340
3302
  return;
@@ -2344,7 +3306,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2344
3306
  pushInfo("(autoscroll on — following the latest)");
2345
3307
  return;
2346
3308
  }
2347
- if (arg === "off") {
3309
+ if (effective === "off") {
2348
3310
  if (!autoScrollRef.current) {
2349
3311
  pushInfo("(autoscroll already off)");
2350
3312
  return;
@@ -2359,6 +3321,60 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2359
3321
  }
2360
3322
  pushInfo(AUTOSCROLL_USAGE);
2361
3323
  }
3324
+ // /goal [<objective>|pause|resume|clear]: session goal state.
3325
+ // View/state-only — safe while busy (never touches the turn, like
3326
+ // /autoscroll). Bare shows the active goal (text, state, cumulative
3327
+ // stats) or the none-hint; `/goal <objective>` sets with fresh stats (a
3328
+ // second set replaces with a notice); `/goal pause` flips the active flag
3329
+ // (pausing mid-turn stops continuation at the next turn end — the loop
3330
+ // reads the flag live — while todos, evidence, and history stay intact);
3331
+ // `/goal resume` re-arms the flag AND starts a continuation turn through
3332
+ // the normal submit path when idle (cumulative stats carry over — nothing
3333
+ // resets), or says so loudly without injecting a turn when busy (the
3334
+ // running turn's next turn end picks the live flag up on its own);
3335
+ // `/goal clear` ends it (harmless notice when absent). Every mutation
3336
+ // persists through the normal save path immediately (no new cadence).
3337
+ function runGoalCommand(raw) {
3338
+ const cmd = parseGoalCommand(raw);
3339
+ if (cmd.kind === "status") {
3340
+ pushInfo(goalStatusText(goalRef.current));
3341
+ return;
3342
+ }
3343
+ if (cmd.kind === "clear") {
3344
+ pushInfo(goalClearNotice(goalRef.current));
3345
+ if (!goalRef.current)
3346
+ return;
3347
+ setGoalBoth(null);
3348
+ persistSession();
3349
+ return;
3350
+ }
3351
+ if (cmd.kind === "pause") {
3352
+ const g = goalRef.current;
3353
+ pushInfo(goalPauseNotice(g));
3354
+ if (!g || !g.active)
3355
+ return;
3356
+ setGoalBoth({ ...g, active: false });
3357
+ persistSession();
3358
+ return;
3359
+ }
3360
+ if (cmd.kind === "resume") {
3361
+ const g = goalRef.current;
3362
+ pushInfo(goalResumeNotice(g));
3363
+ if (!g || g.active)
3364
+ return;
3365
+ setGoalBoth({ ...g, active: true });
3366
+ persistSession();
3367
+ if (busyRef.current) {
3368
+ pushInfo("(goal resumes when the current turn ends — no new turn started while busy)");
3369
+ return;
3370
+ }
3371
+ void submit(goalFollowUp(g.objective));
3372
+ return;
3373
+ }
3374
+ pushInfo(goalSetNotice(cmd.objective, goalRef.current));
3375
+ setGoalBoth({ objective: cmd.objective, active: true, stats: emptyGoalStats() });
3376
+ persistSession();
3377
+ }
2362
3378
  // /models: local-discovery status + refresh. Bare `/models` reports the
2363
3379
  // last snapshot (kicking a first probe when discovery never ran);
2364
3380
  // `/models refresh` re-probes all three runtimes, then reports. Results
@@ -2414,6 +3430,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2414
3430
  case "/clear":
2415
3431
  // Task 6: same base as mount (no AGENTS.md re-read, as before) plus a
2416
3432
  // fresh env block. Fresh array → fresh ledger via trackHistory.
3433
+ // Store mirror: LAZY, matching legacy — /clear wipes the live
3434
+ // transcript only and writes nothing to the store here; the cleared
3435
+ // (empty) conversation persists on the next completed turn via
3436
+ // persistSession()/persistStoreSession().
2417
3437
  historyRef.current = trackHistory([
2418
3438
  { role: "system", content: withEnvBlock(systemPrompt) },
2419
3439
  ]);
@@ -2424,11 +3444,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2424
3444
  setScrollEndBoth(null);
2425
3445
  setClearGen((g) => g + 1);
2426
3446
  setError(null);
2427
- setDraft(null);
3447
+ streamStore.setDraft(null);
2428
3448
  clearThinking();
2429
3449
  setToolHint(null);
2430
- setPhase("idle");
2431
- setPhaseDetail("");
3450
+ setPhaseBoth("idle", "");
2432
3451
  // /clear drops the transcript: load resets (no context), streak
2433
3452
  // resets, pending compact drains, and turn-scoped skill grants go
2434
3453
  // with it (no invisible auto-approvals survive a wiped transcript).
@@ -2443,6 +3462,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2443
3462
  if (clearedDrops > 0) {
2444
3463
  pushInfo(`(/clear — discarded ${clearedDrops} file checkpoint(s); undos do not cross a cleared conversation)`);
2445
3464
  }
3465
+ // /clear wipes the conversation, so the active goal ends here with
3466
+ // a visible notice (same lazy persist as the transcript above: the
3467
+ // cleared state — goal included — persists on the next completed
3468
+ // turn, and the save keeps the pre-clear state until then).
3469
+ const clearedGoal = goalRef.current;
3470
+ setGoalBoth(null);
3471
+ if (clearedGoal) {
3472
+ pushInfo(`(goal cleared — "${clearedGoal.objective}" — /clear wipes the conversation)`);
3473
+ }
2446
3474
  // autoDisabled stays for the session (thrash guard is session-wide).
2447
3475
  lastPromptTokensRef.current = undefined;
2448
3476
  setContextLoadBoth(null);
@@ -2459,7 +3487,30 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2459
3487
  // restores — same path/format as every completed turn, no new
2460
3488
  // schema). No sessions/ archive step: session.ts only has
2461
3489
  // session.json, so no archiving is invented here.
3490
+ // persistSession() also mirrors the pre-/new conversation into the
3491
+ // OLD active store record before the reset below.
2462
3492
  persistSession();
3493
+ // Fresh store record for the new conversation (settings carried over,
3494
+ // empty history/turns/usage; default title = formatSessionTitle(now)
3495
+ // via createSession). Explicit setActiveSession: createSession only
3496
+ // claims the pointer when none is set.
3497
+ try {
3498
+ const created = createSession({
3499
+ cwd: storeCwd(),
3500
+ provider: providerRef.current,
3501
+ model: modelRef.current,
3502
+ effort: effortRef.current,
3503
+ mode: modeRef.current,
3504
+ }, authHome);
3505
+ setActiveSession(created.id, authHome);
3506
+ activeSessionIdRef.current = created.id;
3507
+ setSessionTitleBoth(created.title);
3508
+ // Bind the new record BEFORE the boundary emit (ticket 05).
3509
+ extRuntimeRef.current?.setSessionId(created.id);
3510
+ }
3511
+ catch {
3512
+ // ignore disk errors (in-memory reset below still applies)
3513
+ }
2463
3514
  // Fresh system re-read (system.ts base + current AGENTS.md overlay)
2464
3515
  // plus a fresh Task 6 env block. Fresh array → fresh ledger.
2465
3516
  historyRef.current = trackHistory([
@@ -2471,15 +3522,23 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2471
3522
  content: "(new session started — previous conversation kept, /resume to restore it)",
2472
3523
  },
2473
3524
  ]);
3525
+ // /new replaces the conversation lineage, so the active goal ends
3526
+ // here with a visible notice — a fresh conversation must not inherit
3527
+ // an auto-continuing goal. The pre-/new goal stays in the OLD record
3528
+ // (persisted above), so /resume still brings it back with its stats.
3529
+ const droppedGoal = goalRef.current;
3530
+ setGoalBoth(null);
3531
+ if (droppedGoal) {
3532
+ pushInfo(`(goal cleared — "${droppedGoal.objective}" — /new starts a fresh conversation with no goal)`);
3533
+ }
2474
3534
  // Fresh list: a held view has nothing to hold onto — re-follow.
2475
3535
  setScrollEndBoth(null);
2476
3536
  setClearGen((g) => g + 1);
2477
3537
  setError(null);
2478
- setDraft(null);
3538
+ streamStore.setDraft(null);
2479
3539
  clearThinking();
2480
3540
  setToolHint(null);
2481
- setPhase("idle");
2482
- setPhaseDetail("");
3541
+ setPhaseBoth("idle", "");
2483
3542
  // /new-vs-/clear split: /clear wipes the transcript but KEEPS usage
2484
3543
  // totals; /new resets the counters too (fresh conversation). Session
2485
3544
  // SETTINGS (effort/mode/provider/model) are kept — only the
@@ -2505,6 +3564,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2505
3564
  telemetry.recordEvent("new", "fresh conversation started (previous kept for /resume)");
2506
3565
  persistTelemetry();
2507
3566
  void refreshSkillMenu();
3567
+ replaceExtensionContext("new");
2508
3568
  return;
2509
3569
  case "/compact":
2510
3570
  // Bare /compact with no focus text (slash-menu path). Free-text
@@ -2568,6 +3628,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2568
3628
  case "/autoscroll":
2569
3629
  runAutoScrollCommand("/autoscroll");
2570
3630
  return;
3631
+ case "/goal":
3632
+ runGoalCommand("/goal");
3633
+ return;
2571
3634
  case "/mode":
2572
3635
  if (modeRef.current === "plan") {
2573
3636
  pushInfo("mode: plan (read-only — write/edit/bash blocked with a replan note; Tab to approve + exit)");
@@ -2612,6 +3675,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2612
3675
  case "/resume":
2613
3676
  doResume();
2614
3677
  return;
3678
+ case "/session":
3679
+ openSessionPicker("");
3680
+ return;
3681
+ case "/rename":
3682
+ // Bare exact match (slash-menu Enter on the highlighted name):
3683
+ // usage — the typed-args form is preserved by the menu branch and
3684
+ // the submit prefix route above.
3685
+ runRenameCommand("/rename");
3686
+ return;
2615
3687
  case "/telemetry":
2616
3688
  pushInfo(telemetrySummaryText());
2617
3689
  return;
@@ -2642,7 +3714,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2642
3714
  return;
2643
3715
  }
2644
3716
  default:
2645
- return;
3717
+ // Extension slash commands (ticket 04, bare form from the menu or
3718
+ // palette): typed-args forms route through submit/menu above with
3719
+ // args intact, so only the bare exact name lands here.
3720
+ {
3721
+ const target = parseExtensionCommandInput(cmd);
3722
+ if (target && target.args === "" && getExtensionCommand(target.name)) {
3723
+ void runExtensionCommandFromApp(target.name, "");
3724
+ return;
3725
+ }
3726
+ return;
3727
+ }
2646
3728
  }
2647
3729
  }
2648
3730
  // approve hook for runAgenticLoop: scoped rules first (deny refuses as a
@@ -2798,7 +3880,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2798
3880
  h?.reject(new Error("question cancelled by user"));
2799
3881
  }
2800
3882
  // Submit-time pipeline (ticket 02 — stage order is SUBMIT_PIPELINE_STAGES
2801
- // above; each `SUBMIT STAGE n/4` marker below names its stage plus its
3883
+ // above; each `SUBMIT STAGE n/3` marker below names its stage plus its
2802
3884
  // rollback-scope rule). Local "/" routing precedes the pipeline: exact
2803
3885
  // slash commands, /allow-/deny-/rules, and skill invocations never enter
2804
3886
  // it (no turn, no history, nothing to roll back).
@@ -2818,11 +3900,29 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2818
3900
  await runCompactCommand(focus);
2819
3901
  return;
2820
3902
  }
2821
- // SUBMIT STAGE 1/4 permissions (rollback scope: pre-turn, appends
3903
+ // /rename with optional name: prefix match ("/rename" or "/rename ...").
3904
+ // Instant local store op — safe while busy (the turn-end persist never
3905
+ // carries a title, so it cannot clobber the rename).
3906
+ if (text === "/rename" || text.startsWith("/rename ")) {
3907
+ runRenameCommand(text);
3908
+ return;
3909
+ }
3910
+ // SUBMIT STAGE 1/3 — permissions (rollback scope: pre-turn, appends
2822
3911
  // nothing). Busy guard + API-key check: rejections return before any
2823
3912
  // history mutation, so there is nothing to roll back.
2824
3913
  if (!text)
2825
3914
  return;
3915
+ // An extension command in flight owns the question modal (single slot
3916
+ // shared with ask_question): plain follow-ups and nested extension
3917
+ // commands wait with a notice; view/state slash commands still run
3918
+ // (they never touch the modal or the turn).
3919
+ if (extCommandRunningRef.current) {
3920
+ const nested = parseExtensionCommandInput(text);
3921
+ if ((nested && getExtensionCommand(nested.name)) || !text.startsWith("/")) {
3922
+ pushInfo("(an extension command is already running — wait for its prompt)");
3923
+ return;
3924
+ }
3925
+ }
2826
3926
  // Busy: plain follow-ups queue instead of submitting (Claude-Code-style —
2827
3927
  // the thought is never lost); /queue + /steer manage and inject. Other
2828
3928
  // "/" input still needs idle (pickers/modals would race the turn), so it
@@ -2833,13 +3933,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2833
3933
  runQueueCommand(text);
2834
3934
  return;
2835
3935
  }
2836
- // /autoscroll and /thinking are view-only state (never touch the
2837
- // turn), so they run while busy like /queue + /steer (see
3936
+ // /autoscroll, /thinking, and /goal are view/state-only (never touch
3937
+ // the turn), so they run while busy like /queue + /steer (see
2838
3938
  // slashRunsWhileBusy).
2839
3939
  if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
2840
3940
  runAutoScrollCommand(text);
2841
3941
  return;
2842
3942
  }
3943
+ if (text === "/goal" || text.startsWith("/goal ")) {
3944
+ runGoalCommand(text);
3945
+ return;
3946
+ }
2843
3947
  if (text === "/thinking" || text.startsWith("/thinking ")) {
2844
3948
  runThinkingCommand(text);
2845
3949
  return;
@@ -2861,12 +3965,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2861
3965
  return;
2862
3966
  }
2863
3967
  // /autoscroll takes an optional subcommand (/autoscroll on|off), like the
2864
- // /queue family — SLASH_NAMES only holds the exact command. /thinking
2865
- // is bare-toggle-only; anything appended prints its usage.
3968
+ // /queue family — SLASH_NAMES only holds the exact command. /goal takes
3969
+ // free-text args (/goal <objective>, /goal clear) the same way.
3970
+ // /thinking is bare-toggle-only; anything appended prints its usage.
2866
3971
  if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
2867
3972
  runAutoScrollCommand(text);
2868
3973
  return;
2869
3974
  }
3975
+ if (text === "/goal" || text.startsWith("/goal ")) {
3976
+ runGoalCommand(text);
3977
+ return;
3978
+ }
2870
3979
  if (text === "/thinking" || text.startsWith("/thinking ")) {
2871
3980
  runThinkingCommand(text);
2872
3981
  return;
@@ -2887,6 +3996,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2887
3996
  void runModelsCommand(arg);
2888
3997
  return;
2889
3998
  }
3999
+ // /session takes an optional initial filter ("/session auth" opens the
4000
+ // picker pre-filtered) — SLASH_NAMES only holds the exact command.
4001
+ if (text === "/session" || text.startsWith("/session ")) {
4002
+ const initial = text === "/session" ? "" : text.slice("/session".length).trim();
4003
+ openSessionPicker(initial);
4004
+ return;
4005
+ }
2890
4006
  // Exact full-command + Enter runs it. A single-token "/name" not in
2891
4007
  // SLASH_NAMES resolves through the skill registry (ticket 03, legacy
2892
4008
  // form — the namespaced `/skill:name` below is canonical); anything
@@ -2912,6 +4028,16 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2912
4028
  void invokeSkillByName(namespaced);
2913
4029
  return;
2914
4030
  }
4031
+ // Extension slash commands (ticket 04): "/name args" runs extension
4032
+ // code outside the model turn loop (no history, no telemetry turn —
4033
+ // say() posts transcript turns only). Builtins and /skill: keep
4034
+ // precedence above, so an extension never shadows them; the legacy
4035
+ // /name skill form below yields to extensions deterministically.
4036
+ const extTarget = parseExtensionCommandInput(text);
4037
+ if (extTarget && getExtensionCommand(extTarget.name)) {
4038
+ void runExtensionCommandFromApp(extTarget.name, extTarget.args);
4039
+ return;
4040
+ }
2915
4041
  const skillName = /^\/([A-Za-z0-9_-]+)$/.exec(text)?.[1];
2916
4042
  if (skillName !== undefined) {
2917
4043
  void invokeSkillByName(skillName);
@@ -2934,7 +4060,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2934
4060
  busyRef.current = true;
2935
4061
  setBusy(true);
2936
4062
  setError(null);
2937
- setDraft(null);
4063
+ streamStore.setDraft(null);
2938
4064
  clearThinking();
2939
4065
  // Fresh turn, fresh latch: the queue drain at the end auto-sends only
2940
4066
  // when this turn was NOT cancelled (see the turn-end finally).
@@ -2944,13 +4070,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2944
4070
  // Expiry happens in the turn-end finally below, plus /clear + /new.
2945
4071
  try {
2946
4072
  draftThrottler().reset();
4073
+ thinkingThrottler().reset();
2947
4074
  }
2948
4075
  catch {
2949
4076
  // ignore (first token still paints; at worst one window late)
2950
4077
  }
2951
4078
  setToolHint(null);
2952
- setPhase("thinking");
2953
- setPhaseDetail("");
4079
+ setPhaseBoth("thinking", "");
2954
4080
  // Phase 5: start the elapsed/stall timer (status-bar only, never the
2955
4081
  // transcript). Cleared in finally below and on unmount.
2956
4082
  startTurnTimer();
@@ -2962,25 +4088,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2962
4088
  pendingDiffRef.current = null;
2963
4089
  lastPartialRef.current = "";
2964
4090
  refreshGitInfo();
2965
- // SUBMIT STAGE 2/4 — context-assembly (rollback scope: pre-rollbackTo,
4091
+ // SUBMIT STAGE 2/3 — context-assembly (rollback scope: pre-rollbackTo,
2966
4092
  // survives failure). Refresh the pinned env block ONCE per turn (not per
2967
4093
  // POST — the loop reuses history[0] for all its POSTs, so this is the
2968
- // only git call for the turn). Before the budget check so truncation
2969
- // accounts for the fresh block size; before rollbackTo so the refresh
4094
+ // only git call for the turn). Before rollbackTo so the refresh
2970
4095
  // survives a failed-turn rollback (it is not part of the user turn).
4096
+ // History is uncapped: the full conversation rides every turn.
2971
4097
  refreshSystemEnv();
2972
- // SUBMIT STAGE 3/4budget-check (rollback scope: pre-rollbackTo,
2973
- // survives failure). History budget at turn start, BEFORE the push +
2974
- // rollbackTo capture below (so the existing splice-rollback indices stay
2975
- // valid): drop oldest user-turns first, reserving room for the incoming user message
2976
- // so the loop core's own budget check stays a no-op on entry - exactly
2977
- // one dim notice per truncating turn. /clear drops the notice with the
2978
- // transcript (usage totals still survive). Caps come from the session
2979
- // ContextManager (window-derived), with the same live todo pinning.
2980
- contextManager().trimForSend(historyRef.current, (msg) => {
2981
- appendTurns({ role: "tool", content: `? ${msg}` });
2982
- }, { messages: 1, chars: text.length }, openTodoNeedles());
2983
- // SUBMIT STAGE 4/4 — loop-entry (rollback scope: post-rollbackTo, rolls
4098
+ // SUBMIT STAGE 3/3loop-entry (rollback scope: post-rollbackTo, rolls
2984
4099
  // back on failure). Turn boundary: on POST failure (HTTP/network/empty/
2985
4100
  // truncated) the whole user turn (user message plus any partial
2986
4101
  // assistant/tool loop entries) is removed, so the next request starts
@@ -2989,15 +4104,39 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2989
4104
  // catch). Cancellation (LoopCancelledError) shares the same splice
2990
4105
  // contract.
2991
4106
  const rollbackTo = historyRef.current.length;
4107
+ // Goal work time (ticket 02): wall clock for this submit accrues to the
4108
+ // live goal in the turn finally (all outcomes — success, failure, and
4109
+ // cancel all did work). Pinned to the starting objective so a mid-turn
4110
+ // replacement keeps its own stats.
4111
+ const goalWorkStartMs = goalRef.current ? Date.now() : null;
4112
+ const goalWorkObjective = goalRef.current?.objective ?? null;
2992
4113
  // Local observability: open this turn's trace (no-op when disabled).
2993
4114
  // Provider/model switches surface here per turn; session-level switches
2994
4115
  // are derived from the same updates (see setSessionMeta).
2995
4116
  telemetry.setSessionMeta({ provider: providerRef.current, model: modelRef.current });
4117
+ // Goal snapshot for the trace (ticket 09): the live goal as this turn
4118
+ // opens it (objective, flag, cumulative counters so far). Absent reads
4119
+ // as no-goal; the recorder caps and copies it, never aliasing live state.
4120
+ const turnGoal = goalRef.current
4121
+ ? {
4122
+ objective: goalRef.current.objective,
4123
+ active: goalRef.current.active === true,
4124
+ ...(goalRef.current.stats
4125
+ ? {
4126
+ turns: goalRef.current.stats.turns,
4127
+ requests: goalRef.current.stats.requests,
4128
+ tokens: goalRef.current.stats.tokens,
4129
+ workMs: goalRef.current.stats.workMs,
4130
+ }
4131
+ : {}),
4132
+ }
4133
+ : undefined;
2996
4134
  const telemetryTurnId = telemetry.startTurn(text, {
2997
4135
  provider: providerRef.current,
2998
4136
  model: modelRef.current,
2999
4137
  effort: effortRef.current,
3000
4138
  mode: modeRef.current,
4139
+ ...(turnGoal ? { goal: turnGoal } : {}),
3001
4140
  });
3002
4141
  const telemetrySink = {
3003
4142
  onModelCall: (info) => telemetry.recordModelCall(telemetryTurnId, info),
@@ -3037,6 +4176,45 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3037
4176
  const reply = await runAgenticLoopForProvider(providerRef.current, submitKey, modelRef.current, historyRef.current, {
3038
4177
  approve,
3039
4178
  askUser,
4179
+ // Goal auto-continue (ticket 02): the loop reads the live goal
4180
+ // through getGoal (never imports App state), pauses with a notice
4181
+ // via pauseGoal, and reports slice counters. Turns/requests land
4182
+ // here; tokens accrue in onUsage below; work time in the finally.
4183
+ goal: {
4184
+ getGoal: () => goalRef.current,
4185
+ pauseGoal: (notice) => {
4186
+ pauseGoalWithNotice(notice);
4187
+ },
4188
+ onGoalRequest: () => {
4189
+ patchGoalStats((s) => ({ ...s, requests: s.requests + 1 }));
4190
+ },
4191
+ onGoalTurn: () => {
4192
+ patchGoalStats((s) => ({ ...s, turns: s.turns + 1 }));
4193
+ },
4194
+ },
4195
+ // Evaluator fallback (ticket 04): report-less goal turns get one
4196
+ // bounded, read-only judge call (same provider/model, tools disabled,
4197
+ // 256-token cap — see src/agent/goal-evaluator.ts). Built from live
4198
+ // refs at call time so a mid-run provider/model/key switch applies;
4199
+ // judge spend accumulates exactly like model spend. Transport
4200
+ // failures throw (the loop pauses on them, never crashes); an
4201
+ // unclear verdict resolves null (the loop pauses with a notice).
4202
+ goalJudge: async ({ goal: objective, turns }) => {
4203
+ return requestGoalVerdict({
4204
+ provider: providerRef.current,
4205
+ apiKey: keyForProvider(providerRef.current),
4206
+ model: modelRef.current,
4207
+ systemContent: systemPrompt,
4208
+ goal: objective,
4209
+ turns,
4210
+ baseURL: chatBaseURL(providerRef.current),
4211
+ endpointOverride: activeEndpoint,
4212
+ signal: controller.signal,
4213
+ onUsage: (u) => {
4214
+ accumulateUsage(u, false);
4215
+ },
4216
+ });
4217
+ },
3040
4218
  // Local observability sink: the loop reports completed model/tool
3041
4219
  // calls (iterations, durations, usage) into the open turn trace.
3042
4220
  telemetry: telemetrySink,
@@ -3056,19 +4234,27 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3056
4234
  draftThrottler().push(partial);
3057
4235
  }
3058
4236
  catch {
3059
- setDraft(partial);
4237
+ // Never lose tokens: paint now rather than drop the partial.
4238
+ streamStore.setDraft(partial);
3060
4239
  }
3061
4240
  lastPartialRef.current = partial;
3062
4241
  noteTurnActivity();
3063
4242
  },
3064
4243
  onThinking: (partial) => {
3065
4244
  thinkingRef.current = partial;
3066
- setThinking(partial);
4245
+ try {
4246
+ thinkingThrottler().push(partial);
4247
+ }
4248
+ catch {
4249
+ // Never lose reasoning: paint now rather than drop the partial.
4250
+ streamStore.setThinking(partial);
4251
+ }
3067
4252
  noteTurnActivity();
3068
4253
  },
3069
4254
  onPhase: (p, detail) => {
3070
- setPhase(p);
3071
- setPhaseDetail(detail ?? "");
4255
+ // Change-guarded (zen emits "streaming" per content chunk):
4256
+ // steady-state tokens issue zero setStates here.
4257
+ setPhaseBoth(p, detail ?? "");
3072
4258
  noteTurnActivity();
3073
4259
  if (p === "thinking") {
3074
4260
  // New POST: the previous round's thinking (if any) commits to
@@ -3104,29 +4290,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3104
4290
  // POST accumulates (tool-round POSTs and successful retries each
3105
4291
  // count once — each was billed; failed attempts report nothing, so
3106
4292
  // nothing is deduped). usageTotals drives NK only, never P%.
3107
- const prev = usageRef.current ?? {};
3108
- const next = { ...prev };
3109
- if (u.prompt_tokens !== undefined) {
3110
- next.prompt_tokens = (next.prompt_tokens ?? 0) + u.prompt_tokens;
3111
- // Load metric source: last POST's reported prompt_tokens (the
3112
- // per-POST value, NOT the accumulated total).
3113
- lastPromptTokensRef.current = u.prompt_tokens;
3114
- }
3115
- if (u.completion_tokens !== undefined) {
3116
- next.completion_tokens = (next.completion_tokens ?? 0) + u.completion_tokens;
3117
- }
3118
- if (u.total_tokens !== undefined) {
3119
- next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
3120
- }
3121
- // Prefix-cache counters accumulate like spend (real reports only;
3122
- // absent fields mean "not reported", never zero).
3123
- if (u.cacheReadTokens !== undefined) {
3124
- next.cacheReadTokens = (next.cacheReadTokens ?? 0) + u.cacheReadTokens;
3125
- }
3126
- if (u.cacheWriteTokens !== undefined) {
3127
- next.cacheWriteTokens = (next.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
3128
- }
3129
- setUsageBoth(next);
4293
+ accumulateUsage(u);
3130
4294
  },
3131
4295
  onReasoning: (label) => {
3132
4296
  setReasoning(label);
@@ -3216,12 +4380,6 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3216
4380
  noteTurnActivity();
3217
4381
  },
3218
4382
  signal: controller.signal,
3219
- // Window-aware trim caps for this model's real window (the loop
3220
- // falls back to legacy caps without it — see AgenticOpts.context).
3221
- context: {
3222
- model: modelRef.current,
3223
- toolsChars: TOOLS_SCHEMA_CHARS,
3224
- },
3225
4383
  });
3226
4384
  // Turn-end flush: any trailing throttled partial paints before the
3227
4385
  // commit replaces the draft (byte-exact via `reply` regardless). The
@@ -3318,6 +4476,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3318
4476
  }
3319
4477
  }
3320
4478
  finally {
4479
+ // Goal work time (ticket 02): this submit's wall clock accrues once,
4480
+ // for every outcome (success, failure, and cancel all did work), when
4481
+ // the same goal is still live. A mid-turn replacement keeps its own
4482
+ // stats — we accrue only while the objective still matches.
4483
+ try {
4484
+ if (goalWorkStartMs !== null &&
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
+ }
3321
4494
  turnCancelRef.current = null;
3322
4495
  approvalResolveRef.current = null;
3323
4496
  setPendingApproval(null);
@@ -3340,14 +4513,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3340
4513
  catch {
3341
4514
  // ignore
3342
4515
  }
3343
- setDraft(null);
4516
+ streamStore.setDraft(null);
3344
4517
  clearThinking();
3345
4518
  setToolHint(null);
3346
4519
  clearTurnTimer();
3347
- setStalled(false);
4520
+ setStalledBoth(false);
3348
4521
  setElapsedSecs(0);
3349
- setPhase("idle");
3350
- setPhaseDetail("");
4522
+ setPhaseBoth("idle", "");
3351
4523
  // Queue drain (Claude-Code-style): a clean turn auto-sends the next
3352
4524
  // queued follow-up (chaining while the queue is non-empty); a cancelled
3353
4525
  // turn keeps its queue visible but never auto-sends. A steer stranded
@@ -3476,6 +4648,53 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3476
4648
  }
3477
4649
  return;
3478
4650
  }
4651
+ // 2a. Extension dialog (ticket 10): same keys as the question modal —
4652
+ // arrows + Enter picks, typing + Enter submits custom text (allowCustom
4653
+ // only), Esc cancels with a clean error. Owns the keyboard while open
4654
+ // (like every modal above): resolvers can never clobber each other, and
4655
+ // a dialog stranded by a session switch is already rejected by
4656
+ // invalidate, so resolve/cancel here always hits the live request.
4657
+ const extDlg = extRuntimeRef.current?.getPendingDialog() ?? null;
4658
+ if (extDlg) {
4659
+ const len = Math.max(extDlg.options.length, 1);
4660
+ if (key.upArrow) {
4661
+ setExtDlgSelBoth((extDlgSelRef.current - 1 + len) % len);
4662
+ }
4663
+ else if (key.downArrow) {
4664
+ setExtDlgSelBoth((extDlgSelRef.current + 1) % len);
4665
+ }
4666
+ else if (key.escape) {
4667
+ extRuntimeRef.current?.cancelPendingDialog(`extension "${extDlg.owner}" dialog was cancelled by user`);
4668
+ setExtDlgSelBoth(0);
4669
+ setExtDlgCustomBoth("");
4670
+ }
4671
+ else if (key.return || key.tab) {
4672
+ if (extDlg.allowCustom && extDlgCustomRef.current.trim().length > 0) {
4673
+ if (extRuntimeRef.current?.resolvePendingDialog(extDlgCustomRef.current) === true) {
4674
+ setExtDlgSelBoth(0);
4675
+ setExtDlgCustomBoth("");
4676
+ }
4677
+ }
4678
+ else {
4679
+ const picked = extDlg.options[extDlgSelRef.current];
4680
+ if (picked !== undefined && extRuntimeRef.current?.resolvePendingDialog(picked) === true) {
4681
+ setExtDlgSelBoth(0);
4682
+ setExtDlgCustomBoth("");
4683
+ }
4684
+ }
4685
+ }
4686
+ else if (key.backspace || key.delete) {
4687
+ if (extDlg.allowCustom) {
4688
+ setExtDlgCustomBoth(extDlgCustomRef.current.slice(0, -1));
4689
+ }
4690
+ }
4691
+ else if (ch && !key.ctrl && !key.meta && !key.tab) {
4692
+ if (extDlg.allowCustom) {
4693
+ setExtDlgCustomBoth(extDlgCustomRef.current + ch);
4694
+ }
4695
+ }
4696
+ return;
4697
+ }
3479
4698
  // 2b. Provider key prompt (masked • per char, paste works, Esc safe).
3480
4699
  const kp = keyPromptRef.current;
3481
4700
  if (kp && keyPrompt) {
@@ -3667,28 +4886,22 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3667
4886
  // reported prompt_tokens no longer measures this context); the
3668
4887
  // estimate applies until the new model reports.
3669
4888
  resetContextLoadToEstimate();
3670
- // Re-gate effort on every /model switch: setting persists, but a
3671
- // non-Default effort on an unsupported model warns (kept, not sent).
3672
- if (effortRef.current !== "default" && !isEffortSupported(picked.model)) {
3673
- warnEffortUnsupported(picked.model);
3674
- }
4889
+ // Effort needs no re-gating: it is assumed for every model and
4890
+ // only a server 400 can veto it (the POST retries without it).
3675
4891
  }
3676
4892
  else {
3677
4893
  // Cross-provider pick: switch with the resolved key (env wins,
3678
4894
  // else stored — remote sections only render for keyed providers;
3679
4895
  // local sections need no key) and keep the picked model; the
3680
4896
  // live refresh lands in the background via the standard switch
3681
- // path. reasoning_effort is zen-only, so a non-Default effort
3682
- // warns (kept, not sent).
4897
+ // path. Effort carries over untouched it is valid on every
4898
+ // provider kind.
3683
4899
  const switchedKey = keyForProvider(picked.providerId);
3684
4900
  if (switchedKey || !providerNeedsKey(picked.providerId)) {
3685
4901
  const pickedProvider = picked.providerId;
3686
4902
  const pickedModel = picked.model;
3687
4903
  void (async () => {
3688
4904
  await switchProviderWithKey(pickedProvider, switchedKey, pickedModel);
3689
- if (effortRef.current !== "default") {
3690
- warnEffortUnsupported(pickedModel);
3691
- }
3692
4905
  })();
3693
4906
  }
3694
4907
  }
@@ -3751,6 +4964,51 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3751
4964
  }
3752
4965
  return;
3753
4966
  }
4967
+ // 3a2. Session picker (interactive switcher, same pattern as /skills:
4968
+ // type to filter, ↑/↓ + Enter switches, Esc cancels with the live
4969
+ // session completely unchanged). The list is the open-time snapshot —
4970
+ // filtering never touches disk. Enter on an empty filtered list only
4971
+ // prints a widen hint (no switch, no state change).
4972
+ if (selectingSession) {
4973
+ // Rebuilt per keypress from the same render's state the paint uses, so
4974
+ // highlight/filter/paint never disagree mid-tick.
4975
+ const entries = filterSessionEntries(sessionItems, sessionFilterRef.current);
4976
+ if (key.upArrow) {
4977
+ if (entries.length > 0) {
4978
+ setSessionIndexBoth((sessionIndexRef.current - 1 + entries.length) % entries.length);
4979
+ }
4980
+ }
4981
+ else if (key.downArrow) {
4982
+ if (entries.length > 0) {
4983
+ setSessionIndexBoth((sessionIndexRef.current + 1) % entries.length);
4984
+ }
4985
+ }
4986
+ else if (key.escape) {
4987
+ setSessionFilterBoth("");
4988
+ setSelectingSession(false);
4989
+ }
4990
+ else if (key.return) {
4991
+ const picked = entries[sessionIndexRef.current];
4992
+ setSessionFilterBoth("");
4993
+ setSelectingSession(false);
4994
+ if (picked) {
4995
+ exitHistoryBrowse();
4996
+ void switchToSession(picked.id);
4997
+ }
4998
+ else {
4999
+ pushInfo("(no sessions match — backspace to widen the filter.)");
5000
+ }
5001
+ }
5002
+ else if (key.backspace || key.delete) {
5003
+ setSessionFilterBoth(sessionFilterRef.current.slice(0, -1));
5004
+ setSessionIndexBoth(0);
5005
+ }
5006
+ else if (ch && !key.ctrl && !key.meta && !key.tab) {
5007
+ setSessionFilterBoth(sessionFilterRef.current + ch);
5008
+ setSessionIndexBoth(0);
5009
+ }
5010
+ return;
5011
+ }
3754
5012
  // 3b. Effort picker (/effort): same keyboard pattern as the /model
3755
5013
  // picker (↑/↓ + Enter, Esc cancels).
3756
5014
  if (selectingEffort) {
@@ -3766,10 +5024,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3766
5024
  else if (key.return) {
3767
5025
  const picked = EFFORT_OPTIONS[effortIndexRef.current];
3768
5026
  if (picked) {
5027
+ // No support warning: every model on every provider accepts the
5028
+ // knob; only a server 400 vetoes it (retried without, warned).
3769
5029
  setEffortBoth(picked);
3770
- if (picked !== "default" && !isEffortSupported(modelRef.current)) {
3771
- warnEffortUnsupported(modelRef.current);
3772
- }
3773
5030
  }
3774
5031
  setSelectingEffort(false);
3775
5032
  }
@@ -3830,7 +5087,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3830
5087
  // a newline anywhere means free text (slash commands never span lines).
3831
5088
  const cur = inputRef.current;
3832
5089
  const menu = !slashDismissedRef.current && cur.startsWith("/") && !cur.includes("\n")
3833
- ? buildSlashMenu(cur, skillMenu)
5090
+ ? buildSlashMenu(cur, skillMenu, listExtensionCommands())
3834
5091
  : { items: [], moreSkills: 0 };
3835
5092
  const matches = menu.items;
3836
5093
  if (matches.length > 0) {
@@ -3857,8 +5114,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3857
5114
  }
3858
5115
  else if (key.return || key.tab) {
3859
5116
  const pick = matches[slashIndexRef.current % matches.length];
3860
- // /compact, /queue, /steer, and /autoscroll run while busy (see
3861
- // slashRunsWhileBusy); every other entry still waits idle.
5117
+ // /compact, /queue, /steer, /autoscroll, and /goal run while busy
5118
+ // (see slashRunsWhileBusy); every other entry still waits idle.
3862
5119
  if (pick && (slashRunsWhileBusy(pick.name) || !busyRef.current)) {
3863
5120
  if (pick.skill) {
3864
5121
  // Skill entries stage for confirm (opencode-style): Enter/Tab
@@ -3889,6 +5146,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3889
5146
  setInputBoth("");
3890
5147
  runAutoScrollCommand(raw);
3891
5148
  }
5149
+ else if (pick.name === "/goal" &&
5150
+ (inputRef.current === "/goal" || inputRef.current.startsWith("/goal "))) {
5151
+ // Preserve the typed objective (e.g. "/goal Ship v2"); a bare
5152
+ // highlighted name falls through to status.
5153
+ const raw = inputRef.current;
5154
+ setInputBoth("");
5155
+ runGoalCommand(raw);
5156
+ }
3892
5157
  else if ((pick.name === "/allow" || pick.name === "/deny" || pick.name === "/rules") &&
3893
5158
  inputRef.current.startsWith(pick.name)) {
3894
5159
  // Preserve the typed rule args (e.g. "/allow bash:npm test*");
@@ -3897,6 +5162,27 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3897
5162
  setInputBoth("");
3898
5163
  runRulesCommand(raw);
3899
5164
  }
5165
+ else if (pick.name === "/rename" &&
5166
+ (inputRef.current === "/rename" || inputRef.current.startsWith("/rename "))) {
5167
+ // Preserve the typed name (e.g. '/rename "Build auth"'); a bare
5168
+ // highlighted name falls through to usage.
5169
+ const raw = inputRef.current;
5170
+ setInputBoth("");
5171
+ runRenameCommand(raw);
5172
+ }
5173
+ else if (!pick.skill &&
5174
+ getExtensionCommand(pick.name.slice(1)) &&
5175
+ (inputRef.current === pick.name || inputRef.current.startsWith(`${pick.name} `))) {
5176
+ // Extension slash command (ticket 04): preserve the typed args
5177
+ // like /rename — a bare highlighted name runs with empty args.
5178
+ // Builtins keep precedence (an extension name can never equal a
5179
+ // builtin — activation rejects the collision).
5180
+ const raw = inputRef.current;
5181
+ setInputBoth("");
5182
+ const target = parseExtensionCommandInput(raw);
5183
+ if (target)
5184
+ void runExtensionCommandFromApp(target.name, target.args);
5185
+ }
3900
5186
  else {
3901
5187
  runSlashCommand(pick.name);
3902
5188
  }
@@ -3929,7 +5215,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3929
5215
  // input, and input keys never reach the inspector.
3930
5216
  if (key.ctrl && (ch === "o" || ch === "O")) {
3931
5217
  if (!busyRef.current && !turnCancelRef.current &&
3932
- !pendingApproval && !pendingQuestion &&
5218
+ !pendingApproval && !pendingQuestion && !extDialogOpen &&
3933
5219
  !selecting && !selectingSkills && !selectingProvider &&
3934
5220
  !keyPrompt && !baseURLPrompt && !selectingEffort &&
3935
5221
  !selectingRewind && !selectingRewindScope) {
@@ -3994,8 +5280,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3994
5280
  // open); Enter runs through the shared busy-gate, so only
3995
5281
  // compact/queue/steer fire while busy.
3996
5282
  if (key.ctrl && (ch === "p" || ch === "P")) {
3997
- if (!pendingApproval && !pendingQuestion &&
3998
- !selecting && !selectingSkills && !selectingProvider &&
5283
+ if (!pendingApproval && !pendingQuestion && !extDialogOpen &&
5284
+ !selecting && !selectingSkills && !selectingSession && !selectingProvider &&
3999
5285
  !keyPrompt && !baseURLPrompt && !selectingEffort &&
4000
5286
  !selectingRewind && !selectingRewindScope && !inspecting) {
4001
5287
  if (paletteOpenRef.current)
@@ -4140,6 +5426,35 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4140
5426
  insertAtCursor(ch);
4141
5427
  }
4142
5428
  });
5429
+ // Extension UI surface reads (ticket 10): fresh copies per render,
5430
+ // repainted via the bumpExtUI subscription at boot. Unknown widget
5431
+ // placements never reach here (the host validates fail-closed). Declared
5432
+ // before the paste/memo guards below (render-execution order matters).
5433
+ const extRuntime = extRuntimeRef.current;
5434
+ const extSegments = extRuntime?.getStatusSegments().map((s) => s.text) ?? [];
5435
+ const extWidgets = extRuntime?.getWidgets().filter((w) => w.placement === "panel") ?? [];
5436
+ const extPendingDialog = extRuntime?.getPendingDialog() ?? null;
5437
+ const extDialogOpen = extPendingDialog !== null;
5438
+ const extStatusText = formatExtensionStatusText(extSegments);
5439
+ const extDialogId = extPendingDialog?.id ?? null;
5440
+ // Extension notices: drain the runtime queue into the transcript as
5441
+ // `(owner) message` info lines. Runs every render; drain-then-clear is
5442
+ // idempotent, so re-renders post nothing twice.
5443
+ useEffect(() => {
5444
+ const runtime = extRuntimeRef.current;
5445
+ if (!runtime)
5446
+ return;
5447
+ const notes = runtime.drainNotifications();
5448
+ for (const n of notes)
5449
+ pushInfo(`(${n.owner}) ${n.message}`);
5450
+ });
5451
+ // A new dialog starts with a fresh selection (a session switch that
5452
+ // rejects the old request also drops the modal — see invalidate).
5453
+ useEffect(() => {
5454
+ setExtDlgSelBoth(0);
5455
+ setExtDlgCustomBoth("");
5456
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5457
+ }, [extDialogId]);
4143
5458
  // Bracketed paste (Ink enables `\x1b[?2004h` while active): pasted text —
4144
5459
  // including newlines — inserts at the cursor verbatim and NEVER submits,
4145
5460
  // so multiline pastes can't fire mid-paste. Separate channel from
@@ -4150,8 +5465,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4150
5465
  }, {
4151
5466
  isActive: !pendingApproval &&
4152
5467
  !pendingQuestion &&
5468
+ !extDialogOpen &&
4153
5469
  !selecting &&
4154
5470
  !selectingSkills &&
5471
+ !selectingSession &&
4155
5472
  !selectingProvider &&
4156
5473
  !keyPrompt &&
4157
5474
  !baseURLPrompt &&
@@ -4177,7 +5494,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4177
5494
  : `thinking${theme.symbol.ellipsis}`;
4178
5495
  // Slash menu derived for render (mirrors the useInput computation above):
4179
5496
  // commands first, then matching skills as `/skill:name` entries.
4180
- const slashMenu = !selecting &&
5497
+ // Memoized on every gate input: without this the fuzzy matcher rebuilds
5498
+ // on each 1s tick and tool append even though the menu only depends on
5499
+ // input + overlay state.
5500
+ const slashMenu = useMemo(() => !selecting &&
4181
5501
  !selectingSkills &&
4182
5502
  !selectingEffort &&
4183
5503
  !selectingProvider &&
@@ -4185,13 +5505,31 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4185
5505
  !baseURLPrompt &&
4186
5506
  !pendingApproval &&
4187
5507
  !pendingQuestion &&
5508
+ !extDialogOpen &&
4188
5509
  !selectingRewind &&
4189
5510
  !selectingRewindScope &&
4190
5511
  !slashDismissed &&
4191
5512
  input.startsWith("/") &&
4192
5513
  !input.includes("\n")
4193
5514
  ? buildSlashMenu(input, skillMenu)
4194
- : { items: [], moreSkills: 0 };
5515
+ : { items: [], moreSkills: 0 },
5516
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5517
+ [
5518
+ selecting,
5519
+ selectingSkills,
5520
+ selectingEffort,
5521
+ selectingProvider,
5522
+ keyPrompt,
5523
+ baseURLPrompt,
5524
+ pendingApproval,
5525
+ pendingQuestion,
5526
+ extDialogOpen,
5527
+ selectingRewind,
5528
+ selectingRewindScope,
5529
+ slashDismissed,
5530
+ input,
5531
+ skillMenu,
5532
+ ]);
4195
5533
  const filteredSlash = slashMenu.items;
4196
5534
  const slashVisible = filteredSlash.length > 0;
4197
5535
  const slashHi = filteredSlash.length > 0 ? slashIndex % filteredSlash.length : 0;
@@ -4205,9 +5543,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4205
5543
  // Unified /model picker derived for render (mirrors the useInput
4206
5544
  // computation above): full entries, filtered entries, clamped highlight,
4207
5545
  // and the visible window — the frame never grows past MODEL_PICKER_VISIBLE
4208
- // rows no matter how many models providers list.
4209
- const modelEntriesAll = selecting ? buildModelEntries() : [];
4210
- const modelEntries = selecting ? filterModelEntries(modelEntriesAll, modelFilter) : [];
5546
+ // rows no matter how many providers list. Memoized: the registry walk
5547
+ // (all providers + fallbacks) must not rerun on ticks/appends while open.
5548
+ // Deps cover every read inside buildModelEntries: selecting gate, provider
5549
+ // mirror, live models, auth store (key presence), and the local snapshot
5550
+ // (loopback baseURLs feed the cache keys); cache-ref writes always land
5551
+ // alongside one of these setStates, so the memo can never go stale.
5552
+ const { modelEntriesAll, modelEntries } = useMemo(() => {
5553
+ const all = selecting ? buildModelEntries() : [];
5554
+ return {
5555
+ modelEntriesAll: all,
5556
+ modelEntries: selecting ? filterModelEntries(all, modelFilter) : [],
5557
+ };
5558
+ },
5559
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5560
+ [selecting, provider, models, auth, localSnap, modelFilter]);
4211
5561
  const modelHi = modelEntries.length === 0 ? 0 : Math.max(0, Math.min(selIndex, modelEntries.length - 1));
4212
5562
  const modelWin = pickerWindow(modelEntries.length, modelHi);
4213
5563
  const modelTitle = `Atom — Select model (${modelEntries.length}` +
@@ -4216,13 +5566,35 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4216
5566
  // /skills picker derived for render (mirrors the useInput computation
4217
5567
  // above): names only, filtered, clamped highlight, visible window. The
4218
5568
  // title keeps the `Skills (` prefix the registry header always had.
4219
- const skillEntriesAll = selectingSkills ? skillPickerItems : [];
4220
- const skillEntries = selectingSkills ? filterSkillPicker(skillEntriesAll, skillFilter) : [];
5569
+ // Memoized for the same tick/append reason as the model picker above.
5570
+ const { skillEntriesAll, skillEntries } = useMemo(() => {
5571
+ const all = selectingSkills ? skillPickerItems : [];
5572
+ return {
5573
+ skillEntriesAll: all,
5574
+ skillEntries: selectingSkills ? filterSkillPicker(all, skillFilter) : [],
5575
+ };
5576
+ }, [selectingSkills, skillPickerItems, skillFilter]);
4221
5577
  const skillHi = skillEntries.length === 0 ? 0 : Math.max(0, Math.min(skillIndex, skillEntries.length - 1));
4222
5578
  const skillWin = pickerWindow(skillEntries.length, skillHi);
4223
5579
  const skillTitle = `Skills (${skillEntries.length}` +
4224
5580
  (skillFilter ? ` of ${skillEntriesAll.length}, filter: "${skillFilter}"` : "") +
4225
5581
  `) — type to filter, up/down + Enter, Esc cancels:`;
5582
+ // /session picker derived for render (mirrors the useInput computation
5583
+ // above): open-time snapshot, filtered in memory, clamped highlight,
5584
+ // visible window. Memoized for the same tick/append reason as the pickers
5585
+ // above. nowMs via Date.now (render-time age labels, never persisted).
5586
+ const { sessionEntriesAll, sessionEntries } = useMemo(() => {
5587
+ const all = selectingSession ? sessionItems : [];
5588
+ return {
5589
+ sessionEntriesAll: all,
5590
+ sessionEntries: selectingSession ? filterSessionEntries(all, sessionFilter) : [],
5591
+ };
5592
+ }, [selectingSession, sessionItems, sessionFilter]);
5593
+ const sessionHi = sessionEntries.length === 0 ? 0 : Math.max(0, Math.min(sessionIndex, sessionEntries.length - 1));
5594
+ const sessionWin = pickerWindow(sessionEntries.length, sessionHi);
5595
+ const sessionPickerTitle = `Sessions (${sessionEntries.length}` +
5596
+ (sessionFilter ? ` of ${sessionEntriesAll.length}, filter: "${sessionFilter}"` : "") +
5597
+ `) — type to filter, up/down + Enter, Esc cancels:`;
4226
5598
  // Memoized render derivations (flicker fix): these rebuild arrays on every
4227
5599
  // App render (token paints, keystrokes, 1s ticks), which defeats the memo
4228
5600
  // on the leaf panels below. Memoized, the leaves skip everything but real
@@ -4231,27 +5603,31 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4231
5603
  const checkpointListMemo = useMemo(() => (selectingRewind ? listCheckpoints() : []),
4232
5604
  // eslint-disable-next-line react-hooks/exhaustive-deps
4233
5605
  [selectingRewind]);
5606
+ // Approval description: the tool-call one-liner for the modal. Memoized on
5607
+ // the pending approval itself — the 1s busy tick keeps firing while the
5608
+ // modal waits, and must not rebuild the string (nor re-render the modal).
5609
+ const approvalDescription = useMemo(() => (pendingApproval ? describeToolCall(pendingApproval.name, pendingApproval.args) : ""), [pendingApproval]);
4234
5610
  // (The cursor clamp lives inside the memoized InputBox now, next to its
4235
5611
  // only use — App body no longer reads cursor state for paint.)
4236
- // Status-line reasoning segment wired to the effort session state:
4237
- // non-Default shows the effort (plus " (unsupported)" when the model is
4238
- // outside the verified-support set OR the provider is not opencode-zen);
4239
- // Default shows response metadata or "default" as before.
4240
- // reasoning_effort is sent ONLY for opencode-zen + supported model.
4241
- const effortSupportedNow = effort === "default" ||
4242
- (provider === "opencode-zen" && isEffortSupported(model));
4243
- const reasoningDisplay = effort !== "default"
5612
+ // Status-line reasoning segment wired to the effort session state: a
5613
+ // non-Auto effort always shows the effort (the knob is sent for every
5614
+ // model on every provider kind); Auto shows response metadata or "auto".
5615
+ // "(unsupported)" survives only as a safety net for an unknown provider —
5616
+ // support is otherwise assumed, with the server as the authority (a 400
5617
+ // naming the knob retries the POST without it and warns).
5618
+ const effortSupportedNow = effort === "auto" || isEffortSupported(model, provider);
5619
+ const reasoningDisplay = effort !== "auto"
4244
5620
  ? effortSupportedNow
4245
5621
  ? effort
4246
5622
  : `${effort} (unsupported)`
4247
- : (reasoning ?? "default");
5623
+ : (reasoning ?? "auto");
4248
5624
  // Display-only live tool elapsed: wall-clock now ≈ turn start + elapsed
4249
5625
  // ticks (the 1s busy tick re-renders, so this stays fresh). Null when no
4250
5626
  // tool is running — the running line then paints with no duration.
4251
5627
  const toolElapsedSecs = busy && toolHint && toolStartRef.current !== null
4252
5628
  ? elapsedSecsSince(toolStartRef.current, turnStartRef.current + elapsedSecs * 1000)
4253
5629
  : null;
4254
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TranscriptView, { turns: turns, clearGen: clearGen, end: scrollEnd, held: scrollEnd !== null, showThinking: showThinking }), _jsx(LiveTail, { isEmpty: turns.length === 0, sessionHint: sessionHint, draft: draft, thinking: thinking, 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: describeToolCall(pendingApproval.name, pendingApproval.args), selected: approveIndex, diff: pendingApproval.diff ?? null })) : null, pendingQuestion ? (_jsx(QuestionBox, { question: pendingQuestion.question, options: pendingQuestion.options, allowCustom: pendingQuestion.allowCustom, askCustom: askCustom, askSelIndex: askSelIndex })) : null, _jsx(TodoPanel, { items: todoSnap }), 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, 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) => {
5630
+ 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, 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) => {
4255
5631
  const i = modelWin.start + k;
4256
5632
  const entryLocal = e.local === true;
4257
5633
  const prevLocal = i === 0 ? null : modelEntries[i - 1]?.local === true;
@@ -4266,7 +5642,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4266
5642
  return (_jsxs(PickerRow, { highlighted: i === skillHi, children: ["/skill:", e.name, !e.userInvocable ? _jsx(Text, { dimColor: true, children: " [auto-only]" }) : null] }, `${e.name}-${i}`));
4267
5643
  }), _jsx(PickerMoreBelow, { count: skillEntries.length - skillWin.end }), skillEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: skillEntriesAll.length === 0
4268
5644
  ? "No skills installed — add SKILL.md skills under .claude/skills/, .agents/skills/, or the ~/. counterparts."
4269
- : "No skills 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) => {
5645
+ : "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) => {
5646
+ const i = sessionWin.start + k;
5647
+ const age = formatSessionAge(Date.now(), e.updatedAt);
5648
+ 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}`));
5649
+ }), _jsx(PickerMoreBelow, { count: sessionEntries.length - sessionWin.end }), sessionEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: sessionEntriesAll.length === 0
5650
+ ? "No sessions yet — your current conversation is saved automatically."
5651
+ : "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) => {
4270
5652
  const has = keyForProvider(p.id).length > 0;
4271
5653
  const keyMark = isLocalProviderId(p.id)
4272
5654
  ? `local ${theme.symbol.descSeparator} no key needed`
@@ -4276,7 +5658,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4276
5658
  ? `${theme.symbol.descSeparator} key optional — free models need none`
4277
5659
  : `${theme.symbol.descSeparator} no key`;
4278
5660
  return (_jsxs(PickerRow, { highlighted: i === providerIndex, children: [p.name, " (", p.id, ") ", keyMark, p.id === provider ? " (current)" : ""] }, p.id));
4279
- }) })) : 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 === "default" ? "Default" : o === "max" ? "Max" : o[0]?.toUpperCase() + o.slice(1), o === effort ? " (current)" : ""] }, `${o}-${i}`))), _jsx(Text, { dimColor: true, children: "Top is Max (sent as max); xhigh is not a verified value." })] })) : 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." })] })) : (
5661
+ }) })) : 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." })] })) : (
4280
5662
  // The input is the one boxed, prominent surface (see the memoized
4281
5663
  // InputBox above): a quiet gray frame sets it apart from the
4282
5664
  // transcript above and the status line below. Pickers and modals
@@ -4284,5 +5666,5 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4284
5666
  // border color.
4285
5667
  _jsx(InputBox, { input: input, cursor: cursor })), slashVisible && !inspecting && !paletteOpen ? (_jsxs(PickerShell, { title: slashHasSkills
4286
5668
  ? `Atom commands + skills (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`
4287
- : `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(StatusBar, { provider: provider, model: model, usageTotals: usageTotals, contextLoad: contextLoad, 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, columns: termColumns })] }));
5669
+ : `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, 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: goal ? { objective: goal.objective, active: goal.active === true } : null })] }));
4288
5670
  }