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 +33 -15
- package/package.json +1 -1
- package/src/agent-selector.tsx +6 -1
- package/src/app.tsx +115 -105
- package/src/apply-patch.ts +92 -34
- package/src/check-approvals.ts +9 -203
- package/src/check-mode-prompt.ts +18 -24
- package/src/check-mode.ts +36 -232
- package/src/cli.ts +39 -9
- package/src/explanation-strength.ts +1 -0
- package/src/filesystem-sandbox.ts +190 -0
- package/src/headless.ts +186 -0
- package/src/help-popup.tsx +1 -1
- package/src/identity.ts +62 -0
- package/src/index.tsx +6 -0
- package/src/main.tsx +22 -19
- package/src/message-cache.ts +7 -1
- package/src/news-popup.tsx +27 -16
- package/src/sandbox/index.ts +56 -8
- package/src/sandbox/linux.ts +13 -0
- package/src/sandbox/windows.ts +21 -7
- package/src/sandbox-policy.ts +35 -8
- package/src/settings-popup.tsx +2 -4
- package/src/settings.ts +17 -9
- package/src/subagents/manager.ts +349 -43
- package/src/subagents/readonly.ts +57 -0
- package/src/subagents/spawn-preview-popup.tsx +6 -1
- package/src/subagents/spawn-preview.ts +8 -2
- package/src/subagents/types.ts +4 -1
- package/src/tool-groups.ts +38 -8
- package/src/tool-line.ts +7 -1
- package/src/transcript.tsx +2 -2
- package/src/triggers/manager.ts +72 -4
- package/src/triggers/process.ts +25 -1
- package/src/web-search.ts +103 -20
- package/src/worktree.ts +43 -5
- package/src/check-approval-popup.tsx +0 -224
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
|
|
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
|
-
- **
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
+
There is no approval popup and no approval store. Check mode is off by default.
|
|
245
263
|
|
|
246
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
package/src/agent-selector.tsx
CHANGED
|
@@ -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
|
|
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 {
|
|
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 = { ...
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
1910
|
+
if (selectedAgentId) {
|
|
1845
1911
|
void subagentManager
|
|
1846
|
-
.sendUserMessage(
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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 && !
|
|
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}
|