pum-agent 0.2.0-beta.2 → 0.2.0-beta.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.0-beta.2",
3
+ "version": "0.2.0-beta.4",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/animation.tsx CHANGED
@@ -313,9 +313,9 @@ export function useBlinkingText(opts: {
313
313
  return ref;
314
314
  }
315
315
 
316
- /** Keep both caret frames equivalent to the Markdown parser. */
317
- export function markdownCaretContent(text: string, visible: boolean): string {
318
- return text + (visible ? CARET : CARET_PLACEHOLDER);
316
+ /** Append a stable caret without scheduling Markdown-only source changes. */
317
+ export function markdownCaretContent(text: string): string {
318
+ return text + CARET;
319
319
  }
320
320
 
321
321
  export type MarkdownCaretBinding = {
@@ -323,52 +323,19 @@ export type MarkdownCaretBinding = {
323
323
  content: string;
324
324
  };
325
325
 
326
- /** Keep a blinking caret at the end of incrementally rendered Markdown. */
326
+ /** Keep a stable caret at the end of incrementally rendered Markdown. */
327
327
  export function useMarkdownCaret(
328
328
  text: string,
329
329
  active: boolean,
330
330
  ): MarkdownCaretBinding {
331
331
  const ref = useRef<MarkdownRenderable>(null);
332
- const { subscribe, enabled } = useClock();
333
- const latest = useRef(text);
334
- const caretVisible = useRef(true);
335
- latest.current = text;
336
-
337
- const paint = useCallback(() => {
338
- if (ref.current) {
339
- ref.current.content = markdownCaretContent(latest.current, caretVisible.current);
340
- }
341
- }, []);
342
-
343
- useEffect(() => {
344
- if (!active) return;
345
- if (!enabled) {
346
- caretVisible.current = true;
347
- paint();
348
- return;
349
- }
350
-
351
- let lastVisible = caretVisible.current;
352
- const stop = subscribe((elapsedMs) => {
353
- const nextVisible = elapsedMs % CARET_PERIOD_MS < CARET_PERIOD_MS * 0.6;
354
- if (nextVisible === lastVisible) return;
355
- lastVisible = nextVisible;
356
- caretVisible.current = nextVisible;
357
- paint();
358
- });
359
- paint();
360
- return stop;
361
- }, [active, enabled, paint, subscribe]);
362
-
363
- useEffect(() => {
364
- if (active) paint();
365
- }, [text, active, paint]);
366
332
 
367
- // React applies this value in the same commit as each transcript delta.
368
- // The direct frame writes only change the width-stable caret glyph.
333
+ // A blink would assign MarkdownRenderable.content twice per period. Each
334
+ // assignment starts another incremental parse and asynchronous highlight.
335
+ // Slow terminals can draw the source markers between those highlight passes.
369
336
  return {
370
337
  ref,
371
- content: active ? markdownCaretContent(text, caretVisible.current) : text,
338
+ content: active ? markdownCaretContent(text) : text,
372
339
  };
373
340
  }
374
341
 
package/src/app.tsx CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from "./settings-popup";
20
20
  import {
21
21
  CHECK_MODE_PROFILES,
22
+ checkPathsForProject,
22
23
  MAX_ACTIVE_SUBAGENTS,
23
24
  MIN_ACTIVE_SUBAGENTS,
24
25
  saveSettings,
@@ -72,6 +73,7 @@ import {
72
73
  setExplanationStrength,
73
74
  } from "./explanation-strength";
74
75
  import { setCheckModeConfig } from "./check-mode";
76
+ import { applyCheckPathCommand, parseCheckPathCommand } from "./check-paths";
75
77
  import {
76
78
  captureClipboardImage,
77
79
  cleanupPendingImages,
@@ -908,13 +910,13 @@ export function App({
908
910
  break;
909
911
  case "tool_execution_end":
910
912
  patchTool(event.toolCallId, {
911
- state: isRejectedToolResult(event.result)
913
+ state: isRejectedToolResult(event.result, event.toolCallId)
912
914
  ? "rejected"
913
915
  : event.isError
914
916
  ? "error"
915
917
  : "ok",
916
- detail: isRejectedToolResult(event.result)
917
- ? rejectedToolReason(event.result)
918
+ detail: isRejectedToolResult(event.result, event.toolCallId)
919
+ ? rejectedToolReason(event.result, event.toolCallId)
918
920
  : event.toolName === "edit" || event.toolName === "apply_patch"
919
921
  ? editCounts(event.result)
920
922
  : event.toolName === "questionnaire"
@@ -1027,8 +1029,12 @@ export function App({
1027
1029
  if (patch.explanationStrength !== undefined) {
1028
1030
  setExplanationStrength(patch.explanationStrength);
1029
1031
  }
1030
- if (patch.checkMode !== undefined || patch.checkModel !== undefined) {
1031
- setCheckModeConfig({ profile: next.checkMode, model: next.checkModel });
1032
+ if (patch.checkMode !== undefined || patch.checkModel !== undefined || patch.checkPaths !== undefined) {
1033
+ setCheckModeConfig({
1034
+ profile: next.checkMode,
1035
+ model: next.checkModel,
1036
+ additionalPaths: checkPathsForProject(next, cwd),
1037
+ });
1032
1038
  }
1033
1039
  if (patch.showThinking !== undefined) showThinkingRef.current = patch.showThinking;
1034
1040
  if (patch.maxActiveSubagents !== undefined) {
@@ -1248,9 +1254,10 @@ export function App({
1248
1254
  const clear = /^\/(?:clear|new)$/.test(trimmed);
1249
1255
  const historyCommand = trimmed === "/history";
1250
1256
  const loginCommand = trimmed === "/login";
1257
+ const checkPathCommand = /^\/check-path(?:\s|$)/.test(trimmed);
1251
1258
  const triggersCommand = trimmed === "/triggers";
1252
1259
  const worktreeCommand = /^\/worktree(?:\s+([a-zA-Z0-9_-]+))?$/.exec(trimmed);
1253
- if (!compress && !clear && !historyCommand && !loginCommand && !triggersCommand && !worktreeCommand) return false;
1260
+ if (!compress && !clear && !historyCommand && !loginCommand && !checkPathCommand && !triggersCommand && !worktreeCommand) return false;
1254
1261
  editingStashIndex.current = null;
1255
1262
 
1256
1263
  if (historyCommand) {
@@ -1279,7 +1286,20 @@ export function App({
1279
1286
  }
1280
1287
 
1281
1288
  setWorking(true);
1282
- if (worktreeCommand) {
1289
+ if (checkPathCommand) {
1290
+ Promise.resolve()
1291
+ .then(() => parseCheckPathCommand(trimmed))
1292
+ .then((command) => {
1293
+ if (!command) throw new Error("invalid /check-path command");
1294
+ return applyCheckPathCommand(settings, cwd, command);
1295
+ })
1296
+ .then((result) => {
1297
+ update({ checkPaths: result.settings.checkPaths });
1298
+ append({ kind: "text", role: "system", text: result.message });
1299
+ })
1300
+ .catch((err) => append({ kind: "text", role: "error", text: String(err) }))
1301
+ .finally(() => setWorking(false));
1302
+ } else if (worktreeCommand) {
1283
1303
  runWorktreeCommand({
1284
1304
  name: worktreeCommand[1],
1285
1305
  manager: subagentManager,
@@ -1514,6 +1534,11 @@ export function App({
1514
1534
  settingsPageRef.current = "checkModels";
1515
1535
  setPage("checkModels");
1516
1536
  } },
1537
+ checkPaths: { enter: () => append({
1538
+ kind: "text",
1539
+ role: "system",
1540
+ text: "use /check-path [list|add <directory>|remove <directory>|clear]",
1541
+ }) },
1517
1542
  clearCheckApprovals: { enter: () => {
1518
1543
  const removed = checkApprovalStore?.clearProject(cwd) ?? 0;
1519
1544
  append({ kind: "text", role: "system", text: removed > 0
@@ -1551,6 +1576,7 @@ export function App({
1551
1576
  explanationStrength: `‹ ${settings.explanationStrength} ›`,
1552
1577
  checkMode: `‹ ${settings.checkMode} ›`,
1553
1578
  checkModel: `${settings.checkModel} ›`,
1579
+ checkPaths: `${checkPathsForProject(settings, cwd).length} additional · /check-path ›`,
1554
1580
  clearCheckApprovals: "clear ›",
1555
1581
  thinkingLevel: `‹ ${thinkingLevel} ›`,
1556
1582
  showThinking: `‹ ${settings.showThinking ? "on" : "off"} ›`,
package/src/check-mode.ts CHANGED
@@ -30,9 +30,32 @@ export const CHECK_MODE_CACHE_LIMIT = 256;
30
30
  export type CheckModeConfig = {
31
31
  profile: CheckModeProfile;
32
32
  model: string;
33
+ additionalPaths?: readonly string[];
33
34
  };
34
35
 
35
36
  const REJECTED_TOOL_DETAIL = "pumRejected";
37
+ const pendingRejectedTools = new Map<string, string>();
38
+
39
+ function toolResultText(result: unknown): string | undefined {
40
+ const content = (result as { content?: unknown } | null)?.content;
41
+ if (!Array.isArray(content)) return undefined;
42
+ const text = content
43
+ .filter((block): block is { type: "text"; text: string } => (
44
+ Boolean(block) && typeof block === "object"
45
+ && (block as { type?: unknown }).type === "text"
46
+ && typeof (block as { text?: unknown }).text === "string"
47
+ ))
48
+ .map((block) => block.text)
49
+ .join("")
50
+ .trim();
51
+ return text || undefined;
52
+ }
53
+
54
+ function pendingRejectedToolReason(toolCallId: string | undefined, result: unknown): string | undefined {
55
+ if (!toolCallId) return undefined;
56
+ const reason = pendingRejectedTools.get(toolCallId);
57
+ return reason && toolResultText(result) === reason ? reason : undefined;
58
+ }
36
59
 
37
60
  export function rejectedToolDetails(details: unknown, reason?: string): unknown {
38
61
  const marker = { [REJECTED_TOOL_DETAIL]: true, ...(reason ? { pumRejectionReason: reason } : {}) };
@@ -40,29 +63,32 @@ export function rejectedToolDetails(details: unknown, reason?: string): unknown
40
63
  return marker;
41
64
  }
42
65
 
43
- export function isRejectedToolResult(result: unknown): boolean {
66
+ export function isRejectedToolResult(result: unknown, toolCallId?: string): boolean {
44
67
  const details = (result as { details?: unknown } | null)?.details;
45
- return Boolean(details && typeof details === "object"
46
- && (details as Record<string, unknown>)[REJECTED_TOOL_DETAIL] === true);
68
+ if (details && typeof details === "object"
69
+ && (details as Record<string, unknown>)[REJECTED_TOOL_DETAIL] === true) return true;
70
+ return pendingRejectedToolReason(toolCallId, result) !== undefined;
47
71
  }
48
72
 
49
- export function rejectedToolReason(result: unknown): string | undefined {
73
+ export function rejectedToolReason(result: unknown, toolCallId?: string): string | undefined {
50
74
  const details = (result as { details?: unknown } | null)?.details;
51
- if (!details || typeof details !== "object") return undefined;
52
- const reason = (details as Record<string, unknown>).pumRejectionReason;
53
- return typeof reason === "string" && reason.trim() ? reason : undefined;
75
+ if (details && typeof details === "object") {
76
+ const reason = (details as Record<string, unknown>).pumRejectionReason;
77
+ if (typeof reason === "string" && reason.trim()) return reason;
78
+ }
79
+ return pendingRejectedToolReason(toolCallId, result);
54
80
  }
55
81
 
56
- let current: CheckModeConfig = { profile: "off", model: DEFAULT_CHECK_MODEL };
82
+ let current: Required<CheckModeConfig> = { profile: "off", model: DEFAULT_CHECK_MODEL, additionalPaths: [] };
57
83
 
58
84
  export function setCheckModeConfig(config: CheckModeConfig | { enabled: boolean; model: string }): void {
59
85
  current = "profile" in config
60
- ? { ...config }
61
- : { profile: config.enabled ? "strict" : "off", model: config.model };
86
+ ? { ...config, additionalPaths: [...(config.additionalPaths ?? [])] }
87
+ : { profile: config.enabled ? "strict" : "off", model: config.model, additionalPaths: [] };
62
88
  }
63
89
 
64
90
  export function getCheckModeConfig(): CheckModeConfig {
65
- return { ...current };
91
+ return { ...current, additionalPaths: [...current.additionalPaths] };
66
92
  }
67
93
 
68
94
  function modelRef(model: Model<any>): string {
@@ -216,6 +242,7 @@ export async function prepareCheck(
216
242
  cwd: string,
217
243
  profile: Exclude<CheckModeProfile, "off">,
218
244
  context: UntrustedContext = {},
245
+ additionalPaths: readonly string[] = [],
219
246
  ): Promise<{ prepared?: PreparedCheck; block?: string; balancedAllow?: string }> {
220
247
  let canonicalInput: string;
221
248
  try {
@@ -234,12 +261,13 @@ export async function prepareCheck(
234
261
  args: input.args,
235
262
  cwd: input.cwd,
236
263
  projectCwd: cwd,
264
+ allowedPaths: additionalPaths,
237
265
  profile,
238
266
  });
239
267
  } else {
240
268
  const command = input && typeof input === "object" ? (input as { command?: unknown }).command : undefined;
241
269
  if (typeof command !== "string") return { block: "Bash safety check requires a complete command string or process proposal" };
242
- policy = analyzeCheckPolicy({ command, cwd, profile });
270
+ policy = analyzeCheckPolicy({ command, cwd, profile, allowedPaths: additionalPaths });
243
271
  }
244
272
  bash = policy.analysis;
245
273
  if (!bash.complete || bash.truncated || !bash.syntaxBalanced) {
@@ -248,7 +276,7 @@ export async function prepareCheck(
248
276
  if (policy.decision === "block") return { block: `Check mode hard block: ${policy.reason}` };
249
277
  } else {
250
278
  try {
251
- mutation = await previewMutation(toolName, cwd, input);
279
+ mutation = await previewMutation(toolName, cwd, input, additionalPaths);
252
280
  } catch (error) {
253
281
  return { block: `Check mode blocked invalid or stale ${toolName} input: ${error instanceof Error ? error.message : String(error)}` };
254
282
  }
@@ -281,6 +309,7 @@ export async function prepareCheck(
281
309
  version: 2,
282
310
  complete: true,
283
311
  cwd,
312
+ allowedDirectoryRoots: [cwd, ...additionalPaths],
284
313
  tool: toolName,
285
314
  input,
286
315
  deterministicPolicy: policy ? {
@@ -330,6 +359,7 @@ export async function prepareCheck(
330
359
  version: 2,
331
360
  complete: true,
332
361
  cwd,
362
+ allowedDirectoryRoots: [cwd, ...additionalPaths],
333
363
  tool: toolName,
334
364
  reviewCoverage: {
335
365
  mode: "complete-metadata-digest",
@@ -526,8 +556,10 @@ async function withHardTimeout<T>(operation: (signal: AbortSignal) => Promise<T>
526
556
  }
527
557
  }
528
558
 
529
- function normalizeConfig(config: ToolCheck["config"]): CheckModeConfig {
530
- return "profile" in config ? config : { profile: config.enabled ? "strict" : "off", model: config.model };
559
+ function normalizeConfig(config: ToolCheck["config"]): Required<CheckModeConfig> {
560
+ return "profile" in config
561
+ ? { ...config, additionalPaths: [...(config.additionalPaths ?? [])] }
562
+ : { profile: config.enabled ? "strict" : "off", model: config.model, additionalPaths: [] };
531
563
  }
532
564
 
533
565
  const NPM_BOOLEAN_RELEASE_FLAGS = new Set([
@@ -619,7 +651,14 @@ export async function evaluateToolCall(runtime: CheckerRuntime, cache: BashSafet
619
651
  if (config.profile === "off") return { decision: "allow", reason: "Check mode is off", category: "off" };
620
652
  if (call.signal?.aborted) return { decision: "block", reason: "Safety check aborted", category: "abort" };
621
653
  const profile = config.profile as Exclude<CheckModeProfile, "off">;
622
- const preparedResult = await prepareCheck(call.toolName, call.input, call.cwd, profile, call.context);
654
+ const preparedResult = await prepareCheck(
655
+ call.toolName,
656
+ call.input,
657
+ call.cwd,
658
+ profile,
659
+ call.context,
660
+ config.additionalPaths,
661
+ );
623
662
  if (!preparedResult.prepared) return { decision: "block", reason: preparedResult.block ?? "Safety preparation failed", category: "hard-block" };
624
663
  const prepared = preparedResult.prepared;
625
664
  if (profile === "ask" && call.isApproved?.(prepared)) {
@@ -936,15 +975,32 @@ export function createCheckModeExtension(
936
975
 
937
976
  const visibleReason = redactApprovalPreview(evaluation.reason);
938
977
  rejected.set(event.toolCallId, visibleReason);
978
+ pendingRejectedTools.set(event.toolCallId, visibleReason);
939
979
  return { block: true, reason: visibleReason };
940
980
  });
941
981
 
942
982
  pi.on("tool_result", (event) => {
943
983
  const reason = rejected.get(event.toolCallId);
944
984
  if (!reason) return;
945
- rejected.delete(event.toolCallId);
946
985
  return { details: rejectedToolDetails(event.details, reason) };
947
986
  });
987
+
988
+ // pi 0.84 does not call tool_result for calls blocked by beforeToolCall.
989
+ // Mark the finalized message so session replay keeps the rejected state.
990
+ pi.on("message_end", (event) => {
991
+ const message = event.message as any;
992
+ if (message?.role !== "toolResult" || typeof message.toolCallId !== "string") return;
993
+ const reason = rejected.get(message.toolCallId);
994
+ if (!reason) return;
995
+ rejected.delete(message.toolCallId);
996
+ pendingRejectedTools.delete(message.toolCallId);
997
+ return {
998
+ message: {
999
+ ...message,
1000
+ details: rejectedToolDetails(message.details, reason),
1001
+ },
1002
+ };
1003
+ });
948
1004
  },
949
1005
  };
950
1006
  }
@@ -71,14 +71,21 @@ function insideRoot(root: string, path: string): boolean {
71
71
  return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
72
72
  }
73
73
 
74
- async function validateEditPath(cwd: string, inputPath: string): Promise<{ root: string; absolute: string; display: string; mode: number }> {
74
+ async function validateEditPath(
75
+ cwd: string,
76
+ inputPath: string,
77
+ allowedPaths: readonly string[],
78
+ ): Promise<{ root: string; absolute: string; display: string; mode: number }> {
75
79
  if (!inputPath || inputPath.includes("\0")) throw new Error("Edit path is invalid");
76
80
  if (windowsAbsolute(inputPath) && process.platform !== "win32") {
77
81
  throw new Error(`Edit path is outside the project: ${inputPath}`);
78
82
  }
79
- const root = await realpath(cwd);
80
- const absolute = resolve(root, inputPath);
81
- if (!insideRoot(root, absolute)) throw new Error(`Edit path is outside the project: ${inputPath}`);
83
+ const projectRoot = await realpath(cwd);
84
+ const roots = await Promise.all([projectRoot, ...allowedPaths].map((path) => realpath(path)));
85
+ const absolute = resolve(projectRoot, inputPath);
86
+ const root = roots.sort((first, second) => second.length - first.length)
87
+ .find((candidate) => candidate === absolute || insideRoot(candidate, absolute));
88
+ if (!root) throw new Error(`Edit path is outside the allowed Check mode paths: ${inputPath}`);
82
89
 
83
90
  let component = root;
84
91
  const rel = relative(root, absolute);
@@ -91,7 +98,10 @@ async function validateEditPath(cwd: string, inputPath: string): Promise<{ root:
91
98
  if (!insideRoot(root, canonical)) throw new Error(`Edit path resolves outside the project: ${inputPath}`);
92
99
  const metadata = await lstat(absolute);
93
100
  if (!metadata.isFile()) throw new Error(`Edit path is not a file: ${inputPath}`);
94
- return { root, absolute, display: relative(root, absolute).split(sep).join("/"), mode: metadata.mode };
101
+ const display = projectRoot === absolute || insideRoot(projectRoot, absolute)
102
+ ? relative(projectRoot, absolute).split(sep).join("/")
103
+ : absolute;
104
+ return { root, absolute, display, mode: metadata.mode };
95
105
  }
96
106
 
97
107
  function occurrences(content: string, needle: string): number[] {
@@ -144,7 +154,7 @@ function completeContentMetadata(patch: string): Pick<MutationPreview, "contentC
144
154
  };
145
155
  }
146
156
 
147
- async function previewEdit(cwd: string, input: unknown): Promise<MutationPreview> {
157
+ async function previewEdit(cwd: string, input: unknown, allowedPaths: readonly string[]): Promise<MutationPreview> {
148
158
  if (!input || typeof input !== "object") throw new Error("Edit input is invalid");
149
159
  const value = input as { path?: unknown; edits?: unknown; oldText?: unknown; newText?: unknown };
150
160
  if (typeof value.path !== "string") throw new Error("Edit path is invalid");
@@ -162,7 +172,7 @@ async function previewEdit(cwd: string, input: unknown): Promise<MutationPreview
162
172
  && typeof (edit as any).newText === "string",
163
173
  )) throw new Error("Edit replacements are invalid");
164
174
 
165
- const validated = await validateEditPath(cwd, value.path);
175
+ const validated = await validateEditPath(cwd, value.path, allowedPaths);
166
176
  const buffer = await readFile(validated.absolute);
167
177
  let content: string;
168
178
  try {
@@ -204,8 +214,9 @@ export async function previewMutation(
204
214
  toolName: CheckedToolName,
205
215
  cwd: string,
206
216
  input: unknown,
217
+ allowedPaths: readonly string[] = [],
207
218
  ): Promise<MutationPreview | undefined> {
208
- if (toolName === "edit") return previewEdit(cwd, input);
219
+ if (toolName === "edit") return previewEdit(cwd, input, allowedPaths);
209
220
  if (toolName !== "apply_patch") return undefined;
210
221
  if (!input || typeof input !== "object" || typeof (input as { patch?: unknown }).patch !== "string") {
211
222
  throw new Error("Apply patch input is invalid");
@@ -0,0 +1,128 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
+ import { AGENT_DIR } from "./config";
5
+ import { isCredentialSensitivePath } from "./check-policy";
6
+ import {
7
+ checkPathsForProject,
8
+ MAX_CHECK_PATHS_PER_PROJECT,
9
+ type PumSettings,
10
+ withCheckPathsForProject,
11
+ } from "./settings";
12
+
13
+ export type CheckPathCommand =
14
+ | { action: "list" }
15
+ | { action: "clear" }
16
+ | { action: "add" | "remove"; path: string };
17
+
18
+ function unquote(value: string): string {
19
+ const trimmed = value.trim();
20
+ if (trimmed.length >= 2) {
21
+ const first = trimmed[0];
22
+ if ((first === "\"" || first === "'") && trimmed.at(-1) === first) {
23
+ return trimmed.slice(1, -1);
24
+ }
25
+ }
26
+ return trimmed;
27
+ }
28
+
29
+ export function parseCheckPathCommand(input: string): CheckPathCommand | undefined {
30
+ const match = /^\/check-path(?:\s+(.*))?$/s.exec(input.trim());
31
+ if (!match) return undefined;
32
+ const argumentsText = match[1]?.trim() ?? "";
33
+ if (!argumentsText || argumentsText === "list") return { action: "list" };
34
+ if (argumentsText === "clear") return { action: "clear" };
35
+ const action = /^(add|remove)\s+([\s\S]+)$/.exec(argumentsText);
36
+ if (!action) throw new Error("usage: /check-path [list|add <directory>|remove <directory>|clear]");
37
+ const path = unquote(action[2]!);
38
+ if (!path || path.includes("\0")) throw new Error("Check path is invalid");
39
+ return { action: action[1] as "add" | "remove", path };
40
+ }
41
+
42
+ function insideOrSame(parent: string, candidate: string): boolean {
43
+ const rel = relative(parent, candidate);
44
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
45
+ }
46
+
47
+ async function canonicalDirectory(input: string, cwd: string): Promise<string> {
48
+ const absolute = resolve(cwd, input);
49
+ const canonical = await realpath(absolute);
50
+ const metadata = await lstat(canonical);
51
+ if (!metadata.isDirectory()) throw new Error(`Check path is not a directory: ${input}`);
52
+ if (dirname(canonical) === canonical) throw new Error("Check path cannot be a filesystem root");
53
+ if (isCredentialSensitivePath(canonical)) {
54
+ throw new Error("Check path cannot be a credential-sensitive directory");
55
+ }
56
+ const [agentDirectory, homeDirectory] = await Promise.all([
57
+ realpath(AGENT_DIR).catch(() => resolve(AGENT_DIR)),
58
+ realpath(homedir()).catch(() => resolve(homedir())),
59
+ ]);
60
+ if (insideOrSame(canonical, agentDirectory) || insideOrSame(agentDirectory, canonical)) {
61
+ throw new Error("Check path cannot contain or enter PUM's configuration directory");
62
+ }
63
+ if (insideOrSame(canonical, homeDirectory)) {
64
+ throw new Error("Check path cannot contain the home directory");
65
+ }
66
+ return canonical;
67
+ }
68
+
69
+ async function removalIdentity(input: string, cwd: string): Promise<string> {
70
+ const absolute = resolve(cwd, input);
71
+ return realpath(absolute).catch(() => absolute);
72
+ }
73
+
74
+ export async function applyCheckPathCommand(
75
+ settings: PumSettings,
76
+ cwd: string,
77
+ command: CheckPathCommand,
78
+ ): Promise<{ settings: PumSettings; paths: string[]; message: string }> {
79
+ const paths = checkPathsForProject(settings, cwd);
80
+ if (command.action === "list") {
81
+ return {
82
+ settings,
83
+ paths,
84
+ message: paths.length > 0
85
+ ? `Check mode additional paths:\n${paths.map((path) => `- ${path}`).join("\n")}`
86
+ : "Check mode has no additional paths for this project",
87
+ };
88
+ }
89
+ if (command.action === "clear") {
90
+ const next = withCheckPathsForProject(settings, cwd, []);
91
+ return {
92
+ settings: next,
93
+ paths: [],
94
+ message: paths.length > 0
95
+ ? `cleared ${paths.length} Check mode additional path${paths.length === 1 ? "" : "s"}`
96
+ : "Check mode has no additional paths to clear",
97
+ };
98
+ }
99
+
100
+ const canonical = command.action === "add"
101
+ ? await canonicalDirectory(command.path, cwd)
102
+ : await removalIdentity(command.path, cwd);
103
+ const project = await realpath(cwd);
104
+ if (command.action === "add") {
105
+ if (canonical === project || insideOrSame(project, canonical)) {
106
+ throw new Error("The directory is already inside the project boundary");
107
+ }
108
+ if (paths.includes(canonical)) throw new Error(`Check path is already allowed: ${canonical}`);
109
+ if (paths.length >= MAX_CHECK_PATHS_PER_PROJECT) {
110
+ throw new Error(`Check mode allows at most ${MAX_CHECK_PATHS_PER_PROJECT} additional paths per project`);
111
+ }
112
+ const nextPaths = [...paths, canonical];
113
+ return {
114
+ settings: withCheckPathsForProject(settings, cwd, nextPaths),
115
+ paths: nextPaths,
116
+ message: `allowed Check mode path: ${canonical}`,
117
+ };
118
+ }
119
+
120
+ const index = paths.indexOf(canonical);
121
+ if (index < 0) throw new Error(`Check path is not configured: ${canonical}`);
122
+ const nextPaths = paths.filter((_path, candidate) => candidate !== index);
123
+ return {
124
+ settings: withCheckPathsForProject(settings, cwd, nextPaths),
125
+ paths: nextPaths,
126
+ message: `removed Check mode path: ${canonical}`,
127
+ };
128
+ }
@@ -104,6 +104,8 @@ export type CheckPolicyFileSystem = {
104
104
  export type AnalyzeCheckPolicyOptions = {
105
105
  command: string;
106
106
  cwd: string;
107
+ /** Canonical directory roots explicitly added for this launch project. */
108
+ allowedPaths?: readonly string[];
107
109
  profile?: CheckPolicyProfile;
108
110
  limits?: Partial<CheckPolicyLimits>;
109
111
  fileSystem?: CheckPolicyFileSystem;
@@ -125,6 +127,8 @@ export type ProcessCheckProposal = {
125
127
 
126
128
  export type AnalyzeExecutablePolicyOptions = Pick<ProcessCheckProposal, "executable" | "args" | "cwd"> & {
127
129
  projectCwd?: string;
130
+ /** Canonical directory roots explicitly added for this launch project. */
131
+ allowedPaths?: readonly string[];
128
132
  profile?: CheckPolicyProfile;
129
133
  limits?: Partial<CheckPolicyLimits>;
130
134
  fileSystem?: CheckPolicyFileSystem;
@@ -160,6 +164,7 @@ const PATH_OPERAND_COMMANDS = new Set([
160
164
  "readlink", "rm", "rmdir", "unlink", "shred", "mv", "cp", "install", "mkdir", "touch", "truncate",
161
165
  "chmod", "chown", "chgrp", "setfacl", "tee", "sed", "perl",
162
166
  "node", "bun", "deno", "python", "python3", "ruby", "perl", "php", "java",
167
+ "cd", "chdir", "set-location",
163
168
  ]);
164
169
  const PRIVILEGE_COMMANDS = new Set(["sudo", "doas", "su", "pkexec", "runas"]);
165
170
  const PERSISTENCE_COMMANDS = new Set(["crontab", "at", "schtasks", "launchctl"]);
@@ -517,23 +522,40 @@ function looksLikePath(value: string): boolean {
517
522
  || value.includes("/") || value.includes("\\");
518
523
  }
519
524
 
520
- function normalizedAbsolute(value: string, cwd: string, projectCwd = cwd): { absolute: string; root: string; inside: boolean } {
525
+ function normalizedAbsolute(
526
+ value: string,
527
+ cwd: string,
528
+ projectCwd = cwd,
529
+ allowedPaths: readonly string[] = [],
530
+ ): { absolute: string; root: string; inside: boolean; additional: boolean } {
521
531
  const flavor = pathFlavor(value, cwd);
522
- const root = flavor.resolve(projectCwd);
532
+ const roots = [projectCwd, ...allowedPaths]
533
+ .map((root) => flavor.resolve(root))
534
+ .sort((first, second) => second.length - first.length);
523
535
  const expanded = value === "~" || value.startsWith("~/") || value.startsWith("~\\") ? value : value;
524
536
  const absolute = flavor.resolve(cwd, expanded);
537
+ const root = roots.find((candidate) => {
538
+ const relative = flavor.relative(candidate, absolute);
539
+ return relative === "" || (!relative.startsWith("..") && !flavor.isAbsolute(relative));
540
+ }) ?? flavor.resolve(projectCwd);
525
541
  const relative = flavor.relative(root, absolute);
526
542
  const inside = relative === "" || (!relative.startsWith("..") && !flavor.isAbsolute(relative));
527
- return { absolute, root, inside };
543
+ return {
544
+ absolute,
545
+ root,
546
+ inside,
547
+ additional: flavor.relative(flavor.resolve(projectCwd), root) !== "",
548
+ };
528
549
  }
529
550
 
530
- function isProjectRoot(value: string, cwd: string): boolean {
551
+ function isAllowedRoot(value: string, cwd: string, projectCwd: string, allowedPaths: readonly string[]): boolean {
531
552
  const flavor = pathFlavor(value, cwd);
532
- const resolved = normalizedAbsolute(value, cwd);
533
- return flavor.relative(resolved.root, resolved.absolute) === "";
553
+ const absolute = flavor.resolve(cwd, value);
554
+ return [projectCwd, ...allowedPaths]
555
+ .some((root) => flavor.relative(flavor.resolve(root), absolute) === "");
534
556
  }
535
557
 
536
- function credentialPath(value: string): boolean {
558
+ export function isCredentialSensitivePath(value: string): boolean {
537
559
  const lower = value.toLowerCase().replaceAll("\\", "/");
538
560
  const segments = lower.split("/").filter(Boolean);
539
561
  if (segments.some((segment) => CREDENTIAL_SEGMENTS.has(segment))) return true;
@@ -589,6 +611,22 @@ function symlinkEscapes(path: string, root: string, fs: CheckPolicyFileSystem):
589
611
  }
590
612
  }
591
613
 
614
+ function additionalRootProblem(
615
+ root: string,
616
+ fs: CheckPolicyFileSystem,
617
+ ): "missing" | "link" | undefined {
618
+ try {
619
+ if (!fs.exists(root)) return "missing";
620
+ if (fs.isSymbolicLink(root)) return "link";
621
+ const flavor = pathFlavor(root, root);
622
+ return flavor.relative(flavor.resolve(root), flavor.resolve(fs.realpath(root))) === ""
623
+ ? undefined
624
+ : "link";
625
+ } catch {
626
+ return "link";
627
+ }
628
+ }
629
+
592
630
  function addFinding(findings: CheckPolicyFinding[], finding: CheckPolicyFinding): void {
593
631
  if (!findings.some((item) => item.code === finding.code && item.stage === finding.stage && item.path === finding.path)) findings.push(finding);
594
632
  }
@@ -599,6 +637,7 @@ function inspectHardBlocks(
599
637
  fs: CheckPolicyFileSystem,
600
638
  depth = 0,
601
639
  projectCwd = cwd,
640
+ allowedPaths: readonly string[] = [],
602
641
  ): CheckPolicyFinding[] {
603
642
  const findings: CheckPolicyFinding[] = [];
604
643
  if (!analysis.complete) {
@@ -606,6 +645,7 @@ function inspectHardBlocks(
606
645
  return findings;
607
646
  }
608
647
 
648
+ let activeCwd = cwd;
609
649
  for (const stage of analysis.stages) {
610
650
  const argv = effectiveArgv(stage.argv);
611
651
  const name = commandName(argv[0]);
@@ -643,27 +683,51 @@ function inspectHardBlocks(
643
683
  const recursive = argv.slice(1).some((arg) => arg === "--recursive" || /^-[^-]*r/i.test(arg));
644
684
  const force = argv.slice(1).some((arg) => arg === "--force" || /^-[^-]*f/i.test(arg));
645
685
  const broad = operands.some((arg) => [".", "..", "*", "./*", ".\\*", "/", "\\"].includes(arg)
646
- || /[*?\[]/.test(arg) || isProjectRoot(arg, projectCwd))
686
+ || /[*?\[]/.test(arg) || isAllowedRoot(arg, activeCwd, projectCwd, allowedPaths))
647
687
  || (recursive && force && operands.length !== 1);
648
688
  if (broad) addFinding(findings, { code: "broad-deletion", severity: "hard-block", message: "deletion target is broad or recursive across multiple paths", stage: stage.index });
649
689
  }
650
690
 
651
691
  const effectiveStage = argv === stage.argv ? stage : { ...stage, argv };
652
692
  for (const value of pathOperands(effectiveStage)) {
653
- if (credentialPath(value)) addFinding(findings, { code: "credential-access", severity: "hard-block", message: "command accesses a credential or secret path", stage: stage.index, path: value });
654
- if (isExactPosixNullDevice(value, cwd)) continue;
693
+ if (isCredentialSensitivePath(value)) addFinding(findings, { code: "credential-access", severity: "hard-block", message: "command accesses a credential or secret path", stage: stage.index, path: value });
694
+ if (isExactPosixNullDevice(value, activeCwd)) continue;
655
695
  if (value.startsWith("~")) {
656
696
  addFinding(findings, { code: "outside-project", severity: "hard-block", message: "home-relative path is outside the project boundary", stage: stage.index, path: value });
657
697
  continue;
658
698
  }
659
- const resolved = normalizedAbsolute(value, cwd, projectCwd);
699
+ const resolved = normalizedAbsolute(value, activeCwd, projectCwd, allowedPaths);
700
+ const rootProblem = resolved.additional ? additionalRootProblem(resolved.root, fs) : undefined;
660
701
  if (!resolved.inside) {
661
702
  addFinding(findings, { code: "outside-project", severity: "hard-block", message: "path resolves outside the project boundary", stage: stage.index, path: value });
703
+ } else if (rootProblem === "missing") {
704
+ addFinding(findings, { code: "outside-project", severity: "hard-block", message: "additional Check mode path no longer exists", stage: stage.index, path: value });
705
+ } else if (rootProblem === "link") {
706
+ addFinding(findings, { code: "escaping-symlink", severity: "hard-block", message: "additional Check mode path became a symlink or junction", stage: stage.index, path: value });
662
707
  } else if (symlinkEscapes(resolved.absolute, resolved.root, fs)) {
663
708
  addFinding(findings, { code: "escaping-symlink", severity: "hard-block", message: "path follows a symlink outside the project boundary or cannot be verified", stage: stage.index, path: value });
664
709
  }
665
710
  }
666
711
 
712
+ if (["cd", "chdir", "set-location"].includes(name)) {
713
+ const targets = pathOperands(effectiveStage);
714
+ const target = targets.length === 1
715
+ ? normalizedAbsolute(targets[0]!, activeCwd, projectCwd, allowedPaths)
716
+ : undefined;
717
+ if ((stage.operatorAfter === ";" || stage.operatorAfter === "newline")
718
+ && stage.index < analysis.stages.length - 1) {
719
+ addFinding(findings, {
720
+ code: "outside-project",
721
+ severity: "hard-block",
722
+ message: "an unconditional directory change before another command cannot be bounded safely",
723
+ stage: stage.index,
724
+ path: targets[0],
725
+ });
726
+ } else if (stage.operatorAfter === "&&" && target?.inside) {
727
+ activeCwd = target.absolute;
728
+ }
729
+ }
730
+
667
731
  if (depth < 2) {
668
732
  const nestedCommands = stage.substitutions.map((item) => item.text);
669
733
  const lowerName = name.toLowerCase();
@@ -672,7 +736,7 @@ function inspectHardBlocks(
672
736
  : SHELL_INTERPRETERS.has(name) ? argv.findIndex((arg) => arg === "-c") : -1;
673
737
  if (commandFlag >= 0 && argv[commandFlag + 1]) nestedCommands.push(argv[commandFlag + 1]!);
674
738
  for (const nestedCommand of nestedCommands) {
675
- const nestedFindings = inspectHardBlocks(analyzeBashCommand(nestedCommand), cwd, fs, depth + 1, projectCwd);
739
+ const nestedFindings = inspectHardBlocks(analyzeBashCommand(nestedCommand), cwd, fs, depth + 1, projectCwd, allowedPaths);
676
740
  for (const finding of nestedFindings) {
677
741
  addFinding(findings, { ...finding, stage: stage.index, message: `nested command: ${finding.message}` });
678
742
  }
@@ -795,7 +859,14 @@ export function analyzeCheckPolicy(options: AnalyzeCheckPolicyOptions): CheckPol
795
859
  const profile = options.profile ?? "balanced";
796
860
  const limits = options.limits ?? (profile === "balanced" ? balancedCompleteShellLimits(options.command) : undefined);
797
861
  const analysis = analyzeBashCommand(options.command, limits);
798
- const findings = inspectHardBlocks(analysis, options.cwd, options.fileSystem ?? nodeFileSystem);
862
+ const findings = inspectHardBlocks(
863
+ analysis,
864
+ options.cwd,
865
+ options.fileSystem ?? nodeFileSystem,
866
+ 0,
867
+ options.cwd,
868
+ options.allowedPaths,
869
+ );
799
870
  if (findings.some((item) => item.severity === "hard-block")) {
800
871
  return { profile, decision: "block", reason: findings[0]!.message, findings, analysis };
801
872
  }
@@ -881,8 +952,11 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
881
952
  };
882
953
  const projectCwd = options.projectCwd ?? options.cwd;
883
954
  const fs = options.fileSystem ?? nodeFileSystem;
884
- const findings = inspectHardBlocks(analysis, options.cwd, fs, 0, projectCwd);
885
- const executionDirectory = normalizedAbsolute(options.cwd, projectCwd, projectCwd);
955
+ const findings = inspectHardBlocks(analysis, options.cwd, fs, 0, projectCwd, options.allowedPaths);
956
+ const executionDirectory = normalizedAbsolute(options.cwd, projectCwd, projectCwd, options.allowedPaths);
957
+ const executionRootProblem = executionDirectory.additional
958
+ ? additionalRootProblem(executionDirectory.root, fs)
959
+ : undefined;
886
960
  if (!executionDirectory.inside) {
887
961
  addFinding(findings, {
888
962
  code: "outside-project",
@@ -890,6 +964,20 @@ export function analyzeExecutablePolicy(options: AnalyzeExecutablePolicyOptions)
890
964
  message: "execution cwd resolves outside the project boundary",
891
965
  path: options.cwd,
892
966
  });
967
+ } else if (executionRootProblem === "missing") {
968
+ addFinding(findings, {
969
+ code: "outside-project",
970
+ severity: "hard-block",
971
+ message: "additional Check mode path no longer exists",
972
+ path: options.cwd,
973
+ });
974
+ } else if (executionRootProblem === "link") {
975
+ addFinding(findings, {
976
+ code: "escaping-symlink",
977
+ severity: "hard-block",
978
+ message: "additional Check mode path became a symlink or junction",
979
+ path: options.cwd,
980
+ });
893
981
  } else if (symlinkEscapes(executionDirectory.absolute, executionDirectory.root, fs)) {
894
982
  addFinding(findings, {
895
983
  code: "escaping-symlink",
package/src/commands.ts CHANGED
@@ -25,6 +25,10 @@ export const COMMANDS: Command[] = [
25
25
  name: "/login",
26
26
  description: "Add or update a provider login",
27
27
  },
28
+ {
29
+ name: "/check-path",
30
+ description: "Manage additional Check mode directory roots",
31
+ },
28
32
  {
29
33
  name: "/triggers",
30
34
  description: "Manage external triggers",
@@ -55,6 +55,7 @@ export const HELP_GROUPS: HelpGroup[] = [
55
55
  ["/clear", "Start a fresh session"],
56
56
  ["/history", "Browse saved sessions"],
57
57
  ["/login", "Add or update a provider"],
58
+ ["/check-path", "Manage extra Check mode paths"],
58
59
  ["/triggers", "Manage external triggers"],
59
60
  ["/worktree", "Create a managed worktree"],
60
61
  ["Tab", "Complete a command preview"],
package/src/main.tsx CHANGED
@@ -11,7 +11,7 @@ import { mkdirSync } from "node:fs";
11
11
  import { randomUUID } from "node:crypto";
12
12
  import { App } from "./app";
13
13
  import { AGENT_DIR, AUTH_PATH, MODELS_PATH, sessionDir } from "./config";
14
- import { loadSettings } from "./settings";
14
+ import { checkPathsForProject, loadSettings } from "./settings";
15
15
  import { installWebSearch, webSearch } from "./web-search";
16
16
  import { setWritingStyle, writingStyleExtension } from "./writing-style";
17
17
  import {
@@ -59,7 +59,11 @@ export async function start(options: StartupOptions): Promise<void> {
59
59
  const spawnPreviewManager = new SpawnPreviewManager();
60
60
  const sessionHistoryIndex = new SessionHistoryIndex();
61
61
  const messageCacheController = new MessageCacheController(process.cwd());
62
- setCheckModeConfig({ profile: settings.checkMode, model: settings.checkModel });
62
+ setCheckModeConfig({
63
+ profile: settings.checkMode,
64
+ model: settings.checkModel,
65
+ additionalPaths: checkPathsForProject(settings, process.cwd()),
66
+ });
63
67
  const checkApprovalCoordinator = new CheckApprovalCoordinator();
64
68
  const checkApprovalStore = new CheckApprovalStore();
65
69
  const mainCheckModeExtension = createCheckModeExtension(modelRuntime, undefined, {
@@ -15,6 +15,7 @@ export type SettingRowId =
15
15
  | "explanationStrength"
16
16
  | "checkMode"
17
17
  | "checkModel"
18
+ | "checkPaths"
18
19
  | "clearCheckApprovals"
19
20
  | "thinkingLevel"
20
21
  | "showThinking"
@@ -44,6 +45,7 @@ export const SETTINGS_ROWS: readonly SettingRow[] = [
44
45
  { id: "webSearch", label: "Web search", category: "Agent", keywords: "internet hosted codex provider", description: "Allow hosted web search on supported Codex providers. Other providers are unchanged." },
45
46
  { id: "checkMode", label: "Check mode", category: "Safety", keywords: "profile strict balanced ask verify tools hard block approval bash edit patch", description: "Choose off, strict, balanced, or ask. Hard security rules apply to every active profile." },
46
47
  { id: "checkModel", label: "Check model", category: "Safety", keywords: "verifier tools safety structured verdict model", description: "Select the separate verifier model used for ambiguous Check mode calls." },
48
+ { id: "checkPaths", label: "Allowed paths", category: "Safety", keywords: "additional directories roots boundary command", description: "Use /check-path to manage extra directory roots allowed by Check mode for this project." },
47
49
  { id: "clearCheckApprovals", label: "Clear approvals", category: "Safety", keywords: "remove reset exact project approvals ask", description: "Remove exact Check mode approvals stored for this project." },
48
50
  ];
49
51
 
package/src/settings.ts CHANGED
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { AGENT_DIR } from "./config";
4
4
  import { DEFAULT_CHECK_MODEL } from "./check-mode";
5
+ import { projectStorageKey } from "./platform";
5
6
  import { isWritingStyle, type WritingStyle } from "./writing-style";
6
7
  import {
7
8
  isExplanationStrength,
@@ -15,6 +16,39 @@ export type CheckModeProfile = (typeof CHECK_MODE_PROFILES)[number];
15
16
  export const MIN_ACTIVE_SUBAGENTS = 1;
16
17
  export const MAX_ACTIVE_SUBAGENTS = 25;
17
18
  export const DEFAULT_MAX_ACTIVE_SUBAGENTS = 10;
19
+ export const MAX_CHECK_PATHS_PER_PROJECT = 16;
20
+
21
+ export type CheckPathsByProject = Record<string, string[]>;
22
+
23
+ export function normalizeCheckPathsByProject(value: unknown): CheckPathsByProject {
24
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
25
+ const normalized: CheckPathsByProject = {};
26
+ for (const [project, paths] of Object.entries(value)) {
27
+ if (!project || !Array.isArray(paths)) continue;
28
+ const entries = [...new Set(paths.filter((path): path is string =>
29
+ typeof path === "string" && path.length > 0 && !path.includes("\0"),
30
+ ))].slice(0, MAX_CHECK_PATHS_PER_PROJECT);
31
+ if (entries.length > 0) normalized[project] = entries;
32
+ }
33
+ return normalized;
34
+ }
35
+
36
+ export function checkPathsForProject(settings: Pick<PumSettings, "checkPaths">, cwd: string): string[] {
37
+ return [...(settings.checkPaths?.[projectStorageKey(cwd)] ?? [])];
38
+ }
39
+
40
+ export function withCheckPathsForProject(
41
+ settings: PumSettings,
42
+ cwd: string,
43
+ paths: readonly string[],
44
+ ): PumSettings {
45
+ const key = projectStorageKey(cwd);
46
+ const checkPaths = { ...(settings.checkPaths ?? {}) };
47
+ const normalized = [...new Set(paths)].slice(0, MAX_CHECK_PATHS_PER_PROJECT);
48
+ if (normalized.length > 0) checkPaths[key] = normalized;
49
+ else delete checkPaths[key];
50
+ return { ...settings, checkPaths };
51
+ }
18
52
 
19
53
  export function normalizeMaxActiveSubagents(value: unknown): number {
20
54
  return typeof value === "number"
@@ -49,6 +83,8 @@ export type PumSettings = {
49
83
  explanationStrength: ExplanationStrength;
50
84
  checkMode: CheckModeProfile;
51
85
  checkModel: string;
86
+ /** Additional canonical directory roots allowed by Check mode, keyed by launch project. */
87
+ checkPaths?: CheckPathsByProject;
52
88
  maxActiveSubagents: number;
53
89
  };
54
90
 
@@ -64,6 +100,7 @@ const DEFAULTS: PumSettings = {
64
100
  explanationStrength: "simple",
65
101
  checkMode: "off",
66
102
  checkModel: DEFAULT_CHECK_MODEL,
103
+ checkPaths: {},
67
104
  maxActiveSubagents: DEFAULT_MAX_ACTIVE_SUBAGENTS,
68
105
  };
69
106
 
@@ -91,6 +128,7 @@ export function normalizeSettings(parsed: unknown): PumSettings {
91
128
  typeof merged.checkModel === "string" && merged.checkModel.includes("/")
92
129
  ? merged.checkModel
93
130
  : DEFAULTS.checkModel,
131
+ checkPaths: normalizeCheckPathsByProject(merged.checkPaths),
94
132
  maxActiveSubagents: normalizeMaxActiveSubagents(merged.maxActiveSubagents),
95
133
  };
96
134
  }
@@ -590,13 +590,13 @@ export class SubagentManager {
590
590
  break;
591
591
  case "tool_execution_end":
592
592
  this.patchTool(record, event.toolCallId, {
593
- state: isRejectedToolResult(event.result)
593
+ state: isRejectedToolResult(event.result, event.toolCallId)
594
594
  ? "rejected"
595
595
  : event.isError
596
596
  ? "error"
597
597
  : "ok",
598
- detail: isRejectedToolResult(event.result)
599
- ? rejectedToolReason(event.result)
598
+ detail: isRejectedToolResult(event.result, event.toolCallId)
599
+ ? rejectedToolReason(event.result, event.toolCallId)
600
600
  : event.toolName === "edit" || event.toolName === "apply_patch"
601
601
  ? editCounts(event.result)
602
602
  : event.toolName === "questionnaire"
@@ -375,7 +375,7 @@ export function ToolLine({
375
375
  const bodyChunks = call.arg
376
376
  ? [fg(argColor)(call.arg)]
377
377
  : [fg(toolColor)(call.name)];
378
- if (call.detail) bodyChunks.push(fg(detailColor)(` ${call.detail}`));
378
+ if (call.detail && !rejected) bodyChunks.push(fg(detailColor)(` ${call.detail}`));
379
379
 
380
380
  const caret = useBlinkingText({
381
381
  chunks: bodyChunks,
@@ -385,36 +385,49 @@ export function ToolLine({
385
385
  });
386
386
 
387
387
  return (
388
- <Row
389
- glyph={GUTTER}
390
- glyphColor={toolColor}
391
- background={rejected ? theme.rejectionBg : undefined}
392
- >
393
- <box style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
394
- {prefix ? <text content={prefix} selectable style={{ flexShrink: 0 }} /> : null}
395
- <text
396
- ref={workingCaret ? caret : undefined}
397
- content={workingCaret ? undefined : new StyledText(bodyChunks)}
398
- selectable
399
- wrapMode="word"
400
- style={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}
401
- />
402
- </box>
403
- {/* The transcript scrollbox uses two padding columns and one pinned
404
- scrollbar column. The row then uses a two-column gutter and a
405
- one-column state marker. Bash reserves one additional column before
406
- the marker so commands wrap before the terminal-edge boundary. */}
407
- <box style={{ width: call.name === "bash" ? 2 : 1, flexShrink: 0 }} />
408
- <box style={{ width: 1, flexShrink: 0 }}>
409
- {call.state === "running" ? (
410
- <text ref={spinner} fg={theme.accent} />
411
- ) : (
388
+ <box style={{ flexDirection: "column", width: "100%" }}>
389
+ <Row
390
+ glyph={GUTTER}
391
+ glyphColor={toolColor}
392
+ background={rejected ? theme.rejectionBg : undefined}
393
+ >
394
+ <box style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
395
+ {prefix ? <text content={prefix} selectable style={{ flexShrink: 0 }} /> : null}
412
396
  <text
413
- content={toolStateGlyph(call.state)}
414
- fg={call.state === "ok" ? theme.success : rejected ? theme.rejection : theme.error}
397
+ ref={workingCaret ? caret : undefined}
398
+ content={workingCaret ? undefined : new StyledText(bodyChunks)}
399
+ selectable
400
+ wrapMode="word"
401
+ style={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}
415
402
  />
416
- )}
417
- </box>
418
- </Row>
403
+ </box>
404
+ {/* The transcript scrollbox uses two padding columns and one pinned
405
+ scrollbar column. The row then uses a two-column gutter and a
406
+ one-column state marker. Bash reserves one additional column before
407
+ the marker so commands wrap before the terminal-edge boundary. */}
408
+ <box style={{ width: call.name === "bash" ? 2 : 1, flexShrink: 0 }} />
409
+ <box style={{ width: 1, flexShrink: 0 }}>
410
+ {call.state === "running" ? (
411
+ <text ref={spinner} fg={theme.accent} />
412
+ ) : (
413
+ <text
414
+ content={toolStateGlyph(call.state)}
415
+ fg={call.state === "ok" ? theme.success : rejected ? theme.rejection : theme.error}
416
+ />
417
+ )}
418
+ </box>
419
+ </Row>
420
+ {rejected && call.detail ? (
421
+ <Row glyph={GUTTER} glyphColor={theme.rejection} background={theme.rejectionBg}>
422
+ <text
423
+ content={call.detail}
424
+ fg={theme.rejection}
425
+ selectable
426
+ wrapMode="word"
427
+ style={{ flexGrow: 1, flexShrink: 1, minWidth: 0, width: "100%" }}
428
+ />
429
+ </Row>
430
+ ) : null}
431
+ </box>
419
432
  );
420
433
  }