@hyperdrive.bot/paseo-server 0.3.41 → 0.3.43

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 (35) hide show
  1. package/dist/server/server/agent/agent-manager.d.ts +15 -0
  2. package/dist/server/server/agent/agent-manager.js +142 -25
  3. package/dist/server/server/agent/mcp-server.js +6 -1
  4. package/dist/server/server/agent/providers/claude/background-task-tracker.d.ts +38 -1
  5. package/dist/server/server/agent/providers/claude/background-task-tracker.js +114 -9
  6. package/dist/server/server/agent/providers/claude/background-work-kinds.d.ts +95 -0
  7. package/dist/server/server/agent/providers/claude/background-work-kinds.js +73 -0
  8. package/dist/server/server/agent/providers/claude/pty-session-launcher.js +3 -0
  9. package/dist/server/server/agent/providers/claude/transport/pty.d.ts +17 -0
  10. package/dist/server/server/agent/providers/claude/transport/pty.js +51 -1
  11. package/dist/server/server/agent/providers/claude/transport/tmux.d.ts +74 -0
  12. package/dist/server/server/agent/providers/claude/transport/tmux.js +157 -0
  13. package/dist/server/server/agent/providers/claude/transport/types.d.ts +6 -0
  14. package/dist/server/server/agent/providers/opencode-agent.d.ts +7 -0
  15. package/dist/server/server/agent/providers/opencode-agent.js +51 -1
  16. package/dist/server/server/bootstrap.js +1 -0
  17. package/dist/server/server/session.js +1 -0
  18. package/dist/server/server/workflow/workflow-agent-resolution.d.ts +82 -0
  19. package/dist/server/server/workflow/workflow-agent-resolution.js +105 -0
  20. package/dist/server/server/workflow/workflow-manager.d.ts +155 -20
  21. package/dist/server/server/workflow/workflow-manager.js +439 -31
  22. package/dist/server/server/workflow/workflow-progress.d.ts +53 -0
  23. package/dist/server/server/workflow/workflow-progress.js +96 -0
  24. package/dist/server/server/workspace-directory.js +32 -14
  25. package/dist/server/web-ui/_expo/static/js/web/{index-cb251ddad56c08c3021036af3a43fc0f.js → index-2ff48a7009ad2309c577d5a43b925f69.js} +18 -18
  26. package/dist/server/web-ui/_expo/static/js/web/index-2ff48a7009ad2309c577d5a43b925f69.js.br +0 -0
  27. package/dist/server/web-ui/_expo/static/js/web/index-2ff48a7009ad2309c577d5a43b925f69.js.gz +0 -0
  28. package/dist/server/web-ui/_expo/static/js/web/{index-cb251ddad56c08c3021036af3a43fc0f.js.map.br → index-2ff48a7009ad2309c577d5a43b925f69.js.map.br} +0 -0
  29. package/dist/server/web-ui/_expo/static/js/web/{index-cb251ddad56c08c3021036af3a43fc0f.js.map.gz → index-2ff48a7009ad2309c577d5a43b925f69.js.map.gz} +0 -0
  30. package/dist/server/web-ui/index.html +1 -1
  31. package/dist/server/web-ui/index.html.br +0 -0
  32. package/dist/server/web-ui/index.html.gz +0 -0
  33. package/package.json +6 -6
  34. package/dist/server/web-ui/_expo/static/js/web/index-cb251ddad56c08c3021036af3a43fc0f.js.br +0 -0
  35. package/dist/server/web-ui/_expo/static/js/web/index-cb251ddad56c08c3021036af3a43fc0f.js.gz +0 -0
@@ -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
@@ -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({
@@ -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";
@@ -1925,6 +1925,29 @@ function appendOpenCodeSessionError(event, state, events) {
1925
1925
  });
1926
1926
  }
1927
1927
  }
