tmux-manager 0.1.0
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/LICENSE +201 -0
- package/README.md +521 -0
- package/dist/index.d.mts +669 -0
- package/dist/index.d.ts +669 -0
- package/dist/index.js +1990 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1951 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +63 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1951 @@
|
|
|
1
|
+
// src/ensure-tmux.ts
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
var _checked = false;
|
|
4
|
+
var _version;
|
|
5
|
+
function ensureTmux(log) {
|
|
6
|
+
if (_checked && _version) {
|
|
7
|
+
return _version;
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
const output = execSync("tmux -V", {
|
|
11
|
+
encoding: "utf-8",
|
|
12
|
+
timeout: 5e3,
|
|
13
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
14
|
+
}).trim();
|
|
15
|
+
const match = output.match(/tmux\s+([\w.\-]+)/);
|
|
16
|
+
_version = match?.[1] ?? output;
|
|
17
|
+
_checked = true;
|
|
18
|
+
log?.(`tmux ${_version} found`);
|
|
19
|
+
return _version;
|
|
20
|
+
} catch {
|
|
21
|
+
throw new Error(
|
|
22
|
+
"tmux is required but not found on PATH. Install it with: brew install tmux (macOS) or apt install tmux (Linux)"
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function resetTmuxCheck() {
|
|
27
|
+
_checked = false;
|
|
28
|
+
_version = void 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/tmux-manager.ts
|
|
32
|
+
import { EventEmitter as EventEmitter2 } from "events";
|
|
33
|
+
|
|
34
|
+
// src/adapters/adapter-registry.ts
|
|
35
|
+
import { AdapterRegistry } from "adapter-types";
|
|
36
|
+
|
|
37
|
+
// src/tmux-session.ts
|
|
38
|
+
import { EventEmitter } from "events";
|
|
39
|
+
import { randomUUID } from "crypto";
|
|
40
|
+
|
|
41
|
+
// src/logger.ts
|
|
42
|
+
var consoleLogger = {
|
|
43
|
+
debug: (...args) => {
|
|
44
|
+
if (typeof args[0] === "string") {
|
|
45
|
+
console.debug(args[0], args[1]);
|
|
46
|
+
} else {
|
|
47
|
+
console.debug(args[1], args[0]);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
info: (...args) => {
|
|
51
|
+
if (typeof args[0] === "string") {
|
|
52
|
+
console.info(args[0], args[1]);
|
|
53
|
+
} else {
|
|
54
|
+
console.info(args[1], args[0]);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
warn: (...args) => {
|
|
58
|
+
if (typeof args[0] === "string") {
|
|
59
|
+
console.warn(args[0], args[1]);
|
|
60
|
+
} else {
|
|
61
|
+
console.warn(args[1], args[0]);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
error: (...args) => {
|
|
65
|
+
if (typeof args[0] === "string") {
|
|
66
|
+
console.error(args[0], args[1]);
|
|
67
|
+
} else {
|
|
68
|
+
console.error(args[1], args[0]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// src/tmux-transport.ts
|
|
74
|
+
import { execSync as execSync2 } from "child_process";
|
|
75
|
+
var TMUX_KEY_MAP = {
|
|
76
|
+
"enter": "Enter",
|
|
77
|
+
"return": "Enter",
|
|
78
|
+
"tab": "Tab",
|
|
79
|
+
"escape": "Escape",
|
|
80
|
+
"esc": "Escape",
|
|
81
|
+
"space": "Space",
|
|
82
|
+
"backspace": "BSpace",
|
|
83
|
+
"delete": "DC",
|
|
84
|
+
"insert": "IC",
|
|
85
|
+
"up": "Up",
|
|
86
|
+
"down": "Down",
|
|
87
|
+
"left": "Left",
|
|
88
|
+
"right": "Right",
|
|
89
|
+
"home": "Home",
|
|
90
|
+
"end": "End",
|
|
91
|
+
"pageup": "PageUp",
|
|
92
|
+
"pagedown": "PageDown",
|
|
93
|
+
"f1": "F1",
|
|
94
|
+
"f2": "F2",
|
|
95
|
+
"f3": "F3",
|
|
96
|
+
"f4": "F4",
|
|
97
|
+
"f5": "F5",
|
|
98
|
+
"f6": "F6",
|
|
99
|
+
"f7": "F7",
|
|
100
|
+
"f8": "F8",
|
|
101
|
+
"f9": "F9",
|
|
102
|
+
"f10": "F10",
|
|
103
|
+
"f11": "F11",
|
|
104
|
+
"f12": "F12",
|
|
105
|
+
// Ctrl keys map to tmux C- prefix
|
|
106
|
+
"ctrl+a": "C-a",
|
|
107
|
+
"ctrl+b": "C-b",
|
|
108
|
+
"ctrl+c": "C-c",
|
|
109
|
+
"ctrl+d": "C-d",
|
|
110
|
+
"ctrl+e": "C-e",
|
|
111
|
+
"ctrl+f": "C-f",
|
|
112
|
+
"ctrl+g": "C-g",
|
|
113
|
+
"ctrl+h": "C-h",
|
|
114
|
+
"ctrl+i": "C-i",
|
|
115
|
+
"ctrl+j": "C-j",
|
|
116
|
+
"ctrl+k": "C-k",
|
|
117
|
+
"ctrl+l": "C-l",
|
|
118
|
+
"ctrl+m": "C-m",
|
|
119
|
+
"ctrl+n": "C-n",
|
|
120
|
+
"ctrl+o": "C-o",
|
|
121
|
+
"ctrl+p": "C-p",
|
|
122
|
+
"ctrl+q": "C-q",
|
|
123
|
+
"ctrl+r": "C-r",
|
|
124
|
+
"ctrl+s": "C-s",
|
|
125
|
+
"ctrl+t": "C-t",
|
|
126
|
+
"ctrl+u": "C-u",
|
|
127
|
+
"ctrl+v": "C-v",
|
|
128
|
+
"ctrl+w": "C-w",
|
|
129
|
+
"ctrl+x": "C-x",
|
|
130
|
+
"ctrl+y": "C-y",
|
|
131
|
+
"ctrl+z": "C-z",
|
|
132
|
+
// Shift combinations
|
|
133
|
+
"shift+up": "S-Up",
|
|
134
|
+
"shift+down": "S-Down",
|
|
135
|
+
"shift+left": "S-Left",
|
|
136
|
+
"shift+right": "S-Right",
|
|
137
|
+
"shift+tab": "BTab"
|
|
138
|
+
};
|
|
139
|
+
var TmuxTransport = class {
|
|
140
|
+
pollingTimers = /* @__PURE__ */ new Map();
|
|
141
|
+
lastCapture = /* @__PURE__ */ new Map();
|
|
142
|
+
constructor() {
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Spawn a new tmux session running the given command.
|
|
146
|
+
*/
|
|
147
|
+
spawn(sessionName, options) {
|
|
148
|
+
ensureTmux();
|
|
149
|
+
const fullCommand = [options.command, ...options.args].join(" ");
|
|
150
|
+
const envExports = Object.entries(options.env).map(([k, v]) => `export ${k}=${this.shellEscape(v)}`).join("; ");
|
|
151
|
+
const shellCommand = envExports ? `${envExports}; exec ${fullCommand}` : `exec ${fullCommand}`;
|
|
152
|
+
execSync2(
|
|
153
|
+
`tmux new-session -d -s ${this.shellEscape(sessionName)} -x ${options.cols} -y ${options.rows} -c ${this.shellEscape(options.cwd)} ${this.shellEscape(shellCommand)}`,
|
|
154
|
+
{ stdio: "pipe", timeout: 1e4 }
|
|
155
|
+
);
|
|
156
|
+
this.tmuxExec(`set-option -t ${this.shellEscape(sessionName)} history-limit ${options.historyLimit}`);
|
|
157
|
+
this.tmuxExec(`set-option -t ${this.shellEscape(sessionName)} remain-on-exit on`);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Check if a tmux session exists and is alive.
|
|
161
|
+
*/
|
|
162
|
+
isAlive(sessionName) {
|
|
163
|
+
try {
|
|
164
|
+
execSync2(`tmux has-session -t ${this.shellEscape(sessionName)}`, {
|
|
165
|
+
stdio: "pipe",
|
|
166
|
+
timeout: 3e3
|
|
167
|
+
});
|
|
168
|
+
return true;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Kill a tmux session.
|
|
175
|
+
*/
|
|
176
|
+
kill(sessionName) {
|
|
177
|
+
this.stopOutputStreaming(sessionName);
|
|
178
|
+
try {
|
|
179
|
+
this.tmuxExec(`kill-session -t ${this.shellEscape(sessionName)}`);
|
|
180
|
+
} catch {
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Send a signal to the process in the tmux pane.
|
|
185
|
+
*/
|
|
186
|
+
signal(sessionName, sig) {
|
|
187
|
+
try {
|
|
188
|
+
const pid = this.getPanePid(sessionName);
|
|
189
|
+
if (pid) {
|
|
190
|
+
const nodeSignal = sig === "SIGKILL" ? "-9" : sig === "SIGTERM" ? "-15" : `-${sig}`;
|
|
191
|
+
execSync2(`kill ${nodeSignal} ${pid}`, { stdio: "pipe", timeout: 3e3 });
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Send literal text to the tmux session (no key interpretation).
|
|
198
|
+
*/
|
|
199
|
+
sendText(sessionName, text) {
|
|
200
|
+
this.tmuxExec(`send-keys -t ${this.shellEscape(sessionName)} -l ${this.shellEscape(text)}`);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Send a named key to the tmux session.
|
|
204
|
+
* Uses TMUX_KEY_MAP for translation from our key names.
|
|
205
|
+
*/
|
|
206
|
+
sendKey(sessionName, key) {
|
|
207
|
+
const tmuxKey = TMUX_KEY_MAP[key.toLowerCase()];
|
|
208
|
+
if (tmuxKey) {
|
|
209
|
+
this.tmuxExec(`send-keys -t ${this.shellEscape(sessionName)} ${tmuxKey}`);
|
|
210
|
+
} else {
|
|
211
|
+
this.tmuxExec(`send-keys -t ${this.shellEscape(sessionName)} -l ${this.shellEscape(key)}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Capture the current pane content.
|
|
216
|
+
*/
|
|
217
|
+
capturePane(sessionName, options = {}) {
|
|
218
|
+
const flags = ["-p"];
|
|
219
|
+
if (options.ansi) {
|
|
220
|
+
flags.push("-e");
|
|
221
|
+
}
|
|
222
|
+
if (options.scrollback !== void 0) {
|
|
223
|
+
flags.push(`-S -${options.scrollback}`);
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
return execSync2(
|
|
227
|
+
`tmux capture-pane -t ${this.shellEscape(sessionName)} ${flags.join(" ")}`,
|
|
228
|
+
{ encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }
|
|
229
|
+
);
|
|
230
|
+
} catch {
|
|
231
|
+
return "";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Start polling output from a tmux session via capture-pane.
|
|
236
|
+
* Polls at the given interval and calls the callback with new/changed content.
|
|
237
|
+
*
|
|
238
|
+
* Uses capture-pane with ANSI codes (-e) and scrollback for full fidelity.
|
|
239
|
+
* Compares against last capture to detect changes and emit only new data.
|
|
240
|
+
*/
|
|
241
|
+
startOutputStreaming(sessionName, callback, pollIntervalMs = 100) {
|
|
242
|
+
this.lastCapture.set(sessionName, "");
|
|
243
|
+
const timer = setInterval(() => {
|
|
244
|
+
try {
|
|
245
|
+
const current = this.capturePane(sessionName, { ansi: true, scrollback: 500 });
|
|
246
|
+
const last = this.lastCapture.get(sessionName) || "";
|
|
247
|
+
if (current !== last) {
|
|
248
|
+
this.lastCapture.set(sessionName, current);
|
|
249
|
+
if (current.startsWith(last)) {
|
|
250
|
+
const newData = current.slice(last.length);
|
|
251
|
+
if (newData) callback(newData);
|
|
252
|
+
} else {
|
|
253
|
+
callback(current);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
}
|
|
258
|
+
}, pollIntervalMs);
|
|
259
|
+
this.pollingTimers.set(sessionName, timer);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Stop polling output from a tmux session.
|
|
263
|
+
*/
|
|
264
|
+
stopOutputStreaming(sessionName) {
|
|
265
|
+
const timer = this.pollingTimers.get(sessionName);
|
|
266
|
+
if (timer) {
|
|
267
|
+
clearInterval(timer);
|
|
268
|
+
this.pollingTimers.delete(sessionName);
|
|
269
|
+
}
|
|
270
|
+
this.lastCapture.delete(sessionName);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Get the PID of the process running in the tmux pane.
|
|
274
|
+
*/
|
|
275
|
+
getPanePid(sessionName) {
|
|
276
|
+
try {
|
|
277
|
+
const output = execSync2(
|
|
278
|
+
`tmux display-message -t ${this.shellEscape(sessionName)} -p '#{pane_pid}'`,
|
|
279
|
+
{ encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }
|
|
280
|
+
).trim();
|
|
281
|
+
const pid = parseInt(output, 10);
|
|
282
|
+
return isNaN(pid) ? void 0 : pid;
|
|
283
|
+
} catch {
|
|
284
|
+
return void 0;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Get the pane dimensions.
|
|
289
|
+
*/
|
|
290
|
+
getPaneDimensions(sessionName) {
|
|
291
|
+
try {
|
|
292
|
+
const output = execSync2(
|
|
293
|
+
`tmux display-message -t ${this.shellEscape(sessionName)} -p '#{pane_width}x#{pane_height}'`,
|
|
294
|
+
{ encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }
|
|
295
|
+
).trim();
|
|
296
|
+
const [cols, rows] = output.split("x").map(Number);
|
|
297
|
+
return { cols: cols || 120, rows: rows || 40 };
|
|
298
|
+
} catch {
|
|
299
|
+
return { cols: 120, rows: 40 };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Resize a tmux session window.
|
|
304
|
+
*/
|
|
305
|
+
resize(sessionName, cols, rows) {
|
|
306
|
+
try {
|
|
307
|
+
this.tmuxExec(`resize-window -t ${this.shellEscape(sessionName)} -x ${cols} -y ${rows}`);
|
|
308
|
+
} catch {
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Check if the pane's process has exited.
|
|
313
|
+
* Uses remain-on-exit to detect dead panes.
|
|
314
|
+
*/
|
|
315
|
+
isPaneAlive(sessionName) {
|
|
316
|
+
try {
|
|
317
|
+
const output = execSync2(
|
|
318
|
+
`tmux display-message -t ${this.shellEscape(sessionName)} -p '#{pane_dead}'`,
|
|
319
|
+
{ encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }
|
|
320
|
+
).trim();
|
|
321
|
+
return output !== "1";
|
|
322
|
+
} catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Get the exit status of a dead pane.
|
|
328
|
+
*/
|
|
329
|
+
getPaneExitStatus(sessionName) {
|
|
330
|
+
try {
|
|
331
|
+
const output = execSync2(
|
|
332
|
+
`tmux display-message -t ${this.shellEscape(sessionName)} -p '#{pane_dead_status}'`,
|
|
333
|
+
{ encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }
|
|
334
|
+
).trim();
|
|
335
|
+
const code = parseInt(output, 10);
|
|
336
|
+
return isNaN(code) ? void 0 : code;
|
|
337
|
+
} catch {
|
|
338
|
+
return void 0;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* List all tmux sessions matching a prefix.
|
|
343
|
+
*/
|
|
344
|
+
static listSessions(prefix) {
|
|
345
|
+
try {
|
|
346
|
+
const output = execSync2(
|
|
347
|
+
`tmux list-sessions -F '#{session_name}|#{session_created}|#{session_attached}'`,
|
|
348
|
+
{ encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }
|
|
349
|
+
).trim();
|
|
350
|
+
if (!output) return [];
|
|
351
|
+
return output.split("\n").map((line) => {
|
|
352
|
+
const [name, created, attached] = line.split("|");
|
|
353
|
+
return { name, created, attached: attached === "1" };
|
|
354
|
+
}).filter((s) => !prefix || s.name.startsWith(prefix));
|
|
355
|
+
} catch {
|
|
356
|
+
return [];
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Clean up resources.
|
|
361
|
+
*/
|
|
362
|
+
destroy() {
|
|
363
|
+
for (const [, timer] of this.pollingTimers) {
|
|
364
|
+
clearInterval(timer);
|
|
365
|
+
}
|
|
366
|
+
this.pollingTimers.clear();
|
|
367
|
+
this.lastCapture.clear();
|
|
368
|
+
}
|
|
369
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
370
|
+
// Private helpers
|
|
371
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
372
|
+
tmuxExec(args) {
|
|
373
|
+
execSync2(`tmux ${args}`, { stdio: "pipe", timeout: 5e3 });
|
|
374
|
+
}
|
|
375
|
+
shellEscape(str) {
|
|
376
|
+
return `'${str.replace(/'/g, "'\\''")}'`;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// src/tmux-session.ts
|
|
381
|
+
var SPECIAL_KEYS = {
|
|
382
|
+
// Control keys
|
|
383
|
+
"ctrl+a": "",
|
|
384
|
+
"ctrl+b": "",
|
|
385
|
+
"ctrl+c": "",
|
|
386
|
+
"ctrl+d": "",
|
|
387
|
+
"ctrl+e": "",
|
|
388
|
+
"ctrl+f": "",
|
|
389
|
+
"ctrl+g": "\x07",
|
|
390
|
+
"ctrl+h": "\b",
|
|
391
|
+
"ctrl+i": " ",
|
|
392
|
+
"ctrl+j": "\n",
|
|
393
|
+
"ctrl+k": "\v",
|
|
394
|
+
"ctrl+l": "\f",
|
|
395
|
+
"ctrl+m": "\r",
|
|
396
|
+
"ctrl+n": "",
|
|
397
|
+
"ctrl+o": "",
|
|
398
|
+
"ctrl+p": "",
|
|
399
|
+
"ctrl+q": "",
|
|
400
|
+
"ctrl+r": "",
|
|
401
|
+
"ctrl+s": "",
|
|
402
|
+
"ctrl+t": "",
|
|
403
|
+
"ctrl+u": "",
|
|
404
|
+
"ctrl+v": "",
|
|
405
|
+
"ctrl+w": "",
|
|
406
|
+
"ctrl+x": "",
|
|
407
|
+
"ctrl+y": "",
|
|
408
|
+
"ctrl+z": "",
|
|
409
|
+
// Navigation
|
|
410
|
+
"up": "\x1B[A",
|
|
411
|
+
"down": "\x1B[B",
|
|
412
|
+
"right": "\x1B[C",
|
|
413
|
+
"left": "\x1B[D",
|
|
414
|
+
"home": "\x1B[H",
|
|
415
|
+
"end": "\x1B[F",
|
|
416
|
+
"pageup": "\x1B[5~",
|
|
417
|
+
"pagedown": "\x1B[6~",
|
|
418
|
+
// Editing
|
|
419
|
+
"enter": "\r",
|
|
420
|
+
"return": "\r",
|
|
421
|
+
"tab": " ",
|
|
422
|
+
"backspace": "\x7F",
|
|
423
|
+
"delete": "\x1B[3~",
|
|
424
|
+
"insert": "\x1B[2~",
|
|
425
|
+
"escape": "\x1B",
|
|
426
|
+
"esc": "\x1B",
|
|
427
|
+
"space": " ",
|
|
428
|
+
// Function keys
|
|
429
|
+
"f1": "\x1BOP",
|
|
430
|
+
"f2": "\x1BOQ",
|
|
431
|
+
"f3": "\x1BOR",
|
|
432
|
+
"f4": "\x1BOS",
|
|
433
|
+
"f5": "\x1B[15~",
|
|
434
|
+
"f6": "\x1B[17~",
|
|
435
|
+
"f7": "\x1B[18~",
|
|
436
|
+
"f8": "\x1B[19~",
|
|
437
|
+
"f9": "\x1B[20~",
|
|
438
|
+
"f10": "\x1B[21~",
|
|
439
|
+
"f11": "\x1B[23~",
|
|
440
|
+
"f12": "\x1B[24~"
|
|
441
|
+
};
|
|
442
|
+
function generateId() {
|
|
443
|
+
return `tmux-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
444
|
+
}
|
|
445
|
+
var TmuxSession = class _TmuxSession extends EventEmitter {
|
|
446
|
+
constructor(adapter, config, logger, stallDetectionEnabled, defaultStallTimeoutMs, transport, sessionPrefix, historyLimit) {
|
|
447
|
+
super();
|
|
448
|
+
this.adapter = adapter;
|
|
449
|
+
this.id = config.id || generateId();
|
|
450
|
+
this.config = { ...config, id: this.id };
|
|
451
|
+
this.logger = logger || consoleLogger;
|
|
452
|
+
this._stallDetectionEnabled = stallDetectionEnabled ?? false;
|
|
453
|
+
this._stallTimeoutMs = config.stallTimeoutMs ?? defaultStallTimeoutMs ?? 8e3;
|
|
454
|
+
this._stallBackoffMs = this._stallTimeoutMs;
|
|
455
|
+
this.transport = transport || new TmuxTransport();
|
|
456
|
+
this.sessionPrefix = sessionPrefix || "parallax";
|
|
457
|
+
this.historyLimit = historyLimit || 5e4;
|
|
458
|
+
this.tmuxSessionName = `${this.sessionPrefix}-${this.id}`;
|
|
459
|
+
if (config.ruleOverrides) {
|
|
460
|
+
for (const [key, value] of Object.entries(config.ruleOverrides)) {
|
|
461
|
+
if (value === null) {
|
|
462
|
+
this._disabledRulePatterns.add(key);
|
|
463
|
+
} else {
|
|
464
|
+
this._ruleOverrides.set(key, value);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
transport;
|
|
470
|
+
tmuxSessionName;
|
|
471
|
+
outputBuffer = "";
|
|
472
|
+
_status = "pending";
|
|
473
|
+
_startedAt = null;
|
|
474
|
+
_lastActivityAt = null;
|
|
475
|
+
messageCounter = 0;
|
|
476
|
+
logger;
|
|
477
|
+
sessionRules = [];
|
|
478
|
+
_firedOnceRules = /* @__PURE__ */ new Set();
|
|
479
|
+
_lastBlockingPromptHash = null;
|
|
480
|
+
_ruleOverrides = /* @__PURE__ */ new Map();
|
|
481
|
+
_disabledRulePatterns = /* @__PURE__ */ new Set();
|
|
482
|
+
// Stall detection
|
|
483
|
+
_stallTimer = null;
|
|
484
|
+
_stallTimeoutMs;
|
|
485
|
+
_stallDetectionEnabled;
|
|
486
|
+
_lastStallHash = null;
|
|
487
|
+
_stallStartedAt = null;
|
|
488
|
+
_lastContentHash = null;
|
|
489
|
+
_stallBackoffMs = 0;
|
|
490
|
+
static MAX_STALL_BACKOFF_MS = 3e4;
|
|
491
|
+
_stallEmissionCount = 0;
|
|
492
|
+
static MAX_STALL_EMISSIONS = 5;
|
|
493
|
+
// Task completion detection
|
|
494
|
+
_taskCompleteTimer = null;
|
|
495
|
+
_taskCompletePending = false;
|
|
496
|
+
static TASK_COMPLETE_DEBOUNCE_MS = 1500;
|
|
497
|
+
// Ready detection settle delay
|
|
498
|
+
_readySettleTimer = null;
|
|
499
|
+
_readySettlePending = false;
|
|
500
|
+
// Tool running deduplication
|
|
501
|
+
_lastToolRunningName = null;
|
|
502
|
+
// Output buffer cap
|
|
503
|
+
static MAX_OUTPUT_BUFFER = 1e5;
|
|
504
|
+
// Poll-based exit detection
|
|
505
|
+
_exitPollTimer = null;
|
|
506
|
+
// Session prefix for tmux session naming
|
|
507
|
+
sessionPrefix;
|
|
508
|
+
// History limit for tmux scrollback
|
|
509
|
+
historyLimit;
|
|
510
|
+
id;
|
|
511
|
+
config;
|
|
512
|
+
get status() {
|
|
513
|
+
return this._status;
|
|
514
|
+
}
|
|
515
|
+
get pid() {
|
|
516
|
+
return this.transport.getPanePid(this.tmuxSessionName);
|
|
517
|
+
}
|
|
518
|
+
get startedAt() {
|
|
519
|
+
return this._startedAt ?? void 0;
|
|
520
|
+
}
|
|
521
|
+
get lastActivityAt() {
|
|
522
|
+
return this._lastActivityAt ?? void 0;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Get the tmux session name (for reconnection/debugging).
|
|
526
|
+
*/
|
|
527
|
+
get tmuxName() {
|
|
528
|
+
return this.tmuxSessionName;
|
|
529
|
+
}
|
|
530
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
531
|
+
// Runtime Auto-Response Rules API
|
|
532
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
533
|
+
addAutoResponseRule(rule) {
|
|
534
|
+
const existingIndex = this.sessionRules.findIndex(
|
|
535
|
+
(r) => r.pattern.source === rule.pattern.source && r.pattern.flags === rule.pattern.flags
|
|
536
|
+
);
|
|
537
|
+
if (existingIndex >= 0) {
|
|
538
|
+
this.sessionRules[existingIndex] = rule;
|
|
539
|
+
} else {
|
|
540
|
+
this.sessionRules.push(rule);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
removeAutoResponseRule(pattern) {
|
|
544
|
+
const initialLength = this.sessionRules.length;
|
|
545
|
+
this.sessionRules = this.sessionRules.filter(
|
|
546
|
+
(r) => !(r.pattern.source === pattern.source && r.pattern.flags === pattern.flags)
|
|
547
|
+
);
|
|
548
|
+
return this.sessionRules.length < initialLength;
|
|
549
|
+
}
|
|
550
|
+
setAutoResponseRules(rules) {
|
|
551
|
+
this.sessionRules = [...rules];
|
|
552
|
+
}
|
|
553
|
+
getAutoResponseRules() {
|
|
554
|
+
return [...this.sessionRules];
|
|
555
|
+
}
|
|
556
|
+
clearAutoResponseRules() {
|
|
557
|
+
this.sessionRules = [];
|
|
558
|
+
}
|
|
559
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
560
|
+
// Stall Detection
|
|
561
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
562
|
+
resetStallTimer() {
|
|
563
|
+
if (!this._stallDetectionEnabled || this._status !== "busy" && this._status !== "authenticating") {
|
|
564
|
+
this.clearStallTimer();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const stripped = this.stripAnsiForStall(this.outputBuffer).trim();
|
|
568
|
+
const tail = stripped.slice(-500);
|
|
569
|
+
const hash = this.simpleHash(tail);
|
|
570
|
+
if (hash === this._lastContentHash) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
this._lastContentHash = hash;
|
|
574
|
+
this._stallEmissionCount = 0;
|
|
575
|
+
if (this._stallTimer) {
|
|
576
|
+
clearTimeout(this._stallTimer);
|
|
577
|
+
this._stallTimer = null;
|
|
578
|
+
}
|
|
579
|
+
this._stallStartedAt = Date.now();
|
|
580
|
+
this._lastStallHash = null;
|
|
581
|
+
this._stallBackoffMs = this._stallTimeoutMs;
|
|
582
|
+
this._stallTimer = setTimeout(() => {
|
|
583
|
+
this.onStallTimerFired();
|
|
584
|
+
}, this._stallTimeoutMs);
|
|
585
|
+
}
|
|
586
|
+
clearStallTimer() {
|
|
587
|
+
if (this._stallTimer) {
|
|
588
|
+
clearTimeout(this._stallTimer);
|
|
589
|
+
this._stallTimer = null;
|
|
590
|
+
}
|
|
591
|
+
this._stallStartedAt = null;
|
|
592
|
+
this._lastContentHash = null;
|
|
593
|
+
this._stallBackoffMs = this._stallTimeoutMs;
|
|
594
|
+
this._stallEmissionCount = 0;
|
|
595
|
+
}
|
|
596
|
+
onStallTimerFired() {
|
|
597
|
+
if (this._status !== "busy" && this._status !== "authenticating") {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (this._status === "busy" && this.adapter.detectTaskComplete?.(this.outputBuffer)) {
|
|
601
|
+
this._status = "ready";
|
|
602
|
+
this._lastBlockingPromptHash = null;
|
|
603
|
+
this.outputBuffer = "";
|
|
604
|
+
this.clearStallTimer();
|
|
605
|
+
this.emit("status_changed", "ready");
|
|
606
|
+
this.emit("task_complete");
|
|
607
|
+
this.logger.info({ sessionId: this.id }, "Task complete (adapter fast-path)");
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (this.adapter.detectLoading?.(this.outputBuffer)) {
|
|
611
|
+
this._stallTimer = setTimeout(() => this.onStallTimerFired(), this._stallBackoffMs);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const toolInfo = this.adapter.detectToolRunning?.(this.outputBuffer);
|
|
615
|
+
if (toolInfo) {
|
|
616
|
+
if (toolInfo.toolName !== this._lastToolRunningName) {
|
|
617
|
+
this._lastToolRunningName = toolInfo.toolName;
|
|
618
|
+
this.emit("tool_running", toolInfo);
|
|
619
|
+
}
|
|
620
|
+
this._stallTimer = setTimeout(() => this.onStallTimerFired(), this._stallBackoffMs);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (this._lastToolRunningName) {
|
|
624
|
+
this._lastToolRunningName = null;
|
|
625
|
+
}
|
|
626
|
+
const tail = this.outputBuffer.slice(-500);
|
|
627
|
+
const hash = this.simpleHash(tail);
|
|
628
|
+
if (hash === this._lastStallHash) {
|
|
629
|
+
this._stallTimer = setTimeout(() => this.onStallTimerFired(), this._stallBackoffMs);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
this._lastStallHash = hash;
|
|
633
|
+
this._stallEmissionCount++;
|
|
634
|
+
if (this._stallEmissionCount > _TmuxSession.MAX_STALL_EMISSIONS) {
|
|
635
|
+
this.logger.warn({ sessionId: this.id }, "Max stall emissions reached");
|
|
636
|
+
this.clearStallTimer();
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const recentRaw = this.outputBuffer.slice(-2e3);
|
|
640
|
+
const recentOutput = this.stripAnsiForClassifier(recentRaw).trim();
|
|
641
|
+
const stallDurationMs = this._stallStartedAt ? Date.now() - this._stallStartedAt : this._stallTimeoutMs;
|
|
642
|
+
this.emit("stall_detected", recentOutput, stallDurationMs);
|
|
643
|
+
this._stallTimer = setTimeout(() => this.onStallTimerFired(), this._stallBackoffMs);
|
|
644
|
+
}
|
|
645
|
+
handleStallClassification(classification) {
|
|
646
|
+
if (this._status !== "busy" && this._status !== "authenticating") {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (!classification || classification.state === "still_working") {
|
|
650
|
+
this._stallBackoffMs = Math.min(
|
|
651
|
+
this._stallBackoffMs * 2,
|
|
652
|
+
_TmuxSession.MAX_STALL_BACKOFF_MS
|
|
653
|
+
);
|
|
654
|
+
this._lastContentHash = null;
|
|
655
|
+
this._lastStallHash = null;
|
|
656
|
+
if (this._stallTimer) {
|
|
657
|
+
clearTimeout(this._stallTimer);
|
|
658
|
+
this._stallTimer = null;
|
|
659
|
+
}
|
|
660
|
+
this._stallTimer = setTimeout(() => this.onStallTimerFired(), this._stallBackoffMs);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
switch (classification.state) {
|
|
664
|
+
case "waiting_for_input": {
|
|
665
|
+
const promptInfo = {
|
|
666
|
+
type: "stall_classified",
|
|
667
|
+
prompt: classification.prompt,
|
|
668
|
+
canAutoRespond: !!classification.suggestedResponse
|
|
669
|
+
};
|
|
670
|
+
if (classification.suggestedResponse) {
|
|
671
|
+
const resp = classification.suggestedResponse;
|
|
672
|
+
if (resp.startsWith("keys:")) {
|
|
673
|
+
const keys = resp.slice(5).split(",").map((k) => k.trim());
|
|
674
|
+
this.sendKeySequence(keys);
|
|
675
|
+
} else {
|
|
676
|
+
this.transport.sendText(this.tmuxSessionName, resp);
|
|
677
|
+
this.transport.sendKey(this.tmuxSessionName, "enter");
|
|
678
|
+
}
|
|
679
|
+
this.outputBuffer = "";
|
|
680
|
+
this.emit("blocking_prompt", promptInfo, true);
|
|
681
|
+
} else {
|
|
682
|
+
this.emit("blocking_prompt", promptInfo, false);
|
|
683
|
+
}
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
case "task_complete":
|
|
687
|
+
this._status = "ready";
|
|
688
|
+
this._lastBlockingPromptHash = null;
|
|
689
|
+
this.outputBuffer = "";
|
|
690
|
+
this.clearStallTimer();
|
|
691
|
+
this.emit("ready");
|
|
692
|
+
break;
|
|
693
|
+
case "error":
|
|
694
|
+
this.clearStallTimer();
|
|
695
|
+
this.emit("error", new Error(classification.prompt || "Stall classified as error"));
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
700
|
+
// Task Completion Detection
|
|
701
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
702
|
+
scheduleTaskComplete() {
|
|
703
|
+
if (this._taskCompleteTimer) {
|
|
704
|
+
clearTimeout(this._taskCompleteTimer);
|
|
705
|
+
}
|
|
706
|
+
this._taskCompletePending = true;
|
|
707
|
+
this._taskCompleteTimer = setTimeout(() => {
|
|
708
|
+
this._taskCompleteTimer = null;
|
|
709
|
+
this._taskCompletePending = false;
|
|
710
|
+
const signal = this.isTaskCompleteSignal(this.outputBuffer);
|
|
711
|
+
if (this._status !== "busy") return;
|
|
712
|
+
if (!signal) return;
|
|
713
|
+
this._status = "ready";
|
|
714
|
+
this._lastBlockingPromptHash = null;
|
|
715
|
+
this.outputBuffer = "";
|
|
716
|
+
this.clearStallTimer();
|
|
717
|
+
this.emit("status_changed", "ready");
|
|
718
|
+
this.emit("task_complete");
|
|
719
|
+
this.logger.info({ sessionId: this.id }, "Task complete \u2014 agent returned to idle prompt");
|
|
720
|
+
}, _TmuxSession.TASK_COMPLETE_DEBOUNCE_MS);
|
|
721
|
+
}
|
|
722
|
+
isTaskCompleteSignal(output) {
|
|
723
|
+
if (this.adapter.detectTaskComplete) {
|
|
724
|
+
return this.adapter.detectTaskComplete(output);
|
|
725
|
+
}
|
|
726
|
+
return this.adapter.detectReady(output);
|
|
727
|
+
}
|
|
728
|
+
cancelTaskComplete() {
|
|
729
|
+
if (this._taskCompleteTimer) {
|
|
730
|
+
clearTimeout(this._taskCompleteTimer);
|
|
731
|
+
this._taskCompleteTimer = null;
|
|
732
|
+
}
|
|
733
|
+
this._taskCompletePending = false;
|
|
734
|
+
}
|
|
735
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
736
|
+
// Ready Detection Settle Delay
|
|
737
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
738
|
+
scheduleReadySettle() {
|
|
739
|
+
this._readySettlePending = true;
|
|
740
|
+
if (this._readySettleTimer) {
|
|
741
|
+
clearTimeout(this._readySettleTimer);
|
|
742
|
+
}
|
|
743
|
+
const settleMs = this.config.readySettleMs ?? this.adapter.readySettleMs ?? 100;
|
|
744
|
+
this._readySettleTimer = setTimeout(() => {
|
|
745
|
+
this._readySettleTimer = null;
|
|
746
|
+
this._readySettlePending = false;
|
|
747
|
+
if (this._status !== "starting" && this._status !== "authenticating") return;
|
|
748
|
+
if (!this.adapter.detectReady(this.outputBuffer)) return;
|
|
749
|
+
this._status = "ready";
|
|
750
|
+
this._lastBlockingPromptHash = null;
|
|
751
|
+
this.outputBuffer = "";
|
|
752
|
+
this.clearStallTimer();
|
|
753
|
+
this.emit("ready");
|
|
754
|
+
this.logger.info({ sessionId: this.id }, "Session ready (after settle)");
|
|
755
|
+
}, settleMs);
|
|
756
|
+
}
|
|
757
|
+
cancelReadySettle() {
|
|
758
|
+
if (this._readySettleTimer) {
|
|
759
|
+
clearTimeout(this._readySettleTimer);
|
|
760
|
+
this._readySettleTimer = null;
|
|
761
|
+
}
|
|
762
|
+
this._readySettlePending = false;
|
|
763
|
+
}
|
|
764
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
765
|
+
// Lifecycle
|
|
766
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
767
|
+
async start() {
|
|
768
|
+
this._status = "starting";
|
|
769
|
+
this._startedAt = /* @__PURE__ */ new Date();
|
|
770
|
+
const command = this.adapter.getCommand();
|
|
771
|
+
const args = this.adapter.getArgs(this.config);
|
|
772
|
+
const adapterEnv = this.adapter.getEnv(this.config);
|
|
773
|
+
const env = _TmuxSession.buildSpawnEnv(this.config, adapterEnv);
|
|
774
|
+
this.logger.info(
|
|
775
|
+
{ sessionId: this.id, command, args: args.join(" "), tmuxSession: this.tmuxSessionName },
|
|
776
|
+
"Starting tmux session"
|
|
777
|
+
);
|
|
778
|
+
try {
|
|
779
|
+
this.transport.spawn(this.tmuxSessionName, {
|
|
780
|
+
command,
|
|
781
|
+
args,
|
|
782
|
+
cwd: this.config.workdir || process.cwd(),
|
|
783
|
+
env,
|
|
784
|
+
cols: this.config.cols || 120,
|
|
785
|
+
rows: this.config.rows || 40,
|
|
786
|
+
historyLimit: this.historyLimit
|
|
787
|
+
});
|
|
788
|
+
this.transport.startOutputStreaming(this.tmuxSessionName, (data) => {
|
|
789
|
+
this._lastActivityAt = /* @__PURE__ */ new Date();
|
|
790
|
+
this.outputBuffer += data;
|
|
791
|
+
if (this.outputBuffer.length > _TmuxSession.MAX_OUTPUT_BUFFER) {
|
|
792
|
+
this.outputBuffer = this.outputBuffer.slice(-_TmuxSession.MAX_OUTPUT_BUFFER);
|
|
793
|
+
}
|
|
794
|
+
this.emit("output", data);
|
|
795
|
+
this.processOutputBuffer();
|
|
796
|
+
});
|
|
797
|
+
this._exitPollTimer = setInterval(() => {
|
|
798
|
+
if (!this.transport.isPaneAlive(this.tmuxSessionName)) {
|
|
799
|
+
const exitCode = this.transport.getPaneExitStatus(this.tmuxSessionName) ?? 0;
|
|
800
|
+
this.handleExit(exitCode);
|
|
801
|
+
}
|
|
802
|
+
}, 1e3);
|
|
803
|
+
this.logger.info(
|
|
804
|
+
{ sessionId: this.id, pid: this.pid, tmuxSession: this.tmuxSessionName },
|
|
805
|
+
"Tmux session started"
|
|
806
|
+
);
|
|
807
|
+
} catch (error) {
|
|
808
|
+
this._status = "error";
|
|
809
|
+
this.logger.error({ sessionId: this.id, error }, "Failed to start tmux session");
|
|
810
|
+
throw error;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Reconnect to an existing tmux session.
|
|
815
|
+
* Used for crash recovery.
|
|
816
|
+
*/
|
|
817
|
+
async reconnect(existingTmuxName) {
|
|
818
|
+
if (!this.transport.isAlive(existingTmuxName)) {
|
|
819
|
+
throw new Error(`Tmux session ${existingTmuxName} does not exist`);
|
|
820
|
+
}
|
|
821
|
+
this.tmuxSessionName = existingTmuxName;
|
|
822
|
+
this._status = "starting";
|
|
823
|
+
this._startedAt = /* @__PURE__ */ new Date();
|
|
824
|
+
this.transport.startOutputStreaming(this.tmuxSessionName, (data) => {
|
|
825
|
+
this._lastActivityAt = /* @__PURE__ */ new Date();
|
|
826
|
+
this.outputBuffer += data;
|
|
827
|
+
if (this.outputBuffer.length > _TmuxSession.MAX_OUTPUT_BUFFER) {
|
|
828
|
+
this.outputBuffer = this.outputBuffer.slice(-_TmuxSession.MAX_OUTPUT_BUFFER);
|
|
829
|
+
}
|
|
830
|
+
this.emit("output", data);
|
|
831
|
+
this.processOutputBuffer();
|
|
832
|
+
});
|
|
833
|
+
const currentContent = this.transport.capturePane(this.tmuxSessionName, { ansi: true });
|
|
834
|
+
if (currentContent) {
|
|
835
|
+
this.outputBuffer = currentContent;
|
|
836
|
+
this.processOutputBuffer();
|
|
837
|
+
}
|
|
838
|
+
this._exitPollTimer = setInterval(() => {
|
|
839
|
+
if (!this.transport.isPaneAlive(this.tmuxSessionName)) {
|
|
840
|
+
const exitCode = this.transport.getPaneExitStatus(this.tmuxSessionName) ?? 0;
|
|
841
|
+
this.handleExit(exitCode);
|
|
842
|
+
}
|
|
843
|
+
}, 1e3);
|
|
844
|
+
this.logger.info(
|
|
845
|
+
{ sessionId: this.id, tmuxSession: this.tmuxSessionName },
|
|
846
|
+
"Reconnected to tmux session"
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
handleExit(exitCode) {
|
|
850
|
+
if (this._status === "stopped" || this._status === "stopping") return;
|
|
851
|
+
this._status = "stopped";
|
|
852
|
+
this.clearStallTimer();
|
|
853
|
+
this.cancelTaskComplete();
|
|
854
|
+
this.cancelReadySettle();
|
|
855
|
+
this.stopExitPolling();
|
|
856
|
+
this.transport.stopOutputStreaming(this.tmuxSessionName);
|
|
857
|
+
this.logger.info({ sessionId: this.id, exitCode }, "Tmux session exited");
|
|
858
|
+
this.emit("exit", exitCode);
|
|
859
|
+
}
|
|
860
|
+
stopExitPolling() {
|
|
861
|
+
if (this._exitPollTimer) {
|
|
862
|
+
clearInterval(this._exitPollTimer);
|
|
863
|
+
this._exitPollTimer = null;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
867
|
+
// Output Processing
|
|
868
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
869
|
+
processOutputBuffer() {
|
|
870
|
+
if (this._status === "busy" || this._status === "authenticating") {
|
|
871
|
+
this.resetStallTimer();
|
|
872
|
+
}
|
|
873
|
+
if (this._readySettlePending) {
|
|
874
|
+
if ((this._status === "starting" || this._status === "authenticating") && this.adapter.detectReady(this.outputBuffer)) {
|
|
875
|
+
this.scheduleReadySettle();
|
|
876
|
+
} else {
|
|
877
|
+
this.cancelReadySettle();
|
|
878
|
+
}
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if ((this._status === "starting" || this._status === "authenticating") && this.adapter.detectReady(this.outputBuffer)) {
|
|
882
|
+
this.scheduleReadySettle();
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (this._status === "busy") {
|
|
886
|
+
const toolInfo = this.adapter.detectToolRunning?.(this.outputBuffer);
|
|
887
|
+
if (toolInfo) {
|
|
888
|
+
if (toolInfo.toolName !== this._lastToolRunningName) {
|
|
889
|
+
this._lastToolRunningName = toolInfo.toolName;
|
|
890
|
+
this.emit("tool_running", toolInfo);
|
|
891
|
+
}
|
|
892
|
+
} else if (this._lastToolRunningName) {
|
|
893
|
+
this._lastToolRunningName = null;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (this._status === "busy") {
|
|
897
|
+
const signal = this.isTaskCompleteSignal(this.outputBuffer);
|
|
898
|
+
if (this._taskCompletePending || signal) {
|
|
899
|
+
this.scheduleTaskComplete();
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if (this._status !== "stopping" && this._status !== "stopped") {
|
|
903
|
+
const blockingPrompt = this.detectAndHandleBlockingPrompt();
|
|
904
|
+
if (blockingPrompt) return;
|
|
905
|
+
}
|
|
906
|
+
if (this._status !== "ready" && this._status !== "busy") {
|
|
907
|
+
const loginDetection = this.adapter.detectLogin(this.outputBuffer);
|
|
908
|
+
if (loginDetection.required && this._status !== "authenticating") {
|
|
909
|
+
this._status = "authenticating";
|
|
910
|
+
this.clearStallTimer();
|
|
911
|
+
this.emitAuthRequired({
|
|
912
|
+
type: loginDetection.type,
|
|
913
|
+
url: loginDetection.url,
|
|
914
|
+
deviceCode: loginDetection.deviceCode,
|
|
915
|
+
instructions: loginDetection.instructions
|
|
916
|
+
});
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
const exitDetection = this.adapter.detectExit(this.outputBuffer);
|
|
921
|
+
if (exitDetection.exited) {
|
|
922
|
+
this._status = "stopped";
|
|
923
|
+
this.clearStallTimer();
|
|
924
|
+
this.emit("exit", exitDetection.code || 0);
|
|
925
|
+
}
|
|
926
|
+
if (this._status === "ready") {
|
|
927
|
+
this.tryParseOutput();
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
931
|
+
// Blocking Prompt Detection & Auto-Response
|
|
932
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
933
|
+
detectAndHandleBlockingPrompt() {
|
|
934
|
+
const autoHandled = this.tryAutoResponse();
|
|
935
|
+
if (autoHandled) return true;
|
|
936
|
+
if (this.adapter.detectBlockingPrompt) {
|
|
937
|
+
const detection = this.adapter.detectBlockingPrompt(this.outputBuffer);
|
|
938
|
+
if (detection.detected) {
|
|
939
|
+
const normalizedPrompt = (detection.prompt || "").replace(/\s+/g, " ").replace(/\d+/g, "#").trim().slice(0, 100);
|
|
940
|
+
const promptHash = `${detection.type}:${normalizedPrompt}`;
|
|
941
|
+
if (promptHash === this._lastBlockingPromptHash) return true;
|
|
942
|
+
this._lastBlockingPromptHash = promptHash;
|
|
943
|
+
const promptInfo = {
|
|
944
|
+
type: detection.type || "unknown",
|
|
945
|
+
prompt: detection.prompt,
|
|
946
|
+
options: detection.options,
|
|
947
|
+
canAutoRespond: detection.canAutoRespond || false,
|
|
948
|
+
instructions: detection.instructions,
|
|
949
|
+
url: detection.url
|
|
950
|
+
};
|
|
951
|
+
if (detection.canAutoRespond && detection.suggestedResponse && !this.config.skipAdapterAutoResponse) {
|
|
952
|
+
const resp = detection.suggestedResponse;
|
|
953
|
+
if (resp.startsWith("keys:")) {
|
|
954
|
+
const keys = resp.slice(5).split(",").map((k) => k.trim());
|
|
955
|
+
this.sendKeySequence(keys);
|
|
956
|
+
} else {
|
|
957
|
+
this.transport.sendText(this.tmuxSessionName, resp);
|
|
958
|
+
this.transport.sendKey(this.tmuxSessionName, "enter");
|
|
959
|
+
}
|
|
960
|
+
this.outputBuffer = "";
|
|
961
|
+
this.emit("blocking_prompt", promptInfo, true);
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
964
|
+
if (detection.type === "login") {
|
|
965
|
+
this._status = "authenticating";
|
|
966
|
+
const inferred = this.adapter.detectLogin(this.outputBuffer);
|
|
967
|
+
this.emitAuthRequired({
|
|
968
|
+
type: inferred.required ? inferred.type : void 0,
|
|
969
|
+
url: detection.url ?? inferred.url,
|
|
970
|
+
deviceCode: inferred.required ? inferred.deviceCode : void 0,
|
|
971
|
+
instructions: detection.instructions ?? inferred.instructions
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
this.emit("blocking_prompt", promptInfo, false);
|
|
975
|
+
return true;
|
|
976
|
+
} else {
|
|
977
|
+
this._lastBlockingPromptHash = null;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
return false;
|
|
981
|
+
}
|
|
982
|
+
tryAutoResponse() {
|
|
983
|
+
const adapterRules = (this.adapter.autoResponseRules || []).filter((r) => !this._disabledRulePatterns.has(r.pattern.source)).map((r) => {
|
|
984
|
+
const override = this._ruleOverrides.get(r.pattern.source);
|
|
985
|
+
return override ? { ...r, ...override } : r;
|
|
986
|
+
});
|
|
987
|
+
const allRules = [...this.sessionRules, ...adapterRules];
|
|
988
|
+
if (allRules.length === 0) return false;
|
|
989
|
+
const stripped = this.stripAnsiForStall(this.outputBuffer);
|
|
990
|
+
for (const rule of allRules) {
|
|
991
|
+
if (rule.once) {
|
|
992
|
+
const ruleKey = `${rule.pattern.source}:${rule.pattern.flags}`;
|
|
993
|
+
if (this._firedOnceRules.has(ruleKey)) continue;
|
|
994
|
+
}
|
|
995
|
+
if (rule.pattern.test(stripped)) {
|
|
996
|
+
const safe = rule.safe !== false;
|
|
997
|
+
if (safe) {
|
|
998
|
+
const useKeys = rule.keys && rule.keys.length > 0;
|
|
999
|
+
const isTuiDefault = !rule.responseType && !rule.keys && this.adapter.usesTuiMenus;
|
|
1000
|
+
if (useKeys) {
|
|
1001
|
+
this.sendKeySequence(rule.keys);
|
|
1002
|
+
} else if (isTuiDefault) {
|
|
1003
|
+
this.sendKeys("enter");
|
|
1004
|
+
} else {
|
|
1005
|
+
this.transport.sendText(this.tmuxSessionName, rule.response);
|
|
1006
|
+
this.transport.sendKey(this.tmuxSessionName, "enter");
|
|
1007
|
+
}
|
|
1008
|
+
if (rule.once) {
|
|
1009
|
+
const ruleKey = `${rule.pattern.source}:${rule.pattern.flags}`;
|
|
1010
|
+
this._firedOnceRules.add(ruleKey);
|
|
1011
|
+
}
|
|
1012
|
+
this.outputBuffer = "";
|
|
1013
|
+
const promptInfo = {
|
|
1014
|
+
type: rule.type,
|
|
1015
|
+
prompt: rule.description,
|
|
1016
|
+
canAutoRespond: true
|
|
1017
|
+
};
|
|
1018
|
+
this.emit("blocking_prompt", promptInfo, true);
|
|
1019
|
+
return true;
|
|
1020
|
+
} else {
|
|
1021
|
+
const promptInfo = {
|
|
1022
|
+
type: rule.type,
|
|
1023
|
+
prompt: rule.description,
|
|
1024
|
+
canAutoRespond: false,
|
|
1025
|
+
instructions: `Prompt matched but requires user confirmation: ${rule.description}`
|
|
1026
|
+
};
|
|
1027
|
+
this.emit("blocking_prompt", promptInfo, false);
|
|
1028
|
+
return true;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return false;
|
|
1033
|
+
}
|
|
1034
|
+
tryParseOutput() {
|
|
1035
|
+
const parsed = this.adapter.parseOutput(this.outputBuffer);
|
|
1036
|
+
if (parsed && parsed.isComplete) {
|
|
1037
|
+
this.outputBuffer = "";
|
|
1038
|
+
const message = {
|
|
1039
|
+
id: `${this.id}-msg-${++this.messageCounter}`,
|
|
1040
|
+
sessionId: this.id,
|
|
1041
|
+
direction: "outbound",
|
|
1042
|
+
type: parsed.type,
|
|
1043
|
+
content: parsed.content,
|
|
1044
|
+
metadata: parsed.metadata,
|
|
1045
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
1046
|
+
};
|
|
1047
|
+
this.emit("message", message);
|
|
1048
|
+
if (parsed.isQuestion) {
|
|
1049
|
+
this.emit("question", parsed.content);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1054
|
+
// I/O Methods
|
|
1055
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1056
|
+
/**
|
|
1057
|
+
* Write data to the session (formatted by adapter, with Enter)
|
|
1058
|
+
*/
|
|
1059
|
+
write(data) {
|
|
1060
|
+
this._lastActivityAt = /* @__PURE__ */ new Date();
|
|
1061
|
+
const formatted = this.adapter.formatInput(data);
|
|
1062
|
+
this.transport.sendText(this.tmuxSessionName, formatted);
|
|
1063
|
+
this.transport.sendKey(this.tmuxSessionName, "enter");
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Write raw data directly (no formatting, no Enter)
|
|
1067
|
+
*/
|
|
1068
|
+
writeRaw(data) {
|
|
1069
|
+
this._lastActivityAt = /* @__PURE__ */ new Date();
|
|
1070
|
+
this.transport.sendText(this.tmuxSessionName, data);
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Send a task/message to the session
|
|
1074
|
+
*/
|
|
1075
|
+
send(message) {
|
|
1076
|
+
this._status = "busy";
|
|
1077
|
+
this.outputBuffer = "";
|
|
1078
|
+
this.emit("status_changed", "busy");
|
|
1079
|
+
this._stallEmissionCount = 0;
|
|
1080
|
+
this.resetStallTimer();
|
|
1081
|
+
const msg = {
|
|
1082
|
+
id: `${this.id}-msg-${++this.messageCounter}`,
|
|
1083
|
+
sessionId: this.id,
|
|
1084
|
+
direction: "inbound",
|
|
1085
|
+
type: "task",
|
|
1086
|
+
content: message,
|
|
1087
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
1088
|
+
};
|
|
1089
|
+
const formatted = this.adapter.formatInput(message);
|
|
1090
|
+
this.transport.sendText(this.tmuxSessionName, formatted);
|
|
1091
|
+
setTimeout(() => {
|
|
1092
|
+
this.transport.sendKey(this.tmuxSessionName, "enter");
|
|
1093
|
+
}, 50);
|
|
1094
|
+
return msg;
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Resize the terminal
|
|
1098
|
+
*/
|
|
1099
|
+
resize(cols, rows) {
|
|
1100
|
+
this.transport.resize(this.tmuxSessionName, cols, rows);
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Send special keys to the session.
|
|
1104
|
+
* Uses tmux send-keys with named keys.
|
|
1105
|
+
*/
|
|
1106
|
+
sendKeys(keys) {
|
|
1107
|
+
const keyList = Array.isArray(keys) ? keys : [keys];
|
|
1108
|
+
const normalized = _TmuxSession.normalizeKeyList(keyList);
|
|
1109
|
+
this._stallEmissionCount = 0;
|
|
1110
|
+
this._lastBlockingPromptHash = null;
|
|
1111
|
+
this.outputBuffer = "";
|
|
1112
|
+
this.resetStallTimer();
|
|
1113
|
+
for (const key of normalized) {
|
|
1114
|
+
this._lastActivityAt = /* @__PURE__ */ new Date();
|
|
1115
|
+
this.transport.sendKey(this.tmuxSessionName, key);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Select a TUI menu option by index (0-based).
|
|
1120
|
+
*/
|
|
1121
|
+
async selectMenuOption(optionIndex) {
|
|
1122
|
+
for (let i = 0; i < optionIndex; i++) {
|
|
1123
|
+
this.sendKeys("down");
|
|
1124
|
+
await this.delay(50);
|
|
1125
|
+
}
|
|
1126
|
+
this.sendKeys("enter");
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Send a sequence of keys with staggered timing.
|
|
1130
|
+
*/
|
|
1131
|
+
sendKeySequence(keys) {
|
|
1132
|
+
keys.forEach((key, i) => {
|
|
1133
|
+
setTimeout(() => this.sendKeys(key), i * 50);
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Paste text using bracketed paste mode
|
|
1138
|
+
*/
|
|
1139
|
+
paste(text, useBracketedPaste = true) {
|
|
1140
|
+
this._lastActivityAt = /* @__PURE__ */ new Date();
|
|
1141
|
+
if (useBracketedPaste) {
|
|
1142
|
+
this.transport.sendText(this.tmuxSessionName, "\x1B[200~" + text + "\x1B[201~");
|
|
1143
|
+
} else {
|
|
1144
|
+
this.transport.sendText(this.tmuxSessionName, text);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Notify the session of an external hook event.
|
|
1149
|
+
*/
|
|
1150
|
+
notifyHookEvent(event) {
|
|
1151
|
+
switch (event) {
|
|
1152
|
+
case "tool_running":
|
|
1153
|
+
this._lastActivityAt = /* @__PURE__ */ new Date();
|
|
1154
|
+
this.resetStallTimer();
|
|
1155
|
+
break;
|
|
1156
|
+
case "task_complete":
|
|
1157
|
+
this._status = "ready";
|
|
1158
|
+
this._lastBlockingPromptHash = null;
|
|
1159
|
+
this.outputBuffer = "";
|
|
1160
|
+
this.clearStallTimer();
|
|
1161
|
+
this.emit("status_changed", "ready");
|
|
1162
|
+
this.emit("task_complete");
|
|
1163
|
+
break;
|
|
1164
|
+
case "permission_approved":
|
|
1165
|
+
this._lastActivityAt = /* @__PURE__ */ new Date();
|
|
1166
|
+
this.outputBuffer = "";
|
|
1167
|
+
this.resetStallTimer();
|
|
1168
|
+
break;
|
|
1169
|
+
default:
|
|
1170
|
+
this._lastActivityAt = /* @__PURE__ */ new Date();
|
|
1171
|
+
this.resetStallTimer();
|
|
1172
|
+
break;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Kill the session.
|
|
1177
|
+
*/
|
|
1178
|
+
kill(signal) {
|
|
1179
|
+
this._status = "stopping";
|
|
1180
|
+
this.clearStallTimer();
|
|
1181
|
+
this.cancelTaskComplete();
|
|
1182
|
+
this.cancelReadySettle();
|
|
1183
|
+
this.stopExitPolling();
|
|
1184
|
+
if (signal === "SIGKILL") {
|
|
1185
|
+
this.transport.signal(this.tmuxSessionName, "SIGKILL");
|
|
1186
|
+
setTimeout(() => {
|
|
1187
|
+
this.transport.kill(this.tmuxSessionName);
|
|
1188
|
+
this._status = "stopped";
|
|
1189
|
+
this.emit("exit", 137);
|
|
1190
|
+
}, 200);
|
|
1191
|
+
} else {
|
|
1192
|
+
this.transport.signal(this.tmuxSessionName, signal || "SIGTERM");
|
|
1193
|
+
}
|
|
1194
|
+
this.logger.info({ sessionId: this.id, signal }, "Killing tmux session");
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Get current output buffer
|
|
1198
|
+
*/
|
|
1199
|
+
getOutputBuffer() {
|
|
1200
|
+
return this.outputBuffer;
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Clear output buffer
|
|
1204
|
+
*/
|
|
1205
|
+
clearOutputBuffer() {
|
|
1206
|
+
this.outputBuffer = "";
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Convert to SessionHandle
|
|
1210
|
+
*/
|
|
1211
|
+
toHandle() {
|
|
1212
|
+
return {
|
|
1213
|
+
id: this.id,
|
|
1214
|
+
name: this.config.name,
|
|
1215
|
+
type: this.config.type,
|
|
1216
|
+
status: this._status,
|
|
1217
|
+
pid: this.pid,
|
|
1218
|
+
startedAt: this._startedAt ?? void 0,
|
|
1219
|
+
lastActivityAt: this._lastActivityAt ?? void 0,
|
|
1220
|
+
tmuxSessionName: this.tmuxSessionName
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1224
|
+
// Static Utilities
|
|
1225
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1226
|
+
static buildSpawnEnv(config, adapterEnv) {
|
|
1227
|
+
const baseEnv = config.inheritProcessEnv !== false ? process.env : {};
|
|
1228
|
+
return {
|
|
1229
|
+
...baseEnv,
|
|
1230
|
+
...adapterEnv,
|
|
1231
|
+
...config.env,
|
|
1232
|
+
TERM: "xterm-256color",
|
|
1233
|
+
COLORTERM: "truecolor"
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
static normalizeKeyList(keys) {
|
|
1237
|
+
const MODIFIER_MAP = {
|
|
1238
|
+
control: "ctrl",
|
|
1239
|
+
command: "meta",
|
|
1240
|
+
cmd: "meta",
|
|
1241
|
+
option: "alt",
|
|
1242
|
+
opt: "alt"
|
|
1243
|
+
};
|
|
1244
|
+
const MODIFIER_NAMES = /* @__PURE__ */ new Set([
|
|
1245
|
+
"ctrl",
|
|
1246
|
+
"alt",
|
|
1247
|
+
"shift",
|
|
1248
|
+
"meta",
|
|
1249
|
+
...Object.keys(MODIFIER_MAP)
|
|
1250
|
+
]);
|
|
1251
|
+
const result = [];
|
|
1252
|
+
let i = 0;
|
|
1253
|
+
while (i < keys.length) {
|
|
1254
|
+
let key = keys[i].toLowerCase().trim();
|
|
1255
|
+
if (MODIFIER_MAP[key]) {
|
|
1256
|
+
key = MODIFIER_MAP[key];
|
|
1257
|
+
}
|
|
1258
|
+
if (MODIFIER_NAMES.has(key) && i + 1 < keys.length) {
|
|
1259
|
+
let nextKey = keys[i + 1].toLowerCase().trim();
|
|
1260
|
+
if (MODIFIER_MAP[nextKey]) {
|
|
1261
|
+
nextKey = MODIFIER_MAP[nextKey];
|
|
1262
|
+
}
|
|
1263
|
+
if (!MODIFIER_NAMES.has(nextKey)) {
|
|
1264
|
+
result.push(`${key}+${nextKey}`);
|
|
1265
|
+
i += 2;
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
result.push(key);
|
|
1270
|
+
i++;
|
|
1271
|
+
}
|
|
1272
|
+
return result;
|
|
1273
|
+
}
|
|
1274
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1275
|
+
// Private Helpers
|
|
1276
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1277
|
+
delay(ms) {
|
|
1278
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1279
|
+
}
|
|
1280
|
+
simpleHash(str) {
|
|
1281
|
+
let hash = 0;
|
|
1282
|
+
for (let i = 0; i < str.length; i++) {
|
|
1283
|
+
const char = str.charCodeAt(i);
|
|
1284
|
+
hash = (hash << 5) - hash + char;
|
|
1285
|
+
hash |= 0;
|
|
1286
|
+
}
|
|
1287
|
+
return hash.toString(36);
|
|
1288
|
+
}
|
|
1289
|
+
mapLoginTypeToAuthMethod(type) {
|
|
1290
|
+
switch (type) {
|
|
1291
|
+
case "api_key":
|
|
1292
|
+
return "api_key";
|
|
1293
|
+
case "cli_auth":
|
|
1294
|
+
return "cli_auth";
|
|
1295
|
+
case "device_code":
|
|
1296
|
+
return "device_code";
|
|
1297
|
+
case "oauth":
|
|
1298
|
+
case "browser":
|
|
1299
|
+
return "oauth_browser";
|
|
1300
|
+
default:
|
|
1301
|
+
return "unknown";
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
emitAuthRequired(details) {
|
|
1305
|
+
const info = {
|
|
1306
|
+
method: this.mapLoginTypeToAuthMethod(details.type),
|
|
1307
|
+
url: details.url,
|
|
1308
|
+
deviceCode: details.deviceCode,
|
|
1309
|
+
instructions: details.instructions
|
|
1310
|
+
};
|
|
1311
|
+
this.emit("auth_required", info);
|
|
1312
|
+
this.emit("login_required", info.instructions, info.url);
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Strip ANSI codes for stall detection hashing.
|
|
1316
|
+
* Simplified compared to pty-manager since tmux capture-pane can give clean text.
|
|
1317
|
+
*/
|
|
1318
|
+
stripAnsiForStall(str) {
|
|
1319
|
+
let result = str.replace(/\x1b\[\d*[CDABGdEF]/g, " ");
|
|
1320
|
+
result = result.replace(/\x1b\[\d*(?:;\d+)?[Hf]/g, " ");
|
|
1321
|
+
result = result.replace(/\x1b\[\d*[JK]/g, " ");
|
|
1322
|
+
result = result.replace(/\x1b\](?:[^\x07\x1b]|\x1b[^\\])*(?:\x07|\x1b\\)/g, "");
|
|
1323
|
+
result = result.replace(/\x1bP(?:[^\x1b]|\x1b[^\\])*\x1b\\/g, "");
|
|
1324
|
+
result = result.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
|
|
1325
|
+
result = result.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
1326
|
+
result = result.replace(/\xa0/g, " ");
|
|
1327
|
+
result = result.replace(/[│╭╰╮╯─═╌║╔╗╚╝╠╣╦╩╬┌┐└┘├┤┬┴┼●○❯❮▶◀⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣾⣽⣻⢿⡿⣟⣯⣷✻✶✳✢⏺←→↑↓⬆⬇◆◇▪▫■□▲△▼▽◈⟨⟩⌘⏎⏏⌫⌦⇧⇪⌥]/g, " ");
|
|
1328
|
+
result = result.replace(/\d+[hms](?:\s+\d+[hms])*/g, "0s");
|
|
1329
|
+
result = result.replace(/ {2,}/g, " ");
|
|
1330
|
+
return result;
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Less-aggressive ANSI stripping for classifier context.
|
|
1334
|
+
*/
|
|
1335
|
+
stripAnsiForClassifier(str) {
|
|
1336
|
+
let result = str.replace(/\x1b\[\d*[CDABGdEF]/g, " ");
|
|
1337
|
+
result = result.replace(/\x1b\[\d*(?:;\d+)?[Hf]/g, " ");
|
|
1338
|
+
result = result.replace(/\x1b\[\d*[JK]/g, " ");
|
|
1339
|
+
result = result.replace(/\x1b\](?:[^\x07\x1b]|\x1b[^\\])*(?:\x07|\x1b\\)/g, "");
|
|
1340
|
+
result = result.replace(/\x1bP(?:[^\x1b]|\x1b[^\\])*\x1b\\/g, "");
|
|
1341
|
+
result = result.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
|
|
1342
|
+
result = result.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
1343
|
+
result = result.replace(/\xa0/g, " ");
|
|
1344
|
+
result = result.replace(/ {2,}/g, " ");
|
|
1345
|
+
return result;
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1349
|
+
// src/tmux-manager.ts
|
|
1350
|
+
var TmuxManager = class extends EventEmitter2 {
|
|
1351
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1352
|
+
outputLogs = /* @__PURE__ */ new Map();
|
|
1353
|
+
maxLogLines;
|
|
1354
|
+
logger;
|
|
1355
|
+
transport;
|
|
1356
|
+
adapters;
|
|
1357
|
+
// Stall detection config
|
|
1358
|
+
_stallDetectionEnabled;
|
|
1359
|
+
_stallTimeoutMs;
|
|
1360
|
+
_onStallClassify;
|
|
1361
|
+
// Tmux-specific config
|
|
1362
|
+
_historyLimit;
|
|
1363
|
+
_sessionPrefix;
|
|
1364
|
+
constructor(config = {}) {
|
|
1365
|
+
super();
|
|
1366
|
+
this.adapters = new AdapterRegistry();
|
|
1367
|
+
this.logger = config.logger || consoleLogger;
|
|
1368
|
+
this.maxLogLines = config.maxLogLines || 1e3;
|
|
1369
|
+
this._stallDetectionEnabled = config.stallDetectionEnabled ?? false;
|
|
1370
|
+
this._stallTimeoutMs = config.stallTimeoutMs ?? 8e3;
|
|
1371
|
+
this._onStallClassify = config.onStallClassify;
|
|
1372
|
+
this._historyLimit = config.historyLimit ?? 5e4;
|
|
1373
|
+
this._sessionPrefix = config.sessionPrefix ?? "parallax";
|
|
1374
|
+
this.transport = new TmuxTransport();
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Register a CLI adapter
|
|
1378
|
+
*/
|
|
1379
|
+
registerAdapter(adapter) {
|
|
1380
|
+
this.adapters.register(adapter);
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* Spawn a new tmux session
|
|
1384
|
+
*/
|
|
1385
|
+
async spawn(config) {
|
|
1386
|
+
const adapter = this.adapters.get(config.type);
|
|
1387
|
+
if (!adapter) {
|
|
1388
|
+
throw new Error(
|
|
1389
|
+
`No adapter found for type: ${config.type}. Registered adapters: ${this.adapters.list().join(", ") || "none"}`
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
if (config.id && this.sessions.has(config.id)) {
|
|
1393
|
+
throw new Error(`Session with ID ${config.id} already exists`);
|
|
1394
|
+
}
|
|
1395
|
+
this.logger.info(
|
|
1396
|
+
{ type: config.type, name: config.name },
|
|
1397
|
+
"Spawning tmux session"
|
|
1398
|
+
);
|
|
1399
|
+
const session = new TmuxSession(
|
|
1400
|
+
adapter,
|
|
1401
|
+
config,
|
|
1402
|
+
this.logger,
|
|
1403
|
+
this._stallDetectionEnabled,
|
|
1404
|
+
this._stallTimeoutMs,
|
|
1405
|
+
this.transport,
|
|
1406
|
+
this._sessionPrefix,
|
|
1407
|
+
this._historyLimit
|
|
1408
|
+
);
|
|
1409
|
+
this.setupSessionEvents(session);
|
|
1410
|
+
this.sessions.set(session.id, session);
|
|
1411
|
+
this.outputLogs.set(session.id, []);
|
|
1412
|
+
await session.start();
|
|
1413
|
+
const handle = session.toHandle();
|
|
1414
|
+
this.emit("session_started", handle);
|
|
1415
|
+
return handle;
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Set up event handlers for a session
|
|
1419
|
+
*/
|
|
1420
|
+
setupSessionEvents(session) {
|
|
1421
|
+
session.on("output", (data) => {
|
|
1422
|
+
const logs = this.outputLogs.get(session.id) || [];
|
|
1423
|
+
const lines = data.split("\n");
|
|
1424
|
+
logs.push(...lines);
|
|
1425
|
+
while (logs.length > this.maxLogLines) {
|
|
1426
|
+
logs.shift();
|
|
1427
|
+
}
|
|
1428
|
+
this.outputLogs.set(session.id, logs);
|
|
1429
|
+
});
|
|
1430
|
+
session.on("ready", () => {
|
|
1431
|
+
this.emit("session_ready", session.toHandle());
|
|
1432
|
+
});
|
|
1433
|
+
session.on("login_required", (instructions, url) => {
|
|
1434
|
+
this.emit("login_required", session.toHandle(), instructions, url);
|
|
1435
|
+
});
|
|
1436
|
+
session.on("auth_required", (info) => {
|
|
1437
|
+
this.emit("auth_required", session.toHandle(), info);
|
|
1438
|
+
});
|
|
1439
|
+
session.on("blocking_prompt", (promptInfo, autoResponded) => {
|
|
1440
|
+
this.emit("blocking_prompt", session.toHandle(), promptInfo, autoResponded);
|
|
1441
|
+
});
|
|
1442
|
+
session.on("message", (message) => {
|
|
1443
|
+
this.emit("message", message);
|
|
1444
|
+
});
|
|
1445
|
+
session.on("question", (question) => {
|
|
1446
|
+
this.emit("question", session.toHandle(), question);
|
|
1447
|
+
});
|
|
1448
|
+
session.on("exit", (code) => {
|
|
1449
|
+
const reason = code === 0 ? "normal exit" : `exit code ${code}`;
|
|
1450
|
+
this.emit("session_stopped", session.toHandle(), reason);
|
|
1451
|
+
});
|
|
1452
|
+
session.on("error", (error) => {
|
|
1453
|
+
this.emit("session_error", session.toHandle(), error.message);
|
|
1454
|
+
});
|
|
1455
|
+
session.on("status_changed", () => {
|
|
1456
|
+
this.emit("session_status_changed", session.toHandle());
|
|
1457
|
+
});
|
|
1458
|
+
session.on("task_complete", () => {
|
|
1459
|
+
this.emit("task_complete", session.toHandle());
|
|
1460
|
+
});
|
|
1461
|
+
session.on("tool_running", (info) => {
|
|
1462
|
+
this.emit("tool_running", session.toHandle(), info);
|
|
1463
|
+
});
|
|
1464
|
+
session.on("stall_detected", (recentOutput, stallDurationMs) => {
|
|
1465
|
+
const handle = session.toHandle();
|
|
1466
|
+
this.emit("stall_detected", handle, recentOutput, stallDurationMs);
|
|
1467
|
+
if (this._onStallClassify) {
|
|
1468
|
+
const sanitized = recentOutput.slice(-1500).replace(/\b(ignore|disregard|forget)\s+(all\s+)?(previous|above|prior)\s+(instructions?|prompts?|rules?)\b/gi, "[REDACTED]").replace(/\b(you\s+are|act\s+as|pretend\s+to\s+be|you\s+must|system\s*:)\b/gi, "[REDACTED]");
|
|
1469
|
+
this._onStallClassify(session.id, sanitized, stallDurationMs).then((classification) => {
|
|
1470
|
+
session.handleStallClassification(classification);
|
|
1471
|
+
}).catch((err) => {
|
|
1472
|
+
this.logger.error(
|
|
1473
|
+
{ sessionId: session.id, error: err },
|
|
1474
|
+
"Stall classification callback failed"
|
|
1475
|
+
);
|
|
1476
|
+
session.handleStallClassification(null);
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Stop a session
|
|
1483
|
+
*/
|
|
1484
|
+
async stop(sessionId, options) {
|
|
1485
|
+
const session = this.sessions.get(sessionId);
|
|
1486
|
+
if (!session) {
|
|
1487
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
1488
|
+
}
|
|
1489
|
+
this.logger.info({ sessionId, force: options?.force }, "Stopping session");
|
|
1490
|
+
const timeout = options?.timeout || 5e3;
|
|
1491
|
+
return new Promise((resolve) => {
|
|
1492
|
+
const timer = setTimeout(() => {
|
|
1493
|
+
session.kill("SIGKILL");
|
|
1494
|
+
setTimeout(() => {
|
|
1495
|
+
session.removeAllListeners();
|
|
1496
|
+
this.sessions.delete(sessionId);
|
|
1497
|
+
this.outputLogs.delete(sessionId);
|
|
1498
|
+
resolve();
|
|
1499
|
+
}, 500);
|
|
1500
|
+
}, timeout);
|
|
1501
|
+
session.once("exit", () => {
|
|
1502
|
+
clearTimeout(timer);
|
|
1503
|
+
session.removeAllListeners();
|
|
1504
|
+
this.sessions.delete(sessionId);
|
|
1505
|
+
this.outputLogs.delete(sessionId);
|
|
1506
|
+
resolve();
|
|
1507
|
+
});
|
|
1508
|
+
session.kill(options?.force ? "SIGKILL" : "SIGTERM");
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Stop all sessions
|
|
1513
|
+
*/
|
|
1514
|
+
async stopAll(options) {
|
|
1515
|
+
const stopPromises = Array.from(this.sessions.keys()).map(
|
|
1516
|
+
(id) => this.stop(id, options).catch((err) => {
|
|
1517
|
+
this.logger.warn({ sessionId: id, error: err }, "Error stopping session");
|
|
1518
|
+
})
|
|
1519
|
+
);
|
|
1520
|
+
await Promise.all(stopPromises);
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Get a session by ID
|
|
1524
|
+
*/
|
|
1525
|
+
get(sessionId) {
|
|
1526
|
+
const session = this.sessions.get(sessionId);
|
|
1527
|
+
return session ? session.toHandle() : null;
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* List all sessions
|
|
1531
|
+
*/
|
|
1532
|
+
list(filter) {
|
|
1533
|
+
const handles = [];
|
|
1534
|
+
for (const session of this.sessions.values()) {
|
|
1535
|
+
const handle = session.toHandle();
|
|
1536
|
+
if (filter) {
|
|
1537
|
+
if (filter.status) {
|
|
1538
|
+
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
|
|
1539
|
+
if (!statuses.includes(handle.status)) continue;
|
|
1540
|
+
}
|
|
1541
|
+
if (filter.type) {
|
|
1542
|
+
const types = Array.isArray(filter.type) ? filter.type : [filter.type];
|
|
1543
|
+
if (!types.includes(handle.type)) continue;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
handles.push(handle);
|
|
1547
|
+
}
|
|
1548
|
+
return handles;
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Send a message to a session
|
|
1552
|
+
*/
|
|
1553
|
+
send(sessionId, message) {
|
|
1554
|
+
const session = this.sessions.get(sessionId);
|
|
1555
|
+
if (!session) {
|
|
1556
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
1557
|
+
}
|
|
1558
|
+
return session.send(message);
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* Get logs for a session
|
|
1562
|
+
*/
|
|
1563
|
+
async *logs(sessionId, options) {
|
|
1564
|
+
const logBuffer = this.outputLogs.get(sessionId);
|
|
1565
|
+
if (!logBuffer) {
|
|
1566
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
1567
|
+
}
|
|
1568
|
+
const lines = options?.tail ? logBuffer.slice(-options.tail) : logBuffer;
|
|
1569
|
+
for (const line of lines) {
|
|
1570
|
+
yield line;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Get metrics for a session
|
|
1575
|
+
*/
|
|
1576
|
+
metrics(sessionId) {
|
|
1577
|
+
const session = this.sessions.get(sessionId);
|
|
1578
|
+
if (!session) return null;
|
|
1579
|
+
const handle = session.toHandle();
|
|
1580
|
+
const uptime = handle.startedAt ? Math.floor((Date.now() - handle.startedAt.getTime()) / 1e3) : void 0;
|
|
1581
|
+
return { uptime };
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* Shutdown manager and stop all sessions
|
|
1585
|
+
*/
|
|
1586
|
+
async shutdown() {
|
|
1587
|
+
this.logger.info({ count: this.sessions.size }, "Shutting down all tmux sessions");
|
|
1588
|
+
await this.stopAll({ timeout: 3e3 });
|
|
1589
|
+
this.sessions.clear();
|
|
1590
|
+
this.outputLogs.clear();
|
|
1591
|
+
this.transport.destroy();
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Get count of sessions by status
|
|
1595
|
+
*/
|
|
1596
|
+
getStatusCounts() {
|
|
1597
|
+
const counts = {
|
|
1598
|
+
pending: 0,
|
|
1599
|
+
starting: 0,
|
|
1600
|
+
authenticating: 0,
|
|
1601
|
+
ready: 0,
|
|
1602
|
+
busy: 0,
|
|
1603
|
+
stopping: 0,
|
|
1604
|
+
stopped: 0,
|
|
1605
|
+
error: 0
|
|
1606
|
+
};
|
|
1607
|
+
for (const session of this.sessions.values()) {
|
|
1608
|
+
counts[session.status]++;
|
|
1609
|
+
}
|
|
1610
|
+
return counts;
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Attach to a session's terminal for raw I/O streaming
|
|
1614
|
+
*/
|
|
1615
|
+
attachTerminal(sessionId) {
|
|
1616
|
+
const session = this.sessions.get(sessionId);
|
|
1617
|
+
if (!session) return null;
|
|
1618
|
+
return {
|
|
1619
|
+
onData: (callback) => {
|
|
1620
|
+
session.on("output", callback);
|
|
1621
|
+
return () => session.off("output", callback);
|
|
1622
|
+
},
|
|
1623
|
+
write: (data) => {
|
|
1624
|
+
session.writeRaw(data);
|
|
1625
|
+
},
|
|
1626
|
+
resize: (cols, rows) => {
|
|
1627
|
+
session.resize(cols, rows);
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Check if a session exists
|
|
1633
|
+
*/
|
|
1634
|
+
has(sessionId) {
|
|
1635
|
+
return this.sessions.has(sessionId);
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Get the underlying TmuxSession (for advanced use)
|
|
1639
|
+
*/
|
|
1640
|
+
getSession(sessionId) {
|
|
1641
|
+
return this.sessions.get(sessionId);
|
|
1642
|
+
}
|
|
1643
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1644
|
+
// Tmux-Specific Features
|
|
1645
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1646
|
+
/**
|
|
1647
|
+
* List orphaned tmux sessions from previous runs.
|
|
1648
|
+
* These are tmux sessions with the configured prefix that aren't tracked by this manager.
|
|
1649
|
+
*/
|
|
1650
|
+
listOrphanedSessions() {
|
|
1651
|
+
const tmuxSessions = TmuxTransport.listSessions(this._sessionPrefix);
|
|
1652
|
+
const managedNames = new Set(
|
|
1653
|
+
Array.from(this.sessions.values()).map((s) => s.tmuxName)
|
|
1654
|
+
);
|
|
1655
|
+
return tmuxSessions.filter((s) => !managedNames.has(s.name));
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Clean up orphaned tmux sessions from previous runs.
|
|
1659
|
+
*/
|
|
1660
|
+
cleanupOrphanedSessions() {
|
|
1661
|
+
const orphans = this.listOrphanedSessions();
|
|
1662
|
+
for (const orphan of orphans) {
|
|
1663
|
+
try {
|
|
1664
|
+
this.transport.kill(orphan.name);
|
|
1665
|
+
this.logger.info({ tmuxSession: orphan.name }, "Cleaned up orphaned tmux session");
|
|
1666
|
+
} catch {
|
|
1667
|
+
this.logger.warn({ tmuxSession: orphan.name }, "Failed to clean up orphaned tmux session");
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
return orphans.length;
|
|
1671
|
+
}
|
|
1672
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1673
|
+
// Stall Detection Configuration
|
|
1674
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1675
|
+
configureStallDetection(enabled, timeoutMs, classify) {
|
|
1676
|
+
this._stallDetectionEnabled = enabled;
|
|
1677
|
+
if (timeoutMs !== void 0) {
|
|
1678
|
+
this._stallTimeoutMs = timeoutMs;
|
|
1679
|
+
}
|
|
1680
|
+
if (classify !== void 0) {
|
|
1681
|
+
this._onStallClassify = classify;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1685
|
+
// Runtime Auto-Response Rules API
|
|
1686
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1687
|
+
addAutoResponseRule(sessionId, rule) {
|
|
1688
|
+
const session = this.sessions.get(sessionId);
|
|
1689
|
+
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
|
1690
|
+
session.addAutoResponseRule(rule);
|
|
1691
|
+
}
|
|
1692
|
+
removeAutoResponseRule(sessionId, pattern) {
|
|
1693
|
+
const session = this.sessions.get(sessionId);
|
|
1694
|
+
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
|
1695
|
+
return session.removeAutoResponseRule(pattern);
|
|
1696
|
+
}
|
|
1697
|
+
setAutoResponseRules(sessionId, rules) {
|
|
1698
|
+
const session = this.sessions.get(sessionId);
|
|
1699
|
+
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
|
1700
|
+
session.setAutoResponseRules(rules);
|
|
1701
|
+
}
|
|
1702
|
+
getAutoResponseRules(sessionId) {
|
|
1703
|
+
const session = this.sessions.get(sessionId);
|
|
1704
|
+
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
|
1705
|
+
return session.getAutoResponseRules();
|
|
1706
|
+
}
|
|
1707
|
+
clearAutoResponseRules(sessionId) {
|
|
1708
|
+
const session = this.sessions.get(sessionId);
|
|
1709
|
+
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
|
1710
|
+
session.clearAutoResponseRules();
|
|
1711
|
+
}
|
|
1712
|
+
};
|
|
1713
|
+
|
|
1714
|
+
// src/task-completion-trace.ts
|
|
1715
|
+
function extractTaskCompletionTraceRecords(entries) {
|
|
1716
|
+
const out = [];
|
|
1717
|
+
for (const entry of entries) {
|
|
1718
|
+
let obj = null;
|
|
1719
|
+
if (typeof entry === "string") {
|
|
1720
|
+
const line = entry.trim();
|
|
1721
|
+
if (!line.startsWith("{") || !line.endsWith("}")) continue;
|
|
1722
|
+
try {
|
|
1723
|
+
obj = JSON.parse(line);
|
|
1724
|
+
} catch {
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
} else if (entry && typeof entry === "object") {
|
|
1728
|
+
obj = entry;
|
|
1729
|
+
}
|
|
1730
|
+
if (!obj) continue;
|
|
1731
|
+
if (obj.msg !== "Task completion trace") continue;
|
|
1732
|
+
if (typeof obj.event !== "string") continue;
|
|
1733
|
+
out.push({
|
|
1734
|
+
sessionId: asString(obj.sessionId),
|
|
1735
|
+
adapterType: asString(obj.adapterType),
|
|
1736
|
+
event: obj.event,
|
|
1737
|
+
status: asString(obj.status),
|
|
1738
|
+
taskCompletePending: asBool(obj.taskCompletePending),
|
|
1739
|
+
signal: asBool(obj.signal),
|
|
1740
|
+
wasPending: asBool(obj.wasPending),
|
|
1741
|
+
debounceMs: asNumber(obj.debounceMs),
|
|
1742
|
+
detectTaskComplete: asBool(obj.detectTaskComplete),
|
|
1743
|
+
detectReady: asBool(obj.detectReady),
|
|
1744
|
+
detectLoading: asBool(obj.detectLoading),
|
|
1745
|
+
tailHash: asString(obj.tailHash),
|
|
1746
|
+
tailSnippet: asString(obj.tailSnippet),
|
|
1747
|
+
timestamp: asTimestamp(obj.time) ?? asTimestamp(obj.timestamp)
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
return out;
|
|
1751
|
+
}
|
|
1752
|
+
function buildTaskCompletionTimeline(records, options = {}) {
|
|
1753
|
+
const filtered = records.filter((r) => {
|
|
1754
|
+
if (!options.adapterType) return true;
|
|
1755
|
+
return r.adapterType === options.adapterType;
|
|
1756
|
+
});
|
|
1757
|
+
const turns = [];
|
|
1758
|
+
let current = null;
|
|
1759
|
+
let ignored = 0;
|
|
1760
|
+
filtered.forEach((record, index) => {
|
|
1761
|
+
if (record.event === "busy_signal" && current && current.completed) {
|
|
1762
|
+
current = null;
|
|
1763
|
+
}
|
|
1764
|
+
if (!current) {
|
|
1765
|
+
current = {
|
|
1766
|
+
turn: turns.length + 1,
|
|
1767
|
+
startIndex: index,
|
|
1768
|
+
endIndex: index,
|
|
1769
|
+
completed: false,
|
|
1770
|
+
maxConfidence: 0,
|
|
1771
|
+
finalConfidence: 0,
|
|
1772
|
+
events: []
|
|
1773
|
+
};
|
|
1774
|
+
turns.push(current);
|
|
1775
|
+
}
|
|
1776
|
+
const step = toStep(record, index);
|
|
1777
|
+
if (!step) {
|
|
1778
|
+
ignored++;
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
current.events.push(step);
|
|
1782
|
+
current.endIndex = index;
|
|
1783
|
+
current.maxConfidence = Math.max(current.maxConfidence, step.confidence);
|
|
1784
|
+
current.finalConfidence = step.confidence;
|
|
1785
|
+
if (step.status === "completed") {
|
|
1786
|
+
current.completed = true;
|
|
1787
|
+
}
|
|
1788
|
+
});
|
|
1789
|
+
return {
|
|
1790
|
+
turns,
|
|
1791
|
+
totalRecords: filtered.length,
|
|
1792
|
+
ignoredRecords: ignored
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
function toStep(record, atIndex) {
|
|
1796
|
+
const event = record.event;
|
|
1797
|
+
const confidence = scoreConfidence(record);
|
|
1798
|
+
if (event === "transition_ready") {
|
|
1799
|
+
return withCommon(record, { event, atIndex, status: "completed", confidence: 100 });
|
|
1800
|
+
}
|
|
1801
|
+
if (event === "debounce_reject_signal" || event === "debounce_reject_status") {
|
|
1802
|
+
return withCommon(record, { event, atIndex, status: "rejected", confidence });
|
|
1803
|
+
}
|
|
1804
|
+
if (record.detectLoading) {
|
|
1805
|
+
return withCommon(record, { event, atIndex, status: "active_loading", confidence });
|
|
1806
|
+
}
|
|
1807
|
+
if (event === "debounce_fire" && record.signal) {
|
|
1808
|
+
return withCommon(record, { event, atIndex, status: "likely_complete", confidence });
|
|
1809
|
+
}
|
|
1810
|
+
if (event === "busy_signal" || event === "debounce_schedule" || event === "debounce_fire") {
|
|
1811
|
+
return withCommon(record, { event, atIndex, status: "active", confidence });
|
|
1812
|
+
}
|
|
1813
|
+
return null;
|
|
1814
|
+
}
|
|
1815
|
+
function scoreConfidence(record) {
|
|
1816
|
+
let score = 10;
|
|
1817
|
+
if (record.detectLoading) score -= 40;
|
|
1818
|
+
if (record.detectReady) score += 20;
|
|
1819
|
+
if (record.detectTaskComplete) score += 45;
|
|
1820
|
+
if (record.signal) score += 20;
|
|
1821
|
+
if (record.event === "debounce_reject_signal" || record.event === "debounce_reject_status") {
|
|
1822
|
+
score -= 30;
|
|
1823
|
+
}
|
|
1824
|
+
if (record.event === "transition_ready") score = 100;
|
|
1825
|
+
if (score < 0) return 0;
|
|
1826
|
+
if (score > 100) return 100;
|
|
1827
|
+
return score;
|
|
1828
|
+
}
|
|
1829
|
+
function withCommon(record, step) {
|
|
1830
|
+
return {
|
|
1831
|
+
...step,
|
|
1832
|
+
signal: record.signal,
|
|
1833
|
+
detectTaskComplete: record.detectTaskComplete,
|
|
1834
|
+
detectReady: record.detectReady,
|
|
1835
|
+
detectLoading: record.detectLoading
|
|
1836
|
+
};
|
|
1837
|
+
}
|
|
1838
|
+
function asString(value) {
|
|
1839
|
+
return typeof value === "string" ? value : void 0;
|
|
1840
|
+
}
|
|
1841
|
+
function asBool(value) {
|
|
1842
|
+
return typeof value === "boolean" ? value : void 0;
|
|
1843
|
+
}
|
|
1844
|
+
function asNumber(value) {
|
|
1845
|
+
return typeof value === "number" ? value : void 0;
|
|
1846
|
+
}
|
|
1847
|
+
function asTimestamp(value) {
|
|
1848
|
+
if (typeof value === "string" || typeof value === "number" || value instanceof Date) {
|
|
1849
|
+
return value;
|
|
1850
|
+
}
|
|
1851
|
+
return void 0;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
// src/adapters/base-adapter.ts
|
|
1855
|
+
import { BaseCLIAdapter } from "adapter-types";
|
|
1856
|
+
|
|
1857
|
+
// src/adapters/adapter-factory.ts
|
|
1858
|
+
import { createAdapter } from "adapter-types";
|
|
1859
|
+
|
|
1860
|
+
// src/adapters/shell-adapter.ts
|
|
1861
|
+
var ShellAdapter = class {
|
|
1862
|
+
adapterType = "shell";
|
|
1863
|
+
displayName = "Shell";
|
|
1864
|
+
autoResponseRules = [];
|
|
1865
|
+
shell;
|
|
1866
|
+
promptStr;
|
|
1867
|
+
constructor(options = {}) {
|
|
1868
|
+
this.shell = options.shell || process.env.SHELL || "/bin/bash";
|
|
1869
|
+
this.promptStr = options.prompt || "pty> ";
|
|
1870
|
+
}
|
|
1871
|
+
getCommand() {
|
|
1872
|
+
return this.shell;
|
|
1873
|
+
}
|
|
1874
|
+
getArgs(_config) {
|
|
1875
|
+
if (this.shell.endsWith("/zsh") || this.shell === "zsh") {
|
|
1876
|
+
return ["-f"];
|
|
1877
|
+
}
|
|
1878
|
+
if (this.shell.endsWith("/bash") || this.shell === "bash") {
|
|
1879
|
+
return ["--norc", "--noprofile"];
|
|
1880
|
+
}
|
|
1881
|
+
return [];
|
|
1882
|
+
}
|
|
1883
|
+
getEnv(_config) {
|
|
1884
|
+
return {
|
|
1885
|
+
PS1: this.promptStr,
|
|
1886
|
+
PROMPT: this.promptStr
|
|
1887
|
+
// zsh uses PROMPT instead of PS1
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
detectLogin(_output) {
|
|
1891
|
+
return { required: false };
|
|
1892
|
+
}
|
|
1893
|
+
detectBlockingPrompt(_output) {
|
|
1894
|
+
return { detected: false };
|
|
1895
|
+
}
|
|
1896
|
+
detectReady(output) {
|
|
1897
|
+
if (this.isContinuationPrompt(output)) {
|
|
1898
|
+
return false;
|
|
1899
|
+
}
|
|
1900
|
+
return this.getPromptPattern().test(this.stripAnsi(output));
|
|
1901
|
+
}
|
|
1902
|
+
isContinuationPrompt(output) {
|
|
1903
|
+
const stripped = this.stripAnsi(output);
|
|
1904
|
+
return /(?:quote|dquote|heredoc|bquote|cmdsubst|pipe|then|else|do|loop)>\s*$/.test(stripped) || /(?:quote|dquote|heredoc|bquote)>\s*$/m.test(stripped);
|
|
1905
|
+
}
|
|
1906
|
+
detectExit(output) {
|
|
1907
|
+
if (output.includes("exit")) {
|
|
1908
|
+
return { exited: true, code: 0 };
|
|
1909
|
+
}
|
|
1910
|
+
return { exited: false };
|
|
1911
|
+
}
|
|
1912
|
+
parseOutput(output) {
|
|
1913
|
+
const cleaned = this.stripAnsi(output).trim();
|
|
1914
|
+
if (!cleaned) return null;
|
|
1915
|
+
return {
|
|
1916
|
+
type: "response",
|
|
1917
|
+
content: cleaned,
|
|
1918
|
+
isComplete: true,
|
|
1919
|
+
isQuestion: false
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
formatInput(message) {
|
|
1923
|
+
return message;
|
|
1924
|
+
}
|
|
1925
|
+
getPromptPattern() {
|
|
1926
|
+
const escaped = this.promptStr.trimEnd().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1927
|
+
return new RegExp(`(?:${escaped}|\\$|#)\\s*$`, "m");
|
|
1928
|
+
}
|
|
1929
|
+
async validateInstallation() {
|
|
1930
|
+
return { installed: true };
|
|
1931
|
+
}
|
|
1932
|
+
stripAnsi(str) {
|
|
1933
|
+
return str.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
export {
|
|
1937
|
+
AdapterRegistry,
|
|
1938
|
+
BaseCLIAdapter,
|
|
1939
|
+
SPECIAL_KEYS,
|
|
1940
|
+
ShellAdapter,
|
|
1941
|
+
TMUX_KEY_MAP,
|
|
1942
|
+
TmuxManager,
|
|
1943
|
+
TmuxSession,
|
|
1944
|
+
TmuxTransport,
|
|
1945
|
+
buildTaskCompletionTimeline,
|
|
1946
|
+
createAdapter,
|
|
1947
|
+
ensureTmux,
|
|
1948
|
+
extractTaskCompletionTraceRecords,
|
|
1949
|
+
resetTmuxCheck
|
|
1950
|
+
};
|
|
1951
|
+
//# sourceMappingURL=index.mjs.map
|