pum-agent 0.2.9-beta.1 → 0.2.11-beta.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.
package/README.md CHANGED
@@ -23,7 +23,7 @@ pum
23
23
  PUM opens the login panel automatically on the first start.
24
24
 
25
25
  > [!WARNING]
26
- > PUM can read, write, and delete files. Check mode adds deterministic policy checks and can enforce native Bash isolation on supported Linux and Windows hosts. Other tools, extensions, and external triggers still run in the PUM process boundary described below. Review the safeguards and prerequisites before using untrusted workspaces.
26
+ > PUM can read, write, and delete files. Check mode adds deterministic policy checks, and supported hosts can enforce native Bash isolation. The file-tool sandbox is a process-local path guard, not complete operating-system isolation. Review the safeguards before using untrusted workspaces.
27
27
 
28
28
  ## See PUM in action
29
29
 
@@ -46,7 +46,8 @@ The following screens are real OpenTUI renders captured through `tmux`. A local
46
46
  - **Prompt control:** Steer active work, answer model questionnaires, use an ownership-aware message cache, attach clipboard images, and resume sessions with metadata-rich history.
47
47
  - **External triggers:** Supervise background commands such as `gh run watch` and automatically wake the exact target agent when they exit.
48
48
  - **Provider choice:** Search the providers exposed by pi, or add an OpenAI-compatible custom endpoint.
49
- - **Optional safeguards:** Use strict, balanced, or ask Check mode, plus native Bash sandboxing through Bubblewrap or Windows CreateProcessInSandbox when available.
49
+ - **Filesystem boundary:** `read`, `write`, and `edit` stay inside the project and configured allowed roots. `apply_patch` stays project-local.
50
+ - **Optional safeguards:** Turn on Check mode to gate `bash`, `edit`, `apply_patch`, and external-trigger process proposals. Supported hosts can also use native Bash sandboxing through Bubblewrap or Windows CreateProcessInSandbox.
50
51
  - **Terminal-first appearance:** Nine themes, semantic color overrides, Unicode glyphs, and optional animation.
51
52
 
