@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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { SPECIFIER_SOURCES, escapeLiteral, isToolAllowed, readSpecifierValue, specifierMatches, } from "./tool-allowlist.js";
|
|
4
|
+
/** Quote a path for a POSIX/`sh -c` hook command line. */
|
|
5
|
+
function shellQuote(value) {
|
|
6
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7
|
+
}
|
|
8
|
+
/** The standalone `PreToolUse` hook script, with the rules baked in. */
|
|
9
|
+
export function buildGuardScript(rules) {
|
|
10
|
+
return `#!/usr/bin/env node
|
|
11
|
+
// GENERATED by paseo (tool-allowlist-guard.ts). Do not edit; regenerated per session.
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
const RULES = ${JSON.stringify(rules)};
|
|
15
|
+
const SPECIFIER_SOURCES = ${JSON.stringify(SPECIFIER_SOURCES)};
|
|
16
|
+
|
|
17
|
+
const escapeLiteral = ${escapeLiteral.toString()};
|
|
18
|
+
const specifierMatches = ${specifierMatches.toString()};
|
|
19
|
+
const readSpecifierValue = ${readSpecifierValue.toString()};
|
|
20
|
+
const isToolAllowed = ${isToolAllowed.toString()};
|
|
21
|
+
|
|
22
|
+
let raw = "";
|
|
23
|
+
try {
|
|
24
|
+
raw = readFileSync(0, "utf8");
|
|
25
|
+
} catch {
|
|
26
|
+
raw = "";
|
|
27
|
+
}
|
|
28
|
+
let event = {};
|
|
29
|
+
try {
|
|
30
|
+
event = JSON.parse(raw);
|
|
31
|
+
} catch {
|
|
32
|
+
event = {};
|
|
33
|
+
}
|
|
34
|
+
const toolName = typeof event.tool_name === "string" ? event.tool_name : "";
|
|
35
|
+
const toolInput = event.tool_input ?? {};
|
|
36
|
+
|
|
37
|
+
// Fail closed: an unreadable event is not a licence to run an unknown tool.
|
|
38
|
+
if (!toolName || !isToolAllowed(toolName, toolInput, RULES)) {
|
|
39
|
+
const listed = RULES.map((r) => (r.specifier === undefined ? r.tool : r.tool + "(" + r.specifier + ")")).join(", ");
|
|
40
|
+
process.stderr.write(
|
|
41
|
+
'Tool "' + (toolName || "<unknown>") + '" is blocked by this run\\'s tool allowlist and was not executed. ' +
|
|
42
|
+
"Allowed: " + listed + ". Do not retry this tool; achieve the goal with an allowed tool or report that you cannot.\\n",
|
|
43
|
+
);
|
|
44
|
+
process.exit(2);
|
|
45
|
+
}
|
|
46
|
+
process.exit(0);
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
/** The `--settings` payload that registers the guard for every tool call. */
|
|
50
|
+
export function buildGuardSettings(nodeBinary, guardPath) {
|
|
51
|
+
return `${JSON.stringify({
|
|
52
|
+
hooks: {
|
|
53
|
+
PreToolUse: [
|
|
54
|
+
{
|
|
55
|
+
matcher: "*",
|
|
56
|
+
hooks: [
|
|
57
|
+
{
|
|
58
|
+
type: "command",
|
|
59
|
+
command: `${shellQuote(nodeBinary)} ${shellQuote(guardPath)}`,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
}, null, 2)}\n`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Write the guard script + settings file for a session. Returns null when the
|
|
69
|
+
* allowlist is empty, i.e. no allowlist was configured and nothing is enforced.
|
|
70
|
+
*/
|
|
71
|
+
export function writeGuardArtifacts(options) {
|
|
72
|
+
if (options.rules.length === 0)
|
|
73
|
+
return null;
|
|
74
|
+
const dir = path.join(options.baseDir, `tool-allowlist-${options.sessionId}`);
|
|
75
|
+
mkdirSync(dir, { recursive: true });
|
|
76
|
+
const guardPath = path.join(dir, "guard.mjs");
|
|
77
|
+
const settingsPath = path.join(dir, "settings.json");
|
|
78
|
+
writeFileSync(guardPath, buildGuardScript(options.rules), { mode: 0o700 });
|
|
79
|
+
writeFileSync(settingsPath, buildGuardSettings(options.nodeBinary ?? process.execPath, guardPath));
|
|
80
|
+
return { dir, settingsPath, guardPath };
|
|
81
|
+
}
|
|
82
|
+
/** Best-effort teardown for `writeGuardArtifacts`. */
|
|
83
|
+
export function removeGuardArtifacts(artifacts) {
|
|
84
|
+
if (!artifacts)
|
|
85
|
+
return;
|
|
86
|
+
try {
|
|
87
|
+
rmSync(artifacts.dir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// teardown is best effort — a leftover temp dir must never fail a session
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=tool-allowlist-guard.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-run tool allowlist enforcement for the claude provider.
|
|
3
|
+
*
|
|
4
|
+
* WHY A HOOK AND NOT `--allowedTools`
|
|
5
|
+
* -----------------------------------
|
|
6
|
+
* `--allowedTools` (CLI) and `options.allowedTools` (SDK) are *permission allow
|
|
7
|
+
* rules*: they pre-approve a tool so it does not prompt. They do not restrict
|
|
8
|
+
* anything. Both of paseo's claude transports also run the child with
|
|
9
|
+
* `--dangerously-skip-permissions` / `allowDangerouslySkipPermissions`, under
|
|
10
|
+
* which every permission rule is moot, so passing an allowlist there would be
|
|
11
|
+
* decorative: accepted, parsed, and silently unenforcing.
|
|
12
|
+
*
|
|
13
|
+
* A `PreToolUse` hook is the one gate that still fires in that state. The Agent
|
|
14
|
+
* SDK says so explicitly (sdk.d.ts, PermissionDeniedHookInput): "PreToolUse hook
|
|
15
|
+
* denies bypass canUseTool". Verified against claude 2.1.239 with
|
|
16
|
+
* `--permission-mode acceptEdits --dangerously-skip-permissions`: a hook exiting
|
|
17
|
+
* 2 blocked a Bash call while an allowlisted Read call went through.
|
|
18
|
+
*
|
|
19
|
+
* FAIL CLOSED
|
|
20
|
+
* -----------
|
|
21
|
+
* When an allowlist is configured, anything this module cannot positively match
|
|
22
|
+
* is DENIED. A matcher that is too strict fails loudly (the model is told which
|
|
23
|
+
* rule set rejected it, and the run keeps going), while a matcher that is too
|
|
24
|
+
* lax fails silently and hands back a guarantee that is not real. Strictness is
|
|
25
|
+
* the safe direction, so unknown rule shapes and unknown specifier sources deny.
|
|
26
|
+
*
|
|
27
|
+
* Note this is *stricter* than `claude-pool launch --allowedTools`, where tools
|
|
28
|
+
* that never request permission (Read, Grep, TodoWrite, ...) were unaffected by
|
|
29
|
+
* the allowlist. Here the allowlist means exactly what it says: a tool absent
|
|
30
|
+
* from it cannot run at all.
|
|
31
|
+
*/
|
|
32
|
+
export interface ToolAllowRule {
|
|
33
|
+
/** Tool name, e.g. "Bash", "Read", "mcp__playwright-personal__browser_navigate". */
|
|
34
|
+
readonly tool: string;
|
|
35
|
+
/** Optional specifier from `Tool(specifier)` form, e.g. "git:*". */
|
|
36
|
+
readonly specifier?: string;
|
|
37
|
+
}
|
|
38
|
+
/** Tools whose specifier (the `Tool(spec)` argument) we know how to read off the input. */
|
|
39
|
+
export declare const SPECIFIER_SOURCES: Record<string, string>;
|
|
40
|
+
/**
|
|
41
|
+
* Parse CSV-or-array allowlist entries into rules. Entries are the same strings
|
|
42
|
+
* Claude Code permission rules use: `Bash`, `Bash(git:*)`, `Read`,
|
|
43
|
+
* `mcp__server__tool`. Blank entries are dropped.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseToolAllowlist(entries: readonly string[]): ToolAllowRule[];
|
|
46
|
+
/** Escape a literal for use inside a RegExp, leaving `*` to be expanded by the caller. */
|
|
47
|
+
export declare function escapeLiteral(value: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Match a Claude-Code-style specifier pattern against a value.
|
|
50
|
+
*
|
|
51
|
+
* Supported shapes (anything else denies):
|
|
52
|
+
* - `*` any value
|
|
53
|
+
* - `prefix:*` value starts with `prefix` (Claude Code's command-prefix form)
|
|
54
|
+
* - `glob` `*` wildcards, everything else literal, anchored both ends
|
|
55
|
+
*/
|
|
56
|
+
export declare function specifierMatches(pattern: string, value: string): boolean;
|
|
57
|
+
/** Read the specifier value a `Tool(spec)` rule compares against, or null when unknown. */
|
|
58
|
+
export declare function readSpecifierValue(toolName: string, input: unknown): string | null;
|
|
59
|
+
/**
|
|
60
|
+
* True when `toolName` (with `input`) is permitted by `rules`.
|
|
61
|
+
*
|
|
62
|
+
* An EMPTY rule list means "no allowlist configured" and allows everything; the
|
|
63
|
+
* caller is responsible for not installing the guard at all in that case.
|
|
64
|
+
*/
|
|
65
|
+
export declare function isToolAllowed(toolName: string, input: unknown, rules: readonly ToolAllowRule[]): boolean;
|
|
66
|
+
/** The message handed back to the model when a call is blocked. */
|
|
67
|
+
export declare function formatDenialMessage(toolName: string, rules: readonly ToolAllowRule[]): string;
|
|
68
|
+
//# sourceMappingURL=tool-allowlist.d.ts.map
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-run tool allowlist enforcement for the claude provider.
|
|
3
|
+
*
|
|
4
|
+
* WHY A HOOK AND NOT `--allowedTools`
|
|
5
|
+
* -----------------------------------
|
|
6
|
+
* `--allowedTools` (CLI) and `options.allowedTools` (SDK) are *permission allow
|
|
7
|
+
* rules*: they pre-approve a tool so it does not prompt. They do not restrict
|
|
8
|
+
* anything. Both of paseo's claude transports also run the child with
|
|
9
|
+
* `--dangerously-skip-permissions` / `allowDangerouslySkipPermissions`, under
|
|
10
|
+
* which every permission rule is moot, so passing an allowlist there would be
|
|
11
|
+
* decorative: accepted, parsed, and silently unenforcing.
|
|
12
|
+
*
|
|
13
|
+
* A `PreToolUse` hook is the one gate that still fires in that state. The Agent
|
|
14
|
+
* SDK says so explicitly (sdk.d.ts, PermissionDeniedHookInput): "PreToolUse hook
|
|
15
|
+
* denies bypass canUseTool". Verified against claude 2.1.239 with
|
|
16
|
+
* `--permission-mode acceptEdits --dangerously-skip-permissions`: a hook exiting
|
|
17
|
+
* 2 blocked a Bash call while an allowlisted Read call went through.
|
|
18
|
+
*
|
|
19
|
+
* FAIL CLOSED
|
|
20
|
+
* -----------
|
|
21
|
+
* When an allowlist is configured, anything this module cannot positively match
|
|
22
|
+
* is DENIED. A matcher that is too strict fails loudly (the model is told which
|
|
23
|
+
* rule set rejected it, and the run keeps going), while a matcher that is too
|
|
24
|
+
* lax fails silently and hands back a guarantee that is not real. Strictness is
|
|
25
|
+
* the safe direction, so unknown rule shapes and unknown specifier sources deny.
|
|
26
|
+
*
|
|
27
|
+
* Note this is *stricter* than `claude-pool launch --allowedTools`, where tools
|
|
28
|
+
* that never request permission (Read, Grep, TodoWrite, ...) were unaffected by
|
|
29
|
+
* the allowlist. Here the allowlist means exactly what it says: a tool absent
|
|
30
|
+
* from it cannot run at all.
|
|
31
|
+
*/
|
|
32
|
+
/** Tools whose specifier (the `Tool(spec)` argument) we know how to read off the input. */
|
|
33
|
+
export const SPECIFIER_SOURCES = {
|
|
34
|
+
Bash: "command",
|
|
35
|
+
BashOutput: "bash_id",
|
|
36
|
+
Read: "file_path",
|
|
37
|
+
Edit: "file_path",
|
|
38
|
+
Write: "file_path",
|
|
39
|
+
NotebookEdit: "notebook_path",
|
|
40
|
+
WebFetch: "url",
|
|
41
|
+
Glob: "pattern",
|
|
42
|
+
Grep: "pattern",
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Parse CSV-or-array allowlist entries into rules. Entries are the same strings
|
|
46
|
+
* Claude Code permission rules use: `Bash`, `Bash(git:*)`, `Read`,
|
|
47
|
+
* `mcp__server__tool`. Blank entries are dropped.
|
|
48
|
+
*/
|
|
49
|
+
export function parseToolAllowlist(entries) {
|
|
50
|
+
const rules = [];
|
|
51
|
+
for (const raw of entries) {
|
|
52
|
+
for (const piece of raw.split(",")) {
|
|
53
|
+
const entry = piece.trim();
|
|
54
|
+
if (!entry)
|
|
55
|
+
continue;
|
|
56
|
+
const match = /^([^()\s]+)\((.*)\)$/.exec(entry);
|
|
57
|
+
if (match?.[1] !== undefined && match[2] !== undefined) {
|
|
58
|
+
rules.push({ tool: match[1], specifier: match[2].trim() });
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
rules.push({ tool: entry });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return rules;
|
|
66
|
+
}
|
|
67
|
+
/** Escape a literal for use inside a RegExp, leaving `*` to be expanded by the caller. */
|
|
68
|
+
export function escapeLiteral(value) {
|
|
69
|
+
return value.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Match a Claude-Code-style specifier pattern against a value.
|
|
73
|
+
*
|
|
74
|
+
* Supported shapes (anything else denies):
|
|
75
|
+
* - `*` any value
|
|
76
|
+
* - `prefix:*` value starts with `prefix` (Claude Code's command-prefix form)
|
|
77
|
+
* - `glob` `*` wildcards, everything else literal, anchored both ends
|
|
78
|
+
*/
|
|
79
|
+
export function specifierMatches(pattern, value) {
|
|
80
|
+
if (pattern === "*")
|
|
81
|
+
return true;
|
|
82
|
+
const prefixForm = /^(.*):\*$/.exec(pattern);
|
|
83
|
+
if (prefixForm?.[1] !== undefined) {
|
|
84
|
+
const prefix = prefixForm[1].trim();
|
|
85
|
+
if (!prefix)
|
|
86
|
+
return false;
|
|
87
|
+
return value === prefix || value.startsWith(`${prefix} `);
|
|
88
|
+
}
|
|
89
|
+
const expanded = pattern.split("*").map(escapeLiteral).join(".*");
|
|
90
|
+
return new RegExp(`^${expanded}$`).test(value);
|
|
91
|
+
}
|
|
92
|
+
/** Read the specifier value a `Tool(spec)` rule compares against, or null when unknown. */
|
|
93
|
+
export function readSpecifierValue(toolName, input) {
|
|
94
|
+
const key = SPECIFIER_SOURCES[toolName];
|
|
95
|
+
if (!key)
|
|
96
|
+
return null;
|
|
97
|
+
if (typeof input !== "object" || input === null)
|
|
98
|
+
return null;
|
|
99
|
+
const value = input[key];
|
|
100
|
+
return typeof value === "string" ? value : null;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* True when `toolName` (with `input`) is permitted by `rules`.
|
|
104
|
+
*
|
|
105
|
+
* An EMPTY rule list means "no allowlist configured" and allows everything; the
|
|
106
|
+
* caller is responsible for not installing the guard at all in that case.
|
|
107
|
+
*/
|
|
108
|
+
export function isToolAllowed(toolName, input, rules) {
|
|
109
|
+
if (rules.length === 0)
|
|
110
|
+
return true;
|
|
111
|
+
for (const rule of rules) {
|
|
112
|
+
if (rule.tool !== toolName)
|
|
113
|
+
continue;
|
|
114
|
+
if (rule.specifier === undefined)
|
|
115
|
+
return true;
|
|
116
|
+
const value = readSpecifierValue(toolName, input);
|
|
117
|
+
// Unknown specifier source: we cannot verify the rule, so we do not honor it.
|
|
118
|
+
if (value === null)
|
|
119
|
+
continue;
|
|
120
|
+
if (specifierMatches(rule.specifier, value))
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
/** The message handed back to the model when a call is blocked. */
|
|
126
|
+
export function formatDenialMessage(toolName, rules) {
|
|
127
|
+
const listed = rules
|
|
128
|
+
.map((rule) => (rule.specifier === undefined ? rule.tool : `${rule.tool}(${rule.specifier})`))
|
|
129
|
+
.join(", ");
|
|
130
|
+
return (`Tool "${toolName}" is blocked by this run's tool allowlist and was not executed. ` +
|
|
131
|
+
`Allowed: ${listed}. Do not retry this tool; achieve the goal with an allowed tool or report that you cannot.`);
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=tool-allowlist.js.map
|
|
@@ -59,6 +59,8 @@ export declare class PtyTransport implements AgentTransport {
|
|
|
59
59
|
private hookBuffer;
|
|
60
60
|
private bridgeClose;
|
|
61
61
|
private systemPromptFilePath;
|
|
62
|
+
/** Set only when PASEO_PTY_TMUX=1 wrapped this spawn. Null means a bare pty. */
|
|
63
|
+
private tmux;
|
|
62
64
|
/**
|
|
63
65
|
* Has the child process exited?
|
|
64
66
|
*
|
|
@@ -102,6 +104,21 @@ export declare class PtyTransport implements AgentTransport {
|
|
|
102
104
|
onHookEvent(handler: (event: HookEvent) => void): () => void;
|
|
103
105
|
onData(handler: (chunk: string) => void): () => void;
|
|
104
106
|
onExit(handler: (code: number | null, signal: NodeJS.Signals | null) => void): () => void;
|
|
107
|
+
/**
|
|
108
|
+
* The settled, rendered screen as text, read OUT OF BAND, or null when unavailable.
|
|
109
|
+
*
|
|
110
|
+
* This is the reason to run tmux at all. Everything paseo knows about the screen today is
|
|
111
|
+
* scraped from the live byte stream it is simultaneously racing, which is why the queued
|
|
112
|
+
* footer match is fragile to wrapping and ANSI interleaving. Null on a bare pty and on any
|
|
113
|
+
* tmux error, so a caller must always keep its stream-based path: a diagnostic that can
|
|
114
|
+
* fail a turn is worse than no diagnostic.
|
|
115
|
+
*/
|
|
116
|
+
capturePane(): string | null;
|
|
117
|
+
/** Multiplexer identity, for logs and tests. Null on a bare pty. */
|
|
118
|
+
get multiplexer(): {
|
|
119
|
+
socket: string;
|
|
120
|
+
session: string;
|
|
121
|
+
} | null;
|
|
105
122
|
context(): AgentTransportContext;
|
|
106
123
|
}
|
|
107
124
|
export {};
|
|
@@ -2,6 +2,7 @@ import { unlink } from "node:fs";
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { setTimeout as delay } from "node:timers/promises";
|
|
4
4
|
import { redactChunk } from "./redact.js";
|
|
5
|
+
import { assertTmuxUsable, capturePane, killSession, tmuxEnabled, wrapWithTmux } from "./tmux.js";
|
|
5
6
|
const DEFAULT_COLS = 200;
|
|
6
7
|
const DEFAULT_ROWS = 50;
|
|
7
8
|
const DEFAULT_KILL_TIMEOUT_MS = 5000;
|
|
@@ -83,6 +84,8 @@ export class PtyTransport {
|
|
|
83
84
|
this.hookBuffer = [];
|
|
84
85
|
this.bridgeClose = null;
|
|
85
86
|
this.systemPromptFilePath = null;
|
|
87
|
+
/** Set only when PASEO_PTY_TMUX=1 wrapped this spawn. Null means a bare pty. */
|
|
88
|
+
this.tmux = null;
|
|
86
89
|
}
|
|
87
90
|
/**
|
|
88
91
|
* Has the child process exited?
|
|
@@ -142,7 +145,30 @@ export class PtyTransport {
|
|
|
142
145
|
this._cols = cols;
|
|
143
146
|
this._rows = rows;
|
|
144
147
|
this.systemPromptFilePath = opts.systemPromptFilePath ?? null;
|
|
145
|
-
|
|
148
|
+
// Optionally run the agent inside tmux. The daemon's pty then holds a tmux CLIENT rather
|
|
149
|
+
// than the agent itself, so the agent survives this process: `new-session -A` reattaches
|
|
150
|
+
// an existing session and creates one otherwise, which is the reattach a daemon restart
|
|
151
|
+
// needs. Exit detection is unaffected - when the agent exits, its tmux session ends, our
|
|
152
|
+
// client exits, and node-pty's onExit fires exactly as before.
|
|
153
|
+
let binary = opts.binary;
|
|
154
|
+
let args = opts.args;
|
|
155
|
+
if (tmuxEnabled(opts.env)) {
|
|
156
|
+
// Assert at the boundary where a missing binary actually matters. wrapWithTmux() stays
|
|
157
|
+
// pure so it can be unit-tested on a host without tmux (CI runs node:24-slim).
|
|
158
|
+
assertTmuxUsable();
|
|
159
|
+
const wrap = wrapWithTmux({
|
|
160
|
+
binary: opts.binary,
|
|
161
|
+
args: opts.args,
|
|
162
|
+
cwd: opts.cwd,
|
|
163
|
+
sessionId: opts.sessionId ?? `${process.pid}-${this._cols}x${this._rows}`,
|
|
164
|
+
dims: { cols, rows },
|
|
165
|
+
env: opts.env,
|
|
166
|
+
});
|
|
167
|
+
binary = wrap.binary;
|
|
168
|
+
args = wrap.args;
|
|
169
|
+
this.tmux = { socket: wrap.socket, session: wrap.session };
|
|
170
|
+
}
|
|
171
|
+
this._pty = pty.spawn(binary, args, {
|
|
146
172
|
name: "xterm-256color",
|
|
147
173
|
cols,
|
|
148
174
|
rows,
|
|
@@ -231,6 +257,12 @@ export class PtyTransport {
|
|
|
231
257
|
}
|
|
232
258
|
this.bridgeClose = null;
|
|
233
259
|
}
|
|
260
|
+
// Under tmux, killing our client only DETACHES: the agent would keep running forever,
|
|
261
|
+
// which is the design working against us here. kill() has to mean kill, so end the
|
|
262
|
+
// session first and let the client fall out with it.
|
|
263
|
+
if (this.tmux) {
|
|
264
|
+
killSession(this.tmux.socket, this.tmux.session);
|
|
265
|
+
}
|
|
234
266
|
if (!this._pty)
|
|
235
267
|
return;
|
|
236
268
|
try {
|
|
@@ -285,6 +317,24 @@ export class PtyTransport {
|
|
|
285
317
|
this.exitHandlers = this.exitHandlers.filter((h) => h !== handler);
|
|
286
318
|
};
|
|
287
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* The settled, rendered screen as text, read OUT OF BAND, or null when unavailable.
|
|
322
|
+
*
|
|
323
|
+
* This is the reason to run tmux at all. Everything paseo knows about the screen today is
|
|
324
|
+
* scraped from the live byte stream it is simultaneously racing, which is why the queued
|
|
325
|
+
* footer match is fragile to wrapping and ANSI interleaving. Null on a bare pty and on any
|
|
326
|
+
* tmux error, so a caller must always keep its stream-based path: a diagnostic that can
|
|
327
|
+
* fail a turn is worse than no diagnostic.
|
|
328
|
+
*/
|
|
329
|
+
capturePane() {
|
|
330
|
+
if (!this.tmux)
|
|
331
|
+
return null;
|
|
332
|
+
return capturePane(this.tmux.socket, this.tmux.session);
|
|
333
|
+
}
|
|
334
|
+
/** Multiplexer identity, for logs and tests. Null on a bare pty. */
|
|
335
|
+
get multiplexer() {
|
|
336
|
+
return this.tmux ? { ...this.tmux } : null;
|
|
337
|
+
}
|
|
288
338
|
context() {
|
|
289
339
|
if (!this._pty) {
|
|
290
340
|
throw new Error("PtyTransport.context called before spawn()");
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional tmux multiplexer under the claude PTY.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS, and what it does NOT buy. The dead-session bug people reach for tmux to
|
|
5
|
+
* solve was a missing `onExit` subscription, and it is already fixed in the transport: under
|
|
6
|
+
* tmux the same bug would simply have been spelled `pane_dead` and been equally unread. What
|
|
7
|
+
* tmux actually buys is two things nothing else here provides:
|
|
8
|
+
*
|
|
9
|
+
* 1. PROCESS OWNERSHIP. The `claude` children belong to the tmux server, not to the daemon.
|
|
10
|
+
* Restarting or crashing the daemon no longer takes every live session with it (on
|
|
11
|
+
* 2026-08-19 one restart killed 21).
|
|
12
|
+
* 2. A RENDERED-PANE ORACLE. `capture-pane -p` returns the settled screen as text, out of
|
|
13
|
+
* band. Delivery currently leans on `QUEUED_FOOTER_RE` matched against a live byte
|
|
14
|
+
* stream, which races wrapping and ANSI interleaving; the same regex against a settled
|
|
15
|
+
* pane does not.
|
|
16
|
+
*
|
|
17
|
+
* OPT-IN, AND POSIX ONLY. Enabled per host with `PASEO_PTY_TMUX=1`. There is no tmux on
|
|
18
|
+
* Windows and paseo ships an Electron desktop, so this refuses to run there rather than
|
|
19
|
+
* silently degrading. node-pty is already an optionalDependency that falls back to SDK when
|
|
20
|
+
* absent; stacking a second silent fallback would make "which transport am I actually on"
|
|
21
|
+
* unanswerable, so a missing tmux binary is a LOUD error, never a shrug.
|
|
22
|
+
*/
|
|
23
|
+
/** Cols/rows the daemon pins. tmux would otherwise resize to the smallest attached client. */
|
|
24
|
+
export interface TmuxDims {
|
|
25
|
+
cols: number;
|
|
26
|
+
rows: number;
|
|
27
|
+
}
|
|
28
|
+
export interface TmuxWrap {
|
|
29
|
+
binary: string;
|
|
30
|
+
args: string[];
|
|
31
|
+
socket: string;
|
|
32
|
+
session: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function tmuxEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
35
|
+
/** Stable, collision-free session name. */
|
|
36
|
+
export declare function tmuxSessionName(sessionId: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Build the argv that runs `binary args...` inside tmux.
|
|
39
|
+
*
|
|
40
|
+
* `new-session -A` attaches to an existing session of that name and creates one otherwise,
|
|
41
|
+
* which is exactly the reattach semantics a daemon restart needs: the agent kept running in
|
|
42
|
+
* the tmux server, and the new daemon becomes a client of it.
|
|
43
|
+
*/
|
|
44
|
+
export declare function wrapWithTmux(opts: {
|
|
45
|
+
binary: string;
|
|
46
|
+
args: string[];
|
|
47
|
+
cwd: string;
|
|
48
|
+
sessionId: string;
|
|
49
|
+
dims: TmuxDims;
|
|
50
|
+
/** The SAME env that enabled tmux, so PASEO_HOME picks the socket dir consistently. */
|
|
51
|
+
env: NodeJS.ProcessEnv;
|
|
52
|
+
}): TmuxWrap;
|
|
53
|
+
/** Loud, never a silent downgrade. See the header note on stacked fallbacks. */
|
|
54
|
+
export declare function assertTmuxUsable(): void;
|
|
55
|
+
/**
|
|
56
|
+
* THE ORACLE: the settled, rendered pane as text, read out of band.
|
|
57
|
+
*
|
|
58
|
+
* Everything paseo currently knows about the screen it scrapes from the live byte stream it
|
|
59
|
+
* is simultaneously racing, which is why `QUEUED_FOOTER_RE` is fragile to wrapping and ANSI
|
|
60
|
+
* interleaving. This asks tmux for what is actually on screen instead. Returns null rather
|
|
61
|
+
* than throwing: the caller is always able to fall back to the stream, and a diagnostic must
|
|
62
|
+
* never be able to fail a turn.
|
|
63
|
+
*/
|
|
64
|
+
export declare function capturePane(socket: string, session: string): string | null;
|
|
65
|
+
/** Is the tmux session still alive? An out-of-band liveness check the bare pty cannot offer. */
|
|
66
|
+
export declare function sessionAlive(socket: string, session: string): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Kill the SESSION, not merely our client.
|
|
69
|
+
*
|
|
70
|
+
* Detaching would leave `claude` running forever, which is the whole point of the design and
|
|
71
|
+
* therefore also its sharpest edge: `kill()` has to mean kill.
|
|
72
|
+
*/
|
|
73
|
+
export declare function killSession(socket: string, session: string): void;
|
|
74
|
+
//# sourceMappingURL=tmux.d.ts.map
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
export function tmuxEnabled(env = process.env) {
|
|
6
|
+
return env.PASEO_PTY_TMUX === "1";
|
|
7
|
+
}
|
|
8
|
+
/** Private socket dir. 0700 because of what these sessions are. */
|
|
9
|
+
function socketDir(env) {
|
|
10
|
+
const home = env.PASEO_HOME ?? path.join(os.homedir(), ".paseo");
|
|
11
|
+
const dir = path.join(home, "tmux");
|
|
12
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
13
|
+
// mkdir's mode is umask-masked, so set it explicitly rather than hoping.
|
|
14
|
+
chmodSync(dir, 0o700);
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The config every paseo tmux session runs with. Four settings, each load-bearing, each for a
|
|
19
|
+
* failure this transport would otherwise hit in production rather than in review.
|
|
20
|
+
*/
|
|
21
|
+
function writeConf(dir, dims) {
|
|
22
|
+
const conf = path.join(dir, "paseo.tmux.conf");
|
|
23
|
+
writeFileSync(conf, [
|
|
24
|
+
// 1. NO PREFIX. paseo writes real control chords straight through to the TUI, including
|
|
25
|
+
// Claude Code's `ctrl+x ctrl+k` kill chord, and the protocol layer already encodes
|
|
26
|
+
// \x02 (terminal-key-input.test.ts calls it "the tmux prefix"). Leaving the default
|
|
27
|
+
// ctrl+b bound means tmux and the agent fight over the keyboard.
|
|
28
|
+
"set -g prefix None",
|
|
29
|
+
"set -g prefix2 None",
|
|
30
|
+
"unbind-key -a",
|
|
31
|
+
// 2. ESCAPE TIME ZERO. PtyQuery.interrupt() sends a bare \x1b to interrupt a turn.
|
|
32
|
+
// tmux's default 500ms escape-time holds that byte back while it waits to see if a
|
|
33
|
+
// meta sequence follows, so interrupts would arrive late or be swallowed outright.
|
|
34
|
+
"set -sg escape-time 0",
|
|
35
|
+
// 3. NO STATUS BAR. It steals a row from the pane and would land in capture-pane output,
|
|
36
|
+
// which is precisely the surface the oracle is supposed to read cleanly.
|
|
37
|
+
"set -g status off",
|
|
38
|
+
// 4. SIZE TO THE LARGEST CLIENT, PLUS AN EXPLICIT DEFAULT. tmux sizes a window to its
|
|
39
|
+
// SMALLEST attached client by default, so a human attaching from a phone terminal to
|
|
40
|
+
// troubleshoot would reflow the agent TUI to 80 columns underneath the daemon and
|
|
41
|
+
// silently break every scraper built against the wide layout. The debugging tool must
|
|
42
|
+
// not corrupt the thing being debugged.
|
|
43
|
+
//
|
|
44
|
+
// `window-size manual` is the textbook answer and it is NOT USABLE HERE: on tmux
|
|
45
|
+
// 3.4 both `setw -g window-size manual` and `set -wg window-size manual` CRASH the
|
|
46
|
+
// server outright ("server exited unexpectedly", session never created, verified by
|
|
47
|
+
// bisecting this file line by line). `largest` achieves what we need anyway - a
|
|
48
|
+
// smaller client can no longer shrink the window - and `default-size` pins the
|
|
49
|
+
// dimensions a detached session starts at.
|
|
50
|
+
"set -g window-size largest",
|
|
51
|
+
`set -g default-size ${dims.cols}x${dims.rows}`,
|
|
52
|
+
"set -g history-limit 20000",
|
|
53
|
+
"set -g mouse off",
|
|
54
|
+
"set -g destroy-unattached off",
|
|
55
|
+
].join("\n") + "\n", { mode: 0o600 });
|
|
56
|
+
return conf;
|
|
57
|
+
}
|
|
58
|
+
/** Stable, collision-free session name. */
|
|
59
|
+
export function tmuxSessionName(sessionId) {
|
|
60
|
+
return `paseo-${sessionId.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 40)}`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build the argv that runs `binary args...` inside tmux.
|
|
64
|
+
*
|
|
65
|
+
* `new-session -A` attaches to an existing session of that name and creates one otherwise,
|
|
66
|
+
* which is exactly the reattach semantics a daemon restart needs: the agent kept running in
|
|
67
|
+
* the tmux server, and the new daemon becomes a client of it.
|
|
68
|
+
*/
|
|
69
|
+
export function wrapWithTmux(opts) {
|
|
70
|
+
// NOTE: assertTmuxUsable() is deliberately NOT called here. This function only builds argv
|
|
71
|
+
// and writes a config file, so keeping it free of any dependency on a live tmux binary
|
|
72
|
+
// makes it testable on a host that has none - which is the normal case in CI, where the
|
|
73
|
+
// image is node:24-slim. The assertion belongs at the spawn boundary (PtyTransport.spawn),
|
|
74
|
+
// which is the point where a missing binary actually matters.
|
|
75
|
+
const dir = socketDir(opts.env);
|
|
76
|
+
const socket = path.join(dir, "paseo.sock");
|
|
77
|
+
const conf = writeConf(dir, opts.dims);
|
|
78
|
+
const session = tmuxSessionName(opts.sessionId);
|
|
79
|
+
return {
|
|
80
|
+
binary: "tmux",
|
|
81
|
+
args: [
|
|
82
|
+
"-S",
|
|
83
|
+
socket,
|
|
84
|
+
"-f",
|
|
85
|
+
conf,
|
|
86
|
+
"new-session",
|
|
87
|
+
"-A",
|
|
88
|
+
"-s",
|
|
89
|
+
session,
|
|
90
|
+
"-c",
|
|
91
|
+
opts.cwd,
|
|
92
|
+
"-x",
|
|
93
|
+
String(opts.dims.cols),
|
|
94
|
+
"-y",
|
|
95
|
+
String(opts.dims.rows),
|
|
96
|
+
"--",
|
|
97
|
+
opts.binary,
|
|
98
|
+
...opts.args,
|
|
99
|
+
],
|
|
100
|
+
socket,
|
|
101
|
+
session,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** Loud, never a silent downgrade. See the header note on stacked fallbacks. */
|
|
105
|
+
export function assertTmuxUsable() {
|
|
106
|
+
if (process.platform === "win32") {
|
|
107
|
+
throw new Error("PASEO_PTY_TMUX=1 is set, but tmux does not exist on Windows. Unset it, or run the " +
|
|
108
|
+
"daemon on a POSIX host. Refusing to silently fall back so the active transport stays knowable.");
|
|
109
|
+
}
|
|
110
|
+
const probe = spawnSync("tmux", ["-V"], { encoding: "utf8" });
|
|
111
|
+
if (probe.error || probe.status !== 0) {
|
|
112
|
+
throw new Error("PASEO_PTY_TMUX=1 is set, but `tmux -V` did not run. Install tmux or unset the variable. " +
|
|
113
|
+
"Refusing to silently fall back to a bare pty, because a silent downgrade makes the " +
|
|
114
|
+
"active transport unknowable (node-pty already degrades to SDK when absent).");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* THE ORACLE: the settled, rendered pane as text, read out of band.
|
|
119
|
+
*
|
|
120
|
+
* Everything paseo currently knows about the screen it scrapes from the live byte stream it
|
|
121
|
+
* is simultaneously racing, which is why `QUEUED_FOOTER_RE` is fragile to wrapping and ANSI
|
|
122
|
+
* interleaving. This asks tmux for what is actually on screen instead. Returns null rather
|
|
123
|
+
* than throwing: the caller is always able to fall back to the stream, and a diagnostic must
|
|
124
|
+
* never be able to fail a turn.
|
|
125
|
+
*/
|
|
126
|
+
export function capturePane(socket, session) {
|
|
127
|
+
try {
|
|
128
|
+
return execFileSync("tmux", ["-S", socket, "capture-pane", "-p", "-t", session], {
|
|
129
|
+
encoding: "utf8",
|
|
130
|
+
timeout: 2000,
|
|
131
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Is the tmux session still alive? An out-of-band liveness check the bare pty cannot offer. */
|
|
139
|
+
export function sessionAlive(socket, session) {
|
|
140
|
+
const r = spawnSync("tmux", ["-S", socket, "has-session", "-t", session], { timeout: 2000 });
|
|
141
|
+
return r.status === 0;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Kill the SESSION, not merely our client.
|
|
145
|
+
*
|
|
146
|
+
* Detaching would leave `claude` running forever, which is the whole point of the design and
|
|
147
|
+
* therefore also its sharpest edge: `kill()` has to mean kill.
|
|
148
|
+
*/
|
|
149
|
+
export function killSession(socket, session) {
|
|
150
|
+
try {
|
|
151
|
+
execFileSync("tmux", ["-S", socket, "kill-session", "-t", session], { timeout: 5000 });
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// already gone
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=tmux.js.map
|
|
@@ -14,6 +14,12 @@ export interface AgentTransportSpawnOptions {
|
|
|
14
14
|
* transport is responsible for unlinking this file in kill().
|
|
15
15
|
*/
|
|
16
16
|
systemPromptFilePath?: string;
|
|
17
|
+
/**
|
|
18
|
+
* The claude session id, used to name a tmux session deterministically so a restarted
|
|
19
|
+
* daemon reattaches the SAME agent instead of starting a second one beside it. Optional
|
|
20
|
+
* because a bare pty has no use for it.
|
|
21
|
+
*/
|
|
22
|
+
sessionId?: string;
|
|
17
23
|
}
|
|
18
24
|
export type AgentTransportContext = {
|
|
19
25
|
transport: "pty";
|
|
@@ -165,6 +165,13 @@ export interface OpenCodeEventTranslationState {
|
|
|
165
165
|
pendingChildToolPartsBySessionId?: Map<string, OpenCodeToolPartEventPart[]>;
|
|
166
166
|
modelContextWindowsByModelKey?: ReadonlyMap<string, number>;
|
|
167
167
|
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
|
|
168
|
+
/**
|
|
169
|
+
* Invoked when a provider "retry" status carries a DETERMINISTIC, non-retryable
|
|
170
|
+
* error. opencode never gives up on a retry on its own (it backs off and retries
|
|
171
|
+
* forever), so for these the agent must abort the underlying opencode session or
|
|
172
|
+
* the turn wedges on an infinite retry loop. See isTerminalProviderRetryMessage.
|
|
173
|
+
*/
|
|
174
|
+
onTerminalProviderRetry?: (message: string) => void;
|
|
168
175
|
}
|
|
169
176
|
type OpenCodeToolPartEventPart = Extract<Extract<OpenCodeEvent, {
|
|
170
177
|
type: "message.part.updated";
|