@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.
- package/dist/server/server/agent/agent-manager.d.ts +15 -0
- package/dist/server/server/agent/agent-manager.js +157 -25
- package/dist/server/server/agent/agent-projections.js +3 -0
- package/dist/server/server/agent/agent-sdk-types.d.ts +23 -0
- package/dist/server/server/agent/agent-storage.d.ts +2 -1
- package/dist/server/server/agent/agent-storage.js +4 -0
- package/dist/server/server/agent/mcp-shared.js +5 -2
- package/dist/server/server/agent/providers/claude/agent.d.ts +34 -0
- package/dist/server/server/agent/providers/claude/agent.js +74 -0
- package/dist/server/server/agent/providers/claude/background-task-tracker.d.ts +38 -1
- package/dist/server/server/agent/providers/claude/background-task-tracker.js +114 -9
- package/dist/server/server/agent/providers/claude/background-work-kinds.d.ts +95 -0
- package/dist/server/server/agent/providers/claude/background-work-kinds.js +73 -0
- package/dist/server/server/agent/providers/claude/pty-session-launcher.d.ts +7 -0
- package/dist/server/server/agent/providers/claude/pty-session-launcher.js +9 -0
- package/dist/server/server/agent/providers/claude/tool-allowlist-guard.d.ts +41 -0
- package/dist/server/server/agent/providers/claude/tool-allowlist-guard.js +93 -0
- package/dist/server/server/agent/providers/claude/tool-allowlist.d.ts +68 -0
- package/dist/server/server/agent/providers/claude/tool-allowlist.js +133 -0
- package/dist/server/server/agent/providers/claude/transport/pty.d.ts +17 -0
- package/dist/server/server/agent/providers/claude/transport/pty.js +51 -1
- package/dist/server/server/agent/providers/claude/transport/tmux.d.ts +74 -0
- package/dist/server/server/agent/providers/claude/transport/tmux.js +157 -0
- package/dist/server/server/agent/providers/claude/transport/types.d.ts +6 -0
- package/dist/server/server/agent/providers/opencode-agent.d.ts +7 -0
- package/dist/server/server/agent/providers/opencode-agent.js +51 -1
- package/dist/server/server/agent/tools/paseo-tools.d.ts +19 -0
- package/dist/server/server/agent/tools/paseo-tools.js +213 -38
- package/dist/server/server/agent/tools/read-only-surface.d.ts +1 -0
- package/dist/server/server/agent/tools/read-only-surface.js +1 -0
- package/dist/server/server/persistence-hooks.js +2 -0
- package/dist/server/server/workspace-directory.js +32 -14
- package/dist/server/web-ui/_expo/static/js/web/{index-73ebfe5c6b82437cad59d50a40d2c8ef.js → index-9c3bcdc334cf1c08001b6bea510e0292.js} +17 -17
- package/dist/server/web-ui/_expo/static/js/web/index-9c3bcdc334cf1c08001b6bea510e0292.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-9c3bcdc334cf1c08001b6bea510e0292.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-73ebfe5c6b82437cad59d50a40d2c8ef.js.map.br → index-9c3bcdc334cf1c08001b6bea510e0292.js.map.br} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-73ebfe5c6b82437cad59d50a40d2c8ef.js.map.gz → index-9c3bcdc334cf1c08001b6bea510e0292.js.map.gz} +0 -0
- package/dist/server/web-ui/index.html +1 -1
- package/dist/server/web-ui/index.html.br +0 -0
- package/dist/server/web-ui/index.html.gz +0 -0
- package/package.json +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-73ebfe5c6b82437cad59d50a40d2c8ef.js.br +0 -0
- 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
|
-
/**
|
|
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
|
-
/**
|
|
113
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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({
|
|
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
|