@aragon-agent/cli 0.4.3 → 0.5.1

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 (82) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/README.md +227 -3
  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 +183 -20
  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 +45 -1
  23. package/dist/config/load.d.ts +8 -0
  24. package/dist/config/load.js +59 -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 +61 -0
  30. package/dist/config/schema.js +83 -0
  31. package/dist/config/store.d.ts +30 -12
  32. package/dist/config/store.js +47 -35
  33. package/dist/logging/cli-commands.d.ts +25 -0
  34. package/dist/logging/cli-commands.js +221 -0
  35. package/dist/logging/file-sink.d.ts +114 -0
  36. package/dist/logging/file-sink.js +326 -0
  37. package/dist/logging/install.d.ts +163 -0
  38. package/dist/logging/install.js +302 -0
  39. package/dist/logging/levels.d.ts +40 -0
  40. package/dist/logging/levels.js +59 -0
  41. package/dist/logging/logger.d.ts +110 -0
  42. package/dist/logging/logger.js +237 -0
  43. package/dist/logging/redact.d.ts +28 -0
  44. package/dist/logging/redact.js +90 -0
  45. package/dist/logging/secret-registry.d.ts +30 -0
  46. package/dist/logging/secret-registry.js +55 -0
  47. package/dist/skills/paths.d.ts +2 -4
  48. package/dist/skills/paths.js +7 -9
  49. package/dist/tools/human-input.d.ts +179 -0
  50. package/dist/tools/human-input.js +227 -0
  51. package/dist/tools/index.d.ts +35 -3
  52. package/dist/tools/index.js +96 -9
  53. package/dist/tools/plan-tools.d.ts +64 -0
  54. package/dist/tools/plan-tools.js +198 -0
  55. package/dist/ui/App.d.ts +4 -1
  56. package/dist/ui/App.js +182 -11
  57. package/dist/ui/Composer.d.ts +4 -1
  58. package/dist/ui/Composer.js +26 -7
  59. package/dist/ui/ModeChip.d.ts +29 -0
  60. package/dist/ui/ModeChip.js +15 -0
  61. package/dist/ui/PromptInput.d.ts +8 -1
  62. package/dist/ui/PromptInput.js +12 -2
  63. package/dist/ui/StatusBar.d.ts +15 -0
  64. package/dist/ui/StatusBar.js +10 -2
  65. package/dist/ui/entries/ToolPreview.d.ts +2 -0
  66. package/dist/ui/entries/ToolPreview.js +64 -0
  67. package/dist/ui/glyphs.d.ts +9 -0
  68. package/dist/ui/glyphs.js +8 -0
  69. package/dist/ui/layout/wrap-rows.d.ts +24 -0
  70. package/dist/ui/layout/wrap-rows.js +74 -0
  71. package/dist/ui/overlays/HelpOverlay.js +13 -0
  72. package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
  73. package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
  74. package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
  75. package/dist/ui/overlays/QuestionOverlay.js +193 -0
  76. package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
  77. package/dist/ui/overlays/SettingsScreen.js +8 -1
  78. package/dist/ui/use-startup-notices.d.ts +15 -0
  79. package/dist/ui/use-startup-notices.js +38 -0
  80. package/package.json +3 -2
  81. package/runtime/insecure-tls-warning.cjs +62 -0
  82. 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,7 @@ 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 { HumanInputBridge } from '../tools/human-input.js';
12
13
  export interface ConfirmBridge {
13
14
  handler: ((req: ConfirmRequest) => Promise<boolean>) | null;
14
15
  }
@@ -20,5 +21,7 @@ export interface AppProps {
20
21
  initialOverlay?: Overlay;
21
22
  initialPrompt?: string;
22
23
  confirmBridge?: ConfirmBridge;
24
+ /** The plan-mode human channel; absent in tests that do not exercise it. */
25
+ humanInputBridge?: HumanInputBridge;
23
26
  }
24
- export declare function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, }: AppProps): React.ReactElement;
27
+ export declare function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, }: 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';
@@ -35,14 +38,17 @@ import { helpRows } from './overlays/HelpOverlay.js';
35
38
  import { ModelPicker } from './overlays/ModelPicker.js';
