pum-agent 0.2.2-beta.1 → 0.2.4-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
@@ -138,6 +138,7 @@ Set `PUM_DIR` to override PUM's complete configuration and data directory. Run `
138
138
  | `Ctrl+L` | Open the agent transcript selector |
139
139
  | `Shift+Tab` / `Ctrl+Shift+Tab` | Cycle through agent transcripts |
140
140
  | `Ctrl+H` | Open session history when the terminal reports the key distinctly |
141
+ | `Ctrl+End` | Scroll to the end of the selected transcript |
141
142
  | `Ctrl+P` | Open settings |
142
143
  | `Ctrl+T` | Open supervised external triggers |
143
144
  | `Esc` twice | Cancel the selected working agent |
@@ -177,7 +178,7 @@ PUM runs up to 10 active subagents by default. Configure a limit from 1 through
177
178
  - Its own transcript, draft, usage data, and cancellation state
178
179
  - Tools for progress messages and a single final completion report
179
180
 
180
- Select a range of stashed prompts and press `Enter`. The main agent can group related work and run independent groups in parallel. Successful managed merges remove the completed worktree and branch. A parent cannot finish, merge, or be removed until every retained descendant closes deepest-first.
181
+ Select a range of stashed prompts and press `Enter`. The main agent can group related work and run independent groups in parallel. A managed merge requires both authoritative `completed` status and a persisted completion notice. Idle settlement is not completion. Successful managed merges remove the completed worktree and branch. A parent cannot finish, merge, or be removed until every retained descendant closes deepest-first.
181
182
 
182
183
  Use `Ctrl+L` to select an agent transcript. Input then goes to that agent. Finished or interrupted agents remain available until PUM merges or removes them.
183
184
 
@@ -185,7 +186,7 @@ The public `spawn_subagent` tool accepts `preview: true`. PUM then shows the exa
185
186
 
186
187
  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.
187
188
 
188
- Idle notices report settled work cycles to the direct spawner. They are not completion notices. PUM persists completion intent and delivery state so interrupted notification delivery can resume without duplicate completion messages.
189
+ 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.
189
190
 
190
191
  ## Tools and safeguards
191
192
 
@@ -201,7 +202,7 @@ Main and managed child agents can list and read the current workspace message ca
201
202
 
202
203
  Agents can add entries. An agent can delete only entries created by that exact agent. User-created and legacy entries remain user-owned.
203
204
 
204
- The `message_cache_send` tool accepts stable entry IDs. Single entries use the selected agent delivery path. Multiple entries use main-agent worktree orchestration.
205
+ The `message_cache_send` tool accepts stable entry IDs. Single entries use the selected agent delivery path. Multiple entries use main-agent worktree orchestration. PUM reserves selected entries during delivery and marks them executed only after delivery succeeds. Failed main or child delivery leaves the entries pending.
205
206
 
206
207
  ### External triggers
207
208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.2-beta.1",
3
+ "version": "0.2.4-beta.1",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/app.tsx CHANGED
@@ -486,6 +486,7 @@ export function App({
486
486
  ), [modelRuntime, modelId, modelQuery, loginPage]);
487
487
 
488
488
  const inputRef = useRef<TextareaRenderable>(null);
489
+ const transcriptScrollRef = useRef<ScrollBoxRenderable>(null);
489
490
  const questionnaireInputRef = useRef<TextareaRenderable>(null);
490
491
  const spawnPreviewInputRef = useRef<TextareaRenderable>(null);
491
492
  const settingsOpenRef = useRef(settingsOpen);
