@aragon-agent/cli 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/README.md +254 -7
  3. package/dist/agent/agent-mode.d.ts +53 -0
  4. package/dist/agent/agent-mode.js +69 -0
  5. package/dist/agent/controller.d.ts +69 -0
  6. package/dist/agent/controller.js +154 -1
  7. package/dist/agent/plan-prompt.d.ts +18 -0
  8. package/dist/agent/plan-prompt.js +93 -0
  9. package/dist/agent/reducer.d.ts +14 -1
  10. package/dist/agent/reducer.js +9 -0
  11. package/dist/agent/system-prompt.d.ts +15 -0
  12. package/dist/agent/system-prompt.js +8 -0
  13. package/dist/cli.d.ts +1 -1
  14. package/dist/cli.js +247 -23
  15. package/dist/commands/builtins.js +60 -0
  16. package/dist/commands/registry.d.ts +17 -0
  17. package/dist/config/app-paths.d.ts +71 -4
  18. package/dist/config/app-paths.js +139 -8
  19. package/dist/config/cli-commands.d.ts +43 -0
  20. package/dist/config/cli-commands.js +196 -0
  21. package/dist/config/env.d.ts +7 -1
  22. package/dist/config/env.js +53 -1
  23. package/dist/config/load.d.ts +10 -0
  24. package/dist/config/load.js +82 -2
  25. package/dist/config/migrate-home.d.ts +63 -0
  26. package/dist/config/migrate-home.js +165 -0
  27. package/dist/config/migrate-legacy-state.d.ts +10 -0
  28. package/dist/config/migrate-legacy-state.js +15 -6
  29. package/dist/config/schema.d.ts +81 -0
  30. package/dist/config/schema.js +85 -0
  31. package/dist/config/store.d.ts +30 -12
  32. package/dist/config/store.js +47 -35
  33. package/dist/input/mouse-events.d.ts +61 -0
  34. package/dist/input/mouse-events.js +153 -0
  35. package/dist/input/stdin-mouse-filter.d.ts +42 -0
  36. package/dist/input/stdin-mouse-filter.js +153 -0
  37. package/dist/logging/cli-commands.d.ts +25 -0
  38. package/dist/logging/cli-commands.js +221 -0
  39. package/dist/logging/file-sink.d.ts +114 -0
  40. package/dist/logging/file-sink.js +326 -0
  41. package/dist/logging/install.d.ts +163 -0
  42. package/dist/logging/install.js +302 -0
  43. package/dist/logging/levels.d.ts +40 -0
  44. package/dist/logging/levels.js +59 -0
  45. package/dist/logging/logger.d.ts +110 -0
  46. package/dist/logging/logger.js +237 -0
  47. package/dist/logging/redact.d.ts +28 -0
  48. package/dist/logging/redact.js +90 -0
  49. package/dist/logging/secret-registry.d.ts +30 -0
  50. package/dist/logging/secret-registry.js +55 -0
  51. package/dist/skills/paths.d.ts +2 -4
  52. package/dist/skills/paths.js +7 -9
  53. package/dist/tools/human-input.d.ts +179 -0
  54. package/dist/tools/human-input.js +227 -0
  55. package/dist/tools/index.d.ts +35 -3
  56. package/dist/tools/index.js +96 -9
  57. package/dist/tools/plan-tools.d.ts +64 -0
  58. package/dist/tools/plan-tools.js +198 -0
  59. package/dist/ui/App.d.ts +12 -1
  60. package/dist/ui/App.js +223 -17
  61. package/dist/ui/Composer.d.ts +9 -1
  62. package/dist/ui/Composer.js +26 -7
  63. package/dist/ui/ModeChip.d.ts +29 -0
  64. package/dist/ui/ModeChip.js +15 -0
  65. package/dist/ui/PromptInput.d.ts +22 -1
  66. package/dist/ui/PromptInput.js +49 -2
  67. package/dist/ui/StatusBar.d.ts +15 -0
  68. package/dist/ui/StatusBar.js +10 -2
  69. package/dist/ui/entries/ToolPreview.d.ts +2 -0
  70. package/dist/ui/entries/ToolPreview.js +64 -0
  71. package/dist/ui/glyphs.d.ts +21 -0
  72. package/dist/ui/glyphs.js +12 -0
  73. package/dist/ui/layout/AppShell.d.ts +10 -1
  74. package/dist/ui/layout/AppShell.js +38 -3
  75. package/dist/ui/layout/ScrollIndicator.d.ts +31 -0
  76. package/dist/ui/layout/ScrollIndicator.js +15 -0
  77. package/dist/ui/layout/ScrollViewport.d.ts +8 -2
  78. package/dist/ui/layout/ScrollViewport.js +24 -4
  79. package/dist/ui/layout/regions.d.ts +55 -0
  80. package/dist/ui/layout/regions.js +52 -0
  81. package/dist/ui/layout/scroll-indicator.d.ts +27 -0
  82. package/dist/ui/layout/scroll-indicator.js +35 -0
  83. package/dist/ui/layout/scroll.d.ts +9 -0
  84. package/dist/ui/layout/scroll.js +23 -0
  85. package/dist/ui/layout/wrap-rows.d.ts +24 -0
  86. package/dist/ui/layout/wrap-rows.js +74 -0
  87. package/dist/ui/overlays/HelpOverlay.js +15 -0
  88. package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
  89. package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
  90. package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
  91. package/dist/ui/overlays/QuestionOverlay.js +193 -0
  92. package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
  93. package/dist/ui/overlays/SettingsScreen.js +8 -1
  94. package/dist/ui/screen.d.ts +14 -1
  95. package/dist/ui/screen.js +37 -3
  96. package/dist/ui/use-startup-notices.d.ts +25 -0
  97. package/dist/ui/use-startup-notices.js +64 -0
  98. package/dist/ui/use-wheel-routing.d.ts +68 -0
  99. package/dist/ui/use-wheel-routing.js +139 -0
  100. package/package.json +3 -2
  101. package/runtime/insecure-tls-warning.cjs +62 -0
  102. package/runtime/insecure-tls-warning.d.cts +5 -0