36
39
  import { SettingsScreen } from './overlays/SettingsScreen.js';
37
40
  import { ConfirmDialog } from './overlays/ConfirmDialog.js';
41
+ import { QuestionOverlay } from './overlays/QuestionOverlay.js';
42
+ import { PlanReviewOverlay } from './overlays/PlanReviewOverlay.js';
38
43
  import { CommandRegistry, runSlashInput } from '../commands/registry.js';
39
44
  import { registerBuiltinCommands } from '../commands/builtins.js';
40
45
  import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js';
46
+ import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
41
47
  /** Merge pending delta actions ~33 ms and flush the coalescer buffer. */
42
48
  const COALESCE_MS = 33;
43
49
  /** Rows a PgUp/PgDn moves inside a controlled overlay. */
44
50
  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, }) {
46
52
  const { exit } = useApp();
47
53
  const { stdout } = useStdout();
48
54
  const fullscreen = mode === 'fullscreen';
@@ -50,6 +56,8 @@ 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();
@@ -87,6 +95,32 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
87
95
  const ctrlCTimer = useRef(null);
88
96
  const runBaselineOut = useRef(0);
89
97
  const toastTimers = useRef(new Map());
98
+ /** Every outstanding `gate.request()`. Held in a ref, never in state: it is
99
+ * bookkeeping for promises, and re-rendering on it would be noise. */
100
+ const pendingHuman = useRef(new Set());
101
+ /**
102
+ * THE SINGLE SETTLE PATH for a human request. Idempotent per entry, and it
103
+ * clears the overlay as its last act.
104
+ *
105
+ * Exactly four callers, and there must be no fifth: the overlay's own submit,
106
+ * `Esc` in `App`, the `ctx.signal` abort listener, and `cancelPending()`.
107
+ * Every one of them can race the others, which is what `done` is for.
108
+ */
109
+ const settleHuman = useCallback((entry, value) => {
110
+ if (entry.done)
111
+ return;
112
+ entry.done = true;
113
+ pendingHuman.current.delete(entry);
114
+ entry.resolve(value);
115
+ if (pendingHuman.current.size === 0) {
116
+ setHumanRequest(null);
117
+ dispatch({ type: 'setOverlay', overlay: null });
118
+ }
119
+ }, []);
120
+ const cancelPendingHuman = useCallback(() => {
121
+ for (const entry of [...pendingHuman.current])
122
+ settleHuman(entry, null);
123
+ }, [settleHuman]);
90
124
  const registry = useMemo(() => {
91
125
  const r = new CommandRegistry();
92
126
  // Order matters: built-ins first, so `registerSkillCommands` sees them when
@@ -131,12 +165,31 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
131
165
  dispatch(action);
132
166
  }
133
167
  }
168
+ if (event.type !== 'agent_end')
169
+ return;
170
+ // NO OVERLAY OUTLIVES ITS RUN (P1-4). `ctx.signal` already covers the
171
+ // abort path, so this is DELIBERATE REDUNDANCY: it makes "the card cannot
172
+ // survive the run that opened it" a property of the App's lifecycle
173
+ // rather than a consequence of signal plumbing three modules away.
174
+ // Without it, an aborted run leaves a plan card on screen whose `a` key
175
+ // silently flips the session to build, with no visible cause.
176
+ cancelPendingHuman();
177
+ // A deferred `plan -> build` becomes real here and nowhere else (§3.2).
178
+ const applied = controller.applyPendingMode();
179
+ if (applied) {
180
+ dispatch({ type: 'setAgentMode', mode: applied.effective, pending: null });
181
+ dispatch({
182
+ type: 'pushToast',
183
+ level: 'info',
184
+ text: `${MODE_LABEL[applied.effective]} mode.`,
185
+ });
186
+ }
134
187
  });
135
188
  return () => {
136
189
  flushPending();
137
190
  unsubscribe();
138
191
  };
139
- }, [controller, flushPending]);
192
+ }, [controller, flushPending, cancelPendingHuman]);
140
193
  // --- Elapsed timer + tokens/sec baseline while running. ----------------
