@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.
- package/dist/server/server/agent/agent-manager.d.ts +15 -0
- package/dist/server/server/agent/agent-manager.js +142 -25
- package/dist/server/server/agent/mcp-server.js +6 -1
- 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.js +3 -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/bootstrap.js +1 -0
- package/dist/server/server/session.js +1 -0
- package/dist/server/server/workflow/workflow-agent-resolution.d.ts +82 -0
- package/dist/server/server/workflow/workflow-agent-resolution.js +105 -0
- package/dist/server/server/workflow/workflow-manager.d.ts +155 -20
- package/dist/server/server/workflow/workflow-manager.js +439 -31
- package/dist/server/server/workflow/workflow-progress.d.ts +53 -0
- package/dist/server/server/workflow/workflow-progress.js +96 -0
- package/dist/server/server/workspace-directory.js +32 -14
- package/dist/server/web-ui/_expo/static/js/web/{index-cb251ddad56c08c3021036af3a43fc0f.js → index-2ff48a7009ad2309c577d5a43b925f69.js} +18 -18
- package/dist/server/web-ui/_expo/static/js/web/index-2ff48a7009ad2309c577d5a43b925f69.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-2ff48a7009ad2309c577d5a43b925f69.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-cb251ddad56c08c3021036af3a43fc0f.js.map.br → index-2ff48a7009ad2309c577d5a43b925f69.js.map.br} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-cb251ddad56c08c3021036af3a43fc0f.js.map.gz → index-2ff48a7009ad2309c577d5a43b925f69.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-cb251ddad56c08c3021036af3a43fc0f.js.br +0 -0
- 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
|
-
|
|
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({
|