atom-agent 1.2.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 (54) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +13 -4
  3. package/atom.example.json +11 -0
  4. package/dist/App.js +923 -200
  5. package/dist/adapters.js +82 -13
  6. package/dist/agent/goal-evaluator.js +69 -0
  7. package/dist/agent/loop.js +517 -76
  8. package/dist/cli.js +11 -3
  9. package/dist/compact.js +41 -15
  10. package/dist/config.js +43 -7
  11. package/dist/context-manager.js +16 -198
  12. package/dist/context-windows.js +4 -2
  13. package/dist/env-block.js +5 -5
  14. package/dist/extension-commands.js +196 -0
  15. package/dist/extension-ui.js +153 -0
  16. package/dist/extensions.js +1571 -0
  17. package/dist/goal.js +583 -0
  18. package/dist/project-trust.js +96 -0
  19. package/dist/providers.js +6 -6
  20. package/dist/scheduler.js +74 -36
  21. package/dist/session.js +23 -5
  22. package/dist/sessions.js +25 -6
  23. package/dist/telemetry-dashboard.js +28 -0
  24. package/dist/telemetry.js +39 -0
  25. package/dist/tools/compaction-hooks.js +165 -0
  26. package/dist/tools/custom.js +189 -0
  27. package/dist/tools/intercept.js +145 -0
  28. package/dist/tools/overrides.js +105 -0
  29. package/dist/tools/provider-hooks.js +224 -0
  30. package/dist/tools/registry.js +246 -17
  31. package/dist/tools.js +44 -0
  32. package/dist/ui/palette.js +1 -1
  33. package/dist/ui/status-bar.js +80 -5
  34. package/dist/zen.js +305 -75
  35. package/documentation/architecture.md +114 -0
  36. package/documentation/cli.md +82 -0
  37. package/documentation/compaction.md +50 -0
  38. package/documentation/configuration.md +111 -0
  39. package/documentation/development.md +62 -0
  40. package/documentation/extensions.md +160 -0
  41. package/documentation/getting-started.md +63 -0
  42. package/documentation/goals.md +41 -0
  43. package/documentation/index.md +41 -0
  44. package/documentation/observability.md +70 -0
  45. package/documentation/permissions.md +66 -0
  46. package/documentation/providers.md +78 -0
  47. package/documentation/sessions.md +92 -0
  48. package/documentation/skills.md +57 -0
  49. package/documentation/tools.md +94 -0
  50. package/documentation/troubleshooting.md +54 -0
  51. package/examples/extensions/01-audit-gate.js +24 -0
  52. package/examples/extensions/02-notes-tool.js +32 -0
  53. package/examples/extensions/03-custom-command.js +32 -0
  54. package/package.json +6 -2
package/dist/App.js CHANGED
@@ -6,7 +6,7 @@ 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
8
  import { Box, Text, useApp, useInput, usePaste } 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";
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, collectStoredTouchedFiles, collectTouchedFiles, compactBoundaryLine, compactPct, countUserTurns, estimateTokensForChars, fitSummaryWithFiles, 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";
@@ -25,10 +27,14 @@ import { clearKiloModelsCache, isFreeKiloModel, preferFreeKiloModel, } from "./k
25
27
  import { getGitInfo, withEnvBlock } from "./env-block.js";
26
28
  import { loadPrefs, loadSession, saveSession, sessionExists, } from "./session.js";
27
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";
28
33
  import { loadAtomConfig } from "./config.js";
34
+ import { grantProjectTrust, isProjectTrusted, projectTrustQuestion, } from "./project-trust.js";
29
35
  import { cancelledTurnLine } from "./rollback.js";
30
36
  import { clearSnapshots, conversationCutIndex, getCheckpoint, listCheckpoints, registerHistoryProbe, restoreCheckpointFiles, } from "./snapshots.js";
31
- import { forgetReadFingerprint, refreshReadFingerprint } from "./tools.js";
37
+ import { applyBeforeCompact, beforeCompactInterceptors, forgetReadFingerprint, refreshReadFingerprint } from "./tools.js";
32
38
  import { InputBox } from "./ui/input.js";
33
39
  import { historyNewerIndex, historyOlderIndex, killToLineEnd, killToLineStart, killWordBefore, lineColOf, moveVertically, normalizePaste, offsetOfLines, pushInputHistory as pushInputHistoryList, splitInputLines, } from "./ui/input-model.js";
34
40
  import { LiveTailHost } from "./ui/live-host.js";
@@ -51,7 +57,7 @@ export const SLASH_COMMANDS = [
51
57
  { name: "/provider", description: "Pick AI provider, paste API key once, chat." },
52
58
  {
53
59
  name: "/effort",
54
- 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).",
55
61
  },
56
62
  { name: "/tools", description: "List the tools with one-line descriptions." },
57
63
  { name: "/skills", description: "List installed skills (project + global)." },
