@hyperdrive.bot/paseo-server 0.3.40 → 0.3.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/server/server/agent/agent-manager.d.ts +15 -0
  2. package/dist/server/server/agent/agent-manager.js +157 -25
  3. package/dist/server/server/agent/agent-projections.js +3 -0
  4. package/dist/server/server/agent/agent-sdk-types.d.ts +23 -0
  5. package/dist/server/server/agent/agent-storage.d.ts +2 -1
  6. package/dist/server/server/agent/agent-storage.js +4 -0
  7. package/dist/server/server/agent/mcp-shared.js +5 -2
  8. package/dist/server/server/agent/providers/claude/agent.d.ts +34 -0
  9. package/dist/server/server/agent/providers/claude/agent.js +74 -0
  10. package/dist/server/server/agent/providers/claude/background-task-tracker.d.ts +38 -1
  11. package/dist/server/server/agent/providers/claude/background-task-tracker.js +114 -9
  12. package/dist/server/server/agent/providers/claude/background-work-kinds.d.ts +95 -0
  13. package/dist/server/server/agent/providers/claude/background-work-kinds.js +73 -0
  14. package/dist/server/server/agent/providers/claude/pty-session-launcher.d.ts +7 -0
  15. package/dist/server/server/agent/providers/claude/pty-session-launcher.js +9 -0
  16. package/dist/server/server/agent/providers/claude/tool-allowlist-guard.d.ts +41 -0
  17. package/dist/server/server/agent/providers/claude/tool-allowlist-guard.js +93 -0
  18. package/dist/server/server/agent/providers/claude/tool-allowlist.d.ts +68 -0
  19. package/dist/server/server/agent/providers/claude/tool-allowlist.js +133 -0
  20. package/dist/server/server/agent/providers/claude/transport/pty.d.ts +17 -0
  21. package/dist/server/server/agent/providers/claude/transport/pty.js +51 -1
  22. package/dist/server/server/agent/providers/claude/transport/tmux.d.ts +74 -0
  23. package/dist/server/server/agent/providers/claude/transport/tmux.js +157 -0
  24. package/dist/server/server/agent/providers/claude/transport/types.d.ts +6 -0
  25. package/dist/server/server/agent/providers/opencode-agent.d.ts +7 -0
  26. package/dist/server/server/agent/providers/opencode-agent.js +51 -1
  27. package/dist/server/server/agent/tools/paseo-tools.d.ts +19 -0
  28. package/dist/server/server/agent/tools/paseo-tools.js +213 -38
  29. package/dist/server/server/agent/tools/read-only-surface.d.ts +1 -0
  30. package/dist/server/server/agent/tools/read-only-surface.js +1 -0
  31. package/dist/server/server/persistence-hooks.js +2 -0
  32. package/dist/server/server/workspace-directory.js +32 -14
  33. package/dist/server/web-ui/_expo/static/js/web/{index-73ebfe5c6b82437cad59d50a40d2c8ef.js → index-9c3bcdc334cf1c08001b6bea510e0292.js} +17 -17
  34. package/dist/server/web-ui/_expo/static/js/web/index-9c3bcdc334cf1c08001b6bea510e0292.js.br +0 -0
  35. package/dist/server/web-ui/_expo/static/js/web/index-9c3bcdc334cf1c08001b6bea510e0292.js.gz +0 -0
  36. package/dist/server/web-ui/_expo/static/js/web/{index-73ebfe5c6b82437cad59d50a40d2c8ef.js.map.br → index-9c3bcdc334cf1c08001b6bea510e0292.js.map.br} +0 -0
  37. package/dist/server/web-ui/_expo/static/js/web/{index-73ebfe5c6b82437cad59d50a40d2c8ef.js.map.gz → index-9c3bcdc334cf1c08001b6bea510e0292.js.map.gz} +0 -0
  38. package/dist/server/web-ui/index.html +1 -1
  39. package/dist/server/web-ui/index.html.br +0 -0
  40. package/dist/server/web-ui/index.html.gz +0 -0
  41. package/package.json +6 -6
  42. package/dist/server/web-ui/_expo/static/js/web/index-73ebfe5c6b82437cad59d50a40d2c8ef.js.br +0 -0
  43. package/dist/server/web-ui/_expo/static/js/web/index-73ebfe5c6b82437cad59d50a40d2c8ef.js.gz +0 -0
@@ -24,7 +24,10 @@ import { normalizeProviderReplayTimestamp } from "../../provider-history-timesta
24
24
  import { composeSystemPromptParts } from "../../system-prompt.js";
25
25
  import { SdkTransport } from "./transport/sdk.js";
26
26
  import { PtyTransport } from "./transport/pty.js";
27
+ import { resolvePaseoHome } from "../../../paseo-home.js";
27
28
  import { createPtySession } from "./pty-session-launcher.js";