@@ -0,0 +1,198 @@
1
+ /**
2
+ * `ask_user` and `submit_plan` — the two tools that only exist because a human
3
+ * is attached (plan-mode §4.1 / §4.2 / §5.3).
4
+ *
5
+ * ASCII ONLY: `tools/` is inside the glyph scanner's scope.
6
+ *
7
+ * THE SCHEMAS DECLARE SHAPE, NOT POLICY (P0-4). `type`, `required` and `default`
8
+ * stay, because a `questions` value that is not an array has nothing for a
9
+ * normalizer to repair and both validator strategies agree on that check.
10
+ * Everything else — how many questions, how many options, how long a header may
11
+ * be — lives in the `description`, which is what the model actually reads, and
12
+ * is enforced by `normalizeQuestions` / `normalizePlan`. Putting `maxItems: 5`
13
+ * in the schema instead had two failure modes at once: with `ajv` present
14
+ * `ToolExecutor` rejected a six-question call outright and the repair rules were
15
+ * dead code; without it (ajv is an OPTIONAL dependency of core) the same call
16
+ * sailed straight through to the repair path. One design, two behaviours,
17
+ * selected by whether an optional install step succeeded.
18
+ */
19
+ import { errorResult, textResult } from '@aragon-agent/core';
20
+ import { formatAnswersResult, normalizePlan, normalizeQuestions, } from './human-input.js';
21
+ // ---------------------------------------------------------------------------
22
+ // Shared refusal text
23
+ // ---------------------------------------------------------------------------
24
+ const NO_INTERACTIVE_USER = 'No interactive user is attached (-p/--print mode). Do not ask questions: ' +
25
+ 'state your assumptions explicitly and continue.';
26
+ const NO_INTERACTIVE_REVIEWER = 'No interactive reviewer is attached. Emit the full plan as your final answer ' +
27
+ 'in markdown instead.';
28
+ function roundCapRefusal(used) {
29
+ return (`You have already asked ${used} rounds of questions (the configured maximum). ` +
30
+ 'Proceed with your best assumptions and state them in the plan.');
31
+ }
32
+ // ---------------------------------------------------------------------------
33
+ // ask_user
34
+ // ---------------------------------------------------------------------------
35
+ const ASK_USER_DESCRIPTION = 'Ask the user 1-5 multiple-choice questions when a decision would change the work. ' +
36
+ 'Give each question a stable id, a header of at most 12 characters, and 2-4 options; ' +
37
+ 'exactly one option must set recommended:true, with the reason in its description. ' +
38
+ 'Extra questions or options beyond these limits are dropped, so keep within them. ' +
39
+ 'Do not ask anything you could answer by reading the workspace.';
40
+ function makeAskUser(opts) {
41
+ return {
42
+ name: 'ask_user',
43
+ label: 'Ask the user',
44
+ description: ASK_USER_DESCRIPTION,
45
+ parameters: {
46
+ type: 'object',
47
+ properties: {
48
+ questions: {
49
+ type: 'array',
50
+ description: '1-5 questions, asked in one round.',
51
+ items: {
52
+ type: 'object',
53
+ properties: {
54
+ id: { type: 'string', description: 'Stable key echoed back in the answer.' },
55
+ header: {
56
+ type: 'string',
57
+ description: 'Very short chip label (<= 12 chars), e.g. "Datastore".',
58
+ },
59
+ question: { type: 'string', description: '<= 300 chars.' },
60
+ allowMultiple: { type: 'boolean', default: false },
61
+ options: {
62
+ type: 'array',
63
+ description: '2-4 options. Exactly one must be recommended.',
64
+ items: {
65
+ type: 'object',
66
+ properties: {
67
+ label: { type: 'string', description: '<= 60 chars.' },
68
+ description: {
69
+ type: 'string',
70
+ description: '<= 120 chars. Why this option.',
71
+ },
72
+ recommended: { type: 'boolean', default: false },
73
+ },
74
+ required: ['label'],
75
+ },
76
+ },
77
+ },
78
+ required: ['id', 'header', 'question', 'options'],
79
+ },
80
+ },
81
+ },
82
+ required: ['questions'],
83
+ },
84
+ async execute(_id, params, ctx) {
85
+ // Probed BEFORE the work, never discovered afterwards: a missing human
86
+ // channel must mean "refuse with an actionable message", not "wait
87
+ // forever" and not "assume yes".
88
+ if (!opts.gate.canPrompt())
89
+ return errorResult(NO_INTERACTIVE_USER);
90
+ const questions = normalizeQuestions(params.questions);
91
+ if (questions.length === 0) {
92
+ return errorResult('No usable questions: each needs 2-4 distinct options.');
93
+ }
94
+ const ticket = opts.takeAskRound();
95
+ if (!ticket.ok)
96
+ return errorResult(roundCapRefusal(ticket.used));
97
+ const response = await opts.withHumanWait(() => opts.gate.request({ kind: 'questions', questions }, ctx.signal));
98
+ // `null` covers cancel, unmount, abort AND timeout. From the model's point
99
+ // of view an unanswered question and an abandoned one are the same
100
+ // situation and want the same guidance, so they share a shape — and it is
101
+ // a NON-error result on purpose: a cancellation rendered as a tool failure
102
+ // invites a retry loop.
103
+ const cancelled = response === null || response.kind !== 'answers' || response.cancelled;
104
+ const answers = response !== null && response.kind === 'answers' ? response.answers : [];
105
+ return textResult(formatAnswersResult({
106
+ answers,
107
+ cancelled,
108
+ roundsUsed: ticket.used,
109
+ roundsRemaining: ticket.remaining,
110
+ }, questions.length));
111
+ },
112
+ };
113
+ }
114
+ // ---------------------------------------------------------------------------
115
+ // submit_plan
116
+ // ---------------------------------------------------------------------------
117
+ const SUBMIT_PLAN_DESCRIPTION = 'Present the implementation plan for approval. Call this exactly once, after you ' +
118
+ 'have read enough of the workspace to be specific. Steps must be ordered and ' +
119
+ 'independently verifiable. Limits, enforced by truncation rather than rejection: ' +
120
+ 'title <= 80 chars, summary <= 600, at most 20 steps (title <= 100, detail <= 400), ' +
121
+ 'at most 30 filesTouched, 10 risks, 10 openQuestions.';
122
+ const APPROVED_RESULT = 'Plan approved. You are now in Build mode - implement the plan step by step, in order.';
123
+ const DISMISSED_RESULT = 'The user dismissed the plan review without a verdict. Ask a clarifying question ' +
124
+ 'or refine the plan, then call submit_plan again.';
125
+ function makeSubmitPlan(opts) {
126
+ return {
127
+ name: 'submit_plan',
128
+ label: 'Submit plan',
129
+ description: SUBMIT_PLAN_DESCRIPTION,
130
+ parameters: {
131
+ type: 'object',
132
+ properties: {
133
+ title: { type: 'string' },
134
+ summary: { type: 'string' },
135
+ steps: {
136
+ type: 'array',
137
+ items: {
138
+ type: 'object',
139
+ properties: {
140
+ title: { type: 'string' },
141
+ detail: { type: 'string' },
142
+ },
143
+ required: ['title'],
144
+ },
145
+ },
146
+ filesTouched: { type: 'array', items: { type: 'string' } },
147
+ risks: { type: 'array', items: { type: 'string' } },
148
+ openQuestions: { type: 'array', items: { type: 'string' } },
149
+ },
150
+ required: ['title', 'summary', 'steps'],
151
+ },
152
+ async execute(_id, params, ctx) {
153
+ // Non-error: under `-p` the read-only gate still applies, so "plan this
154
+ // and do not touch my repository" is a legitimate combination and must not
155
+ // read as a malfunction.
156
+ if (!opts.gate.canPrompt())
157
+ return textResult(NO_INTERACTIVE_REVIEWER);
158
+ const plan = normalizePlan(params);
159
+ if (!plan)
160
+ return errorResult('A plan needs at least one step with a title.');
161
+ const response = await opts.withHumanWait(() => opts.gate.request({ kind: 'plan', plan }, ctx.signal));
162
+ if (response === null || response.kind !== 'planDecision') {
163
+ return textResult(DISMISSED_RESULT);
164
+ }
165
+ if (response.decision === 'approved') {
166
+ opts.onPlanApproved();
167
+ return textResult(APPROVED_RESULT);
168
+ }
169
+ if (response.decision === 'revise') {
170
+ return textResult(`Plan rejected. The user's feedback: "${response.feedback}". ` +
171
+ 'Revise and call submit_plan again.');
172
+ }
173
+ return textResult(DISMISSED_RESULT);
174
+ },
175
+ };
176
+ }
177
+ // ---------------------------------------------------------------------------
178
+ // Registration
179
+ // ---------------------------------------------------------------------------
180
+ /**
181
+ * Build the plan tools, or `[]` for a channel that can never reach a human.
182
+ *
183
+ * KEYED ON `neverPrompts`, NOT `canPrompt()` (P0-3). This function runs inside
184
+ * `makeController()`, and in the interactive path the bridge handler is still
185
+ * null there — the App attaches it later, in an effect. So `canPrompt()` is
186
+ * false for EVERY session at this moment, and gating registration on it would
187
+ * ship plan mode with no `ask_user` and no `submit_plan`, in every interactive
188
+ * session, with nothing anywhere reporting it.
189
+ *
190
+ * Registration is a one-time structural question ("can this channel ever reach a
191
+ * human?"). `canPrompt()` answers a live one ("is one attached right now?") and
192
+ * is checked again inside each tool, where it belongs.
193
+ */
194
+ export function createPlanTools(opts) {
195
+ if (opts.gate.neverPrompts)
196
+ return [];
197
+ return [makeAskUser(opts), makeSubmitPlan(opts)];
198
+ }
package/dist/ui/App.d.ts CHANGED
@@ -9,6 +9,9 @@ import type { AgentController } from '../agent/controller.js';
9
9
  import type { ConfirmRequest } from '../tools/index.js';