52
53
  PUM uses [pi](https://github.com/earendil-works/pi) for the agent loop and [OpenTUI](https://github.com/anomalyco/opentui) for rendering.
@@ -141,6 +142,7 @@ Set `PUM_DIR` to override PUM's complete configuration and data directory. Run `
141
142
  | `Shift+Tab` / `Ctrl+Shift+Tab` | Cycle through agent transcripts |
142
143
  | `Ctrl+H` | Open session history when the terminal reports the key distinctly |
143
144
  | `Ctrl+N` | Open recent answers (News) |
145
+ | `n` / `p` in News | Jump to the answer / user prompt |
144
146
  | `Ctrl+End` | Scroll to the end of the selected transcript |
145
147
  | `Ctrl+P` | Open settings |
146
148
  | `Ctrl+T` | Open supervised external triggers |
@@ -177,6 +179,8 @@ PUM limits remote OSC 52 payloads to 100,000 Base64 characters. This limit preve
177
179
  Open the News popup with `Ctrl+N` or `/news`. It lists the final answers of user-initiated turns, newest first. Each entry shows the user prompt and any follow-up steers that produced the answer, above the answer itself.
178
180
 
179
181
  - `←` / `→` — move between answers
182
+ - `n` — jump to the answer
183
+ - `p` — jump to the user prompt
180
184
  - `Space` — toggle an answer between read and unread
181
185
  - `c` — copy the current answer to the clipboard
182
186
  - `Enter` — reply to the current answer with a quoted draft
@@ -199,6 +203,8 @@ Use `Ctrl+L` to select an agent transcript. Input then goes to that agent. Finis
199
203
 
200
204
  The public `spawn_subagent` tool accepts `preview: true`. PUM then shows the exact child task before it creates any worktree or session. Press `Enter` to approve. An optional note becomes a separate visible user instruction to the new child. Press `Esc` to cancel without creating a child. Cancellation discards the preview note because no child exists. PUM preserves the existing parent transcript draft.
201
205
 
206
+ When the Sandbox setting is `Auto` or `Require`, `spawn_subagent` also accepts `readonly: true`. A readonly child can inspect files and run sandboxed inspection commands. PUM omits `write`, `edit`, `apply_patch`, child spawning, inter-agent delegation, process-starting trigger tools, and message-cache mutation tools from that child. The remaining tool guard blocks unknown or mutation-capable child paths. Worktree access is limited to `list` and `status`. Bash requires an enforced native sandbox, receives read-only project, `/check-path`, and managed Git metadata roots, and receives no network access. If native enforcement is unavailable, readonly Bash blocks instead of using the `Auto` direct fallback. PUM persists readonly state across resume. Sandbox `Off` removes the argument from live spawn tool schemas and rejects any explicit readonly spawn request.
207
+
202
208
  Press `↑` on an empty single-line prompt to recall the newest queued user-authored message for the selected transcript. PUM removes the message from the authoritative queue before restoring its text. PUM does not recall inter-agent, trigger, lifecycle, cache, delivered, or image-bearing messages.
203
209
 
204
210
  Idle notices report settled work cycles to the direct spawner. They are not completion notices. PUM acknowledges completion delivery only after the notice enters the parent session. Persisted completion intent and stable message identifiers let interrupted delivery resume without duplicate completion messages.
@@ -231,23 +237,35 @@ Trigger events target one exact main or retained child session. A missing sessio
231
237
 
232
238
  `apply_patch` supports add, update, delete, move, multiple files, and multiple hunks. PUM validates the full patch before changing files. It rejects traversal, absolute paths, escaping symlinks, path conflicts, and ambiguous context. A failed commit restores all touched files.
233
239
 
240
+ ### Filesystem sandbox
241
+
242
+ The process-local filesystem sandbox validates `read`, `write`, `edit`, and `apply_patch` before execution.
243
+
244
+ - Project paths and `/check-path` roots are allowed.
245
+ - Credential-sensitive paths are blocked.
246
+ - Symbolic links and junctions in tool paths are blocked.
247
+ - `apply_patch` remains project-local and keeps its atomic validation.
248
+
249
+ This boundary does not isolate `bash`, package scripts, extensions, or trigger processes from the operating system. Use a container, VM, or policy-controlled sandbox for stronger isolation.
250
+
234
251
  ### Check mode
235
252
 
236
- Select a Check mode profile in `Ctrl+P`. It applies to `bash`, `edit`, `apply_patch`, and external-trigger process execution:
253
+ Select Check mode in `Ctrl+P` — either **Off** or **On**. It applies to `bash`, `edit`, `apply_patch`, and external-trigger process execution:
254
+
255
+ - **Off:** Checked tools run without the deterministic policy or the advisory verifier.
256
+ - **On:** The deterministic policy runs first, then an advisory verifier reviews the complete proposal.
237
257
 
238
- - **Strict:** Run deterministic hard rules, then require a clear verifier approval.
239
- - **Balanced:** Block deterministic hard-rule or suspicious findings. Allow ordinary complete project-local calls, explicit non-sensitive external reads, and narrow lifecycle-disabled npm release verification operations whose writes stay in approved roots. Verifier review is non-blocking unless the verifier returns explicit `UNSAFE`.
240
- - **Ask:** Show the approval popup for every checked call that passes hard rules, unless an exact session or project approval already matches. A verifier `SAFE`, unclear, error, or unavailable result still requires approval.
258
+ On allows ordinary complete project-local calls, explicit on-mode external reads (read-only), and project-local edits. It accepts one direct `npm pack` only when lifecycle scripts are disabled, an explicit cache stays in an approved root, output stays in an approved root, and any package operand is one exact registry version. On also accepts one direct `npm install` of one exact registry version only when `--ignore-scripts`, an approved `--prefix`, and an approved `--cache` are explicit. File, Git, URL, tag, range, composed, general install, and global-install forms remain blocked. On hard-blocks external writes, location changes, execution operands, ambiguous path access, escaping links or junctions, credential access, privilege escalation, persistence, remote-script execution, destructive Git operations, and broad deletion. These hard blocks cannot be overridden and never open a popup.
241
259
 
242
- Every active profile hard-blocks external writes, location changes, execution operands, ambiguous path access, escaping links, credential access, privilege escalation, persistence, remote-script execution, destructive Git operations, and broad deletion. Balanced permits explicit, deterministically classified, non-sensitive external reads. It accepts one direct `npm pack` only when lifecycle scripts are disabled, an explicit cache stays in an approved root, output stays in an approved root, and any package operand is one exact registry version. Balanced also accepts one direct `npm install` of one exact registry version only when `--ignore-scripts`, an approved `--prefix`, and an approved `--cache` are explicit. File, Git, URL, tag, range, composed, general install, and global-install forms remain blocked. These hard blocks cannot be overridden and do not open the popup. An explicit verifier `UNSAFE` verdict also blocks without a popup. The only publication exception is a deterministic match for direct main-agent `npm publish` or `npm dist-tag add`. The verifier category does not control this exception. The exception still requires explicit popup approval. Managed subagents cannot use it.
260
+ On blocks an explicit verifier verdict of `UNSAFE`. An unclear, unavailable, failed, or timed-out review does not block a fully validated call. The only exception outside the verifier verdict is a deterministic match for a direct main-agent `npm publish` or `npm dist-tag add ... latest`, which On allows outright. The verifier category does not control this exception. Managed subagents cannot use it.
243
261
 
244
- Use `/check-path list`, `/check-path add <directory>`, `/check-path remove <directory>`, or `/check-path clear` to manage up to 16 additional directory roots for the current launch project. Bash, edit, and external-trigger checks can use these roots; `apply_patch` remains project-local. Added roots are canonicalized and remain subject to credential, traversal, symlink or junction, broad-deletion, and other hard blocks.
262
+ There is no approval popup and no approval store. Check mode is off by default.
245
263
 
246
- For `edit` and `apply_patch`, PUM validates the complete proposed change before any mutation. Review data includes the unified diff, changed paths, line counts, sensitivity flags, project containment, and full-content SHA-256. Invalid, stale, malformed, escaping, or incompletely analyzed input blocks the call. Patch length alone does not block a valid Balanced call.
264
+ Use `/check-path list`, `/check-path add <directory>`, `/check-path remove <directory>`, or `/check-path clear` to manage up to 16 additional directory roots for the current launch project. The filesystem sandbox applies these roots to `read`, `write`, and `edit`. Bash, edit, and external-trigger checks also use these roots; `apply_patch` remains project-local. Added roots are canonicalized and remain subject to credential, traversal, symlink or junction, broad-deletion, and other hard blocks.
247
265
 
248
- Ask mode can allow an exact call once, for the current session, or for the current project. Approvals match the authoritative main or child identity, tool, verifier model, project, and canonical complete input. Chat text is not approval. Use **Clear approvals** in Settings to remove project approvals.
266
+ For `edit` and `apply_patch`, PUM validates the complete proposed change before any mutation. Review data includes the unified diff, changed paths, line counts, sensitivity flags, project containment, and full-content SHA-256. Invalid, stale, malformed, escaping, or incompletely analyzed input blocks the call. Patch length alone does not block a complete validated on-mode call.
249
267
 
250
- The verifier uses a structured decision schema. One unclear response can receive one adjudication under the shared 15-second watchdog. Strict blocks malformed replies, errors, aborts, and timeouts. Balanced allows a fully validated call after an unclear, unavailable, failed, or timed-out review. Balanced still blocks explicit verifier `UNSAFE`, aborts, deterministic suspicious findings, malformed structures, and incomplete analysis. Ask requires the popup after hard rules for verifier `SAFE`, unclear, error, and unavailable results. Check mode is off by default.
268
+ The verifier uses a structured decision schema. One unclear response can receive one bounded adjudication under the shared 15-second watchdog. On blocks aborts, malformed verifier replies, and incomplete analysis, and it blocks any explicit `UNSAFE` verdict. Check mode is off by default.
251
269
 
252
270
  #### Native Bash sandbox
253
271
 
@@ -259,7 +277,7 @@ The **Sandbox** setting has three modes:
259
277
 
260
278
  Check mode **Off** always uses pi's normal unsandboxed Bash backend. For an active Check mode, PUM recomputes the sandbox policy from the exact approved command, authoritative working directory, configured additional roots, and deterministic access analysis. Model input cannot supply policy fields.
261
279
 
262
- The project and configured additional roots are writable. Explicit Balanced external reads are mounted read-only. PUM configuration and common credential paths are denied, and credential-shaped or process-injection environment variables are removed. A private temporary directory is supplied for the command. Safe pi metadata such as `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL` remains available; session paths and identifiers are withheld.
280
+ The project and configured additional roots are writable. Explicit on-mode external reads are mounted read-only. PUM configuration and common credential paths are denied, and credential-shaped or process-injection environment variables are removed. A private temporary directory is supplied for the command. Safe pi metadata such as `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL` remains available; session paths and identifiers are withheld.
263
281
 
264
282
  Network access is denied unless deterministic analysis recognizes an approved network operation. Bubblewrap's host-network mode is all-or-nothing and is **not domain-filtered**. Windows similarly grants or withholds the SDK's broad network capabilities; it does not provide hostname allowlists.
265
283
 
@@ -267,7 +285,9 @@ The override uses pi's `createBashTool` implementation and custom Bash operation
267
285
 
268
286
  External triggers preserve direct executable/argument boundaries and continue to use deterministic Check mode, but they are not routed through the native sandbox in this release. The trigger manager's synchronous spawn boundary does not carry the exact approved policy object into execution; silently recomputing a second process policy there would weaken approval identity. Trigger output, environment, limits, and process supervision remain unchanged.
269
287
 
270
- Verifier prompts stay bounded. For an oversized Balanced review, PUM sends complete validation metadata, counts, findings, and SHA-256 digests. PUM does not send a raw prefix or suffix as if it were complete. Strict and Ask keep their fail-closed oversized-input behavior.
288
+ The filesystem sandbox is a process-local path guard for `read`, `write`, `edit`, and `apply_patch`. It does not replace native Bash isolation and does not cover scripts, extensions, or trigger processes.
289
+
290
+ Verifier prompts stay bounded. For an oversized On-mode review, PUM sends complete validation metadata, counts, findings, and SHA-256 digests. PUM does not send a raw prefix or suffix as if it were complete.
271
291
 
272
292
  ### Hosted web search
273
293
 
@@ -309,8 +329,6 @@ Set `PUM_DIR` to override the complete PUM data directory.
309
329
  | `prompt-stash.json` | Stashed prompts by working directory |
310
330
  | `sessions/` | Main conversation sessions |
311
331
  | `subagents/` | Persistent subagent sessions |
312
- | `check-mode-cache.json` | Accepted checks for eligible read-only Git commands |
313
- | `check-mode-approvals.json` | Exact project approvals created in Ask mode |
314
332
 
315
333
  PUM preserves all stashed prompt occurrences. PUM also keeps the 100 most recent additional sent-history occurrences for each working directory.
316
334
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.9-beta.1",
3
+ "version": "0.2.11-beta.1",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,6 +17,7 @@ export type AgentTreeRow = {
17
17
  id: string | null;
18
18
  name: string;
19
19
  status?: SubagentSnapshot["status"];
20
+ readonly?: boolean;
20
21
  depth: number;
21
22
  metadata?: StatusMetadataValues;
22
23
  };
@@ -36,7 +37,9 @@ export function agentSelectorRowLayout(
36
37
  const indent = Math.min(2 + row.depth * 2, Math.max(2, popupColumns - 12));
37
38
  // One column remains clear for the pinned scrollbar.
38
39
  const contentColumns = Math.max(1, popupColumns - indent - 1);
39
- const label = row.status ? `${row.name} · ${row.status}` : row.name;
40
+ const label = row.status
41
+ ? `${row.name}${row.readonly ? " · readonly" : ""} · ${row.status}`
42
+ : row.name;
40
43
  const minimumLabelWidth = Math.min(
41
44
  label.length,
42
45
  Math.max(8, Math.ceil(contentColumns * 0.45)),
@@ -73,6 +76,7 @@ export function buildAgentTree(agents: readonly SubagentSnapshot[]): AgentTreeRo
73
76
  id: agent.id,
74
77
  name: agent.name,
75
78
  status: agent.status,
79
+ readonly: agent.readonly === true,
76
80
  depth,
77
81
  metadata: {
78
82
  branch: agent.worktree.branch ?? null,
@@ -95,6 +99,7 @@ export function buildAgentTree(agents: readonly SubagentSnapshot[]): AgentTreeRo
95
99
  id: agent.id,
96
100
  name: agent.name,
97
101
  status: agent.status,
102
+ readonly: agent.readonly === true,
98
103
  depth: 1,
99
104
  metadata: {
100
105
  branch: agent.worktree.branch ?? null,
package/src/app.tsx CHANGED
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
9
9
  import { useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
10
10
  import { getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
11
11
  import type { AgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent";
12
- import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
12
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
13
13
  import { AnimationProvider, supportsTrueColor, useWorkingRule, type WorkingRuleRole } from "./animation";
14
14
  import {
15
15
  filterModels,
@@ -115,16 +115,6 @@ import { LoginController } from "./login-controller";
115
115
  import { providerLoginMethods, refreshAndSelectModel } from "./login-flow";
116
116
  import { questionnaireDetail, QuestionnaireManager } from "./questionnaire";
117
117
  import { QuestionnairePopup } from "./questionnaire-popup";
118
- import {
119
- CheckApprovalPopup,
120
- invokeCheckApprovalDecision,
121
- type CheckApprovalDecision,
122
- } from "./check-approval-popup";
123
- import type {
124
- CheckApprovalCoordinator,
125
- CheckApprovalRequest,
126
- CheckApprovalStore,
127
- } from "./check-approvals";
128
118
  import {
129
119
  moveTriggerSelection,
130
120
  sortTriggers,
@@ -162,6 +152,26 @@ const NAV_KEYS = new Set(["up", "down", "left", "right", "home", "end", "pageup"
162
152
  * than a bit mask: Shift=1, Alt=2, Ctrl=4.
163
153
  */
164
154
  const MODIFIED_ENTER_SEQUENCE = /^\x1b\[(?:13;(\d+)u|27;(\d+);13~)$/;
155
+ const KITTY_PRINTABLE_SEQUENCE = /^\x1b\[(\d+)(?:;\d+(?::\d+)?)?u$/;
156
+ const MODIFY_OTHER_KEYS_PRINTABLE_SEQUENCE = /^\x1b\[27;\d+;(\d+)~$/;
157
+
158
+ function printableKeyCharacter(
159
+ name: string | undefined,
160
+ sequence: string,
161
+ raw = sequence,
162
+ ): string | undefined {
163
+ if (name?.length === 1) return name.toLowerCase();
164
+ for (const candidate of [sequence, raw]) {
165
+ if (candidate.length === 1) return candidate.toLowerCase();
166
+ const kitty = KITTY_PRINTABLE_SEQUENCE.exec(candidate);
167
+ const modifyOtherKeys = MODIFY_OTHER_KEYS_PRINTABLE_SEQUENCE.exec(candidate);
168
+ const codePoint = Number(kitty?.[1] ?? modifyOtherKeys?.[1]);
169
+ if (!Number.isInteger(codePoint) || codePoint < 0x20 || codePoint > 0x10FFFF) continue;
170
+ if (codePoint >= 0xD800 && codePoint <= 0xDFFF) continue;
171
+ return String.fromCodePoint(codePoint).toLowerCase();
172
+ }
173
+ return undefined;
174
+ }
165
175
 
166
176
  function modifiedEnterFromSequence(sequence: string) {
167
177
  const match = MODIFIED_ENTER_SEQUENCE.exec(sequence);
@@ -391,8 +401,6 @@ export function App({
391
401
  stagePastedText = stagePastedTextDefault,
392
402
  copyNewsAnswerText = copyTextToClipboard,
393
403
  onExit = () => process.exit(0),
394
- checkApprovalCoordinator,
395
- checkApprovalStore,
396
404
  triggerManager,
397
405
  messageCacheController,
398
406
  terminalTitle,
@@ -421,8 +429,6 @@ export function App({
421
429
  /** Copies the selected news answer for the popup. */
422
430
  copyNewsAnswerText?: typeof copyTextToClipboard;
423
431
  onExit?: () => void | Promise<void>;
424
- checkApprovalCoordinator?: CheckApprovalCoordinator;
425
- checkApprovalStore?: CheckApprovalStore;
426
432
  triggerManager?: TriggerManagerLike;
427
433
  messageCacheController?: MessageCacheController;
428
434
  terminalTitle?: TerminalTitleController;
@@ -476,6 +482,10 @@ export function App({
476
482
  const [settingsSearchFocused, setSettingsSearchFocused] = useState(true);
477
483
  const [selectedSettingId, setSelectedSettingId] = useState<SettingRowId | null>(SETTINGS_ROWS[0]!.id);
478
484
  const [settings, setSettings] = useState(initial);
485
+ // Mirrors settings for update(): a keypress or an async .then can fire a
486
+ // second update before React commits the first, so update() must build the
487
+ // next value from the latest pending settings, not the render closure.
488
+ const settingsRef = useRef(settings);
479
489
  const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>(
480
490
  session.agent.state.thinkingLevel as ThinkingLevel,
481
491
  );
@@ -509,8 +519,6 @@ export function App({
509
519
  }));
510
520
  const [modelQuery, setModelQuery] = useState("");
511
521
  const [modelSearchFocused, setModelSearchFocused] = useState(false);
512
- const [checkApproval, setCheckApproval] = useState<CheckApprovalRequest | null>(null);
513
- const [checkApprovalDecision, setCheckApprovalDecision] = useState<CheckApprovalDecision>("allowOnce");
514
522
  const [, setAgentRevision] = useState(0);
515
523
  const [triggersOpen, setTriggersOpen] = useState(false);
516
524
  const [triggerCursor, setTriggerCursor] = useState(0);
@@ -735,6 +743,13 @@ export function App({
735
743
  else if (editingIndex !== null && editingIndex > index) {
736
744
  editingStashIndex.current = editingIndex - 1;
737
745
  }
746
+ // The stash is shared across agent views, so a delete shifts the indices
747
+ // that every other view checked out. Reindex the saved per-view values too,
748
+ // or a later submit in another view would overwrite the wrong cached row.
749
+ for (const [key, value] of viewEditingStashIndices.current) {
750
+ if (value === index) viewEditingStashIndices.current.set(key, null);
751
+ else if (value !== null && value > index) viewEditingStashIndices.current.set(key, value - 1);
752
+ }
738
753
 
739
754
  if (next.length === 0) setStashMode(false);
740
755
  else setSelectedStash(Math.min(index, next.length - 1));
@@ -951,6 +966,7 @@ export function App({
951
966
  agentSelectorOpen ||
952
967
  helpOpen ||
953
968
  historyOpen ||
969
+ newsOpenRef.current ||
954
970
  settingsOpenRef.current
955
971
  ) return;
956
972
  const input = inputRef.current;
@@ -1006,10 +1022,6 @@ export function App({
1006
1022
  append({ kind: "text", role: "system", text: warning });
1007
1023
  }), [sandboxWarningSource]);
1008
1024
 
1009
- useEffect(() => checkApprovalCoordinator?.subscribe((request) => {
1010
- setCheckApproval(request);
1011
- setCheckApprovalDecision("allowOnce");
1012
- }), [checkApprovalCoordinator]);
1013
1025
 
1014
1026
  useEffect(() => triggerManager?.subscribe(() => {
1015
1027
  setTriggerRevision((revision) => revision + 1);
@@ -1326,7 +1338,8 @@ export function App({
1326
1338
  }, [activeAgent?.id, activeAgent?.status, activeAgent?.runStartedAt, visibleBusy]);
1327
1339
 
1328
1340
  const update = (patch: Partial<PumSettings>) => {
1329
- const next = { ...settings, ...patch };
1341
+ const next = { ...settingsRef.current, ...patch };
1342
+ settingsRef.current = next;
1330
1343
  setSettings(next);
1331
1344
  if (patch.webSearch !== undefined) webSearch.enabled = patch.webSearch;
1332
1345
  if (patch.writingStyle !== undefined) setWritingStyle(patch.writingStyle);
@@ -1490,6 +1503,44 @@ export function App({
1490
1503
  setNewsCursor(next);
1491
1504
  };
1492
1505
 
1506
+ const jumpFromNews = (target: "answer" | "prompt") => {
1507
+ const item = newsRef.current[newsCursorRef.current];
1508
+ if (!item) return;
1509
+ const lines = txRef.current.lines;
1510
+ const answerIndex = lines.findIndex((line) =>
1511
+ line.kind === "text" && line.role === "assistant" && line.newsId === item.id,
1512
+ );
1513
+ let targetIndex = answerIndex;
1514
+ if (target === "prompt") {
1515
+ const promptText = item.prompts?.find((prompt) => !prompt.steer)?.text
1516
+ ?? item.prompts?.[0]?.text;
1517
+ targetIndex = -1;
1518
+ if (promptText) {
1519
+ const end = answerIndex >= 0 ? answerIndex : lines.length;
1520
+ for (let index = end - 1; index >= 0; index--) {
1521
+ const line = lines[index];
1522
+ if (line?.kind === "text" && line.role === "user" && line.text === promptText) {
1523
+ targetIndex = index;
1524
+ break;
1525
+ }
1526
+ }
1527
+ }
1528
+ }
1529
+ if (targetIndex < 0) return;
1530
+
1531
+ if (activeAgentIdRef.current !== null && !selectAgentView(null)) return;
1532
+ newsOpenRef.current = false;
1533
+ setNewsOpen(false);
1534
+ const scrollToTarget = () => {
1535
+ const transcript = transcriptScrollRef.current;
1536
+ if (!transcript) return;
1537
+ transcript.scrollTop = 0;
1538
+ transcript.scrollChildIntoView(`transcript-line-${targetIndex}`);
1539
+ };
1540
+ queueMicrotask(scrollToTarget);
1541
+ setTimeout(scrollToTarget, 30);
1542
+ };
1543
+
1493
1544
  const toggleCurrentNewsRead = () => {
1494
1545
  const item = newsRef.current[newsCursorRef.current];
1495
1546
  if (!item) return;
@@ -1598,7 +1649,12 @@ export function App({
1598
1649
  const restore = queued.length ? queued[queued.length - 1]! : inFlight.current;
1599
1650
  if (inputRef.current && !inputRef.current.plainText) setEditorText(restore);
1600
1651
  histCursor.current = null;
1601
- session.abort().finally(() => setWorking(false));
1652
+ // clearQueue may have dropped a subagent completion notice queued to the
1653
+ // streaming main agent; re-arm undelivered notices so a merge is not stuck.
1654
+ session.abort().finally(() => {
1655
+ setWorking(false);
1656
+ void subagentManager.resendUndeliveredMainSettlements();
1657
+ });
1602
1658
  };
1603
1659
 
1604
1660
  const recallQueuedUserMessage = async (target: string | null) => {
@@ -1606,7 +1662,12 @@ export function App({
1606
1662
  ? await subagentManager.recallQueuedUserMessage(target)
1607
1663
  : await recallNewestQueuedUserMessage(session, tx.pending);
1608
1664
  if (!recalled) return;
1609
- if (!target) dropPending(recalled.id);
1665
+ // Recalling from the main queue clears it, which can drop a queued subagent
1666
+ // completion notice; re-arm undelivered notices so a merge is not stuck.
1667
+ if (!target) {
1668
+ dropPending(recalled.id);
1669
+ void subagentManager.resendUndeliveredMainSettlements();
1670
+ }
1610
1671
  const key = target ?? "main";
1611
1672
  viewDrafts.current.set(key, recalled.text);
1612
1673
  if (activeAgentIdRef.current === target) {
@@ -1798,6 +1859,11 @@ export function App({
1798
1859
  };
1799
1860
 
1800
1861
  const submitPrompt = (value?: string, stashIndex?: number) => {
1862
+ // Read the selected agent from the ref, not the state. A view switch updates
1863
+ // the ref synchronously, but a switch-then-send in one input chunk runs
1864
+ // before React commits the new state, so the state would still name the
1865
+ // previous agent and deliver the prompt to the wrong session.
1866
+ const selectedAgentId = activeAgentIdRef.current;
1801
1867
  const displayText = value ?? inputRef.current?.plainText ?? "";
1802
1868
  const attachments = value === undefined ? [...pendingImages.current] : [];
1803
1869
  const pastedTexts = value === undefined ? [...pendingPastedTexts.current] : [];
@@ -1826,7 +1892,7 @@ export function App({
1826
1892
  // The temp files must survive until the turn that reads them settles, so
1827
1893
  // move them out of the draft tracking before the editor is cleared below.
1828
1894
  if (value === undefined && pastedTexts.length > 0) {
1829
- const targetKey = activeAgentId ?? "main";
1895
+ const targetKey = selectedAgentId ?? "main";
1830
1896
  const existing = postTurnPastedTexts.current.get(targetKey) ?? [];
1831
1897
  postTurnPastedTexts.current.set(targetKey, [...existing, ...pastedTexts]);
1832
1898
  pendingPastedTexts.current = [];
@@ -1841,9 +1907,9 @@ export function App({
1841
1907
  return;
1842
1908
  }
1843
1909
 
1844
- if (activeAgentId) {
1910
+ if (selectedAgentId) {
1845
1911
  void subagentManager
1846
- .sendUserMessage(activeAgentId, promptText, images, displayText.trim())
1912
+ .sendUserMessage(selectedAgentId, promptText, images, displayText.trim())
1847
1913
  .catch((error) => append({ kind: "text", role: "error", text: String(error) }));
1848
1914
  return;
1849
1915
  }
@@ -1950,18 +2016,6 @@ export function App({
1950
2016
  }));
1951
2017
  };
1952
2018
 
1953
- const resolveCheckApproval = (decision: CheckApprovalDecision) => {
1954
- if (!checkApproval || !checkApprovalCoordinator) return;
1955
- const choice = decision === "allowOnce"
1956
- ? "allow-once"
1957
- : decision === "allowSession"
1958
- ? "allow-session"
1959
- : decision === "allowProject"
1960
- ? "allow-project"
1961
- : "deny";
1962
- checkApprovalCoordinator.resolve(checkApproval.id, choice);
1963
- };
1964
-
1965
2019
  const stepCheckMode = (step: number) => {
1966
2020
  const index = CHECK_MODE_PROFILES.indexOf(settings.checkMode);
1967
2021
  update({ checkMode: CHECK_MODE_PROFILES[(index + step + CHECK_MODE_PROFILES.length) % CHECK_MODE_PROFILES.length]! });
@@ -1994,12 +2048,6 @@ export function App({
1994
2048
  role: "system",
1995
2049
  text: "use /check-path [list|add <directory>|remove <directory>|clear]",
1996
2050
  }) },
1997
- clearCheckApprovals: { enter: () => {
1998
- const removed = checkApprovalStore?.clearProject(cwd) ?? 0;
1999
- append({ kind: "text", role: "system", text: removed > 0
2000
- ? `cleared ${removed} Check mode project approval${removed === 1 ? "" : "s"}`
2001
- : "no Check mode project approvals were stored" });
2002
- } },
2003
2051
  thinkingLevel: { step: stepThinking },
2004
2052
  showThinking: { step: () => update({ showThinking: !settings.showThinking }) },
2005
2053
  maxActiveSubagents: { step: (step) => update({
@@ -2033,7 +2081,6 @@ export function App({
2033
2081
  sandboxMode: `‹ ${settings.sandboxMode ?? "auto"} ›`,
2034
2082
  checkModel: `${settings.checkModel} ›`,
2035
2083
  checkPaths: `${checkPathsForProject(settings, cwd).length} additional · /check-path ›`,
2036
- clearCheckApprovals: "clear ›",
2037
2084
  thinkingLevel: `‹ ${thinkingLevel} ›`,
2038
2085
  showThinking: `‹ ${settings.showThinking ? "on" : "off"} ›`,
2039
2086
  maxActiveSubagents: `‹ ${settings.maxActiveSubagents} ›`,
@@ -2127,40 +2174,19 @@ export function App({
2127
2174
  });
2128
2175
 
2129
2176
  useKeyboard((key) => {
2130
- if (checkApproval) {
2131
- key.stopPropagation();
2132
- if (key.name === "escape" || (key.ctrl && key.name === "c")) {
2133
- resolveCheckApproval("deny");
2134
- return;
2135
- }
2136
- const decisions: CheckApprovalDecision[] = ["allowOnce", "allowSession", "allowProject", "deny"];
2137
- const index = decisions.indexOf(checkApprovalDecision);
2138
- if (["left", "up"].includes(key.name)) {
2139
- setCheckApprovalDecision(decisions[(index - 1 + decisions.length) % decisions.length]!);
2140
- } else if (["right", "down", "tab"].includes(key.name)) {
2141
- setCheckApprovalDecision(decisions[(index + 1) % decisions.length]!);
2142
- } else if (["return", "enter", "kpenter", "linefeed"].includes(key.name)) {
2143
- invokeCheckApprovalDecision(checkApprovalDecision, {
2144
- onAllowOnce: () => resolveCheckApproval("allowOnce"),
2145
- onAllowSession: () => resolveCheckApproval("allowSession"),
2146
- onAllowProject: () => resolveCheckApproval("allowProject"),
2147
- onDeny: () => resolveCheckApproval("deny"),
2148
- });
2149
- }
2150
- return;
2151
- }
2152
-
2153
2177
  if (spawnPreview) {
2154
2178
  const isPreviewReturn = ["return", "enter", "kpenter", "linefeed"].includes(key.name);
2155
2179
  if (key.name === "escape" || (key.ctrl && key.name === "c")) {
2156
2180
  key.stopPropagation();
2157
2181
  spawnPreviewInputRef.current?.setText("");
2158
- spawnPreviewManager?.cancel();
2182
+ // Bind the action to the request the popup rendered; if the active
2183
+ // request changed under it, this is a no-op instead of hitting another.
2184
+ spawnPreviewManager?.cancel("cancelled", spawnPreview.id);
2159
2185
  } else if (isPreviewReturn && !key.shift && !key.ctrl && !key.meta && !key.option) {
2160
2186
  key.stopPropagation();
2161
2187
  const note = spawnPreviewInputRef.current?.plainText ?? "";
2162
2188
  spawnPreviewInputRef.current?.setText("");
2163
- spawnPreviewManager?.approve(note);
2189
+ spawnPreviewManager?.approve(note, spawnPreview.id);
2164
2190
  }
2165
2191
  return;
2166
2192
  }
@@ -2355,19 +2381,22 @@ export function App({
2355
2381
  return;
2356
2382
  }
2357
2383
 
2358
- if (key.ctrl && key.name === "n") {
2384
+ const printableKey = printableKeyCharacter(key.name, key.sequence, key.raw);
2385
+ if (key.ctrl && printableKey === "n") {
2359
2386
  key.stopPropagation();
2360
2387
  if (newsOpenRef.current) closeNews();
2361
2388
  else openNews();
2362
2389
  return;
2363
2390
  }
2364
- if (newsOpenRef.current) {
2391
+ if (newsOpenRef.current || newsOpen) {
2365
2392
  key.stopPropagation();
2366
2393
  if (key.name === "escape") closeNews();
2367
2394
  else if (key.name === "left") moveNewsCursor(1);
2368
2395
  else if (key.name === "right") moveNewsCursor(-1);
2369
2396
  else if (key.name === "space" || key.sequence === " ") toggleCurrentNewsRead();
2370
- else if (key.name === "c" || key.sequence === "c") void copyNewsAnswer();
2397
+ else if (printableKey === "n") jumpFromNews("answer");
2398
+ else if (printableKey === "p") jumpFromNews("prompt");
2399
+ else if (printableKey === "c") void copyNewsAnswer();
2371
2400
  else if (key.name === "return" || key.name === "enter" || key.name === "kpenter")
2372
2401
  replyToCurrentNews();
2373
2402
  return;
@@ -2700,10 +2729,13 @@ export function App({
2700
2729
  } else if ((activeAgentId && visibleBusy) || (!activeAgentId && busyRef.current)) {
2701
2730
  key.stopPropagation();
2702
2731
  const now = Date.now();
2703
- const target = activeAgentId ?? "main";
2732
+ // Cancel the agent named by the ref, not the state: a switch-then-Esc in
2733
+ // one input chunk must abort the newly selected agent, not the previous.
2734
+ const selected = activeAgentIdRef.current;
2735
+ const target = selected ?? "main";
2704
2736
  if (confirmsCancellation(lastCancelPress.current, cancelTarget.current, target, now)) {
2705
2737
  resetCancelArm();
2706
- if (activeAgentId) void subagentManager.abortAgent(activeAgentId);
2738
+ if (selected) void subagentManager.abortAgent(selected);
2707
2739
  else cancel();
2708
2740
  } else {
2709
2741
  lastCancelPress.current = now;
@@ -2812,10 +2844,14 @@ export function App({
2812
2844
  ? `agent:${line.sender}:${line.recipient}:${i}:${line.text}`
2813
2845
  : `text:${line.role}:${i}:${line.text}`;
2814
2846
  return (
2815
- <Fragment key={lineKey}>
2847
+ <box
2848
+ id={`transcript-line-${i}`}
2849
+ key={lineKey}
2850
+ style={{ flexDirection: "column", width: "100%", flexShrink: 0 }}
2851
+ >
2816
2852
  {gapBefore ? <Gap /> : null}
2817
2853
  {row}
2818
- </Fragment>
2854
+ </box>
2819
2855
  );
2820
2856
  })}
2821
2857
  {visibleTx.stream ? (
@@ -2925,7 +2961,7 @@ export function App({
2925
2961
  selectionBg={theme.selectionBg}
2926
2962
  wrapMode="word"
2927
2963
  scrollMargin={1}
2928
- focused={!settingsOpen && !helpOpen && !historyOpen && !agentSelectorOpen && !triggersOpen && !loginOpen && !questionnaire && !spawnPreview && !checkApproval}
2964
+ focused={!settingsOpen && !helpOpen && !historyOpen && !agentSelectorOpen && !triggersOpen && !loginOpen && !questionnaire && !spawnPreview && !newsOpen}
2929
2965
  onContentChange={handleTextareaChange}
2930
2966
  onCursorChange={scheduleInputMetrics}
2931
2967
  onSubmit={() => submitPrompt()}
@@ -3015,32 +3051,6 @@ export function App({
3015
3051
  terminalHeight={height}
3016
3052
  />
3017
3053
  ) : null}
3018
- {checkApproval ? (
3019
- <CheckApprovalPopup
3020
- theme={theme}
3021
- request={{
3022
- tool: checkApproval.toolName,
3023
- summary: checkApproval.summary,
3024
- reason: checkApproval.reason,
3025
- paths: checkApproval.paths,
3026
- preview: { kind: checkApproval.toolName === "bash" ? "command" : "diff", text: checkApproval.preview },
3027
- agentLabel: checkApproval.target?.agentId
3028
- ? agents.find((agent) => agent.id === checkApproval.target?.agentId)?.name ?? "subagent"
3029
- : checkApproval.target?.sessionId
3030
- ? "main"
3031
- : checkApproval.cwd === cwd
3032
- ? "main"
3033
- : agents.find((agent) => agent.worktree.path === checkApproval.cwd)?.name ?? "subagent",
3034
- }}
3035
- selectedDecision={checkApprovalDecision}
3036
- terminalWidth={width}
3037
- terminalHeight={height}
3038
- onAllowOnce={() => resolveCheckApproval("allowOnce")}
3039
- onAllowSession={() => resolveCheckApproval("allowSession")}
3040
- onAllowProject={() => resolveCheckApproval("allowProject")}
3041
- onDeny={() => resolveCheckApproval("deny")}
3042
- />
3043
- ) : null}
3044
3054
  {settingsOpen ? (
3045
3055
  <SettingsPopup
3046
3056
  theme={theme}