@@ -68,7 +74,8 @@ export const SLASH_COMMANDS = [
68
74
  { name: "/context", description: "Show context usage by source (system, tools, history, skills)." },
69
75
  { name: "/queue", description: "List queued follow-ups (/queue clear wipes them)." },
70
76
  { name: "/steer", description: "Steer the running turn, or send when idle (/steer <text>)." },
71
- { name: "/autoscroll", description: "Toggle following new output (off by default; bare toggles, on|off sets it; 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)." },
72
79
  { name: "/thinking", description: "Show or hide model thinking in the TUI (rendering only; the turn is untouched)." },
73
80
  { name: "/resume", description: "Restore the last saved session (turns, history, settings, usage)." },
74
81
  { name: "/session", description: "Switch the active session (interactive picker, most recent first)." },
@@ -84,12 +91,12 @@ const SLASH_NAMES = new Set(SLASH_COMMANDS.map((c) => c.name));
84
91
  // conversation only. Files-only is the default highlight (safest).
85
92
  export const REWIND_SCOPES = ["files only", "files + conversation", "conversation only"];
86
93
  // Submit-time pipeline order (ticket 02): submit() below reads as one
87
- // ordered sequence — permissions → context assembly → budget check → loop
94
+ // ordered sequence — permissions → context assembly → loop
88
95
  // entry — so future submit-time work has exactly one home stage. The
89
96
  // rollback-scope rule per stage states what a failed turn keeps vs drops.
90
97
  // This descriptor is the order test's source of truth:
91
98
  // tests/submit-order.test.ts pins both this order and the matching
92
- // `SUBMIT STAGE n/4` markers inside submit().
99
+ // `SUBMIT STAGE n/3` markers inside submit().
93
100
  export const SUBMIT_PIPELINE_STAGES = [
94
101
  {
95
102
  name: "permissions",
@@ -99,10 +106,6 @@ export const SUBMIT_PIPELINE_STAGES = [
99
106
  name: "context-assembly",
100
107
  rollbackScope: "pre-rollbackTo: the env-block refresh survives a failed turn (it is not part of the user turn)",
101
108
  },
102
- {
103
- name: "budget-check",
104
- rollbackScope: "pre-rollbackTo: the budget trim survives a failed turn (rollback indices are captured after it)",
105
- },
106
109
  {
107
110
  name: "loop-entry",
108
111
  rollbackScope: "post-rollbackTo: the user message, skill context, and loop entries roll back on failure",
@@ -115,9 +118,10 @@ export const SKILL_USAGE = "usage: /skill:<name> — invoke a skill directly (li
115
118
  export const RULE_USAGE = "usage: /allow <tool[:glob]> · /deny <tool[:glob]> · /rules · /rules clear (e.g. /allow bash:npm test*, /deny bash:rm *)";
116
119
  export const QUEUE_USAGE = "usage: /queue (list) · /queue clear (wipe) · /steer <text> (steer the running turn, or send when idle)";
117
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";
118
- export const AUTOSCROLL_USAGE = "usage: /autoscroll [on|off] — off (default) freezes the view while a turn runs (a `↓ N new` indicator offers the jump back); on follows new output as it arrives. Bare /autoscroll toggles between the two.";
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.";
119
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).";
120
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)";
121
125
  // Pure arg parser for /rename (unit-tested): strips the command, trims,
122
126
  // then strips one layer of matching outer quotes (single or double) so
123
127
  // quoted names work even though the command line has no real parser.
@@ -187,6 +191,32 @@ export function paletteEntries(query) {
187
191
  if (c.description.toLowerCase().includes(q))
188
192
  out.push({ c, tier: 3, score: 0, idx });
189
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
+ });
190
220
  const catOrder = (n) => PALETTE_CATEGORY_ORDER.indexOf(paletteCategory(n));
191
221
  out.sort((a, b) => a.tier - b.tier ||
192
222
  (a.tier === 0
@@ -195,13 +225,14 @@ export function paletteEntries(query) {
195
225
  return out.map(({ c }) => ({
196
226
  name: c.name,
197
227
  description: c.description,
198
- category: paletteCategory(c.name),
228
+ category: extNames.has(c.name) ? "Extensions" : paletteCategory(c.name),
199
229
  hint: PALETTE_HINTS[c.name] ?? null,
200
230
  }));
201
231
  }
202
232
  // Busy-gate shared by the slash menu and the palette: /compact sets the
203
233
  // pending flag for turn-end drain; /queue + /steer manage the running turn;
204
234
  // /autoscroll and /thinking only flip view flags (never touch the turn);
235
+ // /goal only flips session goal state (never touches the turn);
205
236
  // /rename only renames the store record + title state (the later turn-end
206
237
  // persist preserves the title, so it never races the turn).
207
238
  // Every other command waits idle.
@@ -211,6 +242,7 @@ export function slashRunsWhileBusy(name) {
211
242
  name === "/steer" ||
212
243
  name === "/autoscroll" ||
213
244
  name === "/thinking" ||
245
+ name === "/goal" ||
214
246
  name === "/rename");
215
247
  }
216
248
  // Fuzzy subsequence match with gap/start/word-boundary scoring (lower is
@@ -267,14 +299,37 @@ export function sameSkillMenuSnapshot(a, b) {
267
299
  // name. Skill rows carry a truncated description for discovery. Pure — the
268
300
  // App feeds it the cached registry snapshot.
269
301
  export const SKILL_MENU_DESC_CHARS = 60;
270
- export function buildSlashMenu(input, skills) {
302
+ export function buildSlashMenu(input, skills, extensions = []) {
271
303
  const items = filterSlashCommands(input).map((c) => ({
272
304
  name: c.name,
273
305
  description: c.description,
274
306
  }));
275
307
  if (input.length < 2)
276
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.
277
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
+ }
278
333
  const skillQ = q.startsWith("skill:") ? q.slice("skill:".length) : q;
279
334
  const pushSkill = (s, shown, more) => {
280
335
  const entry = `/skill:${s.name}`;
@@ -329,6 +384,8 @@ export function commandUsage(name) {
329
384
  return "Usage: /compact [focus text] — summarize older turns (works while busy; drains at turn end).";
330
385
  case "/rename":
331
386
  return RENAME_USAGE;
387
+ case "/goal":
388
+ return GOAL_USAGE;
332
389
  default:
333
390
  return null;
334
391
  }
@@ -543,14 +600,14 @@ export function helpListText() {
543
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.` +
544
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.` +
545
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.` +
546
- `\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.` +
547
- `\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.` +
548
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.` +
549
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.` +
550
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.` +
551
- `\n/effort options: Default/Low/Medium/High/Max (wire: default/low/medium/high/max; Default omits reasoning_effort).` +
552
- `\nNote: xhigh was requested but only Max is verified, so the top setting is Max, sent as max.` +
553
- `\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.` +
554
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.` +
555
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)".` +
556
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). ` +
@@ -566,8 +623,8 @@ export function helpListText() {
566
623
  // as `token: n/a` — never 0, which would imply measurement). The segment
567
624
  // itself lives in ./context-windows.js (single source for the exact
568
625
  // `token: (P%) NK` format); StatusBar (./ui/status-bar.js) is its only
569
- // surface. P% tracks CURRENT context load (last prompt_tokens, else
570
- // 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.
571
628
  function formatKEst(chars) {
572
629
  return `~${(estimateTokensForChars(chars) / 1000).toFixed(1)}K`;
573
630
  }
@@ -582,7 +639,7 @@ const TOOLS_SCHEMA_CHARS = JSON.stringify(TOOL_DEFINITIONS).length;
582
639
  // execution (hostile-perf suite asserts token paints and ticks stay out of
583
640
  // here — only real state transitions may run the orchestrator).
584
641
  export const appRenderProbe = { count: 0 };
585
- export function App({ apiKey, endpoint, initialModel, initialModels, initialProvider, restorePrefs, authHome, skillDirs, configDirs, now, setIntervalFn, clearIntervalFn, setTimeoutFn, clearTimeoutFn, localDiscovery }) {
642
+ export function App({ apiKey, endpoint, initialModel, initialModels, initialProvider, restorePrefs, authHome, skillDirs, configDirs, extensionsLockdown, enableExtensions, disableExtensions, now, setIntervalFn, clearIntervalFn, setTimeoutFn, clearTimeoutFn, localDiscovery }) {
586
643
  appRenderProbe.count += 1;
587
644
  const { exit } = useApp();
588
645
  // Saved preferences (provider/model/effort + resolved key/endpoint), loaded
@@ -777,12 +834,13 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
777
834
  }
778
835
  // Reasoning-effort picker (/effort): same pattern as the /model picker
779
836
  // (↑/↓ + Enter, Esc cancels). Saved effort restores with restorePrefs,
780
- // 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.
781
839
  const [selectingEffort, setSelectingEffort] = useState(false);
782
840
  const [effortIndex, setEffortIndex] = useState(0);
783
841
  const effortIndexRef = useRef(0);
784
- const [effort, setEffort] = useState(prefs?.effort ?? atomConfig.reasoningEffort ?? "default");
785
- 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"));
786
844
  // /provider picker + key/baseURL prompts (same keyboard pattern).
787
845
  const [selectingProvider, setSelectingProvider] = useState(false);
788
846
  const [providerIndex, setProviderIndex] = useState(0);
@@ -930,15 +988,97 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
930
988
  showThinkingRef.current = next;
931
989
  setShowThinking(next);
932
990
  }
933
- // /autoscroll (session-only, default off). Off freezes the view at the
934
- // first busy append (the `↓ N new` indicator offers the jump back); on
935
- // follows new output as it arrives. Bare /autoscroll toggles.
936
- const [autoScroll, setAutoScroll] = useState(false);
937
- const autoScrollRef = useRef(false);
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.
994
+ const [autoScroll, setAutoScroll] = useState(true);
995
+ const autoScrollRef = useRef(true);
938
996
  function setAutoScrollBoth(next) {
939
997
  autoScrollRef.current = next;
940
998
  setAutoScroll(next);
941
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
+ }
942
1082
  // Skill registry (cached metadata): one instance per App, scoped to the
943
1083
  // same dirs the suite injects via skillDirs. Every discovery path below
944
1084
  // reads through it — refresh() revalidates by stat (mtime+size) and only
@@ -1017,6 +1157,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1017
1157
  const askSelIndexRef = useRef(0);
1018
1158
  const [askCustom, setAskCustom] = useState("");
1019
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("");
1020
1168
  const [busy, setBusy] = useState(false);
1021
1169
  const busyRef = useRef(false);
1022
1170
  // Per-turn cancellation (Ctrl+C mid-loop): abort stops after the current
@@ -1052,8 +1200,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1052
1200
  const [usageTotals, setUsageTotals] = useState(null);
1053
1201
  // Synchronous mirror of `usageTotals` (same save-time reason as turnsRef).
1054
1202
  const usageRef = useRef(null);
1055
- // Current context load driving status P% (last POST prompt_tokens when
1056
- // 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
1057
1205
  // completes. NK stays cumulative; P must NOT use the cumulative total.
1058
1206
  const [contextLoad, setContextLoad] = useState(null);
1059
1207
  const contextLoadRef = useRef(null);
@@ -1069,8 +1217,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1069
1217
  setGitInfo(null);
1070
1218
  }
1071
1219
  }
1072
- // Last POST's reported prompt_tokens (load metric source). Summary-request
1073
- // 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.
1074
1223
  const lastPromptTokensRef = useRef(undefined);
1075
1224
  // Thrash guard: consecutive auto-compactions without the load dropping
1076
1225
  // below threshold. At 3, auto disables for the session (manual still
@@ -1103,9 +1252,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1103
1252
  });
1104
1253
  const sessionTitleRef = useRef(sessionTitle);
1105
1254
  // Reasoning label from response metadata (via onReasoning). The status
1106
- // line shows the session effort when non-Default (plus " (unsupported)"
1107
- // when the model is outside the verified-support set); when effort is
1108
- // 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`.
1109
1257
  const [reasoning, setReasoning] = useState(null);
1110
1258
  // Live streaming state: the growing assistant text (onToken) and the
1111
1259
  // thinking channel (onThinking) live in a per-mount StreamStore, NOT in App
@@ -1318,7 +1466,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1318
1466
  // below re-wrap via trackHistory (never assign a raw array here).
1319
1467
  const historyRef = useRef(trackHistory([{ role: "system", content: withEnvBlock(systemPrompt) }]));
1320
1468
  // Task 6 per-turn env block (cwd, git branch/status, node, timestamp):
1321
- // pinned to history[0] (the only slot truncateHistory never drops), NEVER
1469
+ // pinned to history[0] (the system prompt), NEVER
1322
1470
  // to user content. Refreshed once per turn in submit() + after doResume, so
1323
1471
  // the loop's many POSTs reuse one block (no per-POST shell-outs).
1324
1472
  // Failure-silent via withEnvBlock (missing git → block shrinks).
@@ -1474,6 +1622,26 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1474
1622
  // in that gap; runLoopWithChat checks the signal before the first POST).
1475
1623
  useEffect(() => {
1476
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;
1477
1645
  // Local observability: close the session trace on unmount (covers
1478
1646
  // every exit path — /exit, Ctrl+C idle, test teardown) and flush.
1479
1647
  // Best-effort, never throws; idempotent with closeTelemetry callers.
@@ -1606,9 +1774,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1606
1774
  askCustomRef.current = next;
1607
1775
  setAskCustom(next);
1608
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
+ }
1609
1785
  function setEffortBoth(next) {
1610
- effortRef.current = next;
1611
- 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);
1612
1792
  }
1613
1793
  function setEffortIndexBoth(next) {
1614
1794
  effortIndexRef.current = next;
@@ -1830,7 +2010,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1830
2010
  : def.defaultModel);
1831
2011
  setModelBoth(nextModel);
1832
2012
  setProviderBoth(pickedId);
1833
- // Provider switch resets the load latch: the last reported prompt_tokens
2013
+ // Provider switch resets the load latch: the last reported input tokens
1834
2014
  // belonged to the old provider/model tokenizer, so the estimate applies
1835
2015
  // until the new provider reports (usageTotals spend is untouched).
1836
2016
  resetContextLoadToEstimate();
@@ -1881,7 +2061,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1881
2061
  setAutoDisabled(next);
1882
2062
  }
1883
2063
  // Recompute contextLoad after a committed turn (or compaction): last
1884
- // POST prompt_tokens when available, else the 4ch/token estimate.
2064
+ // POST input tokens (incl. cache) when available, else the 4ch/token estimate.
1885
2065
  function refreshContextLoad() {
1886
2066
  // No usage yet → no load (status keeps `token: n/a`).
1887
2067
  if (!usageRef.current) {
@@ -1892,7 +2072,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1892
2072
  setContextLoadBoth(load);
1893
2073
  return load;
1894
2074
  }
1895
- // Load-reset contract (hold-last-known): the reported prompt_tokens survive
2075
+ // Load-reset contract (hold-last-known): the reported input tokens survive
1896
2076
  // silent POSTs — estimates never override a fresher report — and reset ONLY
1897
2077
  // here: compaction, /clear, resume, and model/provider switch. After a reset
1898
2078
  // the chars/4 estimate applies until the next report arrives.
@@ -1908,9 +2088,49 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1908
2088
  function pushInfo(content) {
1909
2089
  appendTurns({ role: "tool", content });
1910
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
+ }
1911
2131
  // The session's ContextManager: window-derived budgets for the active
1912
- // model, measured tool schemas, configured ceilings. Built fresh per call
1913
- // (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
1914
2134
  // the current model; history is measured live on every use. The schema size
1915
2135
  // is memoized once — TOOL_DEFINITIONS never changes at runtime, so every
1916
2136
  // turn must not re-serialize 15KB to ask.
@@ -1941,6 +2161,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1941
2161
  const loadLine = u.loadPct !== undefined && b.windowTokens !== undefined
1942
2162
  ? `load: ${formatKEst(u.historyChars)} (${u.loadPct}% of ${(b.windowTokens / 1000).toFixed(0)}K verified window)`
1943
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`;
1944
2169
  const cfg = atomConfigLoad;
1945
2170
  const cfgSources = cfg.sources.project && cfg.sources.global
1946
2171
  ? "project + global"
@@ -1976,7 +2201,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
1976
2201
  `skill injections live in history: ${skillLoads}\n` +
1977
2202
  `${cfgLine}\n` +
1978
2203
  `${cacheLine}\n` +
1979
- `${loadLine} · budget: ${b.effectiveMaxMessages} msgs / ${(b.effectiveMaxChars / 1000).toFixed(0)}K chars`);
2204
+ `${loadLine}\n` +
2205
+ `${allowanceLine}`);
1980
2206
  }
1981
2207
  // Load a resolved skill into the session (tickets 03/06) with
1982
2208
  // progressive-disclosure tiers (Claude-Code-style):
@@ -2054,6 +2280,37 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2054
2280
  // Failures/cancels never reach here (same rollback rule as legacy).
2055
2281
  // Active id lives in a ref only — never a session list in runtime state.
2056
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
+ }
2057
2314
  function storeCwd() {
2058
2315
  try {
2059
2316
  return process.cwd();
@@ -2113,6 +2370,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2113
2370
  return rest;
2114
2371
  }),
2115
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),
2116
2378
  provider: providerRef.current,
2117
2379
  model: modelRef.current,
2118
2380
  effort: effortRef.current,
@@ -2124,12 +2386,130 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2124
2386
  // ignore disk errors (in-memory session still applies)
2125
2387
  }
2126
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
+ }
2127
2437
  // Mount bootstrap: claim/create the active session before any turn can
2128
2438
  // persist, so every normal conversation belongs to a durable session.
2129
2439
  // Startup never auto-restores conversation state (fresh + legacy hint,
2130
2440
  // matching current UX) — this only ensures the record exists.
2131
2441
  useEffect(() => {
2132
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
+ });
2133
2513
  // eslint-disable-next-line react-hooks/exhaustive-deps
2134
2514
  }, []);
2135
2515
  function persistSession() {
@@ -2140,6 +2520,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2140
2520
  effort: effortRef.current,
2141
2521
  mode: modeRef.current,
2142
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,
2143
2526
  history: historyRef.current,
2144
2527
  turns: turnsRef.current.map((t) => {
2145
2528
  const { diff: _dropped, ...rest } = t;
@@ -2208,6 +2591,20 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2208
2591
  // failure: old history untouched + inline error (oversize retry-once
2209
2592
  // already handled inside requestCompactSummary, which appends /clear).
2210
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
+ }
2211
2608
  async function doCompact(focusText, isAuto) {
2212
2609
  if (countUserTurns(historyRef.current) <= 1) {
2213
2610
  if (!isAuto)
@@ -2238,56 +2635,108 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2238
2635
  }
2239
2636
  const baseURL = chatBaseURL(providerRef.current);
2240
2637
  try {
2241
- const summary = await requestCompactSummary({
2242
- provider: providerRef.current,
2243
- apiKey: submitKey,
2244
- model: modelRef.current,
2245
- systemContent,
2246
- head: split.head,
2247
- focusText,
2248
- baseURL,
2249
- endpointOverride: activeEndpoint,
2250
- onUsage: (u) => {
2251
- // Totals keep accumulating (real summary spend); load source
2252
- // untouched (summary prompt reflects head size, not new context).
2253
- const prev = usageRef.current ?? {};
2254
- const next = { ...prev };
2255
- if (u.prompt_tokens !== undefined) {
2256
- next.prompt_tokens = (next.prompt_tokens ?? 0) + u.prompt_tokens;
2257
- }
2258
- if (u.completion_tokens !== undefined) {
2259
- next.completion_tokens = (next.completion_tokens ?? 0) + u.completion_tokens;
2260
- }
2261
- if (u.total_tokens !== undefined) {
2262
- next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
2263
- }
2264
- if (u.cacheReadTokens !== undefined) {
2265
- next.cacheReadTokens = (next.cacheReadTokens ?? 0) + u.cacheReadTokens;
2266
- }
2267
- if (u.cacheWriteTokens !== undefined) {
2268
- next.cacheWriteTokens = (next.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
2269
- }
2270
- // Only accumulate when the summary actually reported usage;
2271
- // an empty onUsage keeps totals byte-identical.
2272
- if (u.prompt_tokens !== undefined ||
2273
- u.completion_tokens !== undefined ||
2274
- u.total_tokens !== undefined ||
2275
- u.cacheReadTokens !== undefined ||
2276
- u.cacheWriteTokens !== undefined) {
2277
- setUsageBoth(next);
2278
- }
2279
- // Local observability: compaction spend is session-level (it
2280
- // summarizes many turns and often lands after its turn ended), so
2281
- // it is kept separate from per-turn usage. No-op when empty.
2282
- telemetry.recordCompactionUsage(u, isAuto ? "auto" : "manual");
2283
- },
2284
- });
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
+ }));
2285
2730
  // Atomic swap: build the new history first, then replace. The head's
2286
2731
  // touched files (collected from the committed tool_calls the loop
2287
- // already recorded — no new tracking) ride inside the summary within
2288
- // budget, so resumed sessions know what was touched; over-budget lists
2289
- // shrink instead of failing compaction.
2290
- const fitted = fitSummaryWithFiles(summary, collectTouchedFiles(split.head));
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);
2291
2740
  const next = buildCompactedHistory(systemMsg, fitted.text, split.tail, split.olderTurnCount);
2292
2741
  // Replacement: re-wrap so the ledger restarts from the compacted array
2293
2742
  // (the old ledger is discarded with the old array).
@@ -2301,7 +2750,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2301
2750
  pushInfo(`(/compact — discarded ${compactDrops} file checkpoint(s); undos do not cross a compaction)`);
2302
2751
  }
2303
2752
  // P% must drop immediately: the old lastPromptTokens reflects the
2304
- // 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.
2305
2755
  lastPromptTokensRef.current = undefined;
2306
2756
  const newLoad = estimateTokensForChars(historyChars(historyRef.current));
2307
2757
  setContextLoadBoth(newLoad);
@@ -2312,14 +2762,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2312
2762
  autoStreakRef.current = 0;
2313
2763
  }
2314
2764
  else {
2315
- autoStreakRef.current += 1;
2316
- if (isThrashDisabled(autoStreakRef.current)) {
2317
- setAutoDisabledBoth(true);
2318
- appendTurns({
2319
- role: "tool",
2320
- content: "(auto-compact thrashing — disabled, use /compact or /clear)",
2321
- });
2322
- }
2765
+ bumpAutoStreak();
2323
2766
  }
2324
2767
  }
2325
2768
  else {
@@ -2390,6 +2833,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2390
2833
  setEffortBoth(s.effort);
2391
2834
  setModeBoth(s.mode);
2392
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));
2393
2841
  // Replacement: wrap the restored array (see the init comment).
2394
2842
  historyRef.current = trackHistory([...s.history]);
2395
2843
  // Task 6: refresh the pinned env block on the restored system line
@@ -2419,14 +2867,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2419
2867
  content: `(/resume — discarded ${resumedDrops} live file checkpoint(s); undos do not cross a resume)`,
2420
2868
  });
2421
2869
  }
2422
- // Same window-derived caps as the live path (the restored model is
2423
- // already in modelRef above).
2424
- contextManager().trimForSend(historyRef.current, (msg) => {
2425
- pendingNotices.push({ role: "tool", content: `⚠ ${msg}` });
2426
- }, undefined, openTodoNeedles());
2427
- // Surface the touched-file lists stored in compacted summaries, verbatim
2428
- // in the stored format — a resumed session knows what was touched
2429
- // without re-exploring the tree.
2870
+ // Restored history is used whole: no caps, no trimming. A resumed
2871
+ // session knows what was touched without re-exploring the tree.
2430
2872
  for (const section of collectStoredTouchedFiles(historyRef.current)) {
2431
2873
  pendingNotices.push({ role: "tool", content: section });
2432
2874
  }
@@ -2451,6 +2893,10 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2451
2893
  // (create+activate when none exists). Startup itself never auto-restores
2452
2894
  // the store — only this explicit /resume does.
2453
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");
2454
2900
  }
2455
2901
  // /session switch: make the picked record the live conversation. Exactly
2456
2902
  // one history replacement (never merge, never duplicate): the target's
@@ -2467,7 +2913,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2467
2913
  // - a missing/unreadable target errors WITHOUT touching the live session.
2468
2914
  // The legacy session.json follows the switch (same persistSession path as
2469
2915
  // every completed turn) so /resume stays coherent with the live view.
2470
- function switchToSession(id) {
2916
+ async function switchToSession(id) {
2471
2917
  let target = null;
2472
2918
  try {
2473
2919
  target = getSession(id, authHome);
@@ -2484,6 +2930,28 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2484
2930
  pushInfo(`(already on "${target.title}")`);
2485
2931
  return;
2486
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
+ }
2487
2955
  // Snapshot the outgoing conversation into its own record first (same
2488
2956
  // rule as /new's pre-reset save). Guarded: only when the live state
2489
2957
  // actually holds turns of the outgoing record.
@@ -2512,6 +2980,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2512
2980
  setEffortBoth(target.effort);
2513
2981
  setModeBoth(target.mode);
2514
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));
2515
2988
  // Replacement: the target's arrays replace the live ones wholesale (a
2516
2989
  // fresh-created record carries empty history — fall back to a fresh
2517
2990
  // system line so the system-first invariant always holds).
@@ -2558,9 +3031,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2558
3031
  clearTodos();
2559
3032
  setTodoSnap([]);
2560
3033
  }
2561
- contextManager().trimForSend(historyRef.current, (msg) => {
2562
- pendingNotices.push({ role: "tool", content: `⚠ ${msg}` });
2563
- }, undefined, openTodoNeedles());
3034
+ // The target's history/turns REPLACE the live arrays wholesale — used
3035
+ // whole, never trimmed.
2564
3036
  for (const section of collectStoredTouchedFiles(historyRef.current)) {
2565
3037
  pendingNotices.push({ role: "tool", content: section });
2566
3038
  }
@@ -2581,7 +3053,11 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2581
3053
  persistTelemetry();
2582
3054
  // Legacy single-file save follows the switch so /resume restores what
2583
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);
2584
3059
  persistSession();
3060
+ replaceExtensionContext("switch");
2585
3061
  }
2586
3062
  // /rewind conversation scope (ticket 01): truncate history + transcript to
2587
3063
  // the checkpoint's turn. The cut drops the whole containing turn (submit's
@@ -2642,9 +3118,6 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2642
3118
  }
2643
3119
  pushInfo(filesMsg);
2644
3120
  }
2645
- function warnEffortUnsupported(modelName) {
2646
- pushInfo(`reasoning effort is not known to be supported by ${modelName} — setting kept, not sent`);
2647
- }
2648
3121
  // Manual /compact entry: busy → set pending flag, run at turn end (drain
2649
3122
  // boundary, never mid-turn); idle → run now under the busy guard so a
2650
3123
  // concurrent submit cannot interleave. Works for unknown-window models.
@@ -2848,6 +3321,60 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2848
3321
  }
2849
3322
  pushInfo(AUTOSCROLL_USAGE);
2850
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
+ }
2851
3378
  // /models: local-discovery status + refresh. Bare `/models` reports the
2852
3379
  // last snapshot (kicking a first probe when discovery never ran);
2853
3380
  // `/models refresh` re-probes all three runtimes, then reports. Results
@@ -2935,6 +3462,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2935
3462
  if (clearedDrops > 0) {
2936
3463
  pushInfo(`(/clear — discarded ${clearedDrops} file checkpoint(s); undos do not cross a cleared conversation)`);
2937
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
+ }
2938
3474
  // autoDisabled stays for the session (thrash guard is session-wide).
2939
3475
  lastPromptTokensRef.current = undefined;
2940
3476
  setContextLoadBoth(null);
@@ -2969,6 +3505,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2969
3505
  setActiveSession(created.id, authHome);
2970
3506
  activeSessionIdRef.current = created.id;
2971
3507
  setSessionTitleBoth(created.title);
3508
+ // Bind the new record BEFORE the boundary emit (ticket 05).
3509
+ extRuntimeRef.current?.setSessionId(created.id);
2972
3510
  }
2973
3511
  catch {
2974
3512
  // ignore disk errors (in-memory reset below still applies)
@@ -2984,6 +3522,15 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
2984
3522
  content: "(new session started — previous conversation kept, /resume to restore it)",
2985
3523
  },
2986
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
+ }
2987
3534
  // Fresh list: a held view has nothing to hold onto — re-follow.
2988
3535
  setScrollEndBoth(null);
2989
3536
  setClearGen((g) => g + 1);
@@ -3017,6 +3564,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3017
3564
  telemetry.recordEvent("new", "fresh conversation started (previous kept for /resume)");
3018
3565
  persistTelemetry();
3019
3566
  void refreshSkillMenu();
3567
+ replaceExtensionContext("new");
3020
3568
  return;
3021
3569
  case "/compact":
3022
3570
  // Bare /compact with no focus text (slash-menu path). Free-text
@@ -3080,6 +3628,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3080
3628
  case "/autoscroll":
3081
3629
  runAutoScrollCommand("/autoscroll");
3082
3630
  return;
3631
+ case "/goal":
3632
+ runGoalCommand("/goal");
3633
+ return;
3083
3634
  case "/mode":
3084
3635
  if (modeRef.current === "plan") {
3085
3636
  pushInfo("mode: plan (read-only — write/edit/bash blocked with a replan note; Tab to approve + exit)");
@@ -3163,7 +3714,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3163
3714
  return;
3164
3715
  }
3165
3716
  default:
3166
- 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
+ }
3167
3728
  }
3168
3729
  }
3169
3730
  // approve hook for runAgenticLoop: scoped rules first (deny refuses as a
@@ -3319,7 +3880,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3319
3880
  h?.reject(new Error("question cancelled by user"));
3320
3881
  }
3321
3882
  // Submit-time pipeline (ticket 02 — stage order is SUBMIT_PIPELINE_STAGES
3322
- // 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
3323
3884
  // rollback-scope rule). Local "/" routing precedes the pipeline: exact
3324
3885
  // slash commands, /allow-/deny-/rules, and skill invocations never enter
3325
3886
  // it (no turn, no history, nothing to roll back).
@@ -3346,11 +3907,22 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3346
3907
  runRenameCommand(text);
3347
3908
  return;
3348
3909
  }
3349
- // SUBMIT STAGE 1/4 — permissions (rollback scope: pre-turn, appends
3910
+ // SUBMIT STAGE 1/3 — permissions (rollback scope: pre-turn, appends
3350
3911
  // nothing). Busy guard + API-key check: rejections return before any
3351
3912
  // history mutation, so there is nothing to roll back.
3352
3913
  if (!text)
3353
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
+ }
3354
3926
  // Busy: plain follow-ups queue instead of submitting (Claude-Code-style —
3355
3927
  // the thought is never lost); /queue + /steer manage and inject. Other
3356
3928
  // "/" input still needs idle (pickers/modals would race the turn), so it
@@ -3361,13 +3933,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3361
3933
  runQueueCommand(text);
3362
3934
  return;
3363
3935
  }
3364
- // /autoscroll and /thinking are view-only state (never touch the
3365
- // 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
3366
3938
  // slashRunsWhileBusy).
3367
3939
  if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
3368
3940
  runAutoScrollCommand(text);
3369
3941
  return;
3370
3942
  }
3943
+ if (text === "/goal" || text.startsWith("/goal ")) {
3944
+ runGoalCommand(text);
3945
+ return;
3946
+ }
3371
3947
  if (text === "/thinking" || text.startsWith("/thinking ")) {
3372
3948
  runThinkingCommand(text);
3373
3949
  return;
@@ -3389,12 +3965,17 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3389
3965
  return;
3390
3966
  }
3391
3967
  // /autoscroll takes an optional subcommand (/autoscroll on|off), like the
3392
- // /queue family — SLASH_NAMES only holds the exact command. /thinking
3393
- // 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.
3394
3971
  if (text === "/autoscroll" || text.startsWith("/autoscroll ")) {
3395
3972
  runAutoScrollCommand(text);
3396
3973
  return;
3397
3974
  }
3975
+ if (text === "/goal" || text.startsWith("/goal ")) {
3976
+ runGoalCommand(text);
3977
+ return;
3978
+ }
3398
3979
  if (text === "/thinking" || text.startsWith("/thinking ")) {
3399
3980
  runThinkingCommand(text);
3400
3981
  return;
@@ -3447,6 +4028,16 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3447
4028
  void invokeSkillByName(namespaced);
3448
4029
  return;
3449
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
+ }
3450
4041
  const skillName = /^\/([A-Za-z0-9_-]+)$/.exec(text)?.[1];
3451
4042
  if (skillName !== undefined) {
3452
4043
  void invokeSkillByName(skillName);
@@ -3497,25 +4088,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3497
4088
  pendingDiffRef.current = null;
3498
4089
  lastPartialRef.current = "";
3499
4090
  refreshGitInfo();
3500
- // SUBMIT STAGE 2/4 — context-assembly (rollback scope: pre-rollbackTo,
4091
+ // SUBMIT STAGE 2/3 — context-assembly (rollback scope: pre-rollbackTo,
3501
4092
  // survives failure). Refresh the pinned env block ONCE per turn (not per
3502
4093
  // POST — the loop reuses history[0] for all its POSTs, so this is the
3503
- // only git call for the turn). Before the budget check so truncation
3504
- // accounts for the fresh block size; before rollbackTo so the refresh
4094
+ // only git call for the turn). Before rollbackTo so the refresh
3505
4095
  // survives a failed-turn rollback (it is not part of the user turn).
4096
+ // History is uncapped: the full conversation rides every turn.
3506
4097
  refreshSystemEnv();
3507
- // SUBMIT STAGE 3/4budget-check (rollback scope: pre-rollbackTo,
3508
- // survives failure). History budget at turn start, BEFORE the push +
3509
- // rollbackTo capture below (so the existing splice-rollback indices stay
3510
- // valid): drop oldest user-turns first, reserving room for the incoming user message
3511
- // so the loop core's own budget check stays a no-op on entry - exactly
3512
- // one dim notice per truncating turn. /clear drops the notice with the
3513
- // transcript (usage totals still survive). Caps come from the session
3514
- // ContextManager (window-derived), with the same live todo pinning.
3515
- contextManager().trimForSend(historyRef.current, (msg) => {
3516
- appendTurns({ role: "tool", content: `? ${msg}` });
3517
- }, { messages: 1, chars: text.length }, openTodoNeedles());
3518
- // SUBMIT STAGE 4/4 — loop-entry (rollback scope: post-rollbackTo, rolls
4098
+ // SUBMIT STAGE 3/3loop-entry (rollback scope: post-rollbackTo, rolls
3519
4099
  // back on failure). Turn boundary: on POST failure (HTTP/network/empty/
3520
4100
  // truncated) the whole user turn (user message plus any partial
3521
4101
  // assistant/tool loop entries) is removed, so the next request starts
@@ -3524,15 +4104,39 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3524
4104
  // catch). Cancellation (LoopCancelledError) shares the same splice
3525
4105
  // contract.
3526
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;
3527
4113
  // Local observability: open this turn's trace (no-op when disabled).
3528
4114
  // Provider/model switches surface here per turn; session-level switches
3529
4115
  // are derived from the same updates (see setSessionMeta).
3530
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;
3531
4134
  const telemetryTurnId = telemetry.startTurn(text, {
3532
4135
  provider: providerRef.current,
3533
4136
  model: modelRef.current,
3534
4137
  effort: effortRef.current,
3535
4138
  mode: modeRef.current,
4139
+ ...(turnGoal ? { goal: turnGoal } : {}),
3536
4140
  });
3537
4141
  const telemetrySink = {
3538
4142
  onModelCall: (info) => telemetry.recordModelCall(telemetryTurnId, info),
@@ -3572,6 +4176,45 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3572
4176
  const reply = await runAgenticLoopForProvider(providerRef.current, submitKey, modelRef.current, historyRef.current, {
3573
4177
  approve,
3574
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
+ },
3575
4218
  // Local observability sink: the loop reports completed model/tool
3576
4219
  // calls (iterations, durations, usage) into the open turn trace.
3577
4220
  telemetry: telemetrySink,
@@ -3647,29 +4290,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3647
4290
  // POST accumulates (tool-round POSTs and successful retries each
3648
4291
  // count once — each was billed; failed attempts report nothing, so
3649
4292
  // nothing is deduped). usageTotals drives NK only, never P%.
3650
- const prev = usageRef.current ?? {};
3651
- const next = { ...prev };
3652
- if (u.prompt_tokens !== undefined) {
3653
- next.prompt_tokens = (next.prompt_tokens ?? 0) + u.prompt_tokens;
3654
- // Load metric source: last POST's reported prompt_tokens (the
3655
- // per-POST value, NOT the accumulated total).
3656
- lastPromptTokensRef.current = u.prompt_tokens;
3657
- }
3658
- if (u.completion_tokens !== undefined) {
3659
- next.completion_tokens = (next.completion_tokens ?? 0) + u.completion_tokens;
3660
- }
3661
- if (u.total_tokens !== undefined) {
3662
- next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
3663
- }
3664
- // Prefix-cache counters accumulate like spend (real reports only;
3665
- // absent fields mean "not reported", never zero).
3666
- if (u.cacheReadTokens !== undefined) {
3667
- next.cacheReadTokens = (next.cacheReadTokens ?? 0) + u.cacheReadTokens;
3668
- }
3669
- if (u.cacheWriteTokens !== undefined) {
3670
- next.cacheWriteTokens = (next.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
3671
- }
3672
- setUsageBoth(next);
4293
+ accumulateUsage(u);
3673
4294
  },
3674
4295
  onReasoning: (label) => {
3675
4296
  setReasoning(label);
@@ -3759,12 +4380,6 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3759
4380
  noteTurnActivity();
3760
4381
  },
3761
4382
  signal: controller.signal,
3762
- // Window-aware trim caps for this model's real window (the loop
3763
- // falls back to legacy caps without it — see AgenticOpts.context).
3764
- context: {
3765
- model: modelRef.current,
3766
- toolsChars: TOOLS_SCHEMA_CHARS,
3767
- },
3768
4383
  });
3769
4384
  // Turn-end flush: any trailing throttled partial paints before the
3770
4385
  // commit replaces the draft (byte-exact via `reply` regardless). The
@@ -3861,6 +4476,21 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
3861
4476
  }
3862
4477
  }
3863
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
+ }
3864
4494
  turnCancelRef.current = null;
3865
4495
  approvalResolveRef.current = null;
3866
4496
  setPendingApproval(null);
@@ -4018,6 +4648,53 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4018
4648
  }
4019
4649
  return;
4020
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
+ }
4021
4698
  // 2b. Provider key prompt (masked • per char, paste works, Esc safe).
4022
4699
  const kp = keyPromptRef.current;
4023
4700
  if (kp && keyPrompt) {
@@ -4209,28 +4886,22 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4209
4886
  // reported prompt_tokens no longer measures this context); the
4210
4887
  // estimate applies until the new model reports.
4211
4888
  resetContextLoadToEstimate();
4212
- // Re-gate effort on every /model switch: setting persists, but a
4213
- // non-Default effort on an unsupported model warns (kept, not sent).
4214
- if (effortRef.current !== "default" && !isEffortSupported(picked.model)) {
4215
- warnEffortUnsupported(picked.model);
4216
- }
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).
4217
4891
  }
4218
4892
  else {
4219
4893
  // Cross-provider pick: switch with the resolved key (env wins,
4220
4894
  // else stored — remote sections only render for keyed providers;
4221
4895
  // local sections need no key) and keep the picked model; the
4222
4896
  // live refresh lands in the background via the standard switch
4223
- // path. reasoning_effort is zen-only, so a non-Default effort
4224
- // warns (kept, not sent).
4897
+ // path. Effort carries over untouched it is valid on every
4898
+ // provider kind.
4225
4899
  const switchedKey = keyForProvider(picked.providerId);
4226
4900
  if (switchedKey || !providerNeedsKey(picked.providerId)) {
4227
4901
  const pickedProvider = picked.providerId;
4228
4902
  const pickedModel = picked.model;
4229
4903
  void (async () => {
4230
4904
  await switchProviderWithKey(pickedProvider, switchedKey, pickedModel);
4231
- if (effortRef.current !== "default") {
4232
- warnEffortUnsupported(pickedModel);
4233
- }
4234
4905
  })();
4235
4906
  }
4236
4907
  }
@@ -4322,7 +4993,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4322
4993
  setSelectingSession(false);
4323
4994
  if (picked) {
4324
4995
  exitHistoryBrowse();
4325
- switchToSession(picked.id);
4996
+ void switchToSession(picked.id);
4326
4997
  }
4327
4998
  else {
4328
4999
  pushInfo("(no sessions match — backspace to widen the filter.)");
@@ -4353,10 +5024,9 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4353
5024
  else if (key.return) {
4354
5025
  const picked = EFFORT_OPTIONS[effortIndexRef.current];
4355
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).
4356
5029
  setEffortBoth(picked);
4357
- if (picked !== "default" && !isEffortSupported(modelRef.current)) {
4358
- warnEffortUnsupported(modelRef.current);
4359
- }
4360
5030
  }
4361
5031
  setSelectingEffort(false);
4362
5032
  }
@@ -4417,7 +5087,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4417
5087
  // a newline anywhere means free text (slash commands never span lines).
4418
5088
  const cur = inputRef.current;
4419
5089
  const menu = !slashDismissedRef.current && cur.startsWith("/") && !cur.includes("\n")
4420
- ? buildSlashMenu(cur, skillMenu)
5090
+ ? buildSlashMenu(cur, skillMenu, listExtensionCommands())
4421
5091
  : { items: [], moreSkills: 0 };
4422
5092
  const matches = menu.items;
4423
5093
  if (matches.length > 0) {
@@ -4444,8 +5114,8 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4444
5114
  }
4445
5115
  else if (key.return || key.tab) {
4446
5116
  const pick = matches[slashIndexRef.current % matches.length];
4447
- // /compact, /queue, /steer, and /autoscroll run while busy (see
4448
- // 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.
4449
5119
  if (pick && (slashRunsWhileBusy(pick.name) || !busyRef.current)) {
4450
5120
  if (pick.skill) {
4451
5121
  // Skill entries stage for confirm (opencode-style): Enter/Tab
@@ -4476,6 +5146,14 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4476
5146
  setInputBoth("");
4477
5147
  runAutoScrollCommand(raw);
4478
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
+ }
4479
5157
  else if ((pick.name === "/allow" || pick.name === "/deny" || pick.name === "/rules") &&
4480
5158
  inputRef.current.startsWith(pick.name)) {
4481
5159
  // Preserve the typed rule args (e.g. "/allow bash:npm test*");
@@ -4492,6 +5170,19 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4492
5170
  setInputBoth("");
4493
5171
  runRenameCommand(raw);
4494
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
+ }
4495
5186
  else {
4496
5187
  runSlashCommand(pick.name);
4497
5188
  }
@@ -4524,7 +5215,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4524
5215
  // input, and input keys never reach the inspector.
4525
5216
  if (key.ctrl && (ch === "o" || ch === "O")) {
4526
5217
  if (!busyRef.current && !turnCancelRef.current &&
4527
- !pendingApproval && !pendingQuestion &&
5218
+ !pendingApproval && !pendingQuestion && !extDialogOpen &&
4528
5219
  !selecting && !selectingSkills && !selectingProvider &&
4529
5220
  !keyPrompt && !baseURLPrompt && !selectingEffort &&
4530
5221
  !selectingRewind && !selectingRewindScope) {
@@ -4589,7 +5280,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4589
5280
  // open); Enter runs through the shared busy-gate, so only
4590
5281
  // compact/queue/steer fire while busy.
4591
5282
  if (key.ctrl && (ch === "p" || ch === "P")) {
4592
- if (!pendingApproval && !pendingQuestion &&
5283
+ if (!pendingApproval && !pendingQuestion && !extDialogOpen &&
4593
5284
  !selecting && !selectingSkills && !selectingSession && !selectingProvider &&
4594
5285
  !keyPrompt && !baseURLPrompt && !selectingEffort &&
4595
5286
  !selectingRewind && !selectingRewindScope && !inspecting) {
@@ -4735,6 +5426,35 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4735
5426
  insertAtCursor(ch);
4736
5427
  }
4737
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]);
4738
5458
  // Bracketed paste (Ink enables `\x1b[?2004h` while active): pasted text —
4739
5459
  // including newlines — inserts at the cursor verbatim and NEVER submits,
4740
5460
  // so multiline pastes can't fire mid-paste. Separate channel from
@@ -4745,6 +5465,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4745
5465
  }, {
4746
5466
  isActive: !pendingApproval &&
4747
5467
  !pendingQuestion &&
5468
+ !extDialogOpen &&
4748
5469
  !selecting &&
4749
5470
  !selectingSkills &&
4750
5471
  !selectingSession &&
@@ -4784,6 +5505,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4784
5505
  !baseURLPrompt &&
4785
5506
  !pendingApproval &&
4786
5507
  !pendingQuestion &&
5508
+ !extDialogOpen &&
4787
5509
  !selectingRewind &&
4788
5510
  !selectingRewindScope &&
4789
5511
  !slashDismissed &&
@@ -4801,6 +5523,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4801
5523
  baseURLPrompt,
4802
5524
  pendingApproval,
4803
5525
  pendingQuestion,
5526
+ extDialogOpen,
4804
5527
  selectingRewind,
4805
5528
  selectingRewindScope,
4806
5529
  slashDismissed,
@@ -4886,25 +5609,25 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4886
5609
  const approvalDescription = useMemo(() => (pendingApproval ? describeToolCall(pendingApproval.name, pendingApproval.args) : ""), [pendingApproval]);
4887
5610
  // (The cursor clamp lives inside the memoized InputBox now, next to its
4888
5611
  // only use — App body no longer reads cursor state for paint.)
4889
- // Status-line reasoning segment wired to the effort session state:
4890
- // non-Default shows the effort (plus " (unsupported)" when the model is
4891
- // outside the verified-support set OR the provider is not opencode-zen);
4892
- // Default shows response metadata or "default" as before.
4893
- // reasoning_effort is sent ONLY for opencode-zen + supported model.
4894
- const effortSupportedNow = effort === "default" ||
4895
- (provider === "opencode-zen" && isEffortSupported(model));
4896
- 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"
4897
5620
  ? effortSupportedNow
4898
5621
  ? effort
4899
5622
  : `${effort} (unsupported)`
4900
- : (reasoning ?? "default");
5623
+ : (reasoning ?? "auto");
4901
5624
  // Display-only live tool elapsed: wall-clock now ≈ turn start + elapsed
4902
5625
  // ticks (the 1s busy tick re-renders, so this stays fresh). Null when no
4903
5626
  // tool is running — the running line then paints with no duration.
4904
5627
  const toolElapsedSecs = busy && toolHint && toolStartRef.current !== null
4905
5628
  ? elapsedSecsSince(toolStartRef.current, turnStartRef.current + elapsedSecs * 1000)
4906
5629
  : null;
4907
- 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, _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) => {
4908
5631
  const i = modelWin.start + k;
4909
5632
  const entryLocal = e.local === true;
4910
5633
  const prevLocal = i === 0 ? null : modelEntries[i - 1]?.local === true;
@@ -4935,7 +5658,7 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4935
5658
  ? `${theme.symbol.descSeparator} key optional — free models need none`
4936
5659
  : `${theme.symbol.descSeparator} no key`;
4937
5660
  return (_jsxs(PickerRow, { highlighted: i === providerIndex, children: [p.name, " (", p.id, ") ", keyMark, p.id === provider ? " (current)" : ""] }, p.id));
4938
- }) })) : 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." })] })) : (
4939
5662
  // The input is the one boxed, prominent surface (see the memoized
4940
5663
  // InputBox above): a quiet gray frame sets it apart from the
4941
5664
  // transcript above and the status line below. Pickers and modals
@@ -4943,5 +5666,5 @@ export function App({ apiKey, endpoint, initialModel, initialModels, initialProv
4943
5666
  // border color.
4944
5667
  _jsx(InputBox, { input: input, cursor: cursor })), slashVisible && !inspecting && !paletteOpen ? (_jsxs(PickerShell, { title: slashHasSkills
4945
5668
  ? `Atom commands + skills (${theme.symbol.moreAbove}/${theme.symbol.moreBelow} + Enter/Tab to run, Esc dismisses):`
4946
- : `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 })] }));
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 })] }));
4947
5670
  }