141
194
  useEffect(() => {
142
195
  if (state.status !== 'running') {
@@ -180,6 +233,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
180
233
  return undefined;
181
234
  return installConsoleBridge((level, text) => dispatch({ type: 'notice', level, text }));
182
235
  }, [fullscreen]);
236
+ useStartupNotices(dispatch);
183
237
  // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
184
238
  useEffect(() => {
185
239
  publishExitSnapshot({
@@ -205,6 +259,42 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
205
259
  confirmBridge.handler = null;
206
260
  };
207
261
  }, [confirmBridge]);
262
+ // --- Human-input bridge (ask_user / submit_plan). ----------------------
263
+ useEffect(() => {
264
+ if (!humanInputBridge)
265
+ return undefined;
266
+ const bridge = humanInputBridge;
267
+ bridge.handler = (req, signal) => new Promise((resolve) => {
268
+ const entry = { resolve, done: false };
269
+ pendingHuman.current.add(entry);
270
+ // THE ONLY CEILING THIS WAIT HAS. `ToolExecutor`'s timeout is
271
+ // cooperative — it aborts the context signal and keeps awaiting the
272
+ // tool — and the idle watchdog is deliberately paused for the duration.
273
+ // Without this listener both ceilings are gone at once and the process
274
+ // wedges behind an overlay nobody is going to answer (P0-2).
275
+ signal?.addEventListener('abort', () => settleHuman(entry, null), { once: true });
276
+ setHumanRequest(req);
277
+ dispatch({
278
+ type: 'setOverlay',
279
+ overlay: req.kind === 'plan' ? 'plan' : 'question',
280
+ });
281
+ });
282
+ bridge.cancelPending = cancelPendingHuman;
283
+ return () => {
284
+ // BEFORE nulling the handler, not after: `ConfirmBridge` nulls its handler
285
+ // and leaves in-flight promises dangling forever, which with a wizard the
286
+ // user abandons would hang the tool until its ceiling (R-P4).
287
+ cancelPendingHuman();
288
+ bridge.handler = null;
289
+ };
290
+ }, [humanInputBridge, settleHuman, cancelPendingHuman]);
291
+ // --- Seed the mode mirror from its owner, once, on mount. --------------
292
+ // `--plan` / `planModeDefault` are resolved before the App exists, so the
293
+ // reducer's `'build'` seed would render one wrong frame without this.
294
+ useEffect(() => {
295
+ const current = controller.getAgentMode();
296
+ dispatch({ type: 'setAgentMode', mode: current, pending: null });
297
+ }, [controller]);
208
298
  // --- Let the controller drive command-list rebuilds. -------------------
209
299
  useEffect(() => {
210
300
  controller.setOnSkillsChanged(() => setSkillsNonce((n) => n + 1));
@@ -248,15 +338,44 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
248
338
  const persistConfig = (patch) => {
249
339
  try {
250
340
  updatePersistedConfig(patch);
341
+ getLogger().info('config', 'config_write', { keys: Object.keys(patch) });
251
342
  }
252
- catch {
253
- // Best-effort: a failed persist should not break the live session.
343
+ catch (err) {
344
+ // Still best-effort — a failed persist must not break the live session —
345
+ // but no longer SILENT. Swallowing this is the root of "I changed the
346
+ // model in the TUI and it was back to the old one after a restart", a
347
+ // report with nothing anywhere to investigate.
348
+ const reason = err instanceof Error ? err.message : String(err);
349
+ getLogger().error('config', 'config_write_failed', { reason });
350
+ toast('warn', `Could not save settings: ${reason}`);
254
351
  }
255
352
  };
256
353
  const doExit = () => {
257
354
  controller.abort();
258
355
  exit();
259
356
  };
357
+ /**
358
+ * THE SINGLE MODE WRITE PATH (§3.1 / R-P7).
359
+ *
360
+ * `Shift+Tab` and `/plan` both come through here, which is what makes them
361
+ * the same code path rather than two that can drift. Note that the value
362
+ * dispatched into the view is what the controller reports it ADOPTED, never
363
+ * what was requested: dispatching the request is the one way to make the badge
364
+ * claim a permission the gate will not honour.
365
+ */
366
+ const applyMode = (next, opts = {}) => {
367
+ const applied = controller.setAgentMode(next, opts.force ? { force: true } : {});
368
+ dispatch({ type: 'setAgentMode', mode: applied.effective, pending: applied.pending });
369
+ if (opts.silent)
370
+ return applied;
371
+ if (applied.pending) {
372
+ toast('info', `${MODE_LABEL[applied.pending]} mode applies after this run.`);
373
+ }
374
+ else {
375
+ toast('info', `${MODE_LABEL[applied.effective]} mode.`);
376
+ }
377
+ return applied;
378
+ };
260
379
  const makeCtx = (args) => ({
261
380
  args,
262
381
  controller,
@@ -269,6 +388,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
269
388
  exit: doExit,
270
389
  submit: (text) => submitMessage(text),
271
390
  refreshSkills: () => setSkillsNonce((n) => n + 1),
391
+ applyAgentMode: applyMode,
272
392
  });
273
393
  const recordPrompt = (text) => {
274
394
  const next = [...promptHistory.filter((p) => p !== text), text].slice(-PROMPT_HISTORY_CAP);
@@ -322,8 +442,14 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
322
442
  controller.setThinkingLevel(values.thinkingLevel);
323
443
  controller.setMaxTokens(maxTokens);
324
444
  const key = values.apiKey.trim();
325
- if (key.length > 0)
445
+ if (key.length > 0) {
446
+ // Registration site 4 (§4.4.3), and the one that matters most: this is how
447
+ // a key most often enters the process, and it happens long after
448
+ // `installLogging()` ran. A startup snapshot would miss it entirely, and
449
+ // the vendor regexes only cover keys that look like a vendor's.
450
+ registerSecret(key);
326
451
  controller.setApiKey(values.provider, key);
452
+ }
327
453
  const patch = {
328
454
  provider: values.provider,
329
455
  model: values.model,
@@ -333,7 +459,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
333
459
  };
334
460
  if (key.length > 0)
335
461
  patch.apiKeys = { [values.provider]: key };
462
+ // A PARTIAL `log` section, safe only because `updatePersistedConfig` deep-
463
+ // merges it — a shallow merge here would reset `redactSecrets` every time
464
+ // someone touched the level.
465
+ patch.log = { level: values.logLevel };
336
466
  persistConfig(patch);
467
+ getLogger().reconfigure({ ...controller.getConfig().log, level: values.logLevel });
337
468
  dispatch({ type: 'setOverlay', overlay: null });
338
469
  toast('success', 'Settings saved.');
339
470
  };
@@ -347,6 +478,28 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
347
478
  setConfirmState(null);
348
479
  dispatch({ type: 'setOverlay', overlay: null });
349
480
  };
481
+ /** Answer every outstanding request with the same response and close up. */
482
+ const resolveHuman = (response) => {
483
+ for (const entry of [...pendingHuman.current])
484
+ settleHuman(entry, response);
485
+ };
486
+ const handlePlanVerdict = (verdict) => {
487
+ if (verdict.decision === 'approved') {
488
+ // BEFORE resolving, not after. `submit_plan` also calls
489
+ // `setAgentMode('build', { force: true })` when its wait returns — that is
490
+ // the authoritative act, and it stays because it must work even if the App
491
+ // is gone by then. But it runs a microtask later, so reading the mode back
492
+ // here would read `'plan'` and the badge would lag by a frame. Writing it
493
+ // first through the same single path makes the tool's call an idempotent
494
+ // no-op and keeps the mirror exact (§3.1 / R-P7).
495
+ applyMode('build', { force: true, silent: true });
496
+ toast('success', 'Plan approved. Implementing.');
497
+ }
498
+ else {
499
+ toast('info', 'Revision requested.');
500
+ }
501
+ resolveHuman({ kind: 'planDecision', decision: verdict.decision, feedback: verdict.feedback });
502
+ };
350
503
  // A fresh overlay always opens at the top; carrying the previous overlay's
351
504
  // offset over would open Help mid-list for no reason the user can see.
352
505
  useEffect(() => {
@@ -390,7 +543,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
390
543
  const overlay = stateRef.current.overlay;
391
544
  // Modes A only. `model` / `confirm` manage their own keys; registering an
392
545
  // arrow handler for them would double every keypress (R-12).
393
- const controlled = overlay === 'help' || overlay === 'settings';
546
+ const controlled = overlay === 'help' || overlay === 'settings' || overlay === 'plan';
394
547
  if (key.pageUp) {
395
548
  if (controlled)
396
549
  setOverlayScroll((n) => Math.max(0, n - OVERLAY_PAGE));
@@ -402,12 +555,23 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
402
555
  return;
403
556
  }
404
557
  // `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)) {
558
+ // worth more than scrolling six fields (§4.9). `question` keeps them for
559
+ // its option cursor, for the same reason; `plan` has no cursor of its own
560
+ // and scrolls a line at a time.
561
+ if ((overlay === 'help' || overlay === 'plan') && (key.upArrow || key.downArrow)) {
407
562
  setOverlayScroll((n) => Math.max(0, n + (key.upArrow ? -1 : 1)));
408
563
  return;
409
564
  }
410
565
  }
566
+ // AFTER the overlay block, and with an EXPLICIT guard rather than relying on
567
+ // ordering: that block only `return`s for the keys it recognizes, and
568
+ // everything else falls through (AC-P2).
569
+ if (key.tab && key.shift) {
570
+ if (stateRef.current.overlay)
571
+ return;
572
+ applyMode(nextMode(controller.getAgentMode()));
573
+ return;
574
+ }
411
575
  if (key.ctrl && (input === 'l' || input === 'L')) {
412
576
  if (fullscreen) {
413
577
  // Writing \x1B[2J here would erase the screen and then leave it blank:
@@ -462,6 +626,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
462
626
  confirmRef.current.resolve(false);
463
627
  setConfirmState(null);
464
628
  }
629
+ // `App` owns Esc for both plan-mode overlays and they register no
630
+ // handler of their own, so this is the only place a cancellation
631
+ // reaches the waiting tool.
632
+ if (overlay === 'question' || overlay === 'plan')
633
+ cancelPendingHuman();
634
+ setHumanRequest(null);
465
635
  dispatch({ type: 'setOverlay', overlay: null });
466
636
  return;
467
637
  }
@@ -494,7 +664,8 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
494
664
  thinkingLevel: cfg.thinkingLevel,
495
665
  maxTokens: cfg.maxTokens ? String(cfg.maxTokens) : '',
496
666
  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;
667
+ logLevel: cfg.log.level,
668
+ }, 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;
498
669
  // A resize can drop below the usable floor. Do NOT leave the alt-screen —
499
670
  // entering and leaving it on every drag is worse than a placeholder — and take
500
671
  // the height from `frameHeight()` like everything else, because this is the
@@ -517,6 +688,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
517
688
  // rows; unbounded, it pushes the status bar past the frame height where
518
689
  // `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
519
690
  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 }) }));