29
+ import { formatDenialMessage, isToolAllowed, parseToolAllowlist, } from "./tool-allowlist.js";
30
+ import { removeGuardArtifacts, writeGuardArtifacts, } from "./tool-allowlist-guard.js";
28
31
  import { trackTransportSpawned } from "../../../monitoring/telemetry.js";
29
32
  import { isProviderImageMarkdown, materializeProviderImage, renderProviderImageOutputAsAssistantMarkdown, } from "../provider-image-output.js";
30
33
  import { getAgentStreamEventTurnId, } from "../../agent-sdk-types.js";
@@ -1111,6 +1114,9 @@ export class ClaudeAgentClient {
1111
1114
  constructor(options) {
1112
1115
  this.provider = "claude";
1113
1116
  this.capabilities = CLAUDE_CAPABILITIES;
1117
+ // Enforced through a PreToolUse hook on both transports (SDK: options.hooks;
1118
+ // PTY: a generated hook script registered via `claude --settings`).
1119
+ this.supportsToolAllowlist = true;
1114
1120
  this.defaults = options.defaults;
1115
1121
  this.logger = options.logger.child({ module: "agent", provider: "claude" });
1116
1122
  this.runtimeSettings = options.runtimeSettings;
@@ -1575,11 +1581,34 @@ export class ClaudeAgentSession {
1575
1581
  this.emittedUserMessageIds = new Set();
1576
1582
  this.rewindTurnAnchors = [];
1577
1583
  this.pendingFreshSessionId = null;
1584
+ /** PTY-transport guard files for the current session; removed on teardown. */
1585
+ this.toolAllowGuard = null;
1578
1586
  this.cumulativeImageBase64Bytes = 0;
1579
1587
  this.cumulativeImageWarnEmitted = false;
1580
1588
  this.recentStderr = "";
1581
1589
  this.closed = false;
1582
1590
  this.hookEventHandlers = new Map();
1591
+ /**
1592
+ * SDK-transport half of allowlist enforcement. The PTY transport enforces the
1593
+ * same rules through a generated hook script (see tool-allowlist-guard.ts);
1594
+ * both call `isToolAllowed`, so the two transports cannot diverge.
1595
+ */
1596
+ this.enforceToolAllowlistHook = async (input) => {
1597
+ if (input.hook_event_name !== "PreToolUse")
1598
+ return {};
1599
+ const toolName = input.tool_name;
1600
+ if (isToolAllowed(toolName, input.tool_input, this.toolAllowRules))
1601
+ return {};
1602
+ const reason = formatDenialMessage(toolName, this.toolAllowRules);
1603
+ this.logger.warn({ toolName }, "tool call blocked by run tool allowlist");
1604
+ return {
1605
+ hookSpecificOutput: {
1606
+ hookEventName: "PreToolUse",
1607
+ permissionDecision: "deny",
1608
+ permissionDecisionReason: reason,
1609
+ },
1610
+ };
1611
+ };
1583
1612
  this.handlePermissionRequest = async (toolName, input, options) => {
1584
1613
  const requestId = `permission-${randomUUID()}`;
1585
1614
  const kind = resolvePermissionKind(toolName, input);
@@ -1661,6 +1690,10 @@ export class ClaudeAgentSession {
1661
1690
  this.queryFactory = options.queryFactory;
1662
1691
  this.resolveBinary = options.resolveBinary;
1663
1692
  this.contextUsage = new ClaudeContextUsageState(findClaudeModel(this.config.model)?.contextWindowMaxTokens);
1693
+ this.toolAllowRules = parseToolAllowlist(this.config.allowedTools ?? []);
1694
+ if (this.toolAllowRules.length > 0) {
1695
+ this.logger.info({ allowedTools: this.config.allowedTools }, "tool allowlist active: tools outside the list will be blocked before execution");
1696
+ }
1664
1697
  const handle = options.handle;
1665
1698
  if (handle) {
1666
1699
  if (!handle.sessionId) {
@@ -2099,6 +2132,8 @@ export class ClaudeAgentSession {
2099
2132
  hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId),
2100
2133
  }, "provider.claude.session_close.start");
2101
2134
  this.closed = true;
2135
+ removeGuardArtifacts(this.toolAllowGuard);
2136
+ this.toolAllowGuard = null;
2102
2137
  this.rejectAllPendingPermissions(new Error("Claude session closed"));
2103
2138
  this.cancelCurrentTurn?.();
2104
2139
  this.subscribers.clear();
@@ -2559,6 +2594,7 @@ export class ClaudeAgentSession {
2559
2594
  resume: Boolean(resumeId),
2560
2595
  model: this.config.model,
2561
2596
  permissionMode: this.currentMode,
2597
+ ...this.refreshToolAllowGuard(ptySessionId),
2562
2598
  appendSystemPrompt: this.buildAppendedSystemPrompt(),
2563
2599
  input: input.iterable,
2564
2600
  runtimeSettings: this.runtimeSettings,
@@ -2740,8 +2776,46 @@ export class ClaudeAgentSession {
2740
2776
  ...this.runtimeSettings.disallowedTools,
2741
2777
  ];
2742
2778
  }
2779
+ this.applyToolAllowlistHook(base);
2743
2780
  return base;
2744
2781
  }
2782
+ /**
2783
+ * Install the per-run tool allowlist as a `PreToolUse` hook on the SDK options.
2784
+ *
2785
+ * Deliberately NOT `options.allowedTools`: that field is a permission ALLOW rule
2786
+ * (pre-approve, do not prompt), and this session also sets
2787
+ * allowDangerouslySkipPermissions, under which permission rules are moot. A
2788
+ * PreToolUse hook is the only gate that still fires; the SDK says so outright
2789
+ * ("PreToolUse hook denies bypass canUseTool"). Applied last so an
2790
+ * `extra.claude.hooks` override cannot displace it.
2791
+ */
2792
+ applyToolAllowlistHook(base) {
2793
+ if (this.toolAllowRules.length === 0)
2794
+ return;
2795
+ const existing = base.hooks?.PreToolUse ?? [];
2796
+ base.hooks = {
2797
+ ...base.hooks,
2798
+ PreToolUse: [...existing, { hooks: [this.enforceToolAllowlistHook] }],
2799
+ };
2800
+ }
2801
+ /**
2802
+ * PTY-transport half of allowlist enforcement: (re)generate the hook script and
2803
+ * the `--settings` file that registers it.
2804
+ *
2805
+ * Returns a spreadable fragment (`{}` when no allowlist is configured) so the
2806
+ * caller stays branch-free. Regenerated per spawn because a session can restart
2807
+ * (mode swap, model swap, rewind) and the artifacts are keyed to the session id.
2808
+ */
2809
+ refreshToolAllowGuard(ptySessionId) {
2810
+ removeGuardArtifacts(this.toolAllowGuard);
2811
+ this.toolAllowGuard = writeGuardArtifacts({
2812
+ rules: this.toolAllowRules,
2813
+ sessionId: ptySessionId,
2814
+ baseDir: resolvePaseoHome(),
2815
+ });
2816
+ const guard = this.toolAllowGuard;
2817
+ return guard ? { settingsPath: guard.settingsPath } : {};
2818
+ }
2745
2819
  buildSettingsOptions(extraClaudeOptions, input) {
2746
2820
  const fastMode = this.resolveFastModeSetting();
2747
2821
  if (fastMode === null && !input.ultracode) {
@@ -27,9 +27,25 @@
27
27
  * output file's real birth time, reading the output back) is the caller's job.
28
28
  */
29
29
  export type ClaudeBackgroundTaskStatus = "running" | "completed" | "failed" | "canceled";
30
+ /**
31
+ * What kind of background work a record represents. Surfaced so the UI can say
32
+ * WHAT is holding a session open ("pending · monitoring deploy.log") instead of
33
+ * an unexplained spinner, which is how users end up force-quitting sessions.
34
+ *
35
+ * ⚠️ Adding a member here is not enough to make it tracked — it must also get a
36
+ * start pattern above AND a retirement path, and be listed in the exhaustiveness
37
+ * test. See `background-work-kinds.ts`.
38
+ */
39
+ export type ClaudeBackgroundTaskKind = "shell" | "monitor" | "cron";
40
+ export interface BackgroundTaskStart {
41
+ id: string;
42
+ kind: ClaudeBackgroundTaskKind;
43
+ }
30
44
  export interface ClaudeBackgroundTaskRecord {
31
45
  /** Harness-assigned shell id, e.g. `bjuk0pif4`. Stable across start and end. */
32
46
  id: string;
47
+ /** Which flavour of background work this is. Defaults to "shell" (legacy). */
48
+ kind: ClaudeBackgroundTaskKind;
33
49
  /** The shell command, from the originating `tool_use` input. Null if unseen. */
34
50
  command: string | null;
35
51
  /** The tool call's human description ("Run the test suite"), if it carried one. */
@@ -68,8 +84,29 @@ export declare function isTerminalTaskStatus(status: string | null | undefined):
68
84
  * terminal that isn't recognisably a failure or a cancel is a plain completion.
69
85
  */
70
86
  export declare function toTerminalRecordStatus(status: string | null | undefined): Exclude<ClaudeBackgroundTaskStatus, "running">;
71
- /** Extract every background-task id announced in a Bash tool_result's text. */
87
+ /**
88
+ * Extract every background-task id announced in a Bash tool_result's text.
89
+ *
90
+ * Bash shells only — kept at this name and signature because it is part of the
91
+ * module's public surface. For all tracked kinds use
92
+ * {@link extractBackgroundTaskStarts}.
93
+ */
72
94
  export declare function extractBackgroundTaskIds(text: string | null | undefined): string[];
95
+ /** Ids of monitors started in this text. */
96
+ export declare function extractMonitorTaskIds(text: string | null | undefined): string[];
97
+ /** Ids of cron jobs scheduled in this text. */
98
+ export declare function extractCronTaskIds(text: string | null | undefined): string[];
99
+ /** Ids of cron jobs cancelled in this text (a cron's only retirement signal). */
100
+ export declare function extractCancelledCronTaskIds(text: string | null | undefined): string[];
101
+ /**
102
+ * Every background-work start announced in one tool_result, tagged by kind.
103
+ *
104
+ * This is the single place that decides "does this text start something that
105
+ * keeps the agent alive?". A new kind of background work is invisible until it
106
+ * is added HERE — which is precisely how monitors and crons went untracked
107
+ * while `deriveAgentStateBucket` happily reported those sessions as done.
108
+ */
109
+ export declare function extractBackgroundTaskStarts(text: string | null | undefined): BackgroundTaskStart[];
73
110
  /** Extract the output-file path the harness announced alongside the id, if any. */
74
111
  export declare function extractBackgroundOutputFile(text: string | null | undefined): string | null;
75
112
  export declare class ClaudeBackgroundTaskTracker {
@@ -47,6 +47,45 @@ const BACKGROUND_ID_PATTERN = /Command running in background with ID:\s*([A-Za-z
47
47
  * path from the id (the directory is session-scoped and not derivable here).
48
48
  */
49
49
  const OUTPUT_FILE_PATTERN = /Output is being written to:\s*(\S+)/;
50
+ /**
51
+ * Matches the harness line for a started **Monitor** (the long-running watcher
52
+ * tool), which announces itself with a completely different sentence from a
53
+ * backgrounded Bash:
54
+ *
55
+ * Monitor started (task b6dxcqe9y, timeout 20000ms). You will be notified on
56
+ * each event. Keep working — do not poll or sleep.
57
+ *
58
+ * The id is followed by a comma rather than a period, so it needs its own
59
+ * capture. Crucially the monitor's END is already handled: it retires through
60
+ * the SAME `<task-notification>` envelope carrying the same `<task-id>` and a
61
+ * `<status>completed</status>`. Only the start was invisible, which is why a
62
+ * session holding a live monitor reported zero background tasks and settled to
63
+ * "done" while the monitor was still watching.
64
+ *
65
+ * Captured verbatim from a live session on 2026-08-22, not transcribed.
66
+ */
67
+ const MONITOR_ID_PATTERN = /Monitor started \(task\s+([A-Za-z0-9._-]+)/g;
68
+ /**
69
+ * Matches a scheduled **cron** job's announcement. Two shapes, one per mode:
70
+ *
71
+ * Scheduled one-shot task dcdae8f2 (17 4 1 1 *). Session-only ...
72
+ * Scheduled recurring job b8df03d3 (Every Wednesday at 4:23 AM). Session-only ...
73
+ *
74
+ * Note the noun changes with the mode ("task" vs "job"), so both are accepted.
75
+ * Captured verbatim from a live session on 2026-08-22.
76
+ */
77
+ const CRON_ID_PATTERN = /Scheduled\s+(?:one-shot|recurring)\s+(?:task|job)\s+([A-Za-z0-9._-]+)/g;
78
+ /**
79
+ * Matches a cancelled cron job. A cron has no `<task-notification>`, so this
80
+ * tool_result line is its ONLY retirement signal:
81
+ *
82
+ * Cancelled job b8df03d3.
83
+ *
84
+ * The trailing period is glued to the id — the exact defect class documented in
85
+ * {@link stripTrailingSentencePunctuation}. It is stripped, or the id would
86
+ * never match the record and the agent would sit in "pending" forever.
87
+ */
88
+ const CRON_CANCEL_PATTERN = /Cancelled job\s+([A-Za-z0-9._-]+)/g;
50
89
  /**
51
90
  * Trim the sentence punctuation the harness's prose leaves glued to a capture.
52
91
  *
@@ -109,24 +148,66 @@ export function toTerminalRecordStatus(status) {
109
148
  }
110
149
  return "completed";
111
150
  }
112
- /** Extract every background-task id announced in a Bash tool_result's text. */
113
- export function extractBackgroundTaskIds(text) {
151
+ /**
152
+ * Run one module-scoped global pattern over `text` and return every stripped
153
+ * capture. Shared by all start/cancel scanners so the two easy-to-forget
154
+ * details — resetting `lastIndex` on a module-scoped global regex, and
155
+ * stripping the sentence punctuation the harness's prose glues to a capture —
156
+ * are written once instead of per pattern.
157
+ */
158
+ function scanIds(pattern, text) {
114
159
  if (typeof text !== "string" || text.length === 0) {
115
160
  return [];
116
161
  }
117
162
  const ids = [];
118
- // Reset lastIndex defensively — the regex is module-scoped and global.
119
- BACKGROUND_ID_PATTERN.lastIndex = 0;
120
- let match = BACKGROUND_ID_PATTERN.exec(text);
163
+ pattern.lastIndex = 0;
164
+ let match = pattern.exec(text);
121
165
  while (match !== null) {
122
166
  const id = match[1] ? stripTrailingSentencePunctuation(match[1]) : "";
123
167
  if (id) {
124
168
  ids.push(id);
125
169
  }
126
- match = BACKGROUND_ID_PATTERN.exec(text);
170
+ match = pattern.exec(text);
127
171
  }
128
172
  return ids;
129
173
  }
174
+ /**
175
+ * Extract every background-task id announced in a Bash tool_result's text.
176
+ *
177
+ * Bash shells only — kept at this name and signature because it is part of the
178
+ * module's public surface. For all tracked kinds use
179
+ * {@link extractBackgroundTaskStarts}.
180
+ */
181
+ export function extractBackgroundTaskIds(text) {
182
+ return scanIds(BACKGROUND_ID_PATTERN, text);
183
+ }
184
+ /** Ids of monitors started in this text. */
185
+ export function extractMonitorTaskIds(text) {
186
+ return scanIds(MONITOR_ID_PATTERN, text);
187
+ }
188
+ /** Ids of cron jobs scheduled in this text. */
189
+ export function extractCronTaskIds(text) {
190
+ return scanIds(CRON_ID_PATTERN, text);
191
+ }
192
+ /** Ids of cron jobs cancelled in this text (a cron's only retirement signal). */
193
+ export function extractCancelledCronTaskIds(text) {
194
+ return scanIds(CRON_CANCEL_PATTERN, text);
195
+ }
196
+ /**
197
+ * Every background-work start announced in one tool_result, tagged by kind.
198
+ *
199
+ * This is the single place that decides "does this text start something that
200
+ * keeps the agent alive?". A new kind of background work is invisible until it
201
+ * is added HERE — which is precisely how monitors and crons went untracked
202
+ * while `deriveAgentStateBucket` happily reported those sessions as done.
203
+ */
204
+ export function extractBackgroundTaskStarts(text) {
205
+ return [
206
+ ...extractBackgroundTaskIds(text).map((id) => ({ id, kind: "shell" })),
207
+ ...extractMonitorTaskIds(text).map((id) => ({ id, kind: "monitor" })),
208
+ ...extractCronTaskIds(text).map((id) => ({ id, kind: "cron" })),
209
+ ];
210
+ }
130
211
  /** Extract the output-file path the harness announced alongside the id, if any. */
131
212
  export function extractBackgroundOutputFile(text) {
132
213
  if (typeof text !== "string" || text.length === 0) {
@@ -158,15 +239,19 @@ export class ClaudeBackgroundTaskTracker {
158
239
  const outputFile = extractBackgroundOutputFile(normalized.text);
159
240
  const startedAt = normalized.startedAt ?? new Date().toISOString();
160
241
  const added = [];
161
- for (const id of extractBackgroundTaskIds(normalized.text)) {
242
+ for (const { id, kind } of extractBackgroundTaskStarts(normalized.text)) {
162
243
  if (this.running.has(id)) {
163
244
  continue;
164
245
  }
165
246
  this.running.set(id, {
166
247
  id,
248
+ kind,
167
249
  command: normalized.command ?? null,
168
250
  description: normalized.description ?? null,
169
- outputFile,
251
+ // Only a backgrounded shell announces an output file on START. A
252
+ // monitor announces its own on its terminal notification instead, and a
253
+ // cron never has one.
254
+ outputFile: kind === "shell" ? outputFile : null,
170
255
  startedAt,
171
256
  endedAt: null,
172
257
  status: "running",
@@ -174,6 +259,22 @@ export class ClaudeBackgroundTaskTracker {
174
259
  });
175
260
  added.push(id);
176
261
  }
262
+ // A cron has no `<task-notification>`, so its cancellation arrives as
263
+ // another tool_result in this same stream. Retire it here or it stays live
264
+ // forever and pins the agent to "pending" — the "never retires" failure
265
+ // this module already documents for a mis-captured shell id.
266
+ for (const id of extractCancelledCronTaskIds(normalized.text)) {
267
+ const record = this.running.get(id);
268
+ if (!record || record.kind !== "cron") {
269
+ continue;
270
+ }
271
+ this.running.delete(id);
272
+ this.retire({
273
+ ...record,
274
+ status: "canceled",
275
+ endedAt: new Date().toISOString(),
276
+ });
277
+ }
177
278
  return added;
178
279
  }
179
280
  /**
@@ -211,7 +312,11 @@ export class ClaudeBackgroundTaskTracker {
211
312
  return false;
212
313
  }
213
314
  this.running.delete(taskId);
214
- this.retire({ ...record, status: "canceled", endedAt: new Date().toISOString() });
315
+ this.retire({
316
+ ...record,
317
+ status: "canceled",
318
+ endedAt: new Date().toISOString(),
319
+ });
215
320
  return true;
216
321
  }
217
322
  has(taskId) {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The complete census of background work a Claude Code session can hold open.
3
+ *
4
+ * WHY THIS FILE EXISTS
5
+ *
6
+ * `deriveAgentStateBucket` ends in `return "done"`. That makes "done" the
7
+ * FALLTHROUGH: any kind of background work nobody taught the system about
8
+ * becomes invisible by construction, not by decision. Monitors and crons sat in
9
+ * that blind spot for the life of the feature — a session with an armed monitor
10
+ * reported zero background tasks, bucketed as "done", and looked idle while it
11
+ * was actively watching.
12
+ *
13
+ * The fix is not "remember to add a pattern". It is to enumerate what we
14
+ * SILENCE rather than what we want, so an unlisted kind is merely noisy (a
15
+ * failing build) instead of invisible (a lying UI).
16
+ *
17
+ * HOW IT FAILS LOUDLY
18
+ *
19
+ * `BACKGROUND_WORK_KINDS` is declared `satisfies Record<BackgroundWorkKind, …>`.
20
+ * Add a member to the union without adding its entry here and the TYPE CHECK
21
+ * fails. Add an entry claiming `tracked: true` without a start pattern that
22
+ * actually extracts its id and `background-work-kinds.test.ts` fails.
23
+ *
24
+ * SAMPLES ARE CAPTURES, NOT TRANSCRIPTIONS
25
+ *
26
+ * Every `sample` below was pasted verbatim out of a live session's tool_result
27
+ * on 2026-08-22. Do not tidy them. This repo has already shipped a regex that
28
+ * passed 21 green tests against hand-typed fixtures while being broken in
29
+ * production, because the real text was prose and every fixture was a clean
30
+ * one-liner. If you add a kind, capture its real string first.
31
+ */
32
+ /** Every flavour of background work, tracked or deliberately not. */
33
+ export type BackgroundWorkKind = "shell" | "monitor" | "cron" | "scheduled_wakeup" | "subagent";
34
+ interface TrackedKind {
35
+ tracked: true;
36
+ /** Human label for the UI ("pending · monitoring deploy.log"). */
37
+ label: string;
38
+ /** Verbatim capture of the harness text that announces a start. */
39
+ sample: string;
40
+ /** The id the sample must yield, proving the pattern actually works. */
41
+ expectedId: string;
42
+ /** Where the record is retired. */
43
+ retiredBy: string;
44
+ /** Which module owns the tracking. */
45
+ trackedBy: string;
46
+ }
47
+ interface SilencedKind {
48
+ tracked: false;
49
+ label: string;
50
+ /**
51
+ * Why this kind does NOT keep an agent alive. Must be a real, specific
52
+ * reason — "not implemented yet" is one, and an honest one. What is not
53
+ * allowed is for a kind to be absent from this file entirely.
54
+ */
55
+ silencedBecause: string;
56
+ }
57
+ export type BackgroundWorkKindEntry = TrackedKind | SilencedKind;
58
+ export declare const BACKGROUND_WORK_KINDS: {
59
+ readonly shell: {
60
+ readonly tracked: true;
61
+ readonly label: "background shell";
62
+ readonly sample: "Command running in background with ID: beuhoixae. Output is being written to: /tmp/x/beuhoixae.output. You will be notified when it completes.";
63
+ readonly expectedId: "beuhoixae";
64
+ readonly retiredBy: "<task-notification> with a terminal <status>";
65
+ readonly trackedBy: "ClaudeBackgroundTaskTracker.noteToolResultText";
66
+ };
67
+ readonly monitor: {
68
+ readonly tracked: true;
69
+ readonly label: "monitor";
70
+ readonly sample: "Monitor started (task b6dxcqe9y, timeout 20000ms). You will be notified on each event. Keep working — do not poll or sleep.";
71
+ readonly expectedId: "b6dxcqe9y";
72
+ readonly retiredBy: "<task-notification> with a terminal <status>";
73
+ readonly trackedBy: "ClaudeBackgroundTaskTracker.noteToolResultText";
74
+ };
75
+ readonly cron: {
76
+ readonly tracked: true;
77
+ readonly label: "scheduled job";
78
+ readonly sample: "Scheduled recurring job b8df03d3 (Every Wednesday at 4:23 AM). Session-only (not written to disk, dies when Claude exits). Auto-expires after 7 days. Use CronDelete to cancel sooner.";
79
+ readonly expectedId: "b8df03d3";
80
+ readonly retiredBy: "\"Cancelled job <id>.\" in a later tool_result";
81
+ readonly trackedBy: "ClaudeBackgroundTaskTracker.noteToolResultText";
82
+ };
83
+ readonly scheduled_wakeup: {
84
+ readonly tracked: false;
85
+ readonly label: "scheduled wakeup";
86
+ readonly silencedBecause: "ScheduleWakeup only exists inside /loop dynamic mode, and invoking it to capture its announcement would schedule a real wakeup of the capturing session. No verbatim capture has been taken yet, and this module's rule is that a pattern is written from a real string or not at all. A /loop session's transcript must be captured before this can move to tracked:true. Until then a dynamic /loop between wakeups buckets as done.";
87
+ };
88
+ readonly subagent: {
89
+ readonly tracked: false;
90
+ readonly label: "subagent";
91
+ readonly silencedBecause: "Live subagents DO keep the parent alive, but not through this tracker: they arrive as their own agent snapshots and are attributed to the delegation root in workspace-directory.applyAgentBucketContributions (server) and countActiveChildrenByParent (app). Counting them here as well would double-count them.";
92
+ };
93
+ };
94
+ export {};
95
+ //# sourceMappingURL=background-work-kinds.d.ts.map
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The complete census of background work a Claude Code session can hold open.
3
+ *
4
+ * WHY THIS FILE EXISTS
5
+ *
6
+ * `deriveAgentStateBucket` ends in `return "done"`. That makes "done" the
7
+ * FALLTHROUGH: any kind of background work nobody taught the system about
8
+ * becomes invisible by construction, not by decision. Monitors and crons sat in
9
+ * that blind spot for the life of the feature — a session with an armed monitor
10
+ * reported zero background tasks, bucketed as "done", and looked idle while it
11
+ * was actively watching.
12
+ *
13
+ * The fix is not "remember to add a pattern". It is to enumerate what we
14
+ * SILENCE rather than what we want, so an unlisted kind is merely noisy (a
15
+ * failing build) instead of invisible (a lying UI).
16
+ *
17
+ * HOW IT FAILS LOUDLY
18
+ *
19
+ * `BACKGROUND_WORK_KINDS` is declared `satisfies Record<BackgroundWorkKind, …>`.
20
+ * Add a member to the union without adding its entry here and the TYPE CHECK
21
+ * fails. Add an entry claiming `tracked: true` without a start pattern that
22
+ * actually extracts its id and `background-work-kinds.test.ts` fails.
23
+ *
24
+ * SAMPLES ARE CAPTURES, NOT TRANSCRIPTIONS
25
+ *
26
+ * Every `sample` below was pasted verbatim out of a live session's tool_result
27
+ * on 2026-08-22. Do not tidy them. This repo has already shipped a regex that
28
+ * passed 21 green tests against hand-typed fixtures while being broken in
29
+ * production, because the real text was prose and every fixture was a clean
30
+ * one-liner. If you add a kind, capture its real string first.
31
+ */
32
+ export const BACKGROUND_WORK_KINDS = {
33
+ shell: {
34
+ tracked: true,
35
+ label: "background shell",
36
+ sample: "Command running in background with ID: beuhoixae. Output is being written to: /tmp/x/beuhoixae.output. You will be notified when it completes.",
37
+ expectedId: "beuhoixae",
38
+ retiredBy: "<task-notification> with a terminal <status>",
39
+ trackedBy: "ClaudeBackgroundTaskTracker.noteToolResultText",
40
+ },
41
+ monitor: {
42
+ tracked: true,
43
+ label: "monitor",
44
+ sample: "Monitor started (task b6dxcqe9y, timeout 20000ms). You will be notified on each event. Keep working — do not poll or sleep.",
45
+ expectedId: "b6dxcqe9y",
46
+ // Confirmed live: the monitor's completion arrives as the SAME
47
+ // <task-notification> envelope, same <task-id>, <status>completed</status>.
48
+ // Only its START was ever invisible.
49
+ retiredBy: "<task-notification> with a terminal <status>",
50
+ trackedBy: "ClaudeBackgroundTaskTracker.noteToolResultText",
51
+ },
52
+ cron: {
53
+ tracked: true,
54
+ label: "scheduled job",
55
+ sample: "Scheduled recurring job b8df03d3 (Every Wednesday at 4:23 AM). Session-only (not written to disk, dies when Claude exits). Auto-expires after 7 days. Use CronDelete to cancel sooner.",
56
+ expectedId: "b8df03d3",
57
+ // A cron emits NO task-notification. "Cancelled job <id>." in a later
58
+ // tool_result is its only retirement signal — note the glued-on period.
59
+ retiredBy: '"Cancelled job <id>." in a later tool_result',
60
+ trackedBy: "ClaudeBackgroundTaskTracker.noteToolResultText",
61
+ },
62
+ scheduled_wakeup: {
63
+ tracked: false,
64
+ label: "scheduled wakeup",
65
+ silencedBecause: "ScheduleWakeup only exists inside /loop dynamic mode, and invoking it to capture its announcement would schedule a real wakeup of the capturing session. No verbatim capture has been taken yet, and this module's rule is that a pattern is written from a real string or not at all. A /loop session's transcript must be captured before this can move to tracked:true. Until then a dynamic /loop between wakeups buckets as done.",
66
+ },
67
+ subagent: {
68
+ tracked: false,
69
+ label: "subagent",
70
+ silencedBecause: "Live subagents DO keep the parent alive, but not through this tracker: they arrive as their own agent snapshots and are attributed to the delegation root in workspace-directory.applyAgentBucketContributions (server) and countActiveChildrenByParent (app). Counting them here as well would double-count them.",
71
+ },
72
+ };
73
+ //# sourceMappingURL=background-work-kinds.js.map
@@ -11,6 +11,13 @@ export interface CreatePtySessionOptions {
11
11
  resume: boolean;
12
12
  model?: string;
13
13
  permissionMode: string;
14
+ /**
15
+ * Extra settings file passed as `claude --settings <path>`. Used to install the
16
+ * per-run tool-allowlist PreToolUse hook (see tool-allowlist-guard.ts). `--settings`
17
+ * LAYERS onto the user/project/local settings claude already reads, so this adds the
18
+ * guard without displacing anything the user configured.
19
+ */
20
+ settingsPath?: string;
14
21
  appendSystemPrompt?: string;
15
22
  /** The shared input channel fed by startTurn() — PtyQuery types these into the PTY. */
16
23
  input: AsyncIterable<SDKUserMessage>;
@@ -47,6 +47,9 @@ export async function createPtySession(opts) {
47
47
  cwd: realCwd,
48
48
  env,
49
49
  dims: opts.dims ?? DEFAULT_DIMS,
50
+ // Names the tmux session deterministically when PASEO_PTY_TMUX=1, so a daemon restart
51
+ // reattaches this agent rather than spawning a second one alongside it.
52
+ sessionId: opts.sessionId,
50
53
  ...(systemPromptFilePath ? { systemPromptFilePath } : {}),
51
54
  });
52
55
  const query = new PtyQuery({
@@ -70,6 +73,12 @@ function buildInteractiveFlags(opts) {
70
73
  if (opts.permissionMode)
71
74
  flags.push("--permission-mode", opts.permissionMode);
72
75
  flags.push("--dangerously-skip-permissions");
76
+ // Order matters only for readability; claude merges --settings over its own sources.
77
+ // The tool allowlist rides here BECAUSE of the line above: --dangerously-skip-permissions
78
+ // neutralizes every permission rule, and a PreToolUse hook is the one gate it does not
79
+ // skip. Verified on claude 2.1.239.
80
+ if (opts.settingsPath)
81
+ flags.push("--settings", opts.settingsPath);
73
82
  // Inject the same MCP servers the SDK path passes (incl. the paseo MCP) so the PTY agent
74
83
  // keeps its mcp__paseo__* tools (create_agent, terminals, schedules…). claude accepts
75
84
  // --mcp-config as an inline JSON string. Without this, orchestrated agents (the pool /
@@ -0,0 +1,41 @@
1
+ import { type ToolAllowRule } from "./tool-allowlist.js";
2
+ /**
3
+ * Materializes the PTY-transport half of tool-allowlist enforcement: a
4
+ * `PreToolUse` hook script plus the `--settings` file that registers it.
5
+ *
6
+ * The SDK transport enforces the same allowlist in-process (an `options.hooks`
7
+ * callback). The PTY transport spawns a real `claude` terminal, so its gate has
8
+ * to be a command on disk.
9
+ *
10
+ * SINGLE SOURCE OF TRUTH: the guard script is generated by serializing the very
11
+ * functions the in-process path calls (`Function.prototype.toString()`), so the
12
+ * two transports cannot drift into enforcing different rules. `tool-allowlist-guard.test.ts`
13
+ * executes the generated script over the same fixture table that exercises
14
+ * `isToolAllowed()` to keep that guarantee honest. Neither the tsc build nor tsx
15
+ * minifies, so the emitted bodies are valid, type-free JS in dev and in dist.
16
+ */
17
+ export interface GuardArtifacts {
18
+ /** Directory holding both generated files. Delete it on session teardown. */
19
+ readonly dir: string;
20
+ /** Path passed to `claude --settings`. */
21
+ readonly settingsPath: string;
22
+ /** Path of the generated hook script. */
23
+ readonly guardPath: string;
24
+ }
25
+ /** The standalone `PreToolUse` hook script, with the rules baked in. */
26
+ export declare function buildGuardScript(rules: readonly ToolAllowRule[]): string;
27
+ /** The `--settings` payload that registers the guard for every tool call. */
28
+ export declare function buildGuardSettings(nodeBinary: string, guardPath: string): string;
29
+ /**
30
+ * Write the guard script + settings file for a session. Returns null when the
31
+ * allowlist is empty, i.e. no allowlist was configured and nothing is enforced.
32
+ */
33
+ export declare function writeGuardArtifacts(options: {
34
+ readonly rules: readonly ToolAllowRule[];
35
+ readonly sessionId: string;
36
+ readonly baseDir: string;
37
+ readonly nodeBinary?: string;
38
+ }): GuardArtifacts | null;
39
+ /** Best-effort teardown for `writeGuardArtifacts`. */
40
+ export declare function removeGuardArtifacts(artifacts: GuardArtifacts | null | undefined): void;
41
+ //# sourceMappingURL=tool-allowlist-guard.d.ts.map