10
10
  import { type Overlay } from '../agent/reducer.js';
11
11
  import { type RenderMode } from './layout/frame.js';
12
+ import { type FrameGeometry } from './layout/regions.js';
13
+ import type { MouseSource } from '../input/stdin-mouse-filter.js';
14
+ import type { HumanInputBridge } from '../tools/human-input.js';
12
15
  export interface ConfirmBridge {
13
16
  handler: ((req: ConfirmRequest) => Promise<boolean>) | null;
14
17
  }
@@ -20,5 +23,13 @@ export interface AppProps {
20
23
  initialOverlay?: Overlay;
21
24
  initialPrompt?: string;
22
25
  confirmBridge?: ConfirmBridge;
26
+ /** The plan-mode human channel; absent in tests that do not exercise it. */
27
+ humanInputBridge?: HumanInputBridge;
28
+ /** Wheel events, already stripped out of stdin. Absent whenever mouse support
29
+ * is off: inline mode, `--no-mouse`, a non-TTY, or a filter that failed to
30
+ * build (mouse-wheel-region-routing §4.2). */
31
+ mouseSource?: MouseSource;
32
+ /** Test-only seam (§8.2): frame geometry, bypassing `measureElement`. */
33
+ geometryOverride?: FrameGeometry;
23
34
  }
