@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
@@ -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
- this._pty = pty.spawn(opts.binary, opts.args, {
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";