@triflux/core 10.25.1 → 10.27.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/hooks/hook-registry.json +1 -1
- package/hooks/keyword-rules.json +8 -8
- package/hooks/safety-guard.mjs +8 -1
- package/hub/bridge.mjs +221 -1
- package/hub/delegator/contracts.mjs +6 -1
- package/hub/delegator/schema/delegator-tools.schema.json +1 -1
- package/hub/delegator/service.mjs +7 -2
- package/hub/gemini-adapter.mjs +27 -35
- package/hub/routing/q-learning.mjs +3 -3
- package/hub/team/claude-daemon-control.mjs +1130 -0
- package/hub/team/retry-state-machine.mjs +83 -14
- package/hub/workers/worker-utils.mjs +1 -1
- package/hud/constants.mjs +6 -0
- package/hud/hud-qos-status.mjs +14 -10
- package/hud/providers/gemini.mjs +26 -1
- package/hud/renderers.mjs +27 -4
- package/package.json +1 -1
- package/scripts/lib/cli-codex.mjs +3 -3
- package/scripts/lib/cli-gemini.mjs +20 -50
- package/scripts/lib/keyword-rules.mjs +1 -1
- package/scripts/lib/psmux-info.mjs +50 -5
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
export function resolveClaudeConfigDir(env = process.env) {
|
|
8
|
+
if (env.CLAUDE_CONFIG_DIR) return path.resolve(env.CLAUDE_CONFIG_DIR);
|
|
9
|
+
return path.join(os.homedir(), ".claude");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function deriveClaudeDaemonPaths({
|
|
13
|
+
configDir = resolveClaudeConfigDir(),
|
|
14
|
+
uid = typeof process.getuid === "function" ? process.getuid() : 0,
|
|
15
|
+
tmpRoot = "/tmp",
|
|
16
|
+
} = {}) {
|
|
17
|
+
const resolvedConfigDir = path.resolve(configDir);
|
|
18
|
+
const hash = crypto
|
|
19
|
+
.createHash("sha256")
|
|
20
|
+
.update(resolvedConfigDir)
|
|
21
|
+
.digest("hex")
|
|
22
|
+
.slice(0, 8);
|
|
23
|
+
const daemonDir = path.join(tmpRoot, `cc-daemon-${uid}`, hash);
|
|
24
|
+
return {
|
|
25
|
+
configDir: resolvedConfigDir,
|
|
26
|
+
hash,
|
|
27
|
+
daemonDir,
|
|
28
|
+
controlSock: path.join(daemonDir, "control.sock"),
|
|
29
|
+
rosterPath: path.join(resolvedConfigDir, "daemon", "roster.json"),
|
|
30
|
+
sessionsDir: path.join(resolvedConfigDir, "sessions"),
|
|
31
|
+
jobsDir: path.join(resolvedConfigDir, "jobs"),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readFirstJsonLine(text) {
|
|
36
|
+
const line = String(text)
|
|
37
|
+
.split("\n")
|
|
38
|
+
.find((entry) => entry.trim().length > 0);
|
|
39
|
+
if (!line) throw new Error("empty daemon response");
|
|
40
|
+
return JSON.parse(line);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function sendClaudeControlRequest(
|
|
44
|
+
sockPath,
|
|
45
|
+
request,
|
|
46
|
+
{ timeoutMs = 6000 } = {},
|
|
47
|
+
) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const socket = net.connect(sockPath);
|
|
50
|
+
let settled = false;
|
|
51
|
+
let data = "";
|
|
52
|
+
const finish = (error, value) => {
|
|
53
|
+
if (settled) return;
|
|
54
|
+
settled = true;
|
|
55
|
+
socket.destroy();
|
|
56
|
+
if (error) reject(error);
|
|
57
|
+
else resolve(value);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
socket.setTimeout(timeoutMs, () => {
|
|
61
|
+
finish(
|
|
62
|
+
new Error(`Timed out waiting for Claude daemon response: ${sockPath}`),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
socket.on("error", finish);
|
|
66
|
+
socket.on("connect", () => {
|
|
67
|
+
socket.write(`${JSON.stringify(request)}\n`);
|
|
68
|
+
});
|
|
69
|
+
socket.on("data", (chunk) => {
|
|
70
|
+
data += chunk.toString("utf8");
|
|
71
|
+
if (!data.includes("\n")) return;
|
|
72
|
+
try {
|
|
73
|
+
finish(null, readFirstJsonLine(data));
|
|
74
|
+
} catch (error) {
|
|
75
|
+
finish(error);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
socket.on("close", () => {
|
|
79
|
+
if (settled) return;
|
|
80
|
+
if (data.length === 0) {
|
|
81
|
+
finish(
|
|
82
|
+
new Error("Claude daemon closed control socket without a response"),
|
|
83
|
+
);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
finish(null, readFirstJsonLine(data));
|
|
88
|
+
} catch (error) {
|
|
89
|
+
finish(error);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildDaemonAttachRequest({
|
|
96
|
+
short,
|
|
97
|
+
cols = DEFAULT_DAEMON_ATTACH_COLS,
|
|
98
|
+
rows = 40,
|
|
99
|
+
caps = { terminal: null, mux: null, ssh: false },
|
|
100
|
+
} = {}) {
|
|
101
|
+
if (!short) throw new Error("short is required");
|
|
102
|
+
return {
|
|
103
|
+
proto: 1,
|
|
104
|
+
op: "attach",
|
|
105
|
+
short,
|
|
106
|
+
cols,
|
|
107
|
+
rows,
|
|
108
|
+
caps,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const CLAUDE_ATTACH_SPINNER_RE = /^[✻✶✳✢✽·]/u;
|
|
113
|
+
const DEFAULT_DAEMON_ATTACH_COLS = 240;
|
|
114
|
+
const DEFAULT_ATTACH_COMPLETION_QUIESCENCE_MS = 750;
|
|
115
|
+
|
|
116
|
+
function normalizeTerminalCols(cols) {
|
|
117
|
+
const value = Number(cols);
|
|
118
|
+
if (!Number.isFinite(value)) return DEFAULT_DAEMON_ATTACH_COLS;
|
|
119
|
+
return Math.max(1, Math.floor(value));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function terminalCellWidth(char) {
|
|
123
|
+
const cp = char.codePointAt(0);
|
|
124
|
+
if (
|
|
125
|
+
cp === 0 ||
|
|
126
|
+
cp === 0xad ||
|
|
127
|
+
(cp >= 0x0300 && cp <= 0x036f) ||
|
|
128
|
+
(cp >= 0x0610 && cp <= 0x061a) ||
|
|
129
|
+
(cp >= 0x064b && cp <= 0x065f) ||
|
|
130
|
+
(cp >= 0x1ab0 && cp <= 0x1aff) ||
|
|
131
|
+
(cp >= 0x1dc0 && cp <= 0x1dff) ||
|
|
132
|
+
(cp >= 0x20d0 && cp <= 0x20ff) ||
|
|
133
|
+
(cp >= 0xfe20 && cp <= 0xfe2f)
|
|
134
|
+
) {
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (
|
|
139
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
140
|
+
(cp >= 0x2e80 && cp <= 0x2eff) ||
|
|
141
|
+
(cp >= 0x2f00 && cp <= 0x2fff) ||
|
|
142
|
+
(cp >= 0x3000 && cp <= 0x303f) ||
|
|
143
|
+
(cp >= 0x3040 && cp <= 0x309f) ||
|
|
144
|
+
(cp >= 0x30a0 && cp <= 0x30ff) ||
|
|
145
|
+
(cp >= 0x3100 && cp <= 0x312f) ||
|
|
146
|
+
(cp >= 0x3130 && cp <= 0x318f) ||
|
|
147
|
+
(cp >= 0x3190 && cp <= 0x319f) ||
|
|
148
|
+
(cp >= 0x31c0 && cp <= 0x31ef) ||
|
|
149
|
+
(cp >= 0x3200 && cp <= 0x32ff) ||
|
|
150
|
+
(cp >= 0x3300 && cp <= 0x33ff) ||
|
|
151
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
152
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
153
|
+
(cp >= 0xa000 && cp <= 0xa48f) ||
|
|
154
|
+
(cp >= 0xa490 && cp <= 0xa4cf) ||
|
|
155
|
+
(cp >= 0xa960 && cp <= 0xa97f) ||
|
|
156
|
+
(cp >= 0xac00 && cp <= 0xd7af) ||
|
|
157
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
158
|
+
(cp >= 0xfe10 && cp <= 0xfe1f) ||
|
|
159
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
160
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
161
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
162
|
+
(cp >= 0x1b000 && cp <= 0x1b0ff) ||
|
|
163
|
+
(cp >= 0x1f004 && cp <= 0x1f0cf) ||
|
|
164
|
+
(cp >= 0x1f200 && cp <= 0x1f2ff) ||
|
|
165
|
+
(cp >= 0x1f300 && cp <= 0x1f64f) ||
|
|
166
|
+
(cp >= 0x1f680 && cp <= 0x1f6ff) ||
|
|
167
|
+
(cp >= 0x1f900 && cp <= 0x1faff) ||
|
|
168
|
+
(cp >= 0x20000 && cp <= 0x2fffd) ||
|
|
169
|
+
(cp >= 0x30000 && cp <= 0x3fffd)
|
|
170
|
+
) {
|
|
171
|
+
return 2;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseCsiParams(params) {
|
|
178
|
+
const normalized = params.replace(/[?<=>]/g, "");
|
|
179
|
+
if (!normalized) return [];
|
|
180
|
+
return normalized.split(";").map((entry) => {
|
|
181
|
+
const value = Number.parseInt(entry, 10);
|
|
182
|
+
return Number.isFinite(value) ? value : 0;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function readTerminalEscape(text, offset) {
|
|
187
|
+
if (text[offset] !== "\x1b") return null;
|
|
188
|
+
const next = text[offset + 1];
|
|
189
|
+
if (next === undefined) return { end: offset + 1, type: "escape" };
|
|
190
|
+
|
|
191
|
+
if (next === "]") {
|
|
192
|
+
let index = offset + 2;
|
|
193
|
+
while (index < text.length) {
|
|
194
|
+
if (text[index] === "\x07") {
|
|
195
|
+
return { end: index + 1, type: "osc" };
|
|
196
|
+
}
|
|
197
|
+
if (text[index] === "\x1b" && text[index + 1] === "\\") {
|
|
198
|
+
return { end: index + 2, type: "osc" };
|
|
199
|
+
}
|
|
200
|
+
index += 1;
|
|
201
|
+
}
|
|
202
|
+
return { end: text.length, type: "osc" };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (next === "[") {
|
|
206
|
+
let index = offset + 2;
|
|
207
|
+
while (index < text.length) {
|
|
208
|
+
const code = text.charCodeAt(index);
|
|
209
|
+
if (code >= 0x40 && code <= 0x7e) {
|
|
210
|
+
return {
|
|
211
|
+
end: index + 1,
|
|
212
|
+
type: "csi",
|
|
213
|
+
final: text[index],
|
|
214
|
+
params: text.slice(offset + 2, index),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
index += 1;
|
|
218
|
+
}
|
|
219
|
+
return { end: text.length, type: "csi" };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { end: offset + 2, type: "escape", final: next };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function renderTerminalText(text, { cols = DEFAULT_DAEMON_ATTACH_COLS } = {}) {
|
|
226
|
+
const width = normalizeTerminalCols(cols);
|
|
227
|
+
const rows = [[]];
|
|
228
|
+
const softWrappedRows = new Set();
|
|
229
|
+
let row = 0;
|
|
230
|
+
let col = 0;
|
|
231
|
+
let pendingWrap = false;
|
|
232
|
+
let savedCursor = { row: 0, col: 0 };
|
|
233
|
+
|
|
234
|
+
const ensureRow = (targetRow = row) => {
|
|
235
|
+
while (rows.length <= targetRow) rows.push([]);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const setCursor = (nextRow, nextCol) => {
|
|
239
|
+
row = Math.max(0, nextRow);
|
|
240
|
+
col = Math.max(0, Math.min(width - 1, nextCol));
|
|
241
|
+
pendingWrap = false;
|
|
242
|
+
ensureRow(row);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const clearLine = (mode = 0) => {
|
|
246
|
+
ensureRow();
|
|
247
|
+
if (mode === 2) {
|
|
248
|
+
rows[row] = [];
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (mode === 1) {
|
|
252
|
+
for (let index = 0; index <= col; index += 1) rows[row][index] = " ";
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
rows[row].length = col;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const clearScreen = (mode = 0) => {
|
|
259
|
+
if (mode === 2 || mode === 3) {
|
|
260
|
+
rows.length = 1;
|
|
261
|
+
rows[0] = [];
|
|
262
|
+
row = 0;
|
|
263
|
+
col = 0;
|
|
264
|
+
pendingWrap = false;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const applyCsi = ({ final, params = "" }) => {
|
|
269
|
+
const values = parseCsiParams(params);
|
|
270
|
+
const first = values[0] || 1;
|
|
271
|
+
switch (final) {
|
|
272
|
+
case "A":
|
|
273
|
+
setCursor(row - first, col);
|
|
274
|
+
break;
|
|
275
|
+
case "B":
|
|
276
|
+
setCursor(row + first, col);
|
|
277
|
+
break;
|
|
278
|
+
case "C":
|
|
279
|
+
setCursor(row, col + first);
|
|
280
|
+
break;
|
|
281
|
+
case "D":
|
|
282
|
+
setCursor(row, col - first);
|
|
283
|
+
break;
|
|
284
|
+
case "G":
|
|
285
|
+
setCursor(row, first - 1);
|
|
286
|
+
break;
|
|
287
|
+
case "H":
|
|
288
|
+
case "f":
|
|
289
|
+
setCursor((values[0] || 1) - 1, (values[1] || 1) - 1);
|
|
290
|
+
break;
|
|
291
|
+
case "J":
|
|
292
|
+
clearScreen(values[0] || 0);
|
|
293
|
+
break;
|
|
294
|
+
case "K":
|
|
295
|
+
clearLine(values[0] || 0);
|
|
296
|
+
break;
|
|
297
|
+
case "s":
|
|
298
|
+
savedCursor = { row, col };
|
|
299
|
+
break;
|
|
300
|
+
case "u":
|
|
301
|
+
setCursor(savedCursor.row, savedCursor.col);
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const clearWideAt = (targetCol) => {
|
|
307
|
+
ensureRow();
|
|
308
|
+
if (rows[row][targetCol] === "" && targetCol > 0) {
|
|
309
|
+
rows[row][targetCol - 1] = " ";
|
|
310
|
+
}
|
|
311
|
+
if (rows[row][targetCol + 1] === "") {
|
|
312
|
+
rows[row][targetCol + 1] = " ";
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const writeCell = (char, cellWidth) => {
|
|
317
|
+
if (pendingWrap) {
|
|
318
|
+
row += 1;
|
|
319
|
+
col = 0;
|
|
320
|
+
pendingWrap = false;
|
|
321
|
+
softWrappedRows.add(row);
|
|
322
|
+
ensureRow();
|
|
323
|
+
}
|
|
324
|
+
if (col + cellWidth > width && col > 0) {
|
|
325
|
+
row += 1;
|
|
326
|
+
col = 0;
|
|
327
|
+
softWrappedRows.add(row);
|
|
328
|
+
ensureRow();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
clearWideAt(col);
|
|
332
|
+
rows[row][col] = char;
|
|
333
|
+
if (cellWidth === 2 && col + 1 < width) {
|
|
334
|
+
clearWideAt(col + 1);
|
|
335
|
+
rows[row][col + 1] = "";
|
|
336
|
+
}
|
|
337
|
+
col += cellWidth;
|
|
338
|
+
if (col >= width) {
|
|
339
|
+
col = width;
|
|
340
|
+
pendingWrap = true;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const appendCombining = (char) => {
|
|
345
|
+
ensureRow();
|
|
346
|
+
for (let index = Math.min(col - 1, width - 1); index >= 0; index -= 1) {
|
|
347
|
+
if (rows[row][index] && rows[row][index] !== "") {
|
|
348
|
+
rows[row][index] += char;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const writeChar = (char) => {
|
|
355
|
+
if (char === "\n") {
|
|
356
|
+
row += 1;
|
|
357
|
+
col = 0;
|
|
358
|
+
pendingWrap = false;
|
|
359
|
+
ensureRow();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (char === "\r") {
|
|
363
|
+
col = 0;
|
|
364
|
+
pendingWrap = false;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (char === "\b") {
|
|
368
|
+
col = Math.max(0, col - 1);
|
|
369
|
+
pendingWrap = false;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (char === "\t") {
|
|
373
|
+
const spaces = 8 - (col % 8);
|
|
374
|
+
for (let index = 0; index < spaces; index += 1) writeCell(" ", 1);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const cellWidth = terminalCellWidth(char);
|
|
379
|
+
if (cellWidth === 0) {
|
|
380
|
+
appendCombining(char);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
writeCell(char, cellWidth);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const input = String(text);
|
|
387
|
+
for (let index = 0; index < input.length; ) {
|
|
388
|
+
const char = input[index];
|
|
389
|
+
if (char === "\x1b") {
|
|
390
|
+
const sequence = readTerminalEscape(input, index);
|
|
391
|
+
if (sequence?.type === "csi") applyCsi(sequence);
|
|
392
|
+
else if (sequence?.final === "7") savedCursor = { row, col };
|
|
393
|
+
else if (sequence?.final === "8")
|
|
394
|
+
setCursor(savedCursor.row, savedCursor.col);
|
|
395
|
+
index = sequence?.end ?? index + 1;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const code = input.charCodeAt(index);
|
|
399
|
+
if (
|
|
400
|
+
code <= 0x1f &&
|
|
401
|
+
char !== "\n" &&
|
|
402
|
+
char !== "\r" &&
|
|
403
|
+
char !== "\b" &&
|
|
404
|
+
char !== "\t"
|
|
405
|
+
) {
|
|
406
|
+
index += 1;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const cp = input.codePointAt(index);
|
|
410
|
+
const charLength = cp > 0xffff ? 2 : 1;
|
|
411
|
+
writeChar(input.slice(index, index + charLength));
|
|
412
|
+
index += charLength;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const trailingSpaceRows = new Set();
|
|
416
|
+
const lines = rows.map((cells, index) => {
|
|
417
|
+
let last = cells.length - 1;
|
|
418
|
+
while (
|
|
419
|
+
last >= 0 &&
|
|
420
|
+
(cells[last] === undefined || cells[last] === null || cells[last] === "")
|
|
421
|
+
) {
|
|
422
|
+
last -= 1;
|
|
423
|
+
}
|
|
424
|
+
if (last >= 0 && cells[last] === " ") trailingSpaceRows.add(index);
|
|
425
|
+
const line = Array.from({ length: last + 1 }, (_, cellIndex) => {
|
|
426
|
+
const cell = cells[cellIndex];
|
|
427
|
+
if (cell === undefined || cell === null || cell === "") return "";
|
|
428
|
+
return cell;
|
|
429
|
+
}).join("");
|
|
430
|
+
return line.replace(/[^\S\n]+$/g, "");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
text: lines.join("\n"),
|
|
435
|
+
softWrappedRows,
|
|
436
|
+
trailingSpaceRows,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function stripTerminalControl(text, options) {
|
|
441
|
+
return renderTerminalText(text, options).text.replace(/\x7f/g, "");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function isClaudeAttachElapsedStatusLine(line) {
|
|
445
|
+
const trimmed = line.trim();
|
|
446
|
+
return (
|
|
447
|
+
/\bfor\s+\d+s\)?$/i.test(trimmed) ||
|
|
448
|
+
/^\(?\d+s\b(?:\s*[·•]\s*.*\btokens?\s*)?\)?$/i.test(trimmed) ||
|
|
449
|
+
/\(\d+s\b.*\btokens?\)/i.test(trimmed)
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function isClaudeAttachTransientStatusLine(line) {
|
|
454
|
+
const trimmed = line.trim();
|
|
455
|
+
return (
|
|
456
|
+
isClaudeAttachElapsedStatusLine(trimmed) ||
|
|
457
|
+
/esc to interrupt/i.test(trimmed) ||
|
|
458
|
+
CLAUDE_ATTACH_SPINNER_RE.test(trimmed) ||
|
|
459
|
+
/^·?[\p{L}\w-]+…(?:$|\s|\(|\d+$)/u.test(trimmed) ||
|
|
460
|
+
/^[\p{L}\w-]+\s+for\s+\d+s\)?$/u.test(trimmed)
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function isClaudeAttachChromeLine(line) {
|
|
465
|
+
const trimmed = line.trim();
|
|
466
|
+
return (
|
|
467
|
+
trimmed.length === 0 ||
|
|
468
|
+
trimmed === "❯" ||
|
|
469
|
+
/^❯\s/.test(trimmed) ||
|
|
470
|
+
/^⏵/.test(trimmed) ||
|
|
471
|
+
isClaudeAttachTransientStatusLine(trimmed) ||
|
|
472
|
+
/^Ran \d+ shell command/.test(trimmed) ||
|
|
473
|
+
/^Image in clipboard/.test(trimmed) ||
|
|
474
|
+
/^SettingsStatus Config UsageStats/.test(trimmed) ||
|
|
475
|
+
/^Space to change · Enter to save · Esc to cancel/.test(trimmed) ||
|
|
476
|
+
/^⌕ Search settings/.test(trimmed) ||
|
|
477
|
+
/^W\(\d+s\b/.test(trimmed) ||
|
|
478
|
+
/^running stop hooks…/.test(trimmed) ||
|
|
479
|
+
/^Tip: /.test(trimmed) ||
|
|
480
|
+
/^\d+ MCP servers? failed\b/.test(trimmed) ||
|
|
481
|
+
/^(Auto-compact|Showtips|Reducemotion|Thinkingmode|Promptsuggestions|Sessionrecap)/.test(
|
|
482
|
+
trimmed,
|
|
483
|
+
) ||
|
|
484
|
+
/^[-─━▔╭╮╰╯│┌┐└┘├┤┬┴┼\s]+$/.test(trimmed) ||
|
|
485
|
+
/^(c|x|g):\s/.test(trimmed) ||
|
|
486
|
+
/CTX:\d+/.test(trimmed) ||
|
|
487
|
+
/auto mode on/.test(trimmed)
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function normalizeClaudeAttachResponseLine(line) {
|
|
492
|
+
return line.replace(/^ {2}/, "");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function shouldJoinSoftWrappedLine({ rendered, lineIndex }) {
|
|
496
|
+
return rendered.softWrappedRows.has(lineIndex);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function joinSoftWrappedLine(previous, current, previousHadTrailingSpace) {
|
|
500
|
+
if (previous.endsWith(" ") || current.startsWith(" ")) {
|
|
501
|
+
return `${previous}${current}`;
|
|
502
|
+
}
|
|
503
|
+
if (previousHadTrailingSpace) return `${previous} ${current}`;
|
|
504
|
+
return `${previous}${current}`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function extractClaudeDaemonAttachText(
|
|
508
|
+
text,
|
|
509
|
+
{ cols = DEFAULT_DAEMON_ATTACH_COLS } = {},
|
|
510
|
+
) {
|
|
511
|
+
const rendered = renderTerminalText(text, { cols });
|
|
512
|
+
const cleaned = rendered.text;
|
|
513
|
+
const lines = cleaned.split("\n").map((line) => line.trimEnd());
|
|
514
|
+
const assistantIndex = lines.findLastIndex((line) => /^\s*⏺\s*/.test(line));
|
|
515
|
+
if (assistantIndex < 0) return cleaned.trim();
|
|
516
|
+
|
|
517
|
+
const response = [];
|
|
518
|
+
let lastAcceptedLineIndex = -1;
|
|
519
|
+
for (let index = assistantIndex; index < lines.length; index += 1) {
|
|
520
|
+
const rawLine = lines[index];
|
|
521
|
+
const line =
|
|
522
|
+
index === assistantIndex ? rawLine.replace(/^\s*⏺\s*/, "") : rawLine;
|
|
523
|
+
if (index > assistantIndex && line.trim().length === 0) {
|
|
524
|
+
response.push("");
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (
|
|
528
|
+
response.length > 0 &&
|
|
529
|
+
/^[A-Z0-9_./:-]+$/.test(response[0].trim()) &&
|
|
530
|
+
/^[·\d\s]+$/.test(line.trim())
|
|
531
|
+
) {
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
if (index > assistantIndex && isClaudeAttachChromeLine(line)) break;
|
|
535
|
+
if (isClaudeAttachChromeLine(line)) continue;
|
|
536
|
+
if (
|
|
537
|
+
response.length > 0 &&
|
|
538
|
+
lastAcceptedLineIndex === index - 1 &&
|
|
539
|
+
shouldJoinSoftWrappedLine({
|
|
540
|
+
rendered,
|
|
541
|
+
lineIndex: index,
|
|
542
|
+
})
|
|
543
|
+
) {
|
|
544
|
+
response[response.length - 1] = joinSoftWrappedLine(
|
|
545
|
+
response[response.length - 1],
|
|
546
|
+
line,
|
|
547
|
+
rendered.trailingSpaceRows.has(index - 1),
|
|
548
|
+
);
|
|
549
|
+
lastAcceptedLineIndex = index;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
response.push(normalizeClaudeAttachResponseLine(line));
|
|
553
|
+
lastAcceptedLineIndex = index;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return response.join("\n").trim();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function isClaudeAttachBusyLine(line) {
|
|
560
|
+
const trimmed = line.trim();
|
|
561
|
+
if (/esc to interrupt/i.test(trimmed)) return true;
|
|
562
|
+
if (!CLAUDE_ATTACH_SPINNER_RE.test(trimmed)) return false;
|
|
563
|
+
return !isClaudeAttachElapsedStatusLine(trimmed);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function findClaudeAttachBusySignalIndex(text, options) {
|
|
567
|
+
const cleaned = stripTerminalControl(text, options);
|
|
568
|
+
let offset = 0;
|
|
569
|
+
for (const line of cleaned.split("\n")) {
|
|
570
|
+
if (isClaudeAttachBusyLine(line)) return offset;
|
|
571
|
+
offset += line.length + 1;
|
|
572
|
+
}
|
|
573
|
+
return -1;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function findClaudeAttachPromptEchoIndex(text, input, options) {
|
|
577
|
+
const prompt = String(input ?? "").trim();
|
|
578
|
+
if (!prompt) return -1;
|
|
579
|
+
const cleaned = stripTerminalControl(text, options);
|
|
580
|
+
const exact = cleaned.indexOf(prompt);
|
|
581
|
+
if (exact >= 0) return exact;
|
|
582
|
+
const prefix = prompt.slice(0, Math.min(40, prompt.length));
|
|
583
|
+
if (prefix.length >= 12) return cleaned.indexOf(prefix);
|
|
584
|
+
return -1;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function claudeAttachDynamicProgressSignature(text, options) {
|
|
588
|
+
const cleaned = stripTerminalControl(text, options);
|
|
589
|
+
const spinnerGlyphs = [...cleaned.matchAll(/[✻✶✳✢✽·]/gu)]
|
|
590
|
+
.map((match) => match[0])
|
|
591
|
+
.join("")
|
|
592
|
+
.slice(-32);
|
|
593
|
+
const elapsedSeconds = [...cleaned.matchAll(/\b(\d+)s\b/gi)]
|
|
594
|
+
.map((match) => match[1])
|
|
595
|
+
.join(",")
|
|
596
|
+
.slice(-64);
|
|
597
|
+
if (!spinnerGlyphs && !elapsedSeconds) return "";
|
|
598
|
+
return `${spinnerGlyphs}|${elapsedSeconds}`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function hasClaudeAttachPreInputScreen(text, options) {
|
|
602
|
+
const cleaned = stripTerminalControl(text, options);
|
|
603
|
+
return /(^|\n)\s*(⏺|❯|⏵)\s*/.test(cleaned);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function defaultClaudeAttachCompletionMatched(
|
|
607
|
+
streamText,
|
|
608
|
+
{ cols = DEFAULT_DAEMON_ATTACH_COLS } = {},
|
|
609
|
+
) {
|
|
610
|
+
const cleaned = stripTerminalControl(streamText, { cols });
|
|
611
|
+
const assistantIndex = cleaned.lastIndexOf("⏺");
|
|
612
|
+
if (assistantIndex < 0) return false;
|
|
613
|
+
const tail = cleaned.slice(assistantIndex);
|
|
614
|
+
if (/esc to interrupt/i.test(tail)) return false;
|
|
615
|
+
return (
|
|
616
|
+
/(^|\n)\s*❯\s*(?:\n|$)/.test(tail) ||
|
|
617
|
+
(extractClaudeDaemonAttachText(tail, { cols }).length > 0 &&
|
|
618
|
+
tail.split("\n").some((line) => isClaudeAttachElapsedStatusLine(line)))
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function writeClaudeAttachInput(socket, input) {
|
|
623
|
+
socket.write("\x15");
|
|
624
|
+
socket.write(`\x1b[200~${String(input)}\x1b[201~`);
|
|
625
|
+
socket.write("\r");
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function requiresPostInputTransition(handshake) {
|
|
629
|
+
return (
|
|
630
|
+
handshake?.active === true ||
|
|
631
|
+
handshake?.tempo === "active" ||
|
|
632
|
+
handshake?.state === "working" ||
|
|
633
|
+
handshake?.source === "slash" ||
|
|
634
|
+
handshake?.source === "fleet"
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export function attachClaudeDaemonSession({
|
|
639
|
+
controlSock,
|
|
640
|
+
short,
|
|
641
|
+
input,
|
|
642
|
+
cols = DEFAULT_DAEMON_ATTACH_COLS,
|
|
643
|
+
rows = 40,
|
|
644
|
+
caps,
|
|
645
|
+
initialDrainMs = 150,
|
|
646
|
+
timeoutMs = 30_000,
|
|
647
|
+
completionQuiescenceMs = DEFAULT_ATTACH_COMPLETION_QUIESCENCE_MS,
|
|
648
|
+
completionMatched = defaultClaudeAttachCompletionMatched,
|
|
649
|
+
} = {}) {
|
|
650
|
+
if (!controlSock) throw new Error("controlSock is required");
|
|
651
|
+
return new Promise((resolve, reject) => {
|
|
652
|
+
const socket = net.connect(controlSock);
|
|
653
|
+
let settled = false;
|
|
654
|
+
let buffer = Buffer.alloc(0);
|
|
655
|
+
let handshake = null;
|
|
656
|
+
let stream = Buffer.alloc(0);
|
|
657
|
+
let inputSent = false;
|
|
658
|
+
let responseStartOffset = 0;
|
|
659
|
+
let postInputBusySeen = input === undefined;
|
|
660
|
+
let postInputPromptEchoSeen = false;
|
|
661
|
+
let responseReadyText = "";
|
|
662
|
+
let requirePostInputTransition = false;
|
|
663
|
+
let sendInputTimer = null;
|
|
664
|
+
let completionTimer = null;
|
|
665
|
+
let pendingCompletion = null;
|
|
666
|
+
let dynamicProgressSignature = "";
|
|
667
|
+
let postInputDynamicProgressSeen = false;
|
|
668
|
+
|
|
669
|
+
const cancelCompletionTimer = () => {
|
|
670
|
+
if (completionTimer) clearTimeout(completionTimer);
|
|
671
|
+
completionTimer = null;
|
|
672
|
+
pendingCompletion = null;
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
const scheduleCompletion = (extra) => {
|
|
676
|
+
pendingCompletion = extra;
|
|
677
|
+
if (completionTimer) clearTimeout(completionTimer);
|
|
678
|
+
if (completionQuiescenceMs <= 0) {
|
|
679
|
+
const completion = pendingCompletion;
|
|
680
|
+
pendingCompletion = null;
|
|
681
|
+
finish(completion);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
completionTimer = setTimeout(() => {
|
|
685
|
+
const completion = pendingCompletion;
|
|
686
|
+
completionTimer = null;
|
|
687
|
+
pendingCompletion = null;
|
|
688
|
+
finish(completion);
|
|
689
|
+
}, completionQuiescenceMs);
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
const noteDynamicProgress = (text) => {
|
|
693
|
+
const signature = claudeAttachDynamicProgressSignature(text, { cols });
|
|
694
|
+
if (signature && signature !== dynamicProgressSignature) {
|
|
695
|
+
dynamicProgressSignature = signature;
|
|
696
|
+
postInputDynamicProgressSeen = true;
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const fail = (error) => {
|
|
701
|
+
if (settled) return;
|
|
702
|
+
settled = true;
|
|
703
|
+
clearTimeout(timer);
|
|
704
|
+
if (sendInputTimer) clearTimeout(sendInputTimer);
|
|
705
|
+
cancelCompletionTimer();
|
|
706
|
+
socket.destroy();
|
|
707
|
+
error.inputSent = inputSent;
|
|
708
|
+
reject(error);
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const finish = (extra = {}) => {
|
|
712
|
+
if (settled) return;
|
|
713
|
+
settled = true;
|
|
714
|
+
clearTimeout(timer);
|
|
715
|
+
if (sendInputTimer) clearTimeout(sendInputTimer);
|
|
716
|
+
cancelCompletionTimer();
|
|
717
|
+
socket.destroy();
|
|
718
|
+
const streamText = stream.toString("utf8");
|
|
719
|
+
const responseStreamText = stream
|
|
720
|
+
.subarray(responseStartOffset)
|
|
721
|
+
.toString("utf8");
|
|
722
|
+
resolve({
|
|
723
|
+
handshake,
|
|
724
|
+
inputSent,
|
|
725
|
+
streamText,
|
|
726
|
+
responseStreamText,
|
|
727
|
+
streamBytes: stream.length,
|
|
728
|
+
text: extractClaudeDaemonAttachText(
|
|
729
|
+
responseReadyText || responseStreamText || streamText,
|
|
730
|
+
{ cols },
|
|
731
|
+
),
|
|
732
|
+
postInputBusySeen,
|
|
733
|
+
postInputPromptEchoSeen,
|
|
734
|
+
postInputDynamicProgressSeen,
|
|
735
|
+
...extra,
|
|
736
|
+
});
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
const timer = setTimeout(
|
|
740
|
+
() => finish({ timedOut: true, matchedCompletion: false, closed: false }),
|
|
741
|
+
timeoutMs,
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
socket.on("error", (error) => {
|
|
745
|
+
fail(error);
|
|
746
|
+
});
|
|
747
|
+
socket.on("connect", () => {
|
|
748
|
+
socket.write(
|
|
749
|
+
`${JSON.stringify(buildDaemonAttachRequest({ short, cols, rows, caps }))}\n`,
|
|
750
|
+
);
|
|
751
|
+
});
|
|
752
|
+
socket.on("data", (chunk) => {
|
|
753
|
+
if (settled) return;
|
|
754
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
755
|
+
if (!handshake) {
|
|
756
|
+
const newline = buffer.indexOf(10);
|
|
757
|
+
if (newline < 0) return;
|
|
758
|
+
try {
|
|
759
|
+
handshake = JSON.parse(buffer.subarray(0, newline).toString("utf8"));
|
|
760
|
+
} catch (error) {
|
|
761
|
+
fail(error);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
stream = Buffer.concat([stream, buffer.subarray(newline + 1)]);
|
|
765
|
+
buffer = Buffer.alloc(0);
|
|
766
|
+
if (handshake.ok === false) {
|
|
767
|
+
finish({ timedOut: false, matchedCompletion: false, closed: false });
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
requirePostInputTransition = requiresPostInputTransition(handshake);
|
|
771
|
+
if (input !== undefined) {
|
|
772
|
+
sendInputTimer = setTimeout(() => {
|
|
773
|
+
if (settled) return;
|
|
774
|
+
requirePostInputTransition ||= hasClaudeAttachPreInputScreen(
|
|
775
|
+
stream.toString("utf8"),
|
|
776
|
+
{ cols },
|
|
777
|
+
);
|
|
778
|
+
responseStartOffset = stream.length;
|
|
779
|
+
writeClaudeAttachInput(socket, input);
|
|
780
|
+
inputSent = true;
|
|
781
|
+
}, initialDrainMs);
|
|
782
|
+
}
|
|
783
|
+
} else {
|
|
784
|
+
stream = Buffer.concat([stream, buffer]);
|
|
785
|
+
buffer = Buffer.alloc(0);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (input !== undefined && !inputSent) return;
|
|
789
|
+
|
|
790
|
+
const responseStreamText = stream
|
|
791
|
+
.subarray(responseStartOffset)
|
|
792
|
+
.toString("utf8");
|
|
793
|
+
noteDynamicProgress(responseStreamText);
|
|
794
|
+
if (input !== undefined && !responseReadyText) {
|
|
795
|
+
const busyIndex = findClaudeAttachBusySignalIndex(responseStreamText, {
|
|
796
|
+
cols,
|
|
797
|
+
});
|
|
798
|
+
const promptEchoIndex = findClaudeAttachPromptEchoIndex(
|
|
799
|
+
responseStreamText,
|
|
800
|
+
input,
|
|
801
|
+
{ cols },
|
|
802
|
+
);
|
|
803
|
+
const transitionIndexes = [busyIndex, promptEchoIndex].filter(
|
|
804
|
+
(index) => index >= 0,
|
|
805
|
+
);
|
|
806
|
+
if (transitionIndexes.length > 0) {
|
|
807
|
+
const transitionIndex = Math.min(...transitionIndexes);
|
|
808
|
+
responseReadyText = stripTerminalControl(responseStreamText, {
|
|
809
|
+
cols,
|
|
810
|
+
}).slice(transitionIndex);
|
|
811
|
+
postInputBusySeen = busyIndex >= 0;
|
|
812
|
+
postInputPromptEchoSeen = promptEchoIndex >= 0;
|
|
813
|
+
}
|
|
814
|
+
} else if (responseReadyText) {
|
|
815
|
+
const busyIndex = findClaudeAttachBusySignalIndex(responseStreamText, {
|
|
816
|
+
cols,
|
|
817
|
+
});
|
|
818
|
+
const promptEchoIndex = findClaudeAttachPromptEchoIndex(
|
|
819
|
+
responseStreamText,
|
|
820
|
+
input,
|
|
821
|
+
{ cols },
|
|
822
|
+
);
|
|
823
|
+
const transitionIndexes = [busyIndex, promptEchoIndex].filter(
|
|
824
|
+
(index) => index >= 0,
|
|
825
|
+
);
|
|
826
|
+
if (transitionIndexes.length > 0) {
|
|
827
|
+
responseReadyText = stripTerminalControl(responseStreamText, {
|
|
828
|
+
cols,
|
|
829
|
+
}).slice(Math.min(...transitionIndexes));
|
|
830
|
+
postInputBusySeen ||= busyIndex >= 0;
|
|
831
|
+
postInputPromptEchoSeen ||= promptEchoIndex >= 0;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
const completionText = responseReadyText || responseStreamText;
|
|
835
|
+
const readyToMatch =
|
|
836
|
+
(input === undefined ||
|
|
837
|
+
!requirePostInputTransition ||
|
|
838
|
+
responseReadyText) &&
|
|
839
|
+
completionMatched?.(completionText, { cols, rows });
|
|
840
|
+
if (readyToMatch) {
|
|
841
|
+
scheduleCompletion({
|
|
842
|
+
timedOut: false,
|
|
843
|
+
matchedCompletion: true,
|
|
844
|
+
closed: false,
|
|
845
|
+
});
|
|
846
|
+
} else {
|
|
847
|
+
cancelCompletionTimer();
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
socket.on("close", () => {
|
|
851
|
+
if (!settled) {
|
|
852
|
+
if (pendingCompletion) {
|
|
853
|
+
finish(pendingCompletion);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
finish({ timedOut: false, matchedCompletion: false, closed: true });
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export function interruptClaudeDaemonSession({
|
|
863
|
+
controlSock,
|
|
864
|
+
short,
|
|
865
|
+
cols = DEFAULT_DAEMON_ATTACH_COLS,
|
|
866
|
+
rows = 40,
|
|
867
|
+
caps,
|
|
868
|
+
initialDrainMs = 50,
|
|
869
|
+
timeoutMs = 5000,
|
|
870
|
+
} = {}) {
|
|
871
|
+
if (!controlSock) throw new Error("controlSock is required");
|
|
872
|
+
return new Promise((resolve, reject) => {
|
|
873
|
+
const socket = net.connect(controlSock);
|
|
874
|
+
let settled = false;
|
|
875
|
+
let buffer = Buffer.alloc(0);
|
|
876
|
+
let handshake = null;
|
|
877
|
+
let stream = Buffer.alloc(0);
|
|
878
|
+
let inputSent = false;
|
|
879
|
+
let sendInterruptTimer = null;
|
|
880
|
+
|
|
881
|
+
const finish = (extra = {}) => {
|
|
882
|
+
if (settled) return;
|
|
883
|
+
settled = true;
|
|
884
|
+
clearTimeout(timer);
|
|
885
|
+
if (sendInterruptTimer) clearTimeout(sendInterruptTimer);
|
|
886
|
+
socket.destroy();
|
|
887
|
+
const streamText = stream.toString("utf8");
|
|
888
|
+
resolve({
|
|
889
|
+
handshake,
|
|
890
|
+
inputSent,
|
|
891
|
+
streamText,
|
|
892
|
+
streamBytes: stream.length,
|
|
893
|
+
...extra,
|
|
894
|
+
});
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
const fail = (error) => {
|
|
898
|
+
if (settled) return;
|
|
899
|
+
settled = true;
|
|
900
|
+
clearTimeout(timer);
|
|
901
|
+
if (sendInterruptTimer) clearTimeout(sendInterruptTimer);
|
|
902
|
+
socket.destroy();
|
|
903
|
+
error.inputSent = inputSent;
|
|
904
|
+
reject(error);
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
const timer = setTimeout(
|
|
908
|
+
() => finish({ timedOut: true, closed: false }),
|
|
909
|
+
timeoutMs,
|
|
910
|
+
);
|
|
911
|
+
|
|
912
|
+
socket.on("error", fail);
|
|
913
|
+
socket.on("connect", () => {
|
|
914
|
+
socket.write(
|
|
915
|
+
`${JSON.stringify(buildDaemonAttachRequest({ short, cols, rows, caps }))}\n`,
|
|
916
|
+
);
|
|
917
|
+
});
|
|
918
|
+
socket.on("data", (chunk) => {
|
|
919
|
+
if (settled) return;
|
|
920
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
921
|
+
if (!handshake) {
|
|
922
|
+
const newline = buffer.indexOf(10);
|
|
923
|
+
if (newline < 0) return;
|
|
924
|
+
try {
|
|
925
|
+
handshake = JSON.parse(buffer.subarray(0, newline).toString("utf8"));
|
|
926
|
+
} catch (error) {
|
|
927
|
+
fail(error);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
stream = Buffer.concat([stream, buffer.subarray(newline + 1)]);
|
|
931
|
+
buffer = Buffer.alloc(0);
|
|
932
|
+
if (handshake.ok === false) {
|
|
933
|
+
finish({ timedOut: false, closed: false });
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
sendInterruptTimer = setTimeout(() => {
|
|
937
|
+
if (settled) return;
|
|
938
|
+
socket.write("\x1b", () => {
|
|
939
|
+
inputSent = true;
|
|
940
|
+
setTimeout(() => finish({ timedOut: false, closed: false }), 25);
|
|
941
|
+
});
|
|
942
|
+
}, initialDrainMs);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
stream = Buffer.concat([stream, buffer]);
|
|
946
|
+
buffer = Buffer.alloc(0);
|
|
947
|
+
});
|
|
948
|
+
socket.on("close", () => {
|
|
949
|
+
if (!settled) {
|
|
950
|
+
finish({ timedOut: false, closed: true });
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
export function buildDaemonExecDispatchPayload({
|
|
957
|
+
short = crypto.randomBytes(4).toString("hex"),
|
|
958
|
+
sessionId,
|
|
959
|
+
cwd = process.cwd(),
|
|
960
|
+
command,
|
|
961
|
+
name,
|
|
962
|
+
createdAt = Date.now(),
|
|
963
|
+
cols = 120,
|
|
964
|
+
rows = 40,
|
|
965
|
+
} = {}) {
|
|
966
|
+
if (!command) throw new Error("command is required");
|
|
967
|
+
if (!name) throw new Error("name is required");
|
|
968
|
+
const uuid = crypto.randomUUID();
|
|
969
|
+
const resolvedSessionId = sessionId || `${short}${uuid.slice(8)}`;
|
|
970
|
+
return {
|
|
971
|
+
proto: 1,
|
|
972
|
+
short,
|
|
973
|
+
sessionId: resolvedSessionId,
|
|
974
|
+
createdAt,
|
|
975
|
+
source: "shell",
|
|
976
|
+
cwd,
|
|
977
|
+
launch: {
|
|
978
|
+
mode: "exec",
|
|
979
|
+
cmd: "/bin/zsh",
|
|
980
|
+
args: ["-lc", command],
|
|
981
|
+
},
|
|
982
|
+
env: {},
|
|
983
|
+
isolation: "none",
|
|
984
|
+
respawnFlags: [],
|
|
985
|
+
seed: {
|
|
986
|
+
intent: name,
|
|
987
|
+
name,
|
|
988
|
+
},
|
|
989
|
+
cols,
|
|
990
|
+
rows,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
export function findDaemonJobByShort(listResponse, short) {
|
|
995
|
+
if (!Array.isArray(listResponse?.jobs)) return null;
|
|
996
|
+
return listResponse.jobs.find((job) => job?.short === short) || null;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
export function findDaemonJobBySessionId(listResponse, sessionId) {
|
|
1000
|
+
if (!Array.isArray(listResponse?.jobs)) return null;
|
|
1001
|
+
const expected = String(sessionId || "");
|
|
1002
|
+
if (!expected) return null;
|
|
1003
|
+
return (
|
|
1004
|
+
listResponse.jobs.find((job) => {
|
|
1005
|
+
const candidate =
|
|
1006
|
+
job?.sessionId ??
|
|
1007
|
+
job?.session_id ??
|
|
1008
|
+
job?.dispatch?.sessionId ??
|
|
1009
|
+
job?.d?.sessionId ??
|
|
1010
|
+
"";
|
|
1011
|
+
return String(candidate) === expected;
|
|
1012
|
+
}) || null
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
export async function waitForDaemonJobPid(
|
|
1017
|
+
controlSock,
|
|
1018
|
+
short,
|
|
1019
|
+
{ timeoutMs = 5000 } = {},
|
|
1020
|
+
) {
|
|
1021
|
+
const deadline = Date.now() + timeoutMs;
|
|
1022
|
+
while (Date.now() < deadline) {
|
|
1023
|
+
const list = await sendClaudeControlRequest(controlSock, {
|
|
1024
|
+
proto: 1,
|
|
1025
|
+
op: "list",
|
|
1026
|
+
});
|
|
1027
|
+
const job = findDaemonJobByShort(list, short);
|
|
1028
|
+
if (Number.isInteger(job?.pid) && job.pid > 0) return job;
|
|
1029
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1030
|
+
}
|
|
1031
|
+
throw new Error(`Claude daemon dispatch did not expose a pid for ${short}`);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function sleep(ms) {
|
|
1035
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function bridgeSessionIdFromJob(job) {
|
|
1039
|
+
return (
|
|
1040
|
+
job?.bridgeSessionId ||
|
|
1041
|
+
job?.bridge_session_id ||
|
|
1042
|
+
job?.bridge?.sessionId ||
|
|
1043
|
+
job?.state?.bridgeSessionId ||
|
|
1044
|
+
""
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function readDaemonJobStateBridgeSessionId(jobsDir, short) {
|
|
1049
|
+
if (!jobsDir || !short) return "";
|
|
1050
|
+
try {
|
|
1051
|
+
const statePath = path.join(jobsDir, short, "state.json");
|
|
1052
|
+
const parsed = JSON.parse(await fs.readFile(statePath, "utf8"));
|
|
1053
|
+
return bridgeSessionIdFromJob(parsed);
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
if (error?.code === "ENOENT") return "";
|
|
1056
|
+
if (error instanceof SyntaxError) return "";
|
|
1057
|
+
throw error;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
export async function resolveDaemonBridgeSessionId({
|
|
1062
|
+
daemonPaths = {},
|
|
1063
|
+
short,
|
|
1064
|
+
job,
|
|
1065
|
+
timeoutMs = 1000,
|
|
1066
|
+
} = {}) {
|
|
1067
|
+
const direct = bridgeSessionIdFromJob(job);
|
|
1068
|
+
if (direct) return direct;
|
|
1069
|
+
|
|
1070
|
+
const jobsDir =
|
|
1071
|
+
daemonPaths.jobsDir ||
|
|
1072
|
+
(daemonPaths.configDir ? path.join(daemonPaths.configDir, "jobs") : "");
|
|
1073
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
1074
|
+
while (true) {
|
|
1075
|
+
const fromState = await readDaemonJobStateBridgeSessionId(jobsDir, short);
|
|
1076
|
+
if (fromState) return fromState;
|
|
1077
|
+
if (Date.now() >= deadline) return "";
|
|
1078
|
+
await sleep(Math.min(50, Math.max(1, deadline - Date.now())));
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
export async function killDaemonJob(controlSock, short) {
|
|
1083
|
+
return await sendClaudeControlRequest(controlSock, {
|
|
1084
|
+
proto: 1,
|
|
1085
|
+
op: "kill",
|
|
1086
|
+
short,
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
export async function sendKillBySessionId({
|
|
1091
|
+
daemonPaths,
|
|
1092
|
+
sessionId,
|
|
1093
|
+
timeoutMs = 6000,
|
|
1094
|
+
} = {}) {
|
|
1095
|
+
const controlSock = daemonPaths?.controlSock;
|
|
1096
|
+
if (!controlSock || !sessionId) {
|
|
1097
|
+
return { ok: true, killed: false, reason: "missing_target" };
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
try {
|
|
1101
|
+
const list = await sendClaudeControlRequest(
|
|
1102
|
+
controlSock,
|
|
1103
|
+
{
|
|
1104
|
+
proto: 1,
|
|
1105
|
+
op: "list",
|
|
1106
|
+
},
|
|
1107
|
+
{ timeoutMs },
|
|
1108
|
+
);
|
|
1109
|
+
const job = findDaemonJobBySessionId(list, sessionId);
|
|
1110
|
+
if (!job?.short) {
|
|
1111
|
+
return { ok: true, killed: false, reason: "not_found" };
|
|
1112
|
+
}
|
|
1113
|
+
return await sendClaudeControlRequest(
|
|
1114
|
+
controlSock,
|
|
1115
|
+
{
|
|
1116
|
+
proto: 1,
|
|
1117
|
+
op: "kill",
|
|
1118
|
+
short: job.short,
|
|
1119
|
+
},
|
|
1120
|
+
{ timeoutMs },
|
|
1121
|
+
);
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
return {
|
|
1124
|
+
ok: false,
|
|
1125
|
+
killed: false,
|
|
1126
|
+
reason: "control_unavailable",
|
|
1127
|
+
error: error?.message || String(error),
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
}
|