24
- export declare function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, }: AppProps): React.ReactElement;
35
+ export declare function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, geometryOverride, }: AppProps): React.ReactElement;
package/dist/ui/App.js CHANGED
@@ -12,7 +12,10 @@ import { initialViewState, reduceEvent, viewReducer, } from '../agent/reducer.js
12
12
  import { mergeDeltas } from '../agent/coalesce.js';
13
13
  import { coerceMaxTokens, DEFAULT_TRANSCRIPT_WINDOW, PROMPT_HISTORY_CAP, } from '../config/schema.js';
14
14
  import { updatePersistedConfig } from '../config/store.js';
15
+ import { getLogger } from '../logging/logger.js';
16
+ import { registerSecret } from '../logging/secret-registry.js';
15
17
  import { detectCapabilities } from './capabilities.js';
18
+ import { useStartupNotices } from './use-startup-notices.js';
16
19
  import { getTheme } from './theme.js';
17
20
  import { pickGlyphs } from './glyphs.js';
18
21
  import { Header } from './Header.js';
@@ -28,6 +31,8 @@ import { ScrollViewport } from './layout/ScrollViewport.js';
28
31
  import { useTerminalSize } from './layout/useTerminalSize.js';
29
32
  import { frameHeight, MIN_FULLSCREEN_ROWS } from './layout/frame.js';
30
33
  import { HINT_MIN_ROWS, viewportRows as computeViewportRows } from './layout/budget.js';
34
+ import { UNMEASURED_GEOMETRY } from './layout/regions.js';
35
+ import { OVERLAY_PAGE, useWheelRouting } from './use-wheel-routing.js';
31
36
  import { installConsoleBridge } from './console-bridge.js';
32
37
  import { publishExitSnapshot } from './exit-snapshot.js';
33
38
  import { OverlayFrame } from './layout/OverlayFrame.js';
@@ -35,14 +40,15 @@ import { helpRows } from './overlays/HelpOverlay.js';
35
40
  import { ModelPicker } from './overlays/ModelPicker.js';
36
41
  import { SettingsScreen } from './overlays/SettingsScreen.js';
37
42
  import { ConfirmDialog } from './overlays/ConfirmDialog.js';
43
+ import { QuestionOverlay } from './overlays/QuestionOverlay.js';
44
+ import { PlanReviewOverlay } from './overlays/PlanReviewOverlay.js';
38
45
  import { CommandRegistry, runSlashInput } from '../commands/registry.js';
39
46
  import { registerBuiltinCommands } from '../commands/builtins.js';
40
47
  import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js';
48
+ import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
41
49
  /** Merge pending delta actions ~33 ms and flush the coalescer buffer. */
42
50
  const COALESCE_MS = 33;