@@ -1361,12 +1362,12 @@ export function App({
1361
1362
  return true;
1362
1363
  };
1363
1364
 
1364
- const deliverMainPrompt = (
1365
+ const deliverMainPrompt = async (
1365
1366
  promptText: string,
1366
1367
  displayText: string,
1367
1368
  images: ReturnType<typeof imageContent>[] = [],
1368
1369
  recallable = images.length === 0,
1369
- ) => {
1370
+ ): Promise<void> => {
1370
1371
  const userLine: Extract<Line, { kind: "text" }> = {
1371
1372
  kind: "text",
1372
1373
  role: "user",
@@ -1383,20 +1384,26 @@ export function App({
1383
1384
  recallable,
1384
1385
  };
1385
1386
  addPending(pending);
1386
- withSearchRoute(session.sessionId, () => session.steer(promptText, images)).catch((error) => {
1387
+ try {
1388
+ await withSearchRoute(session.sessionId, () => session.steer(promptText, images));
1389
+ } catch (error) {
1387
1390
  dropPending(pending.id);
1388
1391
  append({ kind: "text", role: "error", text: String(error) });
1389
- });
1392
+ throw error;
1393
+ }
1390
1394
  return;
1391
1395
  }
1392
1396
 
1393
1397
  append(userLine);
1394
1398
  inFlight.current = promptText;
1395
1399
  setWorking(true);
1396
- withSearchRoute(session.sessionId, () => session.prompt(promptText, { images })).catch((error) => {
1400
+ try {
1401
+ await withSearchRoute(session.sessionId, () => session.prompt(promptText, { images }));
1402
+ } catch (error) {
1397
1403
  append({ kind: "text", role: "error", text: String(error) });
1398
1404
  setWorking(false);
1399
- });
1405
+ throw error;
1406
+ }
1400
1407
  };
1401
1408
 
1402
1409
  const submitPrompt = (value?: string, stashIndex?: number) => {
@@ -1443,7 +1450,7 @@ export function App({
1443
1450
  histCursor.current = null;
1444
1451
  draft.current = "";
1445
1452
  setSelectedStash(-1);
1446
- deliverMainPrompt(promptText, displayText, images);
1453
+ void deliverMainPrompt(promptText, displayText, images).catch(() => {});
1447
1454
  };
1448
1455
 
1449
1456
  const cachedBatchDisplay = (prompts: readonly string[]): string => [
@@ -1477,7 +1484,7 @@ export function App({
1477
1484
  setStash(next);
1478
1485
  refreshHistoryAfterStashMutation();
1479
1486
  resetAfterCacheExecution();
1480
- deliverMainPrompt(buildStashBatchPrompt(prompts), cachedBatchDisplay(prompts), [], false);
1487
+ void deliverMainPrompt(buildStashBatchPrompt(prompts), cachedBatchDisplay(prompts), [], false).catch(() => {});
1481
1488
  };
1482
1489
 
1483
1490
  useEffect(() => {
@@ -1493,24 +1500,22 @@ export function App({
1493
1500
  const detach = messageCacheController.bindExecutor(
1494
1501
  session.sessionId,
1495
1502
  async (request: MessageCacheSendRequest): Promise<MessageCacheSendResult> => {
1496
- const { entries, state } = messageCacheController.execute(request.ids);
1497
- stashRef.current = state.stash;
1498
- setStash(state.stash);
1499
- history.current = state.history;
1500
- resetAfterCacheExecution(
1501
- request.requester.kind === "subagent" ? request.requester.id : null,
1502
- );
1503
- const prompts = entries.map((entry) => entry.text);
1503
+ const prompts = request.entries.map((entry) => entry.text);
1504
1504
  if (prompts.length > 1) {
1505
- deliverMainPrompt(buildStashBatchPrompt(prompts), cachedBatchDisplay(prompts), [], false);
1505
+ await deliverMainPrompt(buildStashBatchPrompt(prompts), cachedBatchDisplay(prompts), [], false);
1506
+ resetAfterCacheExecution(
1507
+ request.requester.kind === "subagent" ? request.requester.id : null,
1508
+ );
1506
1509
  return { count: prompts.length, route: "main" };
1507
1510
  }
1508
1511
  const prompt = prompts[0]!;
1509
1512
  if (request.requester.kind === "subagent") {
1510
1513
  await subagentManager.sendUserMessage(request.requester.id, prompt, [], prompt, false);
1514
+ resetAfterCacheExecution(request.requester.id);
1511
1515
  return { count: 1, route: "subagent" };
1512
1516
  }
1513
- deliverMainPrompt(prompt, prompt, [], false);
1517
+ await deliverMainPrompt(prompt, prompt, [], false);
1518
+ resetAfterCacheExecution(null);
1514
1519
  return { count: 1, route: "main" };
1515
1520
  },
1516
1521
  );
@@ -1989,6 +1994,13 @@ export function App({
1989
1994
  return;
1990
1995
  }
1991
1996
 
1997
+ if (key.ctrl && key.name === "end") {
1998
+ key.stopPropagation();
1999
+ const transcriptScroll = transcriptScrollRef.current;
2000
+ if (transcriptScroll) transcriptScroll.scrollTop = transcriptScroll.scrollHeight;
2001
+ return;
2002
+ }
2003
+
1992
2004
  const isAgentCycle =
1993
2005
  (key.name === "tab" && key.shift) ||
1994
2006
  key.name === "backtab" ||
@@ -2290,6 +2302,8 @@ export function App({
2290
2302
  />
2291
2303
  <scrollbox
2292
2304
  key={activeAgentId ?? "main"}
2305
+ ref={transcriptScrollRef}
2306
+ id="transcript-scrollbox"
2293
2307
  style={{ flexGrow: 1, paddingLeft: 1, paddingRight: 1 }}
2294
2308
  stickyScroll
2295
2309
  stickyStart="bottom"
@@ -2430,7 +2444,7 @@ export function App({
2430
2444
  textColor={theme.fg}
2431
2445
  cursorColor={theme.accent}
2432
2446
  selectionBg={theme.selectionBg}
2433
- wrapMode="char"
2447
+ wrapMode="word"
2434
2448
  scrollMargin={1}
2435
2449
  focused={!settingsOpen && !helpOpen && !historyOpen && !agentSelectorOpen && !triggersOpen && !loginOpen && !questionnaire && !spawnPreview && !checkApproval}
2436
2450
  onContentChange={handleTextareaChange}
@@ -1,7 +1,7 @@
1
1
  import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
2
2
  import { createHash } from "node:crypto";
3
3
  import { lstat, readFile, realpath } from "node:fs/promises";
4
- import { basename, dirname, relative, resolve, sep } from "node:path";
4
+ import { basename, dirname, parse, relative, resolve, sep } from "node:path";
5
5
  import { previewApplyPatch } from "./apply-patch";
6
6
  import type { CheckedToolName } from "./check-approvals";
7
7
  import {
@@ -85,6 +85,11 @@ async function validateEditPath(
85
85
  const roots = await Promise.all([projectRoot, ...allowedPaths].map((path) => realpath(path)));
86
86
  const absolute = resolve(projectRoot, inputPath);
87
87
  const sortedRoots = roots.sort((first, second) => second.length - first.length);
88
+ if (process.platform === "win32" && windowsAbsolute(inputPath)) {
89
+ const targetRoot = parse(absolute).root.toLowerCase();
90
+ const hasAllowedVolume = sortedRoots.some((candidate) => parse(candidate).root.toLowerCase() === targetRoot);
91
+ if (!hasAllowedVolume) throw new Error(`Edit path is outside the allowed Check mode paths: ${inputPath}`);
92
+ }
88
93
  let root = sortedRoots.find((candidate) => isPathInsideOrSame(candidate, absolute));
89
94
  if (!root) {
90
95
  let targetIdentity: string;
@@ -66,6 +66,7 @@ export const HELP_GROUPS: HelpGroup[] = [
66
66
  controls: [
67
67
  ["Ctrl+P", "Open Settings"],
68
68
  ["Ctrl+T", "Open external triggers"],
69
+ ["Ctrl+End", "Scroll transcript to the end"],
69
70
  ["/ in Settings", "Focus settings search"],
70
71
  ["Esc", "Close; twice to cancel work"],
71
72
  ["Ctrl+C", "Clear; twice quits"],
@@ -154,8 +155,12 @@ export function helpPageSize(terminalHeight: number): number {
154
155
  export function maxHelpScrollOffset(terminalHeight: number): number {
155
156
  const lines = helpLines(terminalHeight);
156
157
  const raw = Math.max(0, lines.length - helpPageSize(terminalHeight));
158
+ const headingBeforeFirstControl =
159
+ raw > 0 && lines[raw]?.kind === "control" && lines[raw - 1]?.kind === "heading"
160
+ ? raw - 1
161
+ : raw;
157
162
  const lastHeading = lines.findLastIndex((line) => line.kind === "heading");
158
- return lastHeading < 0 ? raw : Math.min(raw, lastHeading);
163
+ return lastHeading < 0 ? headingBeforeFirstControl : Math.min(headingBeforeFirstControl, lastHeading);
159
164
  }
160
165
 
161
166
  function HelpLineRow({ line, theme }: { line: HelpLine; theme: Theme }) {
@@ -18,6 +18,7 @@ export type MessageCacheRequester =
18
18
  export type MessageCacheSendRequest = {
19
19
  requester: MessageCacheRequester;
20
20
  ids: string[];
21
+ entries: StashedPrompt[];
21
22
  };
22
23
 
23
24
  export type MessageCacheSendResult = {
@@ -83,6 +84,8 @@ export class MessageCacheController {
83
84
  private executor?: MessageCacheExecutor;
84
85
  private readonly listeners = new Set<() => void>();
85
86
  private readonly active = new Set<string>();
87
+ private readonly reservations = new Map<string, symbol>();
88
+ private readonly operations = new Map<symbol, Set<string>>();
86
89
  private mainSessionId?: string;
87
90
 
88
91
  constructor(
@@ -107,12 +110,28 @@ export class MessageCacheController {
107
110
  if (this.mainSessionId === sessionId) {
108
111
  this.mainSessionId = undefined;
109
112
  this.active.clear();
113
+ this.reservations.clear();
114
+ this.operations.clear();
110
115
  }
111
116
  };
112
117
  }
113
118
 
114
119
  releaseRequester(requester: Pick<MessageCacheRequester, "kind" | "id">): void {
115
- this.active.delete(`${requester.kind}:${requester.id}`);
120
+ const key = `${requester.kind}:${requester.id}`;
121
+ this.active.delete(key);
122
+ for (const [token, keys] of this.operations) {
123
+ keys.delete(key);
124
+ if (keys.size === 0) this.releaseOperation(token);
125
+ }
126
+ }
127
+
128
+ private releaseOperation(token: symbol): void {
129
+ const keys = this.operations.get(token);
130
+ if (keys) for (const key of keys) this.active.delete(key);
131
+ this.operations.delete(token);
132
+ for (const [id, owner] of this.reservations) {
133
+ if (owner === token) this.reservations.delete(id);
134
+ }
116
135
  }
117
136
 
118
137
  list(): StashedPrompt[] {
@@ -135,11 +154,12 @@ export class MessageCacheController {
135
154
  }
136
155
 
137
156
  delete(requester: MessageCacheRequester, id: string): void {
157
+ if (this.reservations.has(id)) throw new Error("The cache entry is reserved by an active send");
138
158
  this.store.removeStashById(this.workspaceCwd, id, requester.id);
139
159
  this.emit();
140
160
  }
141
161
 
142
- execute(ids: readonly string[]): { entries: StashedPrompt[]; state: PromptCacheState } {
162
+ private execute(ids: readonly string[]): { entries: StashedPrompt[]; state: PromptCacheState } {
143
163
  const result = this.store.executeStashByIds(this.workspaceCwd, ids);
144
164
  this.emit();
145
165
  return result;
@@ -150,21 +170,29 @@ export class MessageCacheController {
150
170
  if (requester.kind === "main" && requester.id !== this.mainSessionId) {
151
171
  throw new Error("The requesting main session is no longer active");
152
172
  }
153
- this.read(ids);
173
+ const entries = this.read(ids);
154
174
  const requesterKey = `${requester.kind}:${requester.id}`;
155
175
  const targetKey = ids.length > 1 || requester.kind === "main"
156
176
  ? `main:${this.mainSessionId}`
157
177
  : requesterKey;
158
- if (this.active.has(requesterKey) || this.active.has(targetKey)) {
178
+ if (
179
+ this.active.has(requesterKey)
180
+ || this.active.has(targetKey)
181
+ || ids.some((id) => this.reservations.has(id))
182
+ ) {
159
183
  throw new Error("A message-cache send is already active for this requester or target");
160
184
  }
161
- this.active.add(requesterKey);
162
- this.active.add(targetKey);
185
+ const keys = new Set([requesterKey, targetKey]);
186
+ const token = Symbol("message-cache-send");
187
+ for (const key of keys) this.active.add(key);
188
+ for (const id of ids) this.reservations.set(id, token);
189
+ this.operations.set(token, keys);
163
190
  try {
164
- return await this.executor({ requester, ids: [...ids] });
191
+ const result = await this.executor({ requester, ids: [...ids], entries: [...entries] });
192
+ this.execute(ids);
193
+ return result;
165
194
  } catch (error) {
166
- this.active.delete(requesterKey);
167
- this.active.delete(targetKey);
195
+ this.releaseOperation(token);
168
196
  throw error;
169
197
  }
170
198
  }
@@ -226,10 +254,13 @@ export class MessageCacheController {
226
254
  pi.registerTool({
227
255
  name: "message_cache_send",
228
256
  label: "Message Cache Send",
229
- description: "Send cached messages by stable ID through PUM's authoritative user execution path. Order and duplicates are preserved.",
257
+ description: "Execute cached messages by stable ID through PUM's authoritative user execution path. This marks entries executed and produces the main-agent coordination path. Order and duplicates are preserved.",
230
258
  promptSnippet: "Send one or more cached messages through PUM coordination",
231
259
  promptGuidelines: [
232
260
  "Use stable IDs from message_cache_list or message_cache_read.",
261
+ "When the user asks to do, run, or execute open or pending cached tasks, call message_cache_send before spawning or assigning work.",
262
+ "Listing entries or reading previews is not execution and does not replace message_cache_send.",
263
+ "After the generated coordination prompt arrives, reuse agents already assigned to those tasks and never create duplicate assignments.",
233
264
  "Do not retry a send while the requester or target is still processing a prior cache send.",
234
265
  ],
235
266
  parameters: Type.Object({ ids: IdsSchema }, { additionalProperties: false }),
@@ -1,5 +1,5 @@
1
1
  import type { ChildProcess } from "node:child_process";
2
- import { delimiter, join, win32 } from "node:path";
2
+ import { join, win32 } from "node:path";
3
3
  import type {
4
4
  ContainerConfig,
5
5
  PlatformSupport,
@@ -31,16 +31,19 @@ const REQUIRED_UI_CAPABILITIES = [
31
31
 
32
32
  const loadMxcSdk: MxcSdkLoader = async () => {
33
33
  // MXC 0.7 resolves `whoami /user` through a shell during module import.
34
- // Prefer the native Windows binary so a Git/MSYS PATH does not select GNU whoami.
35
- const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
36
- const previous = process.env[pathKey];
34
+ // Bun caches the default child-process environment, so changing PATH here
35
+ // does not affect MXC after node:child_process has already been imported.
37
36
  const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
38
- if (systemRoot) process.env[pathKey] = [join(systemRoot, "System32"), previous].filter(Boolean).join(delimiter);
37
+ const system32 = join(systemRoot ?? "C:\\Windows", "System32");
38
+ const previousCwd = process.cwd();
39
+ const specifier = import.meta.resolve("@microsoft/mxc-sdk");
39
40
  try {
40
- return await import("@microsoft/mxc-sdk");
41
+ // Bun can synchronously require this ESM package. Keep the cwd change
42
+ // synchronous so no unrelated work can observe the temporary directory.
43
+ process.chdir(system32);
44
+ return require(specifier) as MxcSdk;
41
45
  } finally {
42
- if (previous === undefined) delete process.env[pathKey];
43
- else process.env[pathKey] = previous;
46
+ process.chdir(previousCwd);
44
47
  }
45
48
  };
46
49
 
@@ -9,6 +9,8 @@ export function buildStashBatchPrompt(prompts: string[]): string {
9
9
  return `Coordinate the following cached tasks with managed worktree subagents.
10
10
 
11
11
  Rules:
12
+ - This prompt is the authoritative execution path created by message_cache_send or the cache selection UI.
13
+ - Reuse agents already assigned to these tasks. Never create duplicate assignments.
12
14
  - Count only starting and running subagents as active. Use the configured capacity from the system prompt.
13
15
  - Use spawn_subagent for implementation work while capacity is available.
14
16
  - At capacity, queue related work to an appropriate running subagent with message_agent.
@@ -18,7 +20,7 @@ Rules:
18
20
  - Run independent task groups in parallel.
19
21
  - Keep each subagent task complete and self-contained.
20
22
  - Track every unfinished task group through completion notifications.
21
- - Merge each successful subagent with the worktree tool as soon as it settles.
23
+ - Merge each successful subagent only after a completion notice arrives and authoritative status is \`completed\`; idle settlement is not completion.
22
24
  - Before merging a managed parent, recursively merge or resolve every retained descendant.
23
25
  - Close the deepest descendants first. Every retained status blocks the parent, including completed and failed descendants.
24
26
  - Wait to merge only for a concrete dependency, known conflict risk, or required integration order. State that reason explicitly.
@@ -243,6 +243,7 @@ export class SubagentManager {
243
243
  private readonly messageTimes = new Map<string, number[]>();
244
244
  private readonly settlements = new Map<string, SubagentSettlement>();
245
245
  private readonly acceptedSettlementMessageIds = new Set<string>();
246
+ private readonly settlementDeliveriesInFlight = new Set<string>();
246
247
 
247
248
  constructor(options: ManagerOptions) {
248
249
  this.modelRuntime = options.modelRuntime;
@@ -303,6 +304,7 @@ export class SubagentManager {
303
304
  this.messageTimes.clear();
304
305
  this.settlements.clear();
305
306
  this.acceptedSettlementMessageIds.clear();
307
+ this.settlementDeliveriesInFlight.clear();
306
308
  this.mainApi = pi;
307
309
  this.mainSessionManager = sessionManager;
308
310
  this.mainCwd = cwd;
@@ -430,6 +432,7 @@ export class SubagentManager {
430
432
  });
431
433
  this.mainApi = undefined;
432
434
  this.mainSessionManager = undefined;
435
+ this.settlementDeliveriesInFlight.clear();
433
436
  this.records.clear();
434
437
  this.emit();
435
438
  }
@@ -1343,6 +1346,22 @@ export class SubagentManager {
1343
1346
  );
1344
1347
  }
1345
1348
 
1349
+ private assertManagedMergeReady(record: RuntimeRecord): void {
1350
+ if (record.snapshot.status !== "completed") {
1351
+ throw new Error(
1352
+ `Cannot merge ${record.snapshot.name} while its authoritative status is ${record.snapshot.status}. ` +
1353
+ "A managed merge requires status completed after its completion notice arrives. Idle settlement is not completion.",
1354
+ );
1355
+ }
1356
+ const completion = this.settlements.get(this.settlementId(record, "completed"));
1357
+ if (completion?.acknowledgedAt === undefined) {
1358
+ throw new Error(
1359
+ `Cannot merge ${record.snapshot.name} before its completion notice arrives. ` +
1360
+ "Authoritative status completed alone is not sufficient.",
1361
+ );
1362
+ }
1363
+ }
1364
+
1346
1365
  async sendUserMessage(
1347
1366
  id: string,
1348
1367
  text: string,
@@ -1646,6 +1665,7 @@ export class SubagentManager {
1646
1665
  }
1647
1666
 
1648
1667
  private acknowledgeSettlement(settlement: SubagentSettlement): void {
1668
+ this.settlementDeliveriesInFlight.delete(settlement.messageId);
1649
1669
  if (settlement.acknowledgedAt !== undefined) return;
1650
1670
  settlement.acknowledgedAt = Date.now();
1651
1671
  this.acceptedSettlementMessageIds.add(settlement.messageId);
@@ -1662,6 +1682,14 @@ export class SubagentManager {
1662
1682
  if (settlement) this.acknowledgeSettlement(settlement);
1663
1683
  }
1664
1684
 
1685
+ private clearSettlementDeliveriesForParent(parentAgentId: string): void {
1686
+ for (const settlement of this.settlements.values()) {
1687
+ if (settlement.parentAgentId === parentAgentId) {
1688
+ this.settlementDeliveriesInFlight.delete(settlement.messageId);
1689
+ }
1690
+ }
1691
+ }
1692
+
1665
1693
  private async deliverSettlement(settlement: SubagentSettlement): Promise<boolean> {
1666
1694
  if (settlement.acknowledgedAt !== undefined) return true;
1667
1695
  const source = this.records.get(settlement.agentId);
@@ -1673,6 +1701,7 @@ export class SubagentManager {
1673
1701
  this.acknowledgeSettlement(settlement);
1674
1702
  return true;
1675
1703
  }
1704
+ if (this.settlementDeliveriesInFlight.has(settlement.messageId)) return true;
1676
1705
  const data: AgentMessageData = {
1677
1706
  id: settlement.messageId,
1678
1707
  sender,
@@ -1681,14 +1710,17 @@ export class SubagentManager {
1681
1710
  at: settlement.createdAt,
1682
1711
  kind: settlement.status === "idle" ? "idle" : settlement.status === "completed" ? "completion" : "status",
1683
1712
  };
1713
+ this.settlementDeliveriesInFlight.add(settlement.messageId);
1684
1714
  const delivered = this.wakeMain({
1685
1715
  customType: AGENT_MESSAGE_CUSTOM_TYPE,
1686
1716
  content: settlement.content,
1687
1717
  display: true,
1688
1718
  details: data,
1689
1719
  }, settlement.content);
1690
- if (!delivered) return false;
1691
- this.acknowledgeSettlement(settlement);
1720
+ if (!delivered) {
1721
+ this.settlementDeliveriesInFlight.delete(settlement.messageId);
1722
+ return false;
1723
+ }
1692
1724
  this.emit({ type: "main-line", line: this.agentMessageLine(data) });
1693
1725
  return true;
1694
1726
  }
@@ -1702,6 +1734,7 @@ export class SubagentManager {
1702
1734
  this.acknowledgeSettlement(settlement);
1703
1735
  return true;
1704
1736
  }
1737
+ if (this.settlementDeliveriesInFlight.has(settlement.messageId)) return true;
1705
1738
  const data: AgentMessageData = {
1706
1739
  id: settlement.messageId,
1707
1740
  sender,
@@ -1714,6 +1747,7 @@ export class SubagentManager {
1714
1747
  if (!parent.snapshot.transcript.pending.some((item) => item.id === pending.id)) {
1715
1748
  this.addPending(parent, pending);
1716
1749
  }
1750
+ this.settlementDeliveriesInFlight.add(settlement.messageId);
1717
1751
  try {
1718
1752
  withSearchRoute(parent.session.sessionId, () => {
1719
1753
  parent.api!.sendMessage(
@@ -1730,11 +1764,11 @@ export class SubagentManager {
1730
1764
  );
1731
1765
  });
1732
1766
  } catch {
1767
+ this.settlementDeliveriesInFlight.delete(settlement.messageId);
1733
1768
  this.dropPending(parent, pending.id);
1734
1769
  return false;
1735
1770
  }
1736
1771
  this.updateStatus(parent, "running");
1737
- this.acknowledgeSettlement(settlement);
1738
1772
  return true;
1739
1773
  } catch {
1740
1774
  return false;
@@ -1761,6 +1795,7 @@ export class SubagentManager {
1761
1795
  if (!record) return;
1762
1796
  const sessionId = record.session?.sessionId;
1763
1797
  if (record.dispose) await record.dispose();
1798
+ this.clearSettlementDeliveriesForParent(record.snapshot.id);
1764
1799
  if (sessionId) {
1765
1800
  await this.triggerManager?.invalidateAgent(sessionId, record.snapshot.id);
1766
1801
  this.emit({
@@ -1825,10 +1860,10 @@ export class SubagentManager {
1825
1860
  if (action === "merge") {
1826
1861
  return this.withWorktreeLock(async () => {
1827
1862
  const managedAgent = this.findRecord(target);
1828
- if (managedAgent && ["starting", "running"].includes(managedAgent.snapshot.status)) {
1829
- throw new Error(`Stop ${managedAgent.snapshot.name} before ${action}`);
1863
+ if (managedAgent) {
1864
+ this.assertNoRetainedDescendants(managedAgent, "merge");
1865
+ this.assertManagedMergeReady(managedAgent);
1830
1866
  }
1831
- if (managedAgent) this.assertNoRetainedDescendants(managedAgent, "merge");
1832
1867
  const record = managedAgent?.snapshot.worktree
1833
1868
  ?? (await listWorktrees(cwd)).find((item) => item.name === target || item.branch === target);
1834
1869
  if (!record) throw new Error(`Unknown worktree: ${target}`);
package/src/theme.ts CHANGED
@@ -23,7 +23,7 @@ export type Theme = {
23
23
  success: string;
24
24
  error: string;
25
25
  warn: string;
26
- /** Foreground and background for tool calls blocked before execution. */
26
+ /** Orange foreground and existing background for tool calls blocked before execution. */
27
27
  rejection: string;
28
28
  rejectionBg: string;
29
29
  selectionBg: string;
@@ -59,7 +59,7 @@ const tokyonight: Theme = {
59
59
  success: "#9ece6a",
60
60
  error: "#f7768e",
61
61
  warn: "#ff9e64",
62
- rejection: "#e0af68",
62
+ rejection: "#ff9e64",
63
63
  rejectionBg: "#332b24",
64
64
  selectionBg: "#33467c",
65
65
  popupBg: "#1f2335",
@@ -91,7 +91,7 @@ const gruvbox: Theme = {
91
91
  success: "#b8bb26",
92
92
  error: "#fb4934",
93
93
  warn: "#fe8019",
94
- rejection: "#fabd2f",
94
+ rejection: "#fe8019",
95
95
  rejectionBg: "#3c3836",
96
96
  selectionBg: "#504945",
97
97
  popupBg: "#32302f",
@@ -123,7 +123,7 @@ const catppuccin: Theme = {
123
123
  success: "#a6e3a1",
124
124
  error: "#f38ba8",
125
125
  warn: "#fab387",
126
- rejection: "#f9e2af",
126
+ rejection: "#fab387",
127
127
  rejectionBg: "#3b342f",
128
128
  selectionBg: "#45475a",
129
129
  popupBg: "#181825",
@@ -155,7 +155,7 @@ const nord: Theme = {
155
155
  success: "#a3be8c",
156
156
  error: "#bf616a",
157
157
  warn: "#d08770",
158
- rejection: "#ebcb8b",
158
+ rejection: "#d08770",
159
159
  rejectionBg: "#3b3b3b",
160
160
  selectionBg: "#4c566a",
161
161
  popupBg: "#3b4252",
@@ -187,7 +187,7 @@ const dracula: Theme = {
187
187
  success: "#50fa7b",
188
188
  error: "#ff5555",
189
189
  warn: "#ffb86c",
190
- rejection: "#f1fa8c",
190
+ rejection: "#ffb86c",
191
191
  rejectionBg: "#3d3f36",
192
192
  selectionBg: "#44475a",
193
193
  popupBg: "#21222c",
@@ -251,7 +251,7 @@ const solarized: Theme = {
251
251
  success: "#859900",
252
252
  error: "#dc322f",
253
253
  warn: "#cb4b16",
254
- rejection: "#b58900",
254
+ rejection: "#cb4b16",
255
255
  rejectionBg: "#343b35",
256
256
  selectionBg: "#0b4b59",
257
257
  popupBg: "#073642",
@@ -283,7 +283,7 @@ const kanagawa: Theme = {
283
283
  success: "#98bb6c",
284
284
  error: "#e46876",
285
285
  warn: "#ffa066",
286
- rejection: "#e6c384",
286
+ rejection: "#ffa066",
287
287
  rejectionBg: "#39352d",
288
288
  selectionBg: "#2d4f67",
289
289
  popupBg: "#16161d",
@@ -315,7 +315,7 @@ const githubLight: Theme = {
315
315
  success: "#1a7f37",
316
316
  error: "#cf222e",
317
317
  warn: "#bc4c00",
318
- rejection: "#9a6700",
318
+ rejection: "#bc4c00",
319
319
  rejectionBg: "#fff1c2",
320
320
  selectionBg: "#b6d7ff",
321
321
  popupBg: "#f6f8fa",
@@ -1,4 +1,4 @@
1
- import { StyledText, fg, type MarkdownRenderable, type SyntaxStyle } from "@opentui/core";
1
+ import { StyledText, bold, fg, type MarkdownRenderable, type SyntaxStyle } from "@opentui/core";
2
2
  import type { MarkdownProps } from "@opentui/react";
3
3
  import {
4
4
  useBlinkingText,
@@ -353,6 +353,18 @@ export function toolStateGlyph(state: ToolCall["state"]): string {
353
353
  return "✗";
354
354
  }
355
355
 
356
+ const CHECK_MODE_HARD_BLOCK_PREFIX = "Check mode hard block:";
357
+
358
+ function rejectedDetail(theme: Theme, detail: string): StyledText {
359
+ if (!detail.startsWith(CHECK_MODE_HARD_BLOCK_PREFIX)) {
360
+ return new StyledText([fg(theme.rejection)(detail)]);
361
+ }
362
+ return new StyledText([
363
+ bold(fg(theme.rejection)(CHECK_MODE_HARD_BLOCK_PREFIX)),
364
+ fg(theme.rejection)(detail.slice(CHECK_MODE_HARD_BLOCK_PREFIX.length)),
365
+ ]);
366
+ }
367
+
356
368
  export function ToolLine({
357
369
  theme,
358
370
  call,
@@ -420,8 +432,7 @@ export function ToolLine({
420
432
  {rejected && call.detail ? (
421
433
  <Row glyph={GUTTER} glyphColor={theme.rejection} background={theme.rejectionBg}>
422
434
  <text
423
- content={call.detail}
424
- fg={theme.rejection}
435
+ content={rejectedDetail(theme, call.detail)}
425
436
  selectable
426
437
  wrapMode="word"
427
438
  style={{ flexGrow: 1, flexShrink: 1, minWidth: 0, width: "100%" }}