1928
+ /**
1929
+ * Some opencode "retry" errors are DETERMINISTIC: the exact request that failed
1930
+ * will fail identically on every retry, so opencode's retry-forever policy turns
1931
+ * them into a permanent wedge (endless "Provider retry" lines, spinner never ends,
1932
+ * the turn never reaches a terminal state). We only match the failure whose
1933
+ * unrecoverability is provable from the request itself.
1934
+ *
1935
+ * Deliberately NARROW. Notably EXCLUDED (kept retryable on purpose):
1936
+ * - "model does not exist" / "not found": ollama and other backends lazy-load a
1937
+ * model on first use, so this can resolve mid-retry once the model finishes
1938
+ * loading. A unit test pins this as non-terminal.
1939
+ * - auth failures (401 / invalid api key): a token can be refreshed out of band
1940
+ * between retries in some provider setups.
1941
+ *
1942
+ * Currently the sole member is the model server rejecting a request that has no
1943
+ * user-role message (e.g. ollama 0.32.x "no user query found in messages"). The
1944
+ * message array cannot gain a user turn on retry, so it never recovers. Add a new
1945
+ * pattern here only when its unrecoverability is likewise provable.
1946
+ */
1947
+ const TERMINAL_PROVIDER_RETRY_PATTERNS = [/no user query found in messages/i];
1948
+ function isTerminalProviderRetryMessage(message) {
1949
+ return message.length > 0 && TERMINAL_PROVIDER_RETRY_PATTERNS.some((re) => re.test(message));
1950
+ }
1928
1951
  function appendOpenCodeSessionStatus(event, state, events) {
1929
1952
  if (event.properties.sessionID !== state.sessionId) {
1930
1953
  return;
@@ -1936,12 +1959,32 @@ function appendOpenCodeSessionStatus(event, state, events) {
1936
1959
  return;
1937
1960
  }
1938
1961
  if (status.type === "retry") {
1962
+ const message = typeof status.message === "string" ? status.message.trim() : "";
1963
+ // A deterministic provider error fails identically on every retry, and
1964
+ // opencode never gives up on its own. Fail the turn cleanly and ask the agent
1965
+ // to abort the opencode session so it stops backing off forever.
1966
+ if (isTerminalProviderRetryMessage(message)) {
1967
+ events.push({
1968
+ type: "timeline",
1969
+ provider: "opencode",
1970
+ item: {
1971
+ type: "error",
1972
+ message: `Provider gave up (non-retryable after attempt ${status.attempt}): ${message}`,
1973
+ },
1974
+ });
1975
+ state.onTerminalProviderRetry?.(message);
1976
+ events.push({
1977
+ type: "turn_failed",
1978
+ provider: "opencode",
1979
+ error: message,
1980
+ });
1981
+ return;
1982
+ }
1939
1983
  // Mirror what opencode's TUI shows: retry attempts are visible activity, not
1940
1984
  // terminal. opencode itself never gives up — it backs off and tries again
1941
1985
  // forever. If we silently swallow these the user sees a spinner with no
1942
1986
  // explanation. Forwarding as a timeline error item is a no-op for old
1943
1987
  // clients (the schema already supports it).
1944
- const message = typeof status.message === "string" ? status.message.trim() : "";
1945
1988
  const text = message
1946
1989
  ? `Provider retry (attempt ${status.attempt}): ${message}`
1947
1990
  : `Provider retry (attempt ${status.attempt})`;
@@ -2801,6 +2844,13 @@ class OpenCodeAgentSession {
2801
2844
  this.selectedModelContextWindowMaxTokens = contextWindowMaxTokens;
2802
2845
  }
2803
2846
  },
2847
+ onTerminalProviderRetry: () => {
2848
+ // The translated turn_failed already ends the turn for our clients; this
2849
+ // aborts opencode's session so its own retry-forever loop stops hammering
2850
+ // the provider in the background. Best-effort: an abort failure must not
2851
+ // mask the terminal turn_failed.
2852
+ void this.beginSessionAbort(this.activeForegroundTurnId, "provider_error").catch(() => { });
2853
+ },
2804
2854
  });
2805
2855
  const events = [];
2806
2856
  if (typeof this.accumulatedUsage.totalCostUsd === "number") {
@@ -669,6 +669,7 @@ export async function createPaseoDaemon(config, rootLogger) {
669
669
  const workflowManager = new WorkflowManager({
670
670
  agentManager,
671
671
  storage: workflowStorage,
672
+ logger,
672
673
  });
673
674
  // Story 2.1 — read-only HTTP status + SSE phase-transition routes. Gated on the
674
675
  // same WORKFLOWS_FEATURE_ENABLED constant as the server_info handshake flag.
@@ -1202,6 +1202,7 @@ export class Session {
1202
1202
  baseConfig,
1203
1203
  ...(msg.title ? { title: msg.title } : {}),
1204
1204
  ...(msg.labels ? { labels: msg.labels } : {}),
1205
+ ...(msg.agentPresets ? { agentPresets: msg.agentPresets } : {}),
1205
1206
  });
1206
1207
  const started = await manager.startWorkflow(workflow.id);
1207
1208
  this.emit({