43
- /** Rows a PgUp/PgDn moves inside a controlled overlay. */
44
- const OVERLAY_PAGE = 8;
45
- export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, }) {
51
+ export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, geometryOverride, }) {
46
52
  const { exit } = useApp();
47
53
  const { stdout } = useStdout();
48
54
  const fullscreen = mode === 'fullscreen';
@@ -50,9 +56,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
50
56
  const [state, dispatch] = useReducer(viewReducer, undefined, initialViewState);
51
57
  const [elapsedMs, setElapsedMs] = useState(0);
52
58
  const [confirmState, setConfirmState] = useState(null);
59
+ /** The request the question / plan overlay is currently rendering. */
60
+ const [humanRequest, setHumanRequest] = useState(null);
53
61
  const [promptHistory, setPromptHistory] = useState(() => controller.getConfig().promptHistory ?? []);
54
62
  // Scroll plumbing: intent goes down, one derived display number comes back.
55
63
  const [scrollIntent, setScrollIntent] = useState();
64
+ /** One prompt-history step per wheel notch over the composer band (§4.7). */
65
+ const [historyIntent, setHistoryIntent] = useState();
56
66
  const [pinToBottomNonce, setPinToBottomNonce] = useState(0);
57
67
  const [scrolledLines, setScrolledLines] = useState(0);
58
68
  // I-5 (§4.13): Ctrl+L cannot repaint by writing escapes — Ink dedupes
@@ -87,6 +97,34 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
87
97
  const ctrlCTimer = useRef(null);
88
98
  const runBaselineOut = useRef(0);
89
99
  const toastTimers = useRef(new Map());
100
+ /** Every outstanding `gate.request()`. Held in a ref, never in state: it is
101
+ * bookkeeping for promises, and re-rendering on it would be noise. */
102
+ const pendingHuman = useRef(new Set());
103
+ /** Written by `AppShell`'s layout effect, read at wheel-event time (§4.5). */
104
+ const geometryRef = useRef(UNMEASURED_GEOMETRY);
105
+ /**
106
+ * THE SINGLE SETTLE PATH for a human request. Idempotent per entry, and it
107
+ * clears the overlay as its last act.
108
+ *
109
+ * Exactly four callers, and there must be no fifth: the overlay's own submit,
110
+ * `Esc` in `App`, the `ctx.signal` abort listener, and `cancelPending()`.
111
+ * Every one of them can race the others, which is what `done` is for.
112
+ */
113
+ const settleHuman = useCallback((entry, value) => {
114
+ if (entry.done)
115
+ return;
116
+ entry.done = true;
117
+ pendingHuman.current.delete(entry);
118
+ entry.resolve(value);
119
+ if (pendingHuman.current.size === 0) {
120
+ setHumanRequest(null);
121
+ dispatch({ type: 'setOverlay', overlay: null });
122
+ }
123
+ }, []);
124
+ const cancelPendingHuman = useCallback(() => {
125
+ for (const entry of [...pendingHuman.current])
126
+ settleHuman(entry, null);
127
+ }, [settleHuman]);
90
128
  const registry = useMemo(() => {
91
129
  const r = new CommandRegistry();
92
130
  // Order matters: built-ins first, so `registerSkillCommands` sees them when
@@ -131,12 +169,31 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
131
169
  dispatch(action);
132
170
  }
133
171
  }
172
+ if (event.type !== 'agent_end')
173
+ return;
174
+ // NO OVERLAY OUTLIVES ITS RUN (P1-4). `ctx.signal` already covers the
175
+ // abort path, so this is DELIBERATE REDUNDANCY: it makes "the card cannot
176
+ // survive the run that opened it" a property of the App's lifecycle
177
+ // rather than a consequence of signal plumbing three modules away.
178
+ // Without it, an aborted run leaves a plan card on screen whose `a` key
179
+ // silently flips the session to build, with no visible cause.
180
+ cancelPendingHuman();
181
+ // A deferred `plan -> build` becomes real here and nowhere else (§3.2).
182
+ const applied = controller.applyPendingMode();
183
+ if (applied) {
184
+ dispatch({ type: 'setAgentMode', mode: applied.effective, pending: null });
185
+ dispatch({
186
+ type: 'pushToast',
187
+ level: 'info',
188
+ text: `${MODE_LABEL[applied.effective]} mode.`,
189
+ });
190
+ }
134
191
  });
135
192
  return () => {
136
193
  flushPending();
137
194
  unsubscribe();
138
195
  };
139
- }, [controller, flushPending]);
196
+ }, [controller, flushPending, cancelPendingHuman]);
140
197
  // --- Elapsed timer + tokens/sec baseline while running. ----------------