691
+ 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, 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' }) }) }));
692
+ 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, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode }) }));
522
693
  }
@@ -18,6 +18,7 @@ import React from 'react';
18
18
  import type { Theme } from './theme.js';
19
19
  import type { TermCapabilities } from './capabilities.js';
20
20
  import { type CommandOption } from './PromptInput.js';
21
+ import { type AgentMode } from '../agent/agent-mode.js';
21
22
  export interface ComposerProps {
22
23
  /** False while an overlay owns the keyboard (the input is visually blurred). */
23
24
  isActive: boolean;
@@ -31,10 +32,12 @@ export interface ComposerProps {
31
32
  submitCount: number;
32
33
  /** User opt-out (`--no-hints` / `hints: false`). */
33
34
  hintsEnabled: boolean;
35
+ /** Mirror of the controller's effective mode; drives the chip and the border. */
36
+ agentMode: AgentMode;
34
37
  popupMaxRows?: number;
35
38
  theme: Theme;
36
39
  caps: TermCapabilities;
37
40
  onSubmit: (text: string) => void;
38
41
  onHelp?: () => void;
39
42
  }
40
- export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, popupMaxRows, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
43
+ export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, popupMaxRows, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
@@ -20,6 +20,8 @@ import { Box, Text } from 'ink';
20
20
  import { pickGlyphs } from './glyphs.js';
21
21
  import { HINT_FADE_AFTER } from '../config/schema.js';
22
22
  import { PromptInput } from './PromptInput.js';
23
+ import { ModeChip } from './ModeChip.js';
24
+ import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
23
25
  /**
24
26
  * Build the hint row.
25
27
  *
@@ -30,32 +32,49 @@ import { PromptInput } from './PromptInput.js';
30
32
  * Progressive disclosure is for teaching discoverable things, not for retiring
31
33
  * an emergency exit (§4.6 / A-14 / M-10b).
32
34
  */
33
- function hintText(running, submitCount, glyphs) {
35
+ function hintText(running, submitCount, glyphs, mode) {
34
36
  const dot = ` ${glyphs.midDot} `;
35
37
  if (running) {
36
38
  return [`${glyphs.enterKey} steer`, 'esc abort', `ctrl+c${glyphs.times}2 exit`].join(dot);
37
39
  }
40
+ // The hint names the DESTINATION, not the current state — the correct label
41
+ // for a toggle affordance, and the reason the mode word here is the OPPOSITE
42
+ // of the one on the chip beside it.
43
+ const toggle = `shift+tab ${MODE_LABEL[nextMode(mode)].toLowerCase()}`;
44
+ // The faded form KEEPS the mode toggle. Progressive disclosure exists to
45
+ // retire things the user has demonstrably learned, and `submitCount` counts
46
+ // submissions — a user can send fifty messages without ever discovering
47
+ // Shift+Tab. Same argument that keeps `esc abort` un-faded above.
38
48
  if (submitCount >= HINT_FADE_AFTER)
39
- return '? help';
49
+ return [toggle, '? help'].join(dot);
40
50
  return [
41
51
  `${glyphs.enterKey} send`,
42
52
  `${glyphs.shiftEnter} newline`,
43
53
  '/ commands',
44
54
  '@ files',
55
+ toggle,
45
56
  '? help',
46
57
  ].join(dot);
47
58
  }
48
- export function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, popupMaxRows, theme, caps, onSubmit, onHelp, }) {
59
+ export function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, popupMaxRows, theme, caps, onSubmit, onHelp, }) {
49
60
  const [hasDraft, setHasDraft] = useState(false);
50
61
  const onDraftChange = useCallback((next) => setHasDraft(next), []);
62
+ // Plan mode inserts ONE branch, above `draft` and below `running`. The
63
+ // ordering is load-bearing: an in-flight run's border must stay `toolRunning`,
64
+ // because "is it running" is more urgent than "which mode".
51
65
  const borderColor = !isActive
52
66
  ? theme.toolPending
53
67
  : running
54
68
  ? theme.toolRunning
55
- : hasDraft
56
- ? theme.focusBorder ?? theme.primary
57
- : theme.idleBorder ?? theme.border;
69
+ : agentMode === 'plan'
70
+ ? theme.accent
71
+ : hasDraft
72
+ ? theme.focusBorder ?? theme.primary
73
+ : theme.idleBorder ?? theme.border;
58
74
  const glyphs = pickGlyphs(caps);
59
75
  const visible = showHint && hintsEnabled;
60
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(PromptInput, { isActive: isActive, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, onSubmit: onSubmit, onHelp: onHelp, bordered: { color: borderColor }, onDraftChange: onDraftChange }), visible && (_jsxs(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: [' ', hintText(running, submitCount, glyphs)] }))] }));
76
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(PromptInput, { isActive: isActive, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, onSubmit: onSubmit, onHelp: onHelp, agentMode: agentMode, bordered: { color: borderColor }, onDraftChange: onDraftChange }), visible && (
77
+ // The chip rides the EXISTING hint row rather than adding one, so it
78
+ // costs zero rows on a 24-row terminal.
79
+ _jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsx(Text, { children: ' ' }), _jsx(ModeChip, { mode: agentMode, theme: theme, caps: caps }), _jsx(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: hintText(running, submitCount, glyphs, agentMode) })] }))] }));
61
80
  }