141
198
  useEffect(() => {
142
199
  if (state.status !== 'running') {
@@ -180,6 +237,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
180
237
  return undefined;
181
238
  return installConsoleBridge((level, text) => dispatch({ type: 'notice', level, text }));
182
239
  }, [fullscreen]);
240
+ // `enabled` is `mouseSource`, not `cfg.mouse`: a user whose terminal drops
241
+ // the events must never be shown advice about a mode not in effect (§6.1).
242
+ useStartupNotices(dispatch, {
243
+ enabled: !!mouseSource,
244
+ onSeen: () => persistConfig({ mouseNoticeSeen: true }),
245
+ });
183
246
  // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
184
247
  useEffect(() => {
185
248
  publishExitSnapshot({
@@ -205,6 +268,42 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
205
268
  confirmBridge.handler = null;
206
269
  };
207
270
  }, [confirmBridge]);
271
+ // --- Human-input bridge (ask_user / submit_plan). ----------------------
272
+ useEffect(() => {
273
+ if (!humanInputBridge)
274
+ return undefined;
275
+ const bridge = humanInputBridge;
276
+ bridge.handler = (req, signal) => new Promise((resolve) => {
277
+ const entry = { resolve, done: false };
278
+ pendingHuman.current.add(entry);
279
+ // THE ONLY CEILING THIS WAIT HAS. `ToolExecutor`'s timeout is
280
+ // cooperative — it aborts the context signal and keeps awaiting the
281
+ // tool — and the idle watchdog is deliberately paused for the duration.
282
+ // Without this listener both ceilings are gone at once and the process
283
+ // wedges behind an overlay nobody is going to answer (P0-2).
284
+ signal?.addEventListener('abort', () => settleHuman(entry, null), { once: true });
285
+ setHumanRequest(req);
286
+ dispatch({
287
+ type: 'setOverlay',
288
+ overlay: req.kind === 'plan' ? 'plan' : 'question',
289
+ });
290
+ });
291
+ bridge.cancelPending = cancelPendingHuman;
292
+ return () => {
293
+ // BEFORE nulling the handler, not after: `ConfirmBridge` nulls its handler
294
+ // and leaves in-flight promises dangling forever, which with a wizard the
295
+ // user abandons would hang the tool until its ceiling (R-P4).
296
+ cancelPendingHuman();
297
+ bridge.handler = null;
298
+ };
299
+ }, [humanInputBridge, settleHuman, cancelPendingHuman]);
300
+ // --- Seed the mode mirror from its owner, once, on mount. --------------
301
+ // `--plan` / `planModeDefault` are resolved before the App exists, so the
302
+ // reducer's `'build'` seed would render one wrong frame without this.
303
+ useEffect(() => {
304
+ const current = controller.getAgentMode();
305
+ dispatch({ type: 'setAgentMode', mode: current, pending: null });
306
+ }, [controller]);
208
307
  // --- Let the controller drive command-list rebuilds. -------------------
209
308
  useEffect(() => {
210
309
  controller.setOnSkillsChanged(() => setSkillsNonce((n) => n + 1));
@@ -248,15 +347,44 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
248
347
  const persistConfig = (patch) => {
249
348
  try {
250
349
  updatePersistedConfig(patch);
350
+ getLogger().info('config', 'config_write', { keys: Object.keys(patch) });
251
351
  }
252
- catch {
253
- // Best-effort: a failed persist should not break the live session.
352
+ catch (err) {
353
+ // Still best-effort — a failed persist must not break the live session —
354
+ // but no longer SILENT. Swallowing this is the root of "I changed the
355
+ // model in the TUI and it was back to the old one after a restart", a
356
+ // report with nothing anywhere to investigate.
357
+ const reason = err instanceof Error ? err.message : String(err);
358
+ getLogger().error('config', 'config_write_failed', { reason });
359
+ toast('warn', `Could not save settings: ${reason}`);
254
360
  }
255
361
  };
256
362
  const doExit = () => {
257
363
  controller.abort();
258
364
  exit();
259
365
  };
366
+ /**
367
+ * THE SINGLE MODE WRITE PATH (§3.1 / R-P7).
368
+ *
369
+ * `Shift+Tab` and `/plan` both come through here, which is what makes them
370
+ * the same code path rather than two that can drift. Note that the value
371
+ * dispatched into the view is what the controller reports it ADOPTED, never
372
+ * what was requested: dispatching the request is the one way to make the badge
373
+ * claim a permission the gate will not honour.
374
+ */
375
+ const applyMode = (next, opts = {}) => {
376
+ const applied = controller.setAgentMode(next, opts.force ? { force: true } : {});
377
+ dispatch({ type: 'setAgentMode', mode: applied.effective, pending: applied.pending });
378
+ if (opts.silent)
379
+ return applied;
380
+ if (applied.pending) {
381
+ toast('info', `${MODE_LABEL[applied.pending]} mode applies after this run.`);
382
+ }
383
+ else {
384
+ toast('info', `${MODE_LABEL[applied.effective]} mode.`);
385
+ }
386
+ return applied;
387
+ };
260
388
  const makeCtx = (args) => ({
261
389
  args,
262
390
  controller,
@@ -269,6 +397,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
269
397
  exit: doExit,
270
398
  submit: (text) => submitMessage(text),
271
399
  refreshSkills: () => setSkillsNonce((n) => n + 1),
400
+ applyAgentMode: applyMode,
272
401
  });
273
402
  const recordPrompt = (text) => {
274
403
  const next = [...promptHistory.filter((p) => p !== text), text].slice(-PROMPT_HISTORY_CAP);
@@ -280,8 +409,9 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
280
409
  controller.setSubmitCount(nextCount);
281
410
  persistConfig({ promptHistory: next, submitCount: nextCount });
282
411
  };
283
- const scrollBy = useCallback((kind) => {
284
- setScrollIntent((prev) => ({ kind, nonce: (prev?.nonce ?? 0) + 1 }));
412
+ /** `repeat` folds a coalesced wheel burst; the keyboard always sends one. */
413
+ const scrollBy = useCallback((kind, repeat = 1) => {
414
+ setScrollIntent((prev) => ({ kind, nonce: (prev?.nonce ?? 0) + 1, repeat }));
285
415
  }, []);
286
416
  /**
287
417
  * Send a message to the agent, bypassing slash parsing.
@@ -322,8 +452,14 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
322
452
  controller.setThinkingLevel(values.thinkingLevel);
323
453
  controller.setMaxTokens(maxTokens);
324
454
  const key = values.apiKey.trim();
325
- if (key.length > 0)
455
+ if (key.length > 0) {
456
+ // Registration site 4 (§4.4.3), and the one that matters most: this is how
457
+ // a key most often enters the process, and it happens long after
458
+ // `installLogging()` ran. A startup snapshot would miss it entirely, and
459
+ // the vendor regexes only cover keys that look like a vendor's.
460
+ registerSecret(key);
326
461
  controller.setApiKey(values.provider, key);
462
+ }
327
463
  const patch = {
328
464
  provider: values.provider,
329
465
  model: values.model,
@@ -333,7 +469,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
333
469
  };
334
470
  if (key.length > 0)
335
471
  patch.apiKeys = { [values.provider]: key };
472
+ // A PARTIAL `log` section, safe only because `updatePersistedConfig` deep-
473
+ // merges it — a shallow merge here would reset `redactSecrets` every time
474
+ // someone touched the level.
475
+ patch.log = { level: values.logLevel };
336
476
  persistConfig(patch);
477
+ getLogger().reconfigure({ ...controller.getConfig().log, level: values.logLevel });
337
478
  dispatch({ type: 'setOverlay', overlay: null });
338
479
  toast('success', 'Settings saved.');
339
480
  };
@@ -347,11 +488,52 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
347
488
  setConfirmState(null);
348
489
  dispatch({ type: 'setOverlay', overlay: null });
349
490
  };
491
+ /** Answer every outstanding request with the same response and close up. */
492
+ const resolveHuman = (response) => {
493
+ for (const entry of [...pendingHuman.current])
494
+ settleHuman(entry, response);
495
+ };
496
+ const handlePlanVerdict = (verdict) => {
497
+ if (verdict.decision === 'approved') {
498
+ // BEFORE resolving, not after. `submit_plan` also calls
499
+ // `setAgentMode('build', { force: true })` when its wait returns — that is
500
+ // the authoritative act, and it stays because it must work even if the App
501
+ // is gone by then. But it runs a microtask later, so reading the mode back
502
+ // here would read `'plan'` and the badge would lag by a frame. Writing it
503
+ // first through the same single path makes the tool's call an idempotent
504
+ // no-op and keeps the mirror exact (§3.1 / R-P7).
505
+ applyMode('build', { force: true, silent: true });
506
+ toast('success', 'Plan approved. Implementing.');
507
+ }
508
+ else {
509
+ toast('info', 'Revision requested.');
510
+ }
511
+ resolveHuman({ kind: 'planDecision', decision: verdict.decision, feedback: verdict.feedback });
512
+ };
513
+ // --- Mouse wheel region routing (§4.6). Table and coalescer live in the
514
+ // hook (D-13); only the wiring it cannot own stays here. Inline arrows are
515
+ // fine — the hook re-reads its options through a ref every render. The
516
+ // `Math.max(0, …)` mirrors the keyboard branch below, and for the same
517
+ // reason: `OverlayFrame` only ever reports a clamp DOWNWARD.
518
+ const { clearCoalescer } = useWheelRouting({
519
+ mouseSource,
520
+ geometryRef,
521
+ geometryOverride,
522
+ getOverlay: () => stateRef.current.overlay,
523
+ onScroll: scrollBy,
524
+ onOverlayScroll: (delta) => setOverlayScroll((n) => Math.max(0, n + delta)),
525
+ onHistoryStep: (dir) => setHistoryIntent((prev) => ({ dir, nonce: (prev?.nonce ?? 0) + 1 })),
526
+ });
350
527
  // A fresh overlay always opens at the top; carrying the previous overlay's
351
528
  // offset over would open Help mid-list for no reason the user can see.
529
+ //
530
+ // `clearCoalescer()` rides along here rather than adding a second subscriber
531
+ // to the same transition (§4.7 rule 2): an accumulated-but-unflushed
532
+ // transcript scroll must not survive an overlay opening.
352
533
  useEffect(() => {
353
534
  setOverlayScroll(0);
354
- }, [state.overlay]);
535
+ clearCoalescer();
536
+ }, [state.overlay, clearCoalescer]);
355
537
  // --- Initial overlay / prompt (after handlers are defined). ------------
356
538
  useEffect(() => {
357
539
  if (initialOverlay)
@@ -390,7 +572,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
390
572
  const overlay = stateRef.current.overlay;
391
573
  // Modes A only. `model` / `confirm` manage their own keys; registering an
392
574
  // arrow handler for them would double every keypress (R-12).
393
- const controlled = overlay === 'help' || overlay === 'settings';
575
+ const controlled = overlay === 'help' || overlay === 'settings' || overlay === 'plan';
394
576
  if (key.pageUp) {
395
577
  if (controlled)
396
578
  setOverlayScroll((n) => Math.max(0, n - OVERLAY_PAGE));
@@ -402,12 +584,23 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
402
584
  return;
403
585
  }
404
586
  // `settings` keeps Up/Down for field movement — a high-frequency action
405
- // worth more than scrolling six fields (§4.9).
406
- if (overlay === 'help' && (key.upArrow || key.downArrow)) {
587
+ // worth more than scrolling six fields (§4.9). `question` keeps them for
588
+ // its option cursor, for the same reason; `plan` has no cursor of its own
589
+ // and scrolls a line at a time.
590
+ if ((overlay === 'help' || overlay === 'plan') && (key.upArrow || key.downArrow)) {
407
591
  setOverlayScroll((n) => Math.max(0, n + (key.upArrow ? -1 : 1)));
408
592
  return;
409
593
  }
410
594
  }
595
+ // AFTER the overlay block, and with an EXPLICIT guard rather than relying on
596
+ // ordering: that block only `return`s for the keys it recognizes, and
597
+ // everything else falls through (AC-P2).
598
+ if (key.tab && key.shift) {
599
+ if (stateRef.current.overlay)
600
+ return;
601
+ applyMode(nextMode(controller.getAgentMode()));
602
+ return;
603
+ }
411
604
  if (key.ctrl && (input === 'l' || input === 'L')) {
412
605
  if (fullscreen) {
413
606
  // Writing \x1B[2J here would erase the screen and then leave it blank:
@@ -462,6 +655,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
462
655
  confirmRef.current.resolve(false);
463
656
  setConfirmState(null);
464
657
  }
658
+ // `App` owns Esc for both plan-mode overlays and they register no
659
+ // handler of their own, so this is the only place a cancellation
660
+ // reaches the waiting tool.
661
+ if (overlay === 'question' || overlay === 'plan')
662
+ cancelPendingHuman();
663
+ setHumanRequest(null);
465
664
  dispatch({ type: 'setOverlay', overlay: null });
466
665
  return;
467
666
  }
@@ -494,7 +693,14 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
494
693
  thinkingLevel: cfg.thinkingLevel,
495
694
  maxTokens: cfg.maxTokens ? String(cfg.maxTokens) : '',
496
695
  apiKey: '',
497
- }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onSave: handleSettingsSave })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : null;
696
+ logLevel: cfg.log.level,
697
+ }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onSave: handleSettingsSave })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { questions: humanRequest.questions, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSubmit: (answers) => resolveHuman({ kind: 'answers', answers, cancelled: false }) })) : overlay === 'plan' && humanRequest?.kind === 'plan' ? (_jsx(PlanReviewOverlay, { plan: humanRequest.plan, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
698
+ // A MEASUREMENT MUST NEVER OUTLIVE THE FRAME IT DESCRIBED (P1-5): neither
699
+ // path below renders the full-screen `AppShell`, so nothing else would
700
+ // overwrite a stale `bottom`. See `UNMEASURED_GEOMETRY` for why there is no
701
+ // arithmetic fallback.
702
+ if (!fullscreen || rows < MIN_FULLSCREEN_ROWS)
703
+ geometryRef.current = UNMEASURED_GEOMETRY;
498
704
  // A resize can drop below the usable floor. Do NOT leave the alt-screen —
499
705
  // entering and leaving it on every drag is worse than a placeholder — and take
500
706
  // the height from `frameHeight()` like everything else, because this is the
@@ -512,11 +718,11 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
512
718
  // the header the moment the first message lands.
513
719
  const opener = empty ? (_jsx(SessionOpener, { variant: fullscreen ? pickOpenerVariant(viewportBudget, cols, caps) : 'none', version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: fullscreen ? viewportBudget : Number.POSITIVE_INFINITY, theme: theme, caps: caps })) : null;
514
720
  const density = cfg.density;
515
- const viewport = fullscreen ? (overlayNode ? (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: overlayNode })) : (_jsxs(ScrollViewport, { intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps, windowSize: cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW })] }))) : (_jsxs(_Fragment, { children: [opener, _jsx(Transcript, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }), overlayNode] }));
721
+ const viewport = fullscreen ? (overlayNode ? (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: overlayNode })) : (_jsxs(ScrollViewport, { intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, showScrollIndicator: true, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps, windowSize: cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW })] }))) : (_jsxs(_Fragment, { children: [opener, _jsx(Transcript, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }), overlayNode] }));
516
722
  // The completion popup lives inside the bottom chrome and can add up to 9
517
723
  // rows; unbounded, it pushes the status bar past the frame height where
518
724
  // `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
519
725
  const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
520
- const composer = fullscreen ? (_jsx(Composer, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, popupMaxRows: popupMaxRows, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(PromptInput, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }) }));
521
- return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, header: header, viewport: viewport, toast: _jsx(ToastStack, { toasts: state.toasts, theme: theme, mode: mode }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce }) }));
726
+ const composer = fullscreen ? (_jsx(Composer, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, popupMaxRows: popupMaxRows, historyIntent: historyIntent, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(PromptInput, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), agentMode: state.agentMode, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }) }));
727
+ return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, geometryRef: geometryRef, header: header, viewport: viewport, toast: _jsx(ToastStack, { toasts: state.toasts, theme: theme, mode: mode }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode }) }));
522
728
  }