tmux-agent-monitor 0.0.1 → 0.0.4
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 +21 -0
- package/README.md +97 -28
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/dist/index.js +2575 -0
- package/dist/src-CFxYk-pF.mjs +357 -0
- package/dist/tmux-agent-monitor-hook.js +85 -0
- package/dist/web/assets/index-CITyEb9q.js +55 -0
- package/dist/web/assets/index-DQAPK1tz.css +1 -0
- package/dist/web/index.html +19 -0
- package/package.json +74 -7
package/dist/index.js
ADDED
|
@@ -0,0 +1,2575 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { a as resolveServerKey, c as allowedKeys, i as resolveLogPaths, l as defaultConfig, n as configSchema, o as compileDangerPatterns, r as wsClientMessageSchema, s as isDangerousCommand, t as claudeHookEventSchema } from "./src-CFxYk-pF.mjs";
|
|
3
|
+
import { serve } from "@hono/node-server";
|
|
4
|
+
import { execa } from "execa";
|
|
5
|
+
import qrcode from "qrcode-terminal";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
10
|
+
import { createNodeWebSocket } from "@hono/node-ws";
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
import crypto, { randomUUID } from "node:crypto";
|
|
13
|
+
import os, { networkInterfaces } from "node:os";
|
|
14
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
import fs$1 from "node:fs/promises";
|
|
17
|
+
import { createServer } from "node:net";
|
|
18
|
+
|
|
19
|
+
//#region packages/tmux/src/adapter.ts
|
|
20
|
+
const buildArgs = (args, options) => {
|
|
21
|
+
const prefix = [];
|
|
22
|
+
if (options.socketName) prefix.push("-L", options.socketName);
|
|
23
|
+
if (options.socketPath) prefix.push("-S", options.socketPath);
|
|
24
|
+
return [...prefix, ...args];
|
|
25
|
+
};
|
|
26
|
+
const createTmuxAdapter = (options = {}) => {
|
|
27
|
+
const run = async (args) => {
|
|
28
|
+
const result = await execa("tmux", buildArgs(args, options), { reject: false });
|
|
29
|
+
return {
|
|
30
|
+
stdout: result.stdout,
|
|
31
|
+
stderr: result.stderr,
|
|
32
|
+
exitCode: result.exitCode ?? 0
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
return { run };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region packages/tmux/src/inspector.ts
|
|
40
|
+
const format = [
|
|
41
|
+
"#{pane_id}",
|
|
42
|
+
"#{session_name}",
|
|
43
|
+
"#{window_index}",
|
|
44
|
+
"#{pane_index}",
|
|
45
|
+
"#{window_activity}",
|
|
46
|
+
"#{pane_active}",
|
|
47
|
+
"#{pane_current_command}",
|
|
48
|
+
"#{pane_current_path}",
|
|
49
|
+
"#{pane_tty}",
|
|
50
|
+
"#{pane_dead}",
|
|
51
|
+
"#{pane_pipe}",
|
|
52
|
+
"#{alternate_on}",
|
|
53
|
+
"#{pane_pid}",
|
|
54
|
+
"#{pane_title}",
|
|
55
|
+
"#{pane_start_command}",
|
|
56
|
+
"#{@tmux-agent-monitor_pipe}"
|
|
57
|
+
].join(" ");
|
|
58
|
+
const toNullable = (value) => {
|
|
59
|
+
if (!value) return null;
|
|
60
|
+
const trimmed = value.trim();
|
|
61
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
62
|
+
};
|
|
63
|
+
const toNumber = (value) => {
|
|
64
|
+
if (!value) return null;
|
|
65
|
+
const parsed = Number.parseInt(value, 10);
|
|
66
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
67
|
+
};
|
|
68
|
+
const toEpochSeconds = (value) => {
|
|
69
|
+
if (!value) return null;
|
|
70
|
+
const parsed = Number.parseInt(value, 10);
|
|
71
|
+
if (Number.isNaN(parsed) || parsed <= 0) return null;
|
|
72
|
+
return parsed;
|
|
73
|
+
};
|
|
74
|
+
const toBool = (value) => {
|
|
75
|
+
return value === "1" || value === "on" || value === "true";
|
|
76
|
+
};
|
|
77
|
+
const parseLine = (line) => {
|
|
78
|
+
if (!line) return null;
|
|
79
|
+
const parts = line.split(" ");
|
|
80
|
+
if (parts.length < 16) return null;
|
|
81
|
+
const [paneIdRaw, sessionNameRaw, windowIndexRaw, paneIndexRaw, windowActivityRaw, paneActiveRaw, currentCommand, currentPath, paneTty, paneDead, panePipe, alternateOn, panePid, paneTitle, paneStartCommand, pipeTagValue] = parts;
|
|
82
|
+
if (!paneIdRaw || !sessionNameRaw) return null;
|
|
83
|
+
const paneId = paneIdRaw;
|
|
84
|
+
const sessionName = sessionNameRaw;
|
|
85
|
+
const windowIndex = windowIndexRaw ?? "0";
|
|
86
|
+
const paneIndex = paneIndexRaw ?? "0";
|
|
87
|
+
return {
|
|
88
|
+
paneId,
|
|
89
|
+
sessionName,
|
|
90
|
+
windowIndex: Number.parseInt(windowIndex, 10),
|
|
91
|
+
paneIndex: Number.parseInt(paneIndex, 10),
|
|
92
|
+
windowActivity: toEpochSeconds(windowActivityRaw),
|
|
93
|
+
paneActive: toBool(paneActiveRaw),
|
|
94
|
+
currentCommand: toNullable(currentCommand),
|
|
95
|
+
currentPath: toNullable(currentPath),
|
|
96
|
+
paneTty: toNullable(paneTty),
|
|
97
|
+
paneDead: toBool(paneDead),
|
|
98
|
+
panePipe: toBool(panePipe),
|
|
99
|
+
alternateOn: toBool(alternateOn),
|
|
100
|
+
panePid: toNumber(panePid),
|
|
101
|
+
paneTitle: toNullable(paneTitle),
|
|
102
|
+
paneStartCommand: toNullable(paneStartCommand),
|
|
103
|
+
pipeTagValue: toNullable(pipeTagValue)
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
const createInspector = (adapter) => {
|
|
107
|
+
const listPanes = async () => {
|
|
108
|
+
const result = await adapter.run([
|
|
109
|
+
"list-panes",
|
|
110
|
+
"-a",
|
|
111
|
+
"-F",
|
|
112
|
+
format
|
|
113
|
+
]);
|
|
114
|
+
if (result.exitCode !== 0) throw new Error(result.stderr || "tmux list-panes failed");
|
|
115
|
+
return result.stdout.split("\n").map((line) => line.replace(/\r$/, "")).filter((line) => line.length > 0).map(parseLine).filter((pane) => pane !== null);
|
|
116
|
+
};
|
|
117
|
+
const readUserOption = async (paneId, key) => {
|
|
118
|
+
const result = await adapter.run([
|
|
119
|
+
"show-options",
|
|
120
|
+
"-t",
|
|
121
|
+
paneId,
|
|
122
|
+
"-v",
|
|
123
|
+
key
|
|
124
|
+
]);
|
|
125
|
+
if (result.exitCode !== 0) return null;
|
|
126
|
+
return toNullable(result.stdout);
|
|
127
|
+
};
|
|
128
|
+
const writeUserOption = async (paneId, key, value) => {
|
|
129
|
+
if (value === null) {
|
|
130
|
+
await adapter.run([
|
|
131
|
+
"set-option",
|
|
132
|
+
"-t",
|
|
133
|
+
paneId,
|
|
134
|
+
"-u",
|
|
135
|
+
key
|
|
136
|
+
]);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
await adapter.run([
|
|
140
|
+
"set-option",
|
|
141
|
+
"-t",
|
|
142
|
+
paneId,
|
|
143
|
+
key,
|
|
144
|
+
value
|
|
145
|
+
]);
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
listPanes,
|
|
149
|
+
readUserOption,
|
|
150
|
+
writeUserOption
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region packages/tmux/src/pipe.ts
|
|
156
|
+
const buildPipeCommand = (logPath) => {
|
|
157
|
+
return `cat >> "${logPath.replace(/"/g, "\\\"")}"`;
|
|
158
|
+
};
|
|
159
|
+
const hasConflict = (state) => {
|
|
160
|
+
return state.panePipe && state.pipeTagValue !== "1";
|
|
161
|
+
};
|
|
162
|
+
const createPipeManager = (adapter) => {
|
|
163
|
+
const attachPipe = async (paneId, logPath, state) => {
|
|
164
|
+
if (hasConflict(state)) return {
|
|
165
|
+
attached: false,
|
|
166
|
+
conflict: true
|
|
167
|
+
};
|
|
168
|
+
const command = buildPipeCommand(logPath);
|
|
169
|
+
if ((await adapter.run([
|
|
170
|
+
"pipe-pane",
|
|
171
|
+
"-o",
|
|
172
|
+
"-t",
|
|
173
|
+
paneId,
|
|
174
|
+
command
|
|
175
|
+
])).exitCode !== 0) return {
|
|
176
|
+
attached: false,
|
|
177
|
+
conflict: false
|
|
178
|
+
};
|
|
179
|
+
await adapter.run([
|
|
180
|
+
"set-option",
|
|
181
|
+
"-t",
|
|
182
|
+
paneId,
|
|
183
|
+
"@tmux-agent-monitor_pipe",
|
|
184
|
+
"1"
|
|
185
|
+
]);
|
|
186
|
+
return {
|
|
187
|
+
attached: true,
|
|
188
|
+
conflict: false
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
return {
|
|
192
|
+
attachPipe,
|
|
193
|
+
hasConflict
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
//#endregion
|
|
198
|
+
//#region packages/tmux/src/screen.ts
|
|
199
|
+
const normalizeScreen = (text, lineLimit) => {
|
|
200
|
+
const lines = text.replace(/\r/g, "").split("\n");
|
|
201
|
+
while (lines.length > 0 && lines[lines.length - 1]?.trim() === "") lines.pop();
|
|
202
|
+
if (lines.length > lineLimit) return lines.slice(-lineLimit).join("\n");
|
|
203
|
+
return lines.join("\n");
|
|
204
|
+
};
|
|
205
|
+
const resolveAltFlag = (altScreen, alternateOn) => {
|
|
206
|
+
if (altScreen === "on") return true;
|
|
207
|
+
if (altScreen === "off") return false;
|
|
208
|
+
return alternateOn;
|
|
209
|
+
};
|
|
210
|
+
const getPaneSize = async (adapter, paneId) => {
|
|
211
|
+
const result = await adapter.run([
|
|
212
|
+
"display-message",
|
|
213
|
+
"-p",
|
|
214
|
+
"-t",
|
|
215
|
+
paneId,
|
|
216
|
+
"#{history_size} #{pane_height}"
|
|
217
|
+
]);
|
|
218
|
+
if (result.exitCode !== 0) return null;
|
|
219
|
+
const [historySize, paneHeight] = result.stdout.trim().split(" ");
|
|
220
|
+
const history = Number.parseInt(historySize ?? "", 10);
|
|
221
|
+
const height = Number.parseInt(paneHeight ?? "", 10);
|
|
222
|
+
if (Number.isNaN(history) || Number.isNaN(height)) return null;
|
|
223
|
+
return {
|
|
224
|
+
historySize: history,
|
|
225
|
+
paneHeight: height
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
const createScreenCapture = (adapter) => {
|
|
229
|
+
const captureText = async (options) => {
|
|
230
|
+
const args = [
|
|
231
|
+
"capture-pane",
|
|
232
|
+
"-p",
|
|
233
|
+
"-t",
|
|
234
|
+
options.paneId
|
|
235
|
+
];
|
|
236
|
+
if (options.joinLines) args.push("-J");
|
|
237
|
+
if (options.includeAnsi) args.push("-e");
|
|
238
|
+
if (resolveAltFlag(options.altScreen, options.alternateOn)) args.push("-a");
|
|
239
|
+
args.push("-S", `-${options.lines}`, "-E", "-");
|
|
240
|
+
const result = await adapter.run(args);
|
|
241
|
+
if (result.exitCode !== 0) throw new Error(result.stderr || "capture-pane failed");
|
|
242
|
+
const size = await getPaneSize(adapter, options.paneId);
|
|
243
|
+
const truncated = size === null ? null : size.historySize + size.paneHeight > options.lines;
|
|
244
|
+
return {
|
|
245
|
+
screen: normalizeScreen(result.stdout, options.lines),
|
|
246
|
+
truncated,
|
|
247
|
+
alternateOn: options.alternateOn
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
return { captureText };
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region apps/server/src/config.ts
|
|
255
|
+
const configDirName = ".tmux-agent-monitor";
|
|
256
|
+
const getConfigDir = () => {
|
|
257
|
+
return path.join(os.homedir(), configDirName);
|
|
258
|
+
};
|
|
259
|
+
const getConfigPath = () => {
|
|
260
|
+
return path.join(getConfigDir(), "config.json");
|
|
261
|
+
};
|
|
262
|
+
const ensureDir$1 = (dir) => {
|
|
263
|
+
fs.mkdirSync(dir, {
|
|
264
|
+
recursive: true,
|
|
265
|
+
mode: 448
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
const writeFileSafe = (filePath, data) => {
|
|
269
|
+
fs.writeFileSync(filePath, data, {
|
|
270
|
+
encoding: "utf8",
|
|
271
|
+
mode: 384
|
|
272
|
+
});
|
|
273
|
+
try {
|
|
274
|
+
fs.chmodSync(filePath, 384);
|
|
275
|
+
} catch {}
|
|
276
|
+
};
|
|
277
|
+
const generateToken = () => {
|
|
278
|
+
return crypto.randomBytes(32).toString("hex");
|
|
279
|
+
};
|
|
280
|
+
const loadConfig = () => {
|
|
281
|
+
const configPath = getConfigPath();
|
|
282
|
+
try {
|
|
283
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
284
|
+
const parsed = configSchema.safeParse(JSON.parse(raw));
|
|
285
|
+
if (!parsed.success) return null;
|
|
286
|
+
return parsed.data;
|
|
287
|
+
} catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
const saveConfig = (config) => {
|
|
292
|
+
ensureDir$1(getConfigDir());
|
|
293
|
+
writeFileSafe(getConfigPath(), `${JSON.stringify(config, null, 2)}\n`);
|
|
294
|
+
};
|
|
295
|
+
const ensureConfig = (overrides) => {
|
|
296
|
+
const existing = loadConfig();
|
|
297
|
+
if (existing) {
|
|
298
|
+
let next = existing;
|
|
299
|
+
let migrated = false;
|
|
300
|
+
if (existing.port === 10080 && defaultConfig.port === 11080) {
|
|
301
|
+
next = {
|
|
302
|
+
...existing,
|
|
303
|
+
port: defaultConfig.port
|
|
304
|
+
};
|
|
305
|
+
migrated = true;
|
|
306
|
+
}
|
|
307
|
+
if (existing.screen?.image?.enabled === false && defaultConfig.screen.image.enabled === true) {
|
|
308
|
+
next = {
|
|
309
|
+
...next,
|
|
310
|
+
screen: {
|
|
311
|
+
...next.screen,
|
|
312
|
+
image: {
|
|
313
|
+
...next.screen.image,
|
|
314
|
+
enabled: true
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
migrated = true;
|
|
319
|
+
}
|
|
320
|
+
if (migrated) saveConfig(next);
|
|
321
|
+
return {
|
|
322
|
+
...next,
|
|
323
|
+
...overrides
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const token = generateToken();
|
|
327
|
+
const config = {
|
|
328
|
+
...defaultConfig,
|
|
329
|
+
...overrides,
|
|
330
|
+
token
|
|
331
|
+
};
|
|
332
|
+
saveConfig(config);
|
|
333
|
+
return config;
|
|
334
|
+
};
|
|
335
|
+
const rotateToken = () => {
|
|
336
|
+
const config = ensureConfig();
|
|
337
|
+
const token = generateToken();
|
|
338
|
+
const next = {
|
|
339
|
+
...config,
|
|
340
|
+
token
|
|
341
|
+
};
|
|
342
|
+
saveConfig(next);
|
|
343
|
+
return next;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
//#endregion
|
|
347
|
+
//#region apps/server/src/git-commits.ts
|
|
348
|
+
const execFileAsync$3 = promisify(execFile);
|
|
349
|
+
const LOG_TTL_MS = 3e3;
|
|
350
|
+
const DETAIL_TTL_MS = 3e3;
|
|
351
|
+
const FILE_TTL_MS$1 = 3e3;
|
|
352
|
+
const MAX_PATCH_BYTES$1 = 2e6;
|
|
353
|
+
const MAX_OUTPUT_BUFFER$1 = 2e7;
|
|
354
|
+
const RECORD_SEPARATOR = "";
|
|
355
|
+
const FIELD_SEPARATOR = "";
|
|
356
|
+
const nowIso$1 = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
357
|
+
const logCache = /* @__PURE__ */ new Map();
|
|
358
|
+
const detailCache = /* @__PURE__ */ new Map();
|
|
359
|
+
const fileCache$1 = /* @__PURE__ */ new Map();
|
|
360
|
+
const runGit$1 = async (cwd, args) => {
|
|
361
|
+
try {
|
|
362
|
+
return (await execFileAsync$3("git", [
|
|
363
|
+
"-C",
|
|
364
|
+
cwd,
|
|
365
|
+
...args
|
|
366
|
+
], {
|
|
367
|
+
encoding: "utf8",
|
|
368
|
+
timeout: 5e3,
|
|
369
|
+
maxBuffer: MAX_OUTPUT_BUFFER$1
|
|
370
|
+
})).stdout ?? "";
|
|
371
|
+
} catch (err) {
|
|
372
|
+
if (err && typeof err === "object" && "stdout" in err) return err.stdout ?? "";
|
|
373
|
+
throw err;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
const resolveRepoRoot$1 = async (cwd) => {
|
|
377
|
+
try {
|
|
378
|
+
const trimmed = (await runGit$1(cwd, ["rev-parse", "--show-toplevel"])).trim();
|
|
379
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
const resolveHead = async (repoRoot) => {
|
|
385
|
+
try {
|
|
386
|
+
const trimmed = (await runGit$1(repoRoot, ["rev-parse", "HEAD"])).trim();
|
|
387
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
388
|
+
} catch {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
const pickStatus$1 = (value) => {
|
|
393
|
+
const allowed = [
|
|
394
|
+
"A",
|
|
395
|
+
"M",
|
|
396
|
+
"D",
|
|
397
|
+
"R",
|
|
398
|
+
"C",
|
|
399
|
+
"U",
|
|
400
|
+
"?"
|
|
401
|
+
];
|
|
402
|
+
const status = value.toUpperCase().slice(0, 1);
|
|
403
|
+
return allowed.includes(status) ? status : "?";
|
|
404
|
+
};
|
|
405
|
+
const isBinaryPatch$1 = (patch) => patch.includes("Binary files ") || patch.includes("GIT binary patch") || patch.includes("literal ");
|
|
406
|
+
const parseCommitLogOutput = (output) => {
|
|
407
|
+
if (!output) return [];
|
|
408
|
+
const records = output.split(RECORD_SEPARATOR).filter((record) => record.trim().length > 0);
|
|
409
|
+
const commits = [];
|
|
410
|
+
for (const record of records) {
|
|
411
|
+
const [hash = "", shortHash = "", authorName = "", authorEmailRaw = "", authoredAt = "", subject = "", bodyRaw = ""] = record.split(FIELD_SEPARATOR);
|
|
412
|
+
if (!hash) continue;
|
|
413
|
+
const body = bodyRaw.trim().length > 0 ? bodyRaw : null;
|
|
414
|
+
const authorEmail = authorEmailRaw.trim().length > 0 ? authorEmailRaw : null;
|
|
415
|
+
commits.push({
|
|
416
|
+
hash,
|
|
417
|
+
shortHash,
|
|
418
|
+
subject,
|
|
419
|
+
body,
|
|
420
|
+
authorName,
|
|
421
|
+
authorEmail,
|
|
422
|
+
authoredAt
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return commits;
|
|
426
|
+
};
|
|
427
|
+
const parseNumstat$1 = (output) => {
|
|
428
|
+
const stats = /* @__PURE__ */ new Map();
|
|
429
|
+
const lines = output.split("\n").filter((line) => line.trim().length > 0);
|
|
430
|
+
for (const line of lines) {
|
|
431
|
+
const parts = line.split(" ");
|
|
432
|
+
if (parts.length < 3) continue;
|
|
433
|
+
const addRaw = parts[0] ?? "";
|
|
434
|
+
const delRaw = parts[1] ?? "";
|
|
435
|
+
const pathValue = parts[parts.length - 1] ?? "";
|
|
436
|
+
const additions = addRaw === "-" ? null : Number.parseInt(addRaw, 10);
|
|
437
|
+
const deletions = delRaw === "-" ? null : Number.parseInt(delRaw, 10);
|
|
438
|
+
stats.set(pathValue, {
|
|
439
|
+
additions: Number.isFinite(additions) ? additions : null,
|
|
440
|
+
deletions: Number.isFinite(deletions) ? deletions : null
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return stats;
|
|
444
|
+
};
|
|
445
|
+
const parseNameStatusOutput = (output) => {
|
|
446
|
+
const files = [];
|
|
447
|
+
const lines = output.split("\n").filter((line) => line.trim().length > 0);
|
|
448
|
+
for (const line of lines) {
|
|
449
|
+
const parts = line.split(" ");
|
|
450
|
+
if (parts.length < 2) continue;
|
|
451
|
+
const status = pickStatus$1(parts[0] ?? "");
|
|
452
|
+
if (status === "R" || status === "C") {
|
|
453
|
+
if (parts.length >= 3) files.push({
|
|
454
|
+
status,
|
|
455
|
+
renamedFrom: parts[1] ?? void 0,
|
|
456
|
+
path: parts[2] ?? parts[1] ?? "",
|
|
457
|
+
additions: null,
|
|
458
|
+
deletions: null
|
|
459
|
+
});
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
files.push({
|
|
463
|
+
status,
|
|
464
|
+
path: parts[1] ?? "",
|
|
465
|
+
additions: null,
|
|
466
|
+
deletions: null
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
return files.filter((file) => file.path.length > 0);
|
|
470
|
+
};
|
|
471
|
+
const findStatForFile = (stats, file) => {
|
|
472
|
+
const direct = stats.get(file.path);
|
|
473
|
+
if (direct) return direct;
|
|
474
|
+
if (file.renamedFrom) {
|
|
475
|
+
const renameDirect = stats.get(file.renamedFrom);
|
|
476
|
+
if (renameDirect) return renameDirect;
|
|
477
|
+
}
|
|
478
|
+
for (const [key, value] of stats.entries()) {
|
|
479
|
+
if (file.renamedFrom && key.includes(file.renamedFrom) && key.includes(file.path)) return value;
|
|
480
|
+
if (key.includes(file.path)) return value;
|
|
481
|
+
}
|
|
482
|
+
return null;
|
|
483
|
+
};
|
|
484
|
+
const buildCommitLogSignature = (log) => {
|
|
485
|
+
return JSON.stringify({
|
|
486
|
+
repoRoot: log.repoRoot ?? null,
|
|
487
|
+
rev: log.rev ?? null,
|
|
488
|
+
reason: log.reason ?? null,
|
|
489
|
+
commits: log.commits.map((commit) => commit.hash)
|
|
490
|
+
});
|
|
491
|
+
};
|
|
492
|
+
const fetchCommitLog = async (cwd, options) => {
|
|
493
|
+
if (!cwd) return {
|
|
494
|
+
repoRoot: null,
|
|
495
|
+
rev: null,
|
|
496
|
+
generatedAt: nowIso$1(),
|
|
497
|
+
commits: [],
|
|
498
|
+
reason: "cwd_unknown"
|
|
499
|
+
};
|
|
500
|
+
const repoRoot = await resolveRepoRoot$1(cwd);
|
|
501
|
+
if (!repoRoot) return {
|
|
502
|
+
repoRoot: null,
|
|
503
|
+
rev: null,
|
|
504
|
+
generatedAt: nowIso$1(),
|
|
505
|
+
commits: [],
|
|
506
|
+
reason: "not_git"
|
|
507
|
+
};
|
|
508
|
+
const limit = Math.max(1, Math.min(options?.limit ?? 10, 50));
|
|
509
|
+
const skip = Math.max(0, options?.skip ?? 0);
|
|
510
|
+
const head = await resolveHead(repoRoot);
|
|
511
|
+
const cacheKey = `${repoRoot}:${limit}:${skip}`;
|
|
512
|
+
const cached = logCache.get(cacheKey);
|
|
513
|
+
const nowMs = Date.now();
|
|
514
|
+
if (!options?.force && cached && nowMs - cached.at < LOG_TTL_MS && cached.rev === head) return cached.log;
|
|
515
|
+
try {
|
|
516
|
+
const format = [
|
|
517
|
+
RECORD_SEPARATOR,
|
|
518
|
+
"%H",
|
|
519
|
+
FIELD_SEPARATOR,
|
|
520
|
+
"%h",
|
|
521
|
+
FIELD_SEPARATOR,
|
|
522
|
+
"%an",
|
|
523
|
+
FIELD_SEPARATOR,
|
|
524
|
+
"%ae",
|
|
525
|
+
FIELD_SEPARATOR,
|
|
526
|
+
"%ad",
|
|
527
|
+
FIELD_SEPARATOR,
|
|
528
|
+
"%s",
|
|
529
|
+
FIELD_SEPARATOR,
|
|
530
|
+
"%b"
|
|
531
|
+
].join("");
|
|
532
|
+
const commits = parseCommitLogOutput(await runGit$1(repoRoot, [
|
|
533
|
+
"log",
|
|
534
|
+
"-n",
|
|
535
|
+
String(limit),
|
|
536
|
+
"--skip",
|
|
537
|
+
String(skip),
|
|
538
|
+
"--date=iso-strict",
|
|
539
|
+
`--format=${format}`
|
|
540
|
+
]));
|
|
541
|
+
const log = {
|
|
542
|
+
repoRoot,
|
|
543
|
+
rev: head,
|
|
544
|
+
generatedAt: nowIso$1(),
|
|
545
|
+
commits
|
|
546
|
+
};
|
|
547
|
+
logCache.set(cacheKey, {
|
|
548
|
+
at: nowMs,
|
|
549
|
+
rev: head,
|
|
550
|
+
log,
|
|
551
|
+
signature: buildCommitLogSignature(log)
|
|
552
|
+
});
|
|
553
|
+
return log;
|
|
554
|
+
} catch {
|
|
555
|
+
return {
|
|
556
|
+
repoRoot,
|
|
557
|
+
rev: head,
|
|
558
|
+
generatedAt: nowIso$1(),
|
|
559
|
+
commits: [],
|
|
560
|
+
reason: "error"
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
const fetchCommitDetail = async (repoRoot, hash, options) => {
|
|
565
|
+
const cacheKey = `${repoRoot}:${hash}`;
|
|
566
|
+
const cached = detailCache.get(cacheKey);
|
|
567
|
+
const nowMs = Date.now();
|
|
568
|
+
if (!options?.force && cached && nowMs - cached.at < DETAIL_TTL_MS) return cached.detail;
|
|
569
|
+
try {
|
|
570
|
+
const meta = parseCommitLogOutput(await runGit$1(repoRoot, [
|
|
571
|
+
"show",
|
|
572
|
+
"-s",
|
|
573
|
+
"--date=iso-strict",
|
|
574
|
+
`--format=${[
|
|
575
|
+
RECORD_SEPARATOR,
|
|
576
|
+
"%H",
|
|
577
|
+
FIELD_SEPARATOR,
|
|
578
|
+
"%h",
|
|
579
|
+
FIELD_SEPARATOR,
|
|
580
|
+
"%an",
|
|
581
|
+
FIELD_SEPARATOR,
|
|
582
|
+
"%ae",
|
|
583
|
+
FIELD_SEPARATOR,
|
|
584
|
+
"%ad",
|
|
585
|
+
FIELD_SEPARATOR,
|
|
586
|
+
"%s",
|
|
587
|
+
FIELD_SEPARATOR,
|
|
588
|
+
"%b"
|
|
589
|
+
].join("")}`,
|
|
590
|
+
hash
|
|
591
|
+
]))[0];
|
|
592
|
+
if (!meta) return null;
|
|
593
|
+
const nameStatusOutput = await runGit$1(repoRoot, [
|
|
594
|
+
"show",
|
|
595
|
+
"--name-status",
|
|
596
|
+
"--format=",
|
|
597
|
+
hash
|
|
598
|
+
]);
|
|
599
|
+
const numstatOutput = await runGit$1(repoRoot, [
|
|
600
|
+
"show",
|
|
601
|
+
"--numstat",
|
|
602
|
+
"--format=",
|
|
603
|
+
hash
|
|
604
|
+
]);
|
|
605
|
+
const files = parseNameStatusOutput(nameStatusOutput);
|
|
606
|
+
const stats = parseNumstat$1(numstatOutput);
|
|
607
|
+
const withStats = files.map((file) => {
|
|
608
|
+
const stat = findStatForFile(stats, file);
|
|
609
|
+
return {
|
|
610
|
+
...file,
|
|
611
|
+
additions: stat?.additions ?? null,
|
|
612
|
+
deletions: stat?.deletions ?? null
|
|
613
|
+
};
|
|
614
|
+
});
|
|
615
|
+
const detail = {
|
|
616
|
+
...meta,
|
|
617
|
+
files: withStats
|
|
618
|
+
};
|
|
619
|
+
detailCache.set(cacheKey, {
|
|
620
|
+
at: nowMs,
|
|
621
|
+
detail
|
|
622
|
+
});
|
|
623
|
+
return detail;
|
|
624
|
+
} catch {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
const fetchCommitFile = async (repoRoot, hash, file, options) => {
|
|
629
|
+
const cacheKey = `${repoRoot}:${hash}:${file.path}`;
|
|
630
|
+
const cached = fileCache$1.get(cacheKey);
|
|
631
|
+
const nowMs = Date.now();
|
|
632
|
+
if (!options?.force && cached && nowMs - cached.at < FILE_TTL_MS$1) return cached.file;
|
|
633
|
+
let patch = "";
|
|
634
|
+
try {
|
|
635
|
+
patch = await runGit$1(repoRoot, [
|
|
636
|
+
"show",
|
|
637
|
+
"--find-renames",
|
|
638
|
+
hash,
|
|
639
|
+
"--",
|
|
640
|
+
file.path
|
|
641
|
+
]);
|
|
642
|
+
if (!patch && file.renamedFrom) patch = await runGit$1(repoRoot, [
|
|
643
|
+
"show",
|
|
644
|
+
"--find-renames",
|
|
645
|
+
hash,
|
|
646
|
+
"--",
|
|
647
|
+
file.renamedFrom
|
|
648
|
+
]);
|
|
649
|
+
} catch {
|
|
650
|
+
patch = "";
|
|
651
|
+
}
|
|
652
|
+
const binary = isBinaryPatch$1(patch) || file.additions === null || file.deletions === null;
|
|
653
|
+
let truncated = false;
|
|
654
|
+
if (patch.length > MAX_PATCH_BYTES$1) {
|
|
655
|
+
truncated = true;
|
|
656
|
+
patch = patch.slice(0, MAX_PATCH_BYTES$1);
|
|
657
|
+
}
|
|
658
|
+
const diff = {
|
|
659
|
+
path: file.path,
|
|
660
|
+
status: file.status,
|
|
661
|
+
patch: patch.length > 0 ? patch : null,
|
|
662
|
+
binary,
|
|
663
|
+
truncated
|
|
664
|
+
};
|
|
665
|
+
fileCache$1.set(cacheKey, {
|
|
666
|
+
at: nowMs,
|
|
667
|
+
file: diff
|
|
668
|
+
});
|
|
669
|
+
return diff;
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
//#endregion
|
|
673
|
+
//#region apps/server/src/git-diff.ts
|
|
674
|
+
const execFileAsync$2 = promisify(execFile);
|
|
675
|
+
const SUMMARY_TTL_MS = 3e3;
|
|
676
|
+
const FILE_TTL_MS = 3e3;
|
|
677
|
+
const MAX_PATCH_BYTES = 2e6;
|
|
678
|
+
const MAX_OUTPUT_BUFFER = 2e7;
|
|
679
|
+
const nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
680
|
+
const summaryCache = /* @__PURE__ */ new Map();
|
|
681
|
+
const fileCache = /* @__PURE__ */ new Map();
|
|
682
|
+
const createRevision = (statusOutput) => crypto.createHash("sha1").update(statusOutput).digest("hex");
|
|
683
|
+
const runGit = async (cwd, args) => {
|
|
684
|
+
try {
|
|
685
|
+
return (await execFileAsync$2("git", [
|
|
686
|
+
"-C",
|
|
687
|
+
cwd,
|
|
688
|
+
...args
|
|
689
|
+
], {
|
|
690
|
+
encoding: "utf8",
|
|
691
|
+
timeout: 5e3,
|
|
692
|
+
maxBuffer: MAX_OUTPUT_BUFFER
|
|
693
|
+
})).stdout ?? "";
|
|
694
|
+
} catch (err) {
|
|
695
|
+
if (err && typeof err === "object" && "stdout" in err) return err.stdout ?? "";
|
|
696
|
+
throw err;
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
const resolveRepoRoot = async (cwd) => {
|
|
700
|
+
try {
|
|
701
|
+
const trimmed = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).trim();
|
|
702
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
703
|
+
} catch {
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
const pickStatus = (value) => {
|
|
708
|
+
return [
|
|
709
|
+
"A",
|
|
710
|
+
"M",
|
|
711
|
+
"D",
|
|
712
|
+
"R",
|
|
713
|
+
"C",
|
|
714
|
+
"U",
|
|
715
|
+
"?"
|
|
716
|
+
].includes(value) ? value : "?";
|
|
717
|
+
};
|
|
718
|
+
const parseNumstat = (output) => {
|
|
719
|
+
const stats = /* @__PURE__ */ new Map();
|
|
720
|
+
const lines = output.split("\n").filter((line) => line.trim().length > 0);
|
|
721
|
+
for (const line of lines) {
|
|
722
|
+
const parts = line.split(" ");
|
|
723
|
+
if (parts.length < 3) continue;
|
|
724
|
+
const addRaw = parts[0] ?? "";
|
|
725
|
+
const delRaw = parts[1] ?? "";
|
|
726
|
+
const pathValue = parts[parts.length - 1] ?? "";
|
|
727
|
+
const additions = addRaw === "-" ? null : Number.parseInt(addRaw, 10);
|
|
728
|
+
const deletions = delRaw === "-" ? null : Number.parseInt(delRaw, 10);
|
|
729
|
+
stats.set(pathValue, {
|
|
730
|
+
additions: Number.isFinite(additions) ? additions : null,
|
|
731
|
+
deletions: Number.isFinite(deletions) ? deletions : null
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
return stats;
|
|
735
|
+
};
|
|
736
|
+
const parseNumstatLine = (output) => {
|
|
737
|
+
const line = output.split("\n").map((value) => value.trim()).find((value) => value.length > 0);
|
|
738
|
+
if (!line) return null;
|
|
739
|
+
const parts = line.split(" ");
|
|
740
|
+
if (parts.length < 2) return null;
|
|
741
|
+
const addRaw = parts[0] ?? "";
|
|
742
|
+
const delRaw = parts[1] ?? "";
|
|
743
|
+
const additions = addRaw === "-" ? null : Number.parseInt(addRaw, 10);
|
|
744
|
+
const deletions = delRaw === "-" ? null : Number.parseInt(delRaw, 10);
|
|
745
|
+
return {
|
|
746
|
+
additions: Number.isFinite(additions) ? additions : null,
|
|
747
|
+
deletions: Number.isFinite(deletions) ? deletions : null
|
|
748
|
+
};
|
|
749
|
+
};
|
|
750
|
+
const parseGitStatus = (statusOutput) => {
|
|
751
|
+
if (!statusOutput) return [];
|
|
752
|
+
const tokens = statusOutput.split("\0").filter((token) => token.length > 0);
|
|
753
|
+
const files = [];
|
|
754
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
755
|
+
const token = tokens[i] ?? "";
|
|
756
|
+
if (token.length < 3) continue;
|
|
757
|
+
const statusCode = token.slice(0, 2);
|
|
758
|
+
if (statusCode === "!!") continue;
|
|
759
|
+
const rawPath = token.length > 3 ? token.slice(3) : "";
|
|
760
|
+
if (!rawPath) continue;
|
|
761
|
+
let pathValue = rawPath;
|
|
762
|
+
let renamedFrom;
|
|
763
|
+
const xStatus = statusCode[0] ?? " ";
|
|
764
|
+
const yStatus = statusCode[1] ?? " ";
|
|
765
|
+
if ((xStatus === "R" || xStatus === "C" || yStatus === "R" || yStatus === "C") && tokens[i + 1]) {
|
|
766
|
+
renamedFrom = rawPath;
|
|
767
|
+
pathValue = tokens[i + 1] ?? rawPath;
|
|
768
|
+
i += 1;
|
|
769
|
+
}
|
|
770
|
+
const staged = xStatus !== " " && xStatus !== "?";
|
|
771
|
+
let status;
|
|
772
|
+
if (statusCode === "??") status = "?";
|
|
773
|
+
else if (xStatus !== " ") status = pickStatus(xStatus);
|
|
774
|
+
else status = pickStatus(yStatus);
|
|
775
|
+
files.push({
|
|
776
|
+
path: pathValue,
|
|
777
|
+
status,
|
|
778
|
+
staged,
|
|
779
|
+
renamedFrom
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
return files;
|
|
783
|
+
};
|
|
784
|
+
const resolveSafePath = (repoRoot, filePath) => {
|
|
785
|
+
const resolved = path.resolve(repoRoot, filePath);
|
|
786
|
+
const normalizedRoot = repoRoot.endsWith(path.sep) ? repoRoot : `${repoRoot}${path.sep}`;
|
|
787
|
+
if (!resolved.startsWith(normalizedRoot)) return null;
|
|
788
|
+
return resolved;
|
|
789
|
+
};
|
|
790
|
+
const fetchDiffSummary = async (cwd, options) => {
|
|
791
|
+
if (!cwd) return {
|
|
792
|
+
repoRoot: null,
|
|
793
|
+
rev: null,
|
|
794
|
+
generatedAt: nowIso(),
|
|
795
|
+
files: [],
|
|
796
|
+
reason: "cwd_unknown"
|
|
797
|
+
};
|
|
798
|
+
const repoRoot = await resolveRepoRoot(cwd);
|
|
799
|
+
if (!repoRoot) return {
|
|
800
|
+
repoRoot: null,
|
|
801
|
+
rev: null,
|
|
802
|
+
generatedAt: nowIso(),
|
|
803
|
+
files: [],
|
|
804
|
+
reason: "not_git"
|
|
805
|
+
};
|
|
806
|
+
const cached = summaryCache.get(repoRoot);
|
|
807
|
+
const nowMs = Date.now();
|
|
808
|
+
if (!options?.force && cached && nowMs - cached.at < SUMMARY_TTL_MS) return cached.summary;
|
|
809
|
+
try {
|
|
810
|
+
const statusOutput = await runGit(repoRoot, [
|
|
811
|
+
"status",
|
|
812
|
+
"--porcelain",
|
|
813
|
+
"-z"
|
|
814
|
+
]);
|
|
815
|
+
const files = parseGitStatus(statusOutput);
|
|
816
|
+
const stats = parseNumstat(await runGit(repoRoot, [
|
|
817
|
+
"diff",
|
|
818
|
+
"HEAD",
|
|
819
|
+
"--numstat",
|
|
820
|
+
"--"
|
|
821
|
+
]));
|
|
822
|
+
const untrackedStats = /* @__PURE__ */ new Map();
|
|
823
|
+
for (const file of files) {
|
|
824
|
+
if (file.status !== "?") continue;
|
|
825
|
+
const safePath = resolveSafePath(repoRoot, file.path);
|
|
826
|
+
if (!safePath) continue;
|
|
827
|
+
const parsed = parseNumstatLine(await runGit(repoRoot, [
|
|
828
|
+
"diff",
|
|
829
|
+
"--no-index",
|
|
830
|
+
"--numstat",
|
|
831
|
+
"--",
|
|
832
|
+
"/dev/null",
|
|
833
|
+
safePath
|
|
834
|
+
]));
|
|
835
|
+
if (parsed) untrackedStats.set(file.path, parsed);
|
|
836
|
+
}
|
|
837
|
+
const withStats = files.map((file) => {
|
|
838
|
+
const stat = file.status === "?" ? untrackedStats.get(file.path) : stats.get(file.path);
|
|
839
|
+
return {
|
|
840
|
+
...file,
|
|
841
|
+
additions: stat?.additions ?? null,
|
|
842
|
+
deletions: stat?.deletions ?? null
|
|
843
|
+
};
|
|
844
|
+
});
|
|
845
|
+
const summary = {
|
|
846
|
+
repoRoot,
|
|
847
|
+
rev: createRevision(statusOutput),
|
|
848
|
+
generatedAt: nowIso(),
|
|
849
|
+
files: withStats
|
|
850
|
+
};
|
|
851
|
+
summaryCache.set(repoRoot, {
|
|
852
|
+
at: nowMs,
|
|
853
|
+
summary,
|
|
854
|
+
statusOutput
|
|
855
|
+
});
|
|
856
|
+
return summary;
|
|
857
|
+
} catch {
|
|
858
|
+
return {
|
|
859
|
+
repoRoot,
|
|
860
|
+
rev: null,
|
|
861
|
+
generatedAt: nowIso(),
|
|
862
|
+
files: [],
|
|
863
|
+
reason: "error"
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
const isBinaryPatch = (patch) => patch.includes("Binary files ") || patch.includes("GIT binary patch") || patch.includes("literal ");
|
|
868
|
+
const fetchDiffFile = async (repoRoot, file, rev, options) => {
|
|
869
|
+
const cacheKey = `${repoRoot}:${file.path}:${rev}`;
|
|
870
|
+
const cached = fileCache.get(cacheKey);
|
|
871
|
+
const nowMs = Date.now();
|
|
872
|
+
if (!options?.force && cached && nowMs - cached.at < FILE_TTL_MS) return cached.file;
|
|
873
|
+
const safePath = resolveSafePath(repoRoot, file.path);
|
|
874
|
+
if (!safePath) return {
|
|
875
|
+
path: file.path,
|
|
876
|
+
status: file.status,
|
|
877
|
+
patch: null,
|
|
878
|
+
binary: false,
|
|
879
|
+
truncated: false,
|
|
880
|
+
rev
|
|
881
|
+
};
|
|
882
|
+
let patch = "";
|
|
883
|
+
let numstat = null;
|
|
884
|
+
try {
|
|
885
|
+
if (file.status === "?") {
|
|
886
|
+
patch = await runGit(repoRoot, [
|
|
887
|
+
"diff",
|
|
888
|
+
"--no-index",
|
|
889
|
+
"--",
|
|
890
|
+
"/dev/null",
|
|
891
|
+
safePath
|
|
892
|
+
]);
|
|
893
|
+
numstat = parseNumstatLine(await runGit(repoRoot, [
|
|
894
|
+
"diff",
|
|
895
|
+
"--no-index",
|
|
896
|
+
"--numstat",
|
|
897
|
+
"--",
|
|
898
|
+
"/dev/null",
|
|
899
|
+
safePath
|
|
900
|
+
]));
|
|
901
|
+
} else {
|
|
902
|
+
patch = await runGit(repoRoot, [
|
|
903
|
+
"diff",
|
|
904
|
+
"HEAD",
|
|
905
|
+
"--",
|
|
906
|
+
file.path
|
|
907
|
+
]);
|
|
908
|
+
numstat = parseNumstatLine(await runGit(repoRoot, [
|
|
909
|
+
"diff",
|
|
910
|
+
"HEAD",
|
|
911
|
+
"--numstat",
|
|
912
|
+
"--",
|
|
913
|
+
file.path
|
|
914
|
+
]));
|
|
915
|
+
}
|
|
916
|
+
} catch {
|
|
917
|
+
patch = "";
|
|
918
|
+
}
|
|
919
|
+
const binary = isBinaryPatch(patch) || numstat?.additions === null || numstat?.deletions === null;
|
|
920
|
+
let truncated = false;
|
|
921
|
+
if (patch.length > MAX_PATCH_BYTES) {
|
|
922
|
+
truncated = true;
|
|
923
|
+
patch = patch.slice(0, MAX_PATCH_BYTES);
|
|
924
|
+
}
|
|
925
|
+
const diffFile = {
|
|
926
|
+
path: file.path,
|
|
927
|
+
status: file.status,
|
|
928
|
+
patch: patch.length > 0 ? patch : null,
|
|
929
|
+
binary,
|
|
930
|
+
truncated,
|
|
931
|
+
rev
|
|
932
|
+
};
|
|
933
|
+
fileCache.set(cacheKey, {
|
|
934
|
+
at: nowMs,
|
|
935
|
+
rev,
|
|
936
|
+
file: diffFile
|
|
937
|
+
});
|
|
938
|
+
return diffFile;
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
//#endregion
|
|
942
|
+
//#region apps/server/src/activity-suppressor.ts
|
|
943
|
+
const lastPaneFocusAt = /* @__PURE__ */ new Map();
|
|
944
|
+
const SUPPRESS_WINDOW_MS = 2e3;
|
|
945
|
+
const STALE_WINDOW_MS = 15e3;
|
|
946
|
+
const markPaneFocus = (paneId) => {
|
|
947
|
+
if (!paneId) return;
|
|
948
|
+
lastPaneFocusAt.set(paneId, Date.now());
|
|
949
|
+
};
|
|
950
|
+
const shouldSuppressActivity = (paneId, activityIso) => {
|
|
951
|
+
if (!paneId || !activityIso) return false;
|
|
952
|
+
const lastFocus = lastPaneFocusAt.get(paneId);
|
|
953
|
+
if (!lastFocus) return false;
|
|
954
|
+
const activityTs = Date.parse(activityIso);
|
|
955
|
+
if (Number.isNaN(activityTs)) return false;
|
|
956
|
+
if (Date.now() - lastFocus > STALE_WINDOW_MS) {
|
|
957
|
+
lastPaneFocusAt.delete(paneId);
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
return activityTs >= lastFocus && activityTs - lastFocus <= SUPPRESS_WINDOW_MS;
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
//#endregion
|
|
964
|
+
//#region apps/server/src/screen-service.ts
|
|
965
|
+
const execFileAsync$1 = promisify(execFile);
|
|
966
|
+
const isMacOS = () => process.platform === "darwin";
|
|
967
|
+
const TTY_PATH_PATTERN = /^\/dev\/(ttys?\d+|pts\/\d+)$/;
|
|
968
|
+
const normalizeTty$1 = (tty) => tty.startsWith("/dev/") ? tty : `/dev/${tty}`;
|
|
969
|
+
const isValidTty = (tty) => TTY_PATH_PATTERN.test(normalizeTty$1(tty));
|
|
970
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
971
|
+
const parseBounds = (input) => {
|
|
972
|
+
const parts = input.split(",").map((value) => Number.parseInt(value.trim(), 10)).filter((value) => !Number.isNaN(value));
|
|
973
|
+
if (parts.length !== 4) return null;
|
|
974
|
+
const [x, y, width, height] = parts;
|
|
975
|
+
if (x === void 0 || y === void 0 || width === void 0 || height === void 0) return null;
|
|
976
|
+
return {
|
|
977
|
+
x,
|
|
978
|
+
y,
|
|
979
|
+
width,
|
|
980
|
+
height
|
|
981
|
+
};
|
|
982
|
+
};
|
|
983
|
+
const runAppleScript = async (script) => {
|
|
984
|
+
try {
|
|
985
|
+
return ((await execFileAsync$1("osascript", ["-e", script], { encoding: "utf8" })).stdout ?? "").trim();
|
|
986
|
+
} catch {
|
|
987
|
+
return "";
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
const buildTerminalBoundsScript = (appName) => `
|
|
991
|
+
tell application "System Events"
|
|
992
|
+
if not (exists process "${appName}") then return ""
|
|
993
|
+
tell process "${appName}"
|
|
994
|
+
try
|
|
995
|
+
set windowFrame to value of attribute "AXFrame" of front window
|
|
996
|
+
set pos to {item 1 of windowFrame, item 2 of windowFrame}
|
|
997
|
+
set sz to {item 3 of windowFrame, item 4 of windowFrame}
|
|
998
|
+
set contentPos to pos
|
|
999
|
+
set contentSize to sz
|
|
1000
|
+
try
|
|
1001
|
+
set scrollArea to first UI element of front window whose role is "AXScrollArea"
|
|
1002
|
+
set contentFrame to value of attribute "AXFrame" of scrollArea
|
|
1003
|
+
set contentPos to {item 1 of contentFrame, item 2 of contentFrame}
|
|
1004
|
+
set contentSize to {item 3 of contentFrame, item 4 of contentFrame}
|
|
1005
|
+
end try
|
|
1006
|
+
return (item 1 of contentPos as text) & ", " & (item 2 of contentPos as text) & ", " & (item 1 of contentSize as text) & ", " & (item 2 of contentSize as text) & "|" & (item 1 of pos as text) & ", " & (item 2 of pos as text) & ", " & (item 1 of sz as text) & ", " & (item 2 of sz as text)
|
|
1007
|
+
end try
|
|
1008
|
+
end tell
|
|
1009
|
+
end tell
|
|
1010
|
+
return ""
|
|
1011
|
+
`;
|
|
1012
|
+
const focusTerminalApp = async (appName) => {
|
|
1013
|
+
await runAppleScript(`tell application "${appName}" to activate`);
|
|
1014
|
+
};
|
|
1015
|
+
const captureRegion = async (bounds) => {
|
|
1016
|
+
const tempPath = `/tmp/tmux-agent-monitor-${randomUUID()}.png`;
|
|
1017
|
+
const region = `${bounds.x},${bounds.y},${bounds.width},${bounds.height}`;
|
|
1018
|
+
try {
|
|
1019
|
+
await execFileAsync$1("screencapture", [
|
|
1020
|
+
"-R",
|
|
1021
|
+
region,
|
|
1022
|
+
"-x",
|
|
1023
|
+
tempPath
|
|
1024
|
+
], { timeout: 1e4 });
|
|
1025
|
+
const data = await fs$1.readFile(tempPath);
|
|
1026
|
+
await fs$1.unlink(tempPath).catch(() => null);
|
|
1027
|
+
return data.toString("base64");
|
|
1028
|
+
} catch {
|
|
1029
|
+
await fs$1.unlink(tempPath).catch(() => null);
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
const parsePaneGeometry = (input) => {
|
|
1034
|
+
const parts = input.trim().split(" ").map((value) => Number.parseInt(value.trim(), 10));
|
|
1035
|
+
if (parts.length !== 6 || parts.some((value) => Number.isNaN(value))) return null;
|
|
1036
|
+
const [left, top, width, height, windowWidth, windowHeight] = parts;
|
|
1037
|
+
if (left === void 0 || top === void 0 || width === void 0 || height === void 0 || windowWidth === void 0 || windowHeight === void 0) return null;
|
|
1038
|
+
return {
|
|
1039
|
+
left,
|
|
1040
|
+
top,
|
|
1041
|
+
width,
|
|
1042
|
+
height,
|
|
1043
|
+
windowWidth,
|
|
1044
|
+
windowHeight
|
|
1045
|
+
};
|
|
1046
|
+
};
|
|
1047
|
+
const buildTmuxArgs = (args, options) => {
|
|
1048
|
+
const prefix = [];
|
|
1049
|
+
if (options?.socketName) prefix.push("-L", options.socketName);
|
|
1050
|
+
if (options?.socketPath) prefix.push("-S", options.socketPath);
|
|
1051
|
+
return [...prefix, ...args];
|
|
1052
|
+
};
|
|
1053
|
+
const getPaneSession = async (paneId, options) => {
|
|
1054
|
+
try {
|
|
1055
|
+
const name = ((await execFileAsync$1("tmux", buildTmuxArgs([
|
|
1056
|
+
"display-message",
|
|
1057
|
+
"-p",
|
|
1058
|
+
"-t",
|
|
1059
|
+
paneId,
|
|
1060
|
+
"-F",
|
|
1061
|
+
"#{session_name}"
|
|
1062
|
+
], options), {
|
|
1063
|
+
encoding: "utf8",
|
|
1064
|
+
timeout: 2e3
|
|
1065
|
+
})).stdout ?? "").trim();
|
|
1066
|
+
return name.length > 0 ? name : null;
|
|
1067
|
+
} catch {
|
|
1068
|
+
return null;
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
const focusTmuxPane = async (paneId, options) => {
|
|
1072
|
+
if (!paneId) return;
|
|
1073
|
+
if (options?.primaryClient) await execFileAsync$1("tmux", buildTmuxArgs([
|
|
1074
|
+
"switch-client",
|
|
1075
|
+
"-t",
|
|
1076
|
+
options.primaryClient
|
|
1077
|
+
], options), {
|
|
1078
|
+
encoding: "utf8",
|
|
1079
|
+
timeout: 2e3
|
|
1080
|
+
}).catch(() => null);
|
|
1081
|
+
const sessionName = await getPaneSession(paneId, options);
|
|
1082
|
+
if (sessionName) await execFileAsync$1("tmux", buildTmuxArgs([
|
|
1083
|
+
"switch-client",
|
|
1084
|
+
"-t",
|
|
1085
|
+
sessionName
|
|
1086
|
+
], options), {
|
|
1087
|
+
encoding: "utf8",
|
|
1088
|
+
timeout: 2e3
|
|
1089
|
+
}).catch(() => null);
|
|
1090
|
+
await execFileAsync$1("tmux", buildTmuxArgs([
|
|
1091
|
+
"select-window",
|
|
1092
|
+
"-t",
|
|
1093
|
+
paneId
|
|
1094
|
+
], options), {
|
|
1095
|
+
encoding: "utf8",
|
|
1096
|
+
timeout: 2e3
|
|
1097
|
+
}).catch(() => null);
|
|
1098
|
+
await execFileAsync$1("tmux", buildTmuxArgs([
|
|
1099
|
+
"select-pane",
|
|
1100
|
+
"-t",
|
|
1101
|
+
paneId
|
|
1102
|
+
], options), {
|
|
1103
|
+
encoding: "utf8",
|
|
1104
|
+
timeout: 2e3
|
|
1105
|
+
}).catch(() => null);
|
|
1106
|
+
};
|
|
1107
|
+
const getPaneGeometry = async (paneId, options) => {
|
|
1108
|
+
try {
|
|
1109
|
+
return parsePaneGeometry((await execFileAsync$1("tmux", buildTmuxArgs([
|
|
1110
|
+
"display-message",
|
|
1111
|
+
"-p",
|
|
1112
|
+
"-t",
|
|
1113
|
+
paneId,
|
|
1114
|
+
"-F",
|
|
1115
|
+
[
|
|
1116
|
+
"#{pane_left}",
|
|
1117
|
+
"#{pane_top}",
|
|
1118
|
+
"#{pane_width}",
|
|
1119
|
+
"#{pane_height}",
|
|
1120
|
+
"#{window_width}",
|
|
1121
|
+
"#{window_height}"
|
|
1122
|
+
].join(" ")
|
|
1123
|
+
], options), {
|
|
1124
|
+
encoding: "utf8",
|
|
1125
|
+
timeout: 2e3
|
|
1126
|
+
})).stdout ?? "");
|
|
1127
|
+
} catch {
|
|
1128
|
+
return null;
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
const parseBoundsSet = (input) => {
|
|
1132
|
+
const [contentRaw, windowRaw] = input.split("|").map((part) => part.trim());
|
|
1133
|
+
const content = contentRaw ? parseBounds(contentRaw) : null;
|
|
1134
|
+
return {
|
|
1135
|
+
content,
|
|
1136
|
+
window: (windowRaw ? parseBounds(windowRaw) : null) ?? content
|
|
1137
|
+
};
|
|
1138
|
+
};
|
|
1139
|
+
const cropPaneBounds = (base, geometry) => {
|
|
1140
|
+
if (geometry.windowWidth <= 0 || geometry.windowHeight <= 0) return null;
|
|
1141
|
+
const cellWidth = base.width / geometry.windowWidth;
|
|
1142
|
+
const cellHeight = base.height / geometry.windowHeight;
|
|
1143
|
+
const x = Math.round(base.x + geometry.left * cellWidth);
|
|
1144
|
+
const y = Math.round(base.y + geometry.top * cellHeight);
|
|
1145
|
+
const width = Math.round(geometry.width * cellWidth);
|
|
1146
|
+
const height = Math.round(geometry.height * cellHeight);
|
|
1147
|
+
if (width <= 0 || height <= 0) return null;
|
|
1148
|
+
return {
|
|
1149
|
+
x,
|
|
1150
|
+
y,
|
|
1151
|
+
width,
|
|
1152
|
+
height
|
|
1153
|
+
};
|
|
1154
|
+
};
|
|
1155
|
+
const captureTerminalScreen = async (tty, options = {}) => {
|
|
1156
|
+
if (!isMacOS()) return null;
|
|
1157
|
+
if (tty && !isValidTty(tty)) return null;
|
|
1158
|
+
const backend = options.backend ?? "terminal";
|
|
1159
|
+
const candidates = [
|
|
1160
|
+
{
|
|
1161
|
+
key: "alacritty",
|
|
1162
|
+
appName: "Alacritty"
|
|
1163
|
+
},
|
|
1164
|
+
{
|
|
1165
|
+
key: "terminal",
|
|
1166
|
+
appName: "Terminal"
|
|
1167
|
+
},
|
|
1168
|
+
{
|
|
1169
|
+
key: "iterm",
|
|
1170
|
+
appName: "iTerm2"
|
|
1171
|
+
},
|
|
1172
|
+
{
|
|
1173
|
+
key: "wezterm",
|
|
1174
|
+
appName: "WezTerm"
|
|
1175
|
+
},
|
|
1176
|
+
{
|
|
1177
|
+
key: "ghostty",
|
|
1178
|
+
appName: "Ghostty"
|
|
1179
|
+
}
|
|
1180
|
+
];
|
|
1181
|
+
const isRunning = async (appName) => {
|
|
1182
|
+
return (await runAppleScript(`tell application "System Events" to (exists process "${appName}")`)).trim() === "true";
|
|
1183
|
+
};
|
|
1184
|
+
const app = candidates.find((candidate) => candidate.key === backend) ?? null;
|
|
1185
|
+
if (!app) return null;
|
|
1186
|
+
if (!await isRunning(app.appName)) return null;
|
|
1187
|
+
await focusTerminalApp(app.appName);
|
|
1188
|
+
await wait(200);
|
|
1189
|
+
if (options.paneId) {
|
|
1190
|
+
markPaneFocus(options.paneId);
|
|
1191
|
+
await focusTmuxPane(options.paneId, options.tmux);
|
|
1192
|
+
await wait(200);
|
|
1193
|
+
}
|
|
1194
|
+
const maxAttempts = 3;
|
|
1195
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
1196
|
+
const boundsRaw = await runAppleScript(buildTerminalBoundsScript(app.appName));
|
|
1197
|
+
const boundsSet = boundsRaw ? parseBoundsSet(boundsRaw) : {
|
|
1198
|
+
content: null,
|
|
1199
|
+
window: null
|
|
1200
|
+
};
|
|
1201
|
+
const bounds = boundsSet.content ?? boundsSet.window;
|
|
1202
|
+
const paneGeometry = options.cropPane !== false && options.paneId ? await getPaneGeometry(options.paneId, options.tmux) : null;
|
|
1203
|
+
if (bounds) {
|
|
1204
|
+
const croppedBounds = paneGeometry ? cropPaneBounds(bounds, paneGeometry) : null;
|
|
1205
|
+
const imageBase64 = await captureRegion(croppedBounds ?? bounds);
|
|
1206
|
+
if (imageBase64) return {
|
|
1207
|
+
imageBase64,
|
|
1208
|
+
cropped: Boolean(croppedBounds)
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
if (attempt < maxAttempts - 1) await wait(200);
|
|
1212
|
+
}
|
|
1213
|
+
return null;
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
//#endregion
|
|
1217
|
+
//#region apps/server/src/app.ts
|
|
1218
|
+
const now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
1219
|
+
const buildError$1 = (code, message) => ({
|
|
1220
|
+
code,
|
|
1221
|
+
message
|
|
1222
|
+
});
|
|
1223
|
+
const buildEnvelope = (type, data, reqId) => ({
|
|
1224
|
+
type,
|
|
1225
|
+
ts: now(),
|
|
1226
|
+
reqId,
|
|
1227
|
+
data
|
|
1228
|
+
});
|
|
1229
|
+
const createRateLimiter = (windowMs, max) => {
|
|
1230
|
+
const hits = /* @__PURE__ */ new Map();
|
|
1231
|
+
return (key) => {
|
|
1232
|
+
const nowMs = Date.now();
|
|
1233
|
+
const entry = hits.get(key);
|
|
1234
|
+
if (!entry || entry.expiresAt <= nowMs) {
|
|
1235
|
+
hits.set(key, {
|
|
1236
|
+
count: 1,
|
|
1237
|
+
expiresAt: nowMs + windowMs
|
|
1238
|
+
});
|
|
1239
|
+
return true;
|
|
1240
|
+
}
|
|
1241
|
+
if (entry.count >= max) return false;
|
|
1242
|
+
entry.count += 1;
|
|
1243
|
+
return true;
|
|
1244
|
+
};
|
|
1245
|
+
};
|
|
1246
|
+
const createApp = ({ config, monitor, tmuxActions }) => {
|
|
1247
|
+
const app = new Hono();
|
|
1248
|
+
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
|
1249
|
+
const wsClients = /* @__PURE__ */ new Set();
|
|
1250
|
+
const sendLimiter = createRateLimiter(config.rateLimit.send.windowMs, config.rateLimit.send.max);
|
|
1251
|
+
const screenLimiter = createRateLimiter(config.rateLimit.screen.windowMs, config.rateLimit.screen.max);
|
|
1252
|
+
const requireAuth = (c) => {
|
|
1253
|
+
const auth = c.req.header("authorization") ?? c.req.header("Authorization");
|
|
1254
|
+
if (!auth?.startsWith("Bearer ")) return false;
|
|
1255
|
+
return auth.replace("Bearer ", "").trim() === config.token;
|
|
1256
|
+
};
|
|
1257
|
+
const requireStaticAuth = (c) => {
|
|
1258
|
+
const token = c.req.query("token");
|
|
1259
|
+
if (!token) return false;
|
|
1260
|
+
return token === config.token;
|
|
1261
|
+
};
|
|
1262
|
+
const isOriginAllowed = (origin, host) => {
|
|
1263
|
+
if (!origin || config.allowedOrigins.length === 0) return config.allowedOrigins.length === 0 || (host ? config.allowedOrigins.includes(host) : true);
|
|
1264
|
+
return config.allowedOrigins.includes(origin) || (host ? config.allowedOrigins.includes(host) : false);
|
|
1265
|
+
};
|
|
1266
|
+
const sendWs = (ws, message) => {
|
|
1267
|
+
ws.send(JSON.stringify(message));
|
|
1268
|
+
};
|
|
1269
|
+
const broadcast = (message) => {
|
|
1270
|
+
const payload = JSON.stringify(message);
|
|
1271
|
+
wsClients.forEach((ws) => ws.send(payload));
|
|
1272
|
+
};
|
|
1273
|
+
monitor.registry.onChanged((session) => {
|
|
1274
|
+
broadcast(buildEnvelope("session.updated", { session }));
|
|
1275
|
+
});
|
|
1276
|
+
monitor.registry.onRemoved((paneId) => {
|
|
1277
|
+
broadcast(buildEnvelope("session.removed", { paneId }));
|
|
1278
|
+
});
|
|
1279
|
+
app.use("/api/*", async (c, next) => {
|
|
1280
|
+
if (!requireAuth(c)) return c.json({ error: buildError$1("INVALID_PAYLOAD", "unauthorized") }, 401);
|
|
1281
|
+
if (!isOriginAllowed(c.req.header("origin"), c.req.header("host"))) return c.json({ error: buildError$1("INVALID_PAYLOAD", "origin not allowed") }, 403);
|
|
1282
|
+
await next();
|
|
1283
|
+
});
|
|
1284
|
+
app.get("/api/sessions", (c) => {
|
|
1285
|
+
return c.json({
|
|
1286
|
+
sessions: monitor.registry.snapshot(),
|
|
1287
|
+
serverTime: now()
|
|
1288
|
+
});
|
|
1289
|
+
});
|
|
1290
|
+
app.get("/api/sessions/:paneId", (c) => {
|
|
1291
|
+
const paneId = c.req.param("paneId");
|
|
1292
|
+
if (!paneId) return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
|
|
1293
|
+
const detail = monitor.registry.getDetail(paneId);
|
|
1294
|
+
if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
|
|
1295
|
+
return c.json({ session: detail });
|
|
1296
|
+
});
|
|
1297
|
+
app.get("/api/sessions/:paneId/diff", async (c) => {
|
|
1298
|
+
const paneId = c.req.param("paneId");
|
|
1299
|
+
if (!paneId) return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
|
|
1300
|
+
const detail = monitor.registry.getDetail(paneId);
|
|
1301
|
+
if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
|
|
1302
|
+
const force = c.req.query("force") === "1";
|
|
1303
|
+
const summary = await fetchDiffSummary(detail.currentPath, { force });
|
|
1304
|
+
return c.json({ summary });
|
|
1305
|
+
});
|
|
1306
|
+
app.get("/api/sessions/:paneId/diff/file", async (c) => {
|
|
1307
|
+
const paneId = c.req.param("paneId");
|
|
1308
|
+
if (!paneId) return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
|
|
1309
|
+
const detail = monitor.registry.getDetail(paneId);
|
|
1310
|
+
if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
|
|
1311
|
+
const pathParam = c.req.query("path");
|
|
1312
|
+
if (!pathParam) return c.json({ error: buildError$1("INVALID_PAYLOAD", "missing path") }, 400);
|
|
1313
|
+
const force = c.req.query("force") === "1";
|
|
1314
|
+
const summary = await fetchDiffSummary(detail.currentPath, { force });
|
|
1315
|
+
if (!summary.repoRoot || summary.reason || !summary.rev) return c.json({ error: buildError$1("INVALID_PAYLOAD", "diff summary unavailable") }, 400);
|
|
1316
|
+
const target = summary.files.find((file) => file.path === pathParam);
|
|
1317
|
+
if (!target) return c.json({ error: buildError$1("NOT_FOUND", "file not found") }, 404);
|
|
1318
|
+
const file = await fetchDiffFile(summary.repoRoot, target, summary.rev, { force });
|
|
1319
|
+
return c.json({ file });
|
|
1320
|
+
});
|
|
1321
|
+
app.get("/api/sessions/:paneId/commits", async (c) => {
|
|
1322
|
+
const paneId = c.req.param("paneId");
|
|
1323
|
+
if (!paneId) return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
|
|
1324
|
+
const detail = monitor.registry.getDetail(paneId);
|
|
1325
|
+
if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
|
|
1326
|
+
const limit = Number.parseInt(c.req.query("limit") ?? "10", 10);
|
|
1327
|
+
const skip = Number.parseInt(c.req.query("skip") ?? "0", 10);
|
|
1328
|
+
const force = c.req.query("force") === "1";
|
|
1329
|
+
const log = await fetchCommitLog(detail.currentPath, {
|
|
1330
|
+
limit: Number.isFinite(limit) ? limit : 10,
|
|
1331
|
+
skip: Number.isFinite(skip) ? skip : 0,
|
|
1332
|
+
force
|
|
1333
|
+
});
|
|
1334
|
+
return c.json({ log });
|
|
1335
|
+
});
|
|
1336
|
+
app.get("/api/sessions/:paneId/commits/:hash", async (c) => {
|
|
1337
|
+
const paneId = c.req.param("paneId");
|
|
1338
|
+
if (!paneId) return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
|
|
1339
|
+
const detail = monitor.registry.getDetail(paneId);
|
|
1340
|
+
if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
|
|
1341
|
+
const hash = c.req.param("hash");
|
|
1342
|
+
if (!hash) return c.json({ error: buildError$1("INVALID_PAYLOAD", "missing hash") }, 400);
|
|
1343
|
+
const log = await fetchCommitLog(detail.currentPath, {
|
|
1344
|
+
limit: 1,
|
|
1345
|
+
skip: 0
|
|
1346
|
+
});
|
|
1347
|
+
if (!log.repoRoot || log.reason) return c.json({ error: buildError$1("INVALID_PAYLOAD", "commit log unavailable") }, 400);
|
|
1348
|
+
const commit = await fetchCommitDetail(log.repoRoot, hash, { force: c.req.query("force") === "1" });
|
|
1349
|
+
if (!commit) return c.json({ error: buildError$1("NOT_FOUND", "commit not found") }, 404);
|
|
1350
|
+
return c.json({ commit });
|
|
1351
|
+
});
|
|
1352
|
+
app.get("/api/sessions/:paneId/commits/:hash/file", async (c) => {
|
|
1353
|
+
const paneId = c.req.param("paneId");
|
|
1354
|
+
if (!paneId) return c.json({ error: buildError$1("INVALID_PAYLOAD", "invalid pane id") }, 400);
|
|
1355
|
+
const detail = monitor.registry.getDetail(paneId);
|
|
1356
|
+
if (!detail) return c.json({ error: buildError$1("NOT_FOUND", "pane not found") }, 404);
|
|
1357
|
+
const hash = c.req.param("hash");
|
|
1358
|
+
const pathParam = c.req.query("path");
|
|
1359
|
+
if (!hash || !pathParam) return c.json({ error: buildError$1("INVALID_PAYLOAD", "missing hash or path") }, 400);
|
|
1360
|
+
const log = await fetchCommitLog(detail.currentPath, {
|
|
1361
|
+
limit: 1,
|
|
1362
|
+
skip: 0
|
|
1363
|
+
});
|
|
1364
|
+
if (!log.repoRoot || log.reason) return c.json({ error: buildError$1("INVALID_PAYLOAD", "commit log unavailable") }, 400);
|
|
1365
|
+
const commit = await fetchCommitDetail(log.repoRoot, hash, { force: true });
|
|
1366
|
+
if (!commit) return c.json({ error: buildError$1("NOT_FOUND", "commit not found") }, 404);
|
|
1367
|
+
const target = commit.files.find((file) => file.path === pathParam) ?? commit.files.find((file) => file.renamedFrom === pathParam);
|
|
1368
|
+
if (!target) return c.json({ error: buildError$1("NOT_FOUND", "file not found") }, 404);
|
|
1369
|
+
const file = await fetchCommitFile(log.repoRoot, hash, target, { force: c.req.query("force") === "1" });
|
|
1370
|
+
return c.json({ file });
|
|
1371
|
+
});
|
|
1372
|
+
app.post("/api/admin/token/rotate", (c) => {
|
|
1373
|
+
const next = rotateToken();
|
|
1374
|
+
config.token = next.token;
|
|
1375
|
+
return c.json({ token: next.token });
|
|
1376
|
+
});
|
|
1377
|
+
const wsHandler = upgradeWebSocket(() => ({
|
|
1378
|
+
onOpen: (_event, ws) => {
|
|
1379
|
+
wsClients.add(ws);
|
|
1380
|
+
sendWs(ws, buildEnvelope("sessions.snapshot", { sessions: monitor.registry.snapshot() }));
|
|
1381
|
+
sendWs(ws, buildEnvelope("server.health", { version: "0.0.1" }));
|
|
1382
|
+
},
|
|
1383
|
+
onClose: (_event, ws) => {
|
|
1384
|
+
wsClients.delete(ws);
|
|
1385
|
+
},
|
|
1386
|
+
onMessage: async (event, ws) => {
|
|
1387
|
+
let parsedJson;
|
|
1388
|
+
try {
|
|
1389
|
+
parsedJson = JSON.parse(event.data.toString());
|
|
1390
|
+
} catch {
|
|
1391
|
+
sendWs(ws, buildEnvelope("command.response", {
|
|
1392
|
+
ok: false,
|
|
1393
|
+
error: buildError$1("INVALID_PAYLOAD", "invalid json")
|
|
1394
|
+
}));
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
const parsed = wsClientMessageSchema.safeParse(parsedJson);
|
|
1398
|
+
if (!parsed.success) {
|
|
1399
|
+
sendWs(ws, buildEnvelope("command.response", {
|
|
1400
|
+
ok: false,
|
|
1401
|
+
error: buildError$1("INVALID_PAYLOAD", "invalid payload")
|
|
1402
|
+
}));
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
const message = parsed.data;
|
|
1406
|
+
const reqId = message.reqId;
|
|
1407
|
+
if (message.type === "client.ping") {
|
|
1408
|
+
sendWs(ws, buildEnvelope("server.health", { version: "0.0.1" }, reqId));
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
const target = monitor.registry.getDetail(message.data.paneId);
|
|
1412
|
+
if (!target) {
|
|
1413
|
+
if (message.type === "screen.request") sendWs(ws, buildEnvelope("screen.response", {
|
|
1414
|
+
ok: false,
|
|
1415
|
+
paneId: message.data.paneId,
|
|
1416
|
+
mode: message.data.mode ?? config.screen.mode,
|
|
1417
|
+
capturedAt: now(),
|
|
1418
|
+
error: buildError$1("NOT_FOUND", "pane not found")
|
|
1419
|
+
}, reqId));
|
|
1420
|
+
else sendWs(ws, buildEnvelope("command.response", {
|
|
1421
|
+
ok: false,
|
|
1422
|
+
error: buildError$1("NOT_FOUND", "pane not found")
|
|
1423
|
+
}, reqId));
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
if (message.type === "screen.request") {
|
|
1427
|
+
if (!screenLimiter("ws")) {
|
|
1428
|
+
sendWs(ws, buildEnvelope("screen.response", {
|
|
1429
|
+
ok: false,
|
|
1430
|
+
paneId: message.data.paneId,
|
|
1431
|
+
mode: "text",
|
|
1432
|
+
capturedAt: now(),
|
|
1433
|
+
error: buildError$1("RATE_LIMIT", "rate limited")
|
|
1434
|
+
}, reqId));
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
const mode = message.data.mode ?? config.screen.mode;
|
|
1438
|
+
const lineCount = Math.min(message.data.lines ?? config.screen.defaultLines, config.screen.maxLines);
|
|
1439
|
+
if (mode === "image") {
|
|
1440
|
+
if (!config.screen.image.enabled) try {
|
|
1441
|
+
const text = await monitor.getScreenCapture().captureText({
|
|
1442
|
+
paneId: message.data.paneId,
|
|
1443
|
+
lines: lineCount,
|
|
1444
|
+
joinLines: config.screen.joinLines,
|
|
1445
|
+
includeAnsi: config.screen.ansi,
|
|
1446
|
+
altScreen: config.screen.altScreen,
|
|
1447
|
+
alternateOn: target.alternateOn
|
|
1448
|
+
});
|
|
1449
|
+
sendWs(ws, buildEnvelope("screen.response", {
|
|
1450
|
+
ok: true,
|
|
1451
|
+
paneId: message.data.paneId,
|
|
1452
|
+
mode: "text",
|
|
1453
|
+
capturedAt: now(),
|
|
1454
|
+
lines: lineCount,
|
|
1455
|
+
truncated: text.truncated,
|
|
1456
|
+
alternateOn: target.alternateOn,
|
|
1457
|
+
screen: text.screen,
|
|
1458
|
+
fallbackReason: "image_disabled"
|
|
1459
|
+
}, reqId));
|
|
1460
|
+
return;
|
|
1461
|
+
} catch {
|
|
1462
|
+
sendWs(ws, buildEnvelope("screen.response", {
|
|
1463
|
+
ok: false,
|
|
1464
|
+
paneId: message.data.paneId,
|
|
1465
|
+
mode: "text",
|
|
1466
|
+
capturedAt: now(),
|
|
1467
|
+
error: buildError$1("INTERNAL", "screen capture failed")
|
|
1468
|
+
}, reqId));
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
const imageResult = await captureTerminalScreen(target.paneTty, {
|
|
1472
|
+
paneId: message.data.paneId,
|
|
1473
|
+
tmux: config.tmux,
|
|
1474
|
+
cropPane: config.screen.image.cropPane,
|
|
1475
|
+
backend: config.screen.image.backend
|
|
1476
|
+
});
|
|
1477
|
+
if (imageResult) {
|
|
1478
|
+
sendWs(ws, buildEnvelope("screen.response", {
|
|
1479
|
+
ok: true,
|
|
1480
|
+
paneId: message.data.paneId,
|
|
1481
|
+
mode: "image",
|
|
1482
|
+
capturedAt: now(),
|
|
1483
|
+
imageBase64: imageResult.imageBase64,
|
|
1484
|
+
cropped: imageResult.cropped
|
|
1485
|
+
}, reqId));
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
try {
|
|
1489
|
+
const text = await monitor.getScreenCapture().captureText({
|
|
1490
|
+
paneId: message.data.paneId,
|
|
1491
|
+
lines: lineCount,
|
|
1492
|
+
joinLines: config.screen.joinLines,
|
|
1493
|
+
includeAnsi: config.screen.ansi,
|
|
1494
|
+
altScreen: config.screen.altScreen,
|
|
1495
|
+
alternateOn: target.alternateOn
|
|
1496
|
+
});
|
|
1497
|
+
sendWs(ws, buildEnvelope("screen.response", {
|
|
1498
|
+
ok: true,
|
|
1499
|
+
paneId: message.data.paneId,
|
|
1500
|
+
mode: "text",
|
|
1501
|
+
capturedAt: now(),
|
|
1502
|
+
lines: lineCount,
|
|
1503
|
+
truncated: text.truncated,
|
|
1504
|
+
alternateOn: target.alternateOn,
|
|
1505
|
+
screen: text.screen,
|
|
1506
|
+
fallbackReason: "image_failed"
|
|
1507
|
+
}, reqId));
|
|
1508
|
+
return;
|
|
1509
|
+
} catch {
|
|
1510
|
+
sendWs(ws, buildEnvelope("screen.response", {
|
|
1511
|
+
ok: false,
|
|
1512
|
+
paneId: message.data.paneId,
|
|
1513
|
+
mode: "text",
|
|
1514
|
+
capturedAt: now(),
|
|
1515
|
+
error: buildError$1("INTERNAL", "screen capture failed")
|
|
1516
|
+
}, reqId));
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
try {
|
|
1521
|
+
const text = await monitor.getScreenCapture().captureText({
|
|
1522
|
+
paneId: message.data.paneId,
|
|
1523
|
+
lines: lineCount,
|
|
1524
|
+
joinLines: config.screen.joinLines,
|
|
1525
|
+
includeAnsi: config.screen.ansi,
|
|
1526
|
+
altScreen: config.screen.altScreen,
|
|
1527
|
+
alternateOn: target.alternateOn
|
|
1528
|
+
});
|
|
1529
|
+
sendWs(ws, buildEnvelope("screen.response", {
|
|
1530
|
+
ok: true,
|
|
1531
|
+
paneId: message.data.paneId,
|
|
1532
|
+
mode: "text",
|
|
1533
|
+
capturedAt: now(),
|
|
1534
|
+
lines: lineCount,
|
|
1535
|
+
truncated: text.truncated,
|
|
1536
|
+
alternateOn: target.alternateOn,
|
|
1537
|
+
screen: text.screen
|
|
1538
|
+
}, reqId));
|
|
1539
|
+
return;
|
|
1540
|
+
} catch {
|
|
1541
|
+
sendWs(ws, buildEnvelope("screen.response", {
|
|
1542
|
+
ok: false,
|
|
1543
|
+
paneId: message.data.paneId,
|
|
1544
|
+
mode: "text",
|
|
1545
|
+
capturedAt: now(),
|
|
1546
|
+
error: buildError$1("INTERNAL", "screen capture failed")
|
|
1547
|
+
}, reqId));
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
if (config.readOnly) {
|
|
1552
|
+
sendWs(ws, buildEnvelope("command.response", {
|
|
1553
|
+
ok: false,
|
|
1554
|
+
error: buildError$1("READ_ONLY", "read-only mode")
|
|
1555
|
+
}, reqId));
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
if (!sendLimiter("ws")) {
|
|
1559
|
+
sendWs(ws, buildEnvelope("command.response", {
|
|
1560
|
+
ok: false,
|
|
1561
|
+
error: buildError$1("RATE_LIMIT", "rate limited")
|
|
1562
|
+
}, reqId));
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
if (message.type === "send.text") {
|
|
1566
|
+
sendWs(ws, buildEnvelope("command.response", await tmuxActions.sendText(message.data.paneId, message.data.text, message.data.enter ?? true), reqId));
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
if (message.type === "send.keys") {
|
|
1570
|
+
sendWs(ws, buildEnvelope("command.response", await tmuxActions.sendKeys(message.data.paneId, message.data.keys), reqId));
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}));
|
|
1575
|
+
app.use("/ws", async (c, next) => {
|
|
1576
|
+
const token = c.req.query("token");
|
|
1577
|
+
if (!token || token !== config.token) return c.text("Unauthorized", 401);
|
|
1578
|
+
if (!isOriginAllowed(c.req.header("origin"), c.req.header("host"))) return c.text("Forbidden", 403);
|
|
1579
|
+
await next();
|
|
1580
|
+
});
|
|
1581
|
+
app.get("/ws", wsHandler);
|
|
1582
|
+
const distRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
1583
|
+
const bundledDistDir = path.resolve(distRoot, "web");
|
|
1584
|
+
const workspaceDistDir = path.resolve(distRoot, "../../web/dist");
|
|
1585
|
+
const distDir = fs.existsSync(bundledDistDir) ? bundledDistDir : workspaceDistDir;
|
|
1586
|
+
if (fs.existsSync(distDir)) {
|
|
1587
|
+
app.use("/*", async (c, next) => {
|
|
1588
|
+
if (!config.staticAuth) return next();
|
|
1589
|
+
if (!requireStaticAuth(c)) return c.text("Unauthorized", 401);
|
|
1590
|
+
return next();
|
|
1591
|
+
});
|
|
1592
|
+
app.use("/*", serveStatic({ root: distDir }));
|
|
1593
|
+
app.get("/*", serveStatic({
|
|
1594
|
+
root: distDir,
|
|
1595
|
+
path: "index.html"
|
|
1596
|
+
}));
|
|
1597
|
+
}
|
|
1598
|
+
return {
|
|
1599
|
+
app,
|
|
1600
|
+
injectWebSocket
|
|
1601
|
+
};
|
|
1602
|
+
};
|
|
1603
|
+
|
|
1604
|
+
//#endregion
|
|
1605
|
+
//#region packages/agents/src/state-estimator.ts
|
|
1606
|
+
const toTimestamp = (value) => {
|
|
1607
|
+
if (!value) return null;
|
|
1608
|
+
const ts = Date.parse(value);
|
|
1609
|
+
return Number.isNaN(ts) ? null : ts;
|
|
1610
|
+
};
|
|
1611
|
+
const mapHookState = (hookState) => {
|
|
1612
|
+
return {
|
|
1613
|
+
state: hookState.state,
|
|
1614
|
+
reason: hookState.reason
|
|
1615
|
+
};
|
|
1616
|
+
};
|
|
1617
|
+
const estimateState = (signals) => {
|
|
1618
|
+
if (signals.paneDead) return {
|
|
1619
|
+
state: "UNKNOWN",
|
|
1620
|
+
reason: "pane_dead"
|
|
1621
|
+
};
|
|
1622
|
+
if (signals.hookState) return mapHookState(signals.hookState);
|
|
1623
|
+
const lastOutputTs = toTimestamp(signals.lastOutputAt);
|
|
1624
|
+
if (lastOutputTs !== null) {
|
|
1625
|
+
const diff = Date.now() - lastOutputTs;
|
|
1626
|
+
if (diff <= signals.thresholds.runningThresholdMs) return {
|
|
1627
|
+
state: "RUNNING",
|
|
1628
|
+
reason: "recent_output"
|
|
1629
|
+
};
|
|
1630
|
+
if (diff >= signals.thresholds.inactiveThresholdMs) return {
|
|
1631
|
+
state: "WAITING_INPUT",
|
|
1632
|
+
reason: "inactive_timeout"
|
|
1633
|
+
};
|
|
1634
|
+
return {
|
|
1635
|
+
state: "WAITING_INPUT",
|
|
1636
|
+
reason: "recently_inactive"
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
return {
|
|
1640
|
+
state: "UNKNOWN",
|
|
1641
|
+
reason: "no_signal"
|
|
1642
|
+
};
|
|
1643
|
+
};
|
|
1644
|
+
|
|
1645
|
+
//#endregion
|
|
1646
|
+
//#region apps/server/src/logs.ts
|
|
1647
|
+
const ensureDir = async (dir) => {
|
|
1648
|
+
await fs$1.mkdir(dir, {
|
|
1649
|
+
recursive: true,
|
|
1650
|
+
mode: 448
|
|
1651
|
+
});
|
|
1652
|
+
};
|
|
1653
|
+
const rotateLogIfNeeded = async (filePath, maxBytes, retainRotations) => {
|
|
1654
|
+
const stat = await fs$1.stat(filePath).catch(() => null);
|
|
1655
|
+
if (!stat || stat.size <= maxBytes) return;
|
|
1656
|
+
const dir = path.dirname(filePath);
|
|
1657
|
+
const base = path.basename(filePath);
|
|
1658
|
+
const rotatedPath = path.join(dir, `${base}.${Date.now()}`);
|
|
1659
|
+
const data = await fs$1.readFile(filePath);
|
|
1660
|
+
await fs$1.writeFile(rotatedPath, data);
|
|
1661
|
+
await fs$1.truncate(filePath, 0);
|
|
1662
|
+
const rotations = (await fs$1.readdir(dir)).filter((name) => name.startsWith(`${base}.`)).map((name) => ({
|
|
1663
|
+
name,
|
|
1664
|
+
fullPath: path.join(dir, name)
|
|
1665
|
+
}));
|
|
1666
|
+
if (rotations.length > retainRotations) {
|
|
1667
|
+
const toDelete = rotations.sort((a, b) => a.name.localeCompare(b.name)).slice(0, rotations.length - retainRotations);
|
|
1668
|
+
await Promise.all(toDelete.map((entry) => fs$1.unlink(entry.fullPath).catch(() => null)));
|
|
1669
|
+
}
|
|
1670
|
+
};
|
|
1671
|
+
const createLogActivityPoller = (pollIntervalMs) => {
|
|
1672
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1673
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1674
|
+
let timer = null;
|
|
1675
|
+
const register = (paneId, filePath) => {
|
|
1676
|
+
if (!entries.has(filePath)) entries.set(filePath, {
|
|
1677
|
+
paneId,
|
|
1678
|
+
size: 0
|
|
1679
|
+
});
|
|
1680
|
+
};
|
|
1681
|
+
const onActivity = (listener) => {
|
|
1682
|
+
listeners.add(listener);
|
|
1683
|
+
return () => listeners.delete(listener);
|
|
1684
|
+
};
|
|
1685
|
+
const start = () => {
|
|
1686
|
+
if (timer) return;
|
|
1687
|
+
timer = setInterval(async () => {
|
|
1688
|
+
await Promise.all(Array.from(entries.entries()).map(async ([filePath, entry]) => {
|
|
1689
|
+
const stat = await fs$1.stat(filePath).catch(() => null);
|
|
1690
|
+
if (!stat) return;
|
|
1691
|
+
if (stat.size < entry.size) {
|
|
1692
|
+
entry.size = stat.size;
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
if (stat.size > entry.size) {
|
|
1696
|
+
entry.size = stat.size;
|
|
1697
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
1698
|
+
listeners.forEach((listener) => listener(entry.paneId, at));
|
|
1699
|
+
}
|
|
1700
|
+
}));
|
|
1701
|
+
}, pollIntervalMs);
|
|
1702
|
+
};
|
|
1703
|
+
const stop = () => {
|
|
1704
|
+
if (timer) {
|
|
1705
|
+
clearInterval(timer);
|
|
1706
|
+
timer = null;
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
return {
|
|
1710
|
+
register,
|
|
1711
|
+
onActivity,
|
|
1712
|
+
start,
|
|
1713
|
+
stop
|
|
1714
|
+
};
|
|
1715
|
+
};
|
|
1716
|
+
const createJsonlTailer = (pollIntervalMs) => {
|
|
1717
|
+
let offset = 0;
|
|
1718
|
+
let buffer = "";
|
|
1719
|
+
let timer = null;
|
|
1720
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1721
|
+
const onLine = (listener) => {
|
|
1722
|
+
listeners.add(listener);
|
|
1723
|
+
return () => listeners.delete(listener);
|
|
1724
|
+
};
|
|
1725
|
+
const start = (filePath) => {
|
|
1726
|
+
if (timer) return;
|
|
1727
|
+
timer = setInterval(async () => {
|
|
1728
|
+
const stat = await fs$1.stat(filePath).catch(() => null);
|
|
1729
|
+
if (!stat) return;
|
|
1730
|
+
if (stat.size < offset) {
|
|
1731
|
+
offset = 0;
|
|
1732
|
+
buffer = "";
|
|
1733
|
+
}
|
|
1734
|
+
if (stat.size === offset) return;
|
|
1735
|
+
const fd = await fs$1.open(filePath, "r");
|
|
1736
|
+
const length = stat.size - offset;
|
|
1737
|
+
const chunk = Buffer.alloc(length);
|
|
1738
|
+
await fd.read(chunk, 0, length, offset);
|
|
1739
|
+
await fd.close();
|
|
1740
|
+
offset = stat.size;
|
|
1741
|
+
buffer += chunk.toString("utf8");
|
|
1742
|
+
const lines = buffer.split("\n");
|
|
1743
|
+
buffer = lines.pop() ?? "";
|
|
1744
|
+
lines.forEach((line) => {
|
|
1745
|
+
if (line.trim().length === 0) return;
|
|
1746
|
+
listeners.forEach((listener) => listener(line));
|
|
1747
|
+
});
|
|
1748
|
+
}, pollIntervalMs);
|
|
1749
|
+
};
|
|
1750
|
+
const stop = () => {
|
|
1751
|
+
if (timer) {
|
|
1752
|
+
clearInterval(timer);
|
|
1753
|
+
timer = null;
|
|
1754
|
+
}
|
|
1755
|
+
};
|
|
1756
|
+
return {
|
|
1757
|
+
onLine,
|
|
1758
|
+
start,
|
|
1759
|
+
stop
|
|
1760
|
+
};
|
|
1761
|
+
};
|
|
1762
|
+
|
|
1763
|
+
//#endregion
|
|
1764
|
+
//#region apps/server/src/session-registry.ts
|
|
1765
|
+
const toSummary = (detail) => {
|
|
1766
|
+
const { startCommand: _startCommand, panePid: _panePid, ...summary } = detail;
|
|
1767
|
+
return summary;
|
|
1768
|
+
};
|
|
1769
|
+
const createSessionRegistry = () => {
|
|
1770
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
1771
|
+
const changeListeners = /* @__PURE__ */ new Set();
|
|
1772
|
+
const removedListeners = /* @__PURE__ */ new Set();
|
|
1773
|
+
const snapshot = () => {
|
|
1774
|
+
return Array.from(sessions.values()).map(toSummary);
|
|
1775
|
+
};
|
|
1776
|
+
const getDetail = (paneId) => {
|
|
1777
|
+
return sessions.get(paneId) ?? null;
|
|
1778
|
+
};
|
|
1779
|
+
const update = (detail) => {
|
|
1780
|
+
const existing = sessions.get(detail.paneId);
|
|
1781
|
+
const next = detail;
|
|
1782
|
+
sessions.set(detail.paneId, next);
|
|
1783
|
+
if (!existing || JSON.stringify(toSummary(existing)) !== JSON.stringify(toSummary(next))) {
|
|
1784
|
+
const summary = toSummary(next);
|
|
1785
|
+
changeListeners.forEach((listener) => listener(summary));
|
|
1786
|
+
}
|
|
1787
|
+
};
|
|
1788
|
+
const removeMissing = (activePaneIds) => {
|
|
1789
|
+
const removed = [];
|
|
1790
|
+
sessions.forEach((_, paneId) => {
|
|
1791
|
+
if (!activePaneIds.has(paneId)) {
|
|
1792
|
+
sessions.delete(paneId);
|
|
1793
|
+
removed.push(paneId);
|
|
1794
|
+
removedListeners.forEach((listener) => listener(paneId));
|
|
1795
|
+
}
|
|
1796
|
+
});
|
|
1797
|
+
return removed;
|
|
1798
|
+
};
|
|
1799
|
+
const onChanged = (listener) => {
|
|
1800
|
+
changeListeners.add(listener);
|
|
1801
|
+
return () => changeListeners.delete(listener);
|
|
1802
|
+
};
|
|
1803
|
+
const onRemoved = (listener) => {
|
|
1804
|
+
removedListeners.add(listener);
|
|
1805
|
+
return () => removedListeners.delete(listener);
|
|
1806
|
+
};
|
|
1807
|
+
const values = () => Array.from(sessions.values());
|
|
1808
|
+
return {
|
|
1809
|
+
snapshot,
|
|
1810
|
+
getDetail,
|
|
1811
|
+
update,
|
|
1812
|
+
removeMissing,
|
|
1813
|
+
onChanged,
|
|
1814
|
+
onRemoved,
|
|
1815
|
+
values
|
|
1816
|
+
};
|
|
1817
|
+
};
|
|
1818
|
+
|
|
1819
|
+
//#endregion
|
|
1820
|
+
//#region apps/server/src/state-store.ts
|
|
1821
|
+
const getStatePath = () => {
|
|
1822
|
+
return path.join(os.homedir(), ".tmux-agent-monitor", "state.json");
|
|
1823
|
+
};
|
|
1824
|
+
const loadState = () => {
|
|
1825
|
+
try {
|
|
1826
|
+
const raw = fs.readFileSync(getStatePath(), "utf8");
|
|
1827
|
+
return JSON.parse(raw);
|
|
1828
|
+
} catch {
|
|
1829
|
+
return null;
|
|
1830
|
+
}
|
|
1831
|
+
};
|
|
1832
|
+
const saveState = (sessions) => {
|
|
1833
|
+
const data = {
|
|
1834
|
+
version: 1,
|
|
1835
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1836
|
+
sessions: Object.fromEntries(sessions.map((session) => [session.paneId, {
|
|
1837
|
+
paneId: session.paneId,
|
|
1838
|
+
lastOutputAt: session.lastOutputAt,
|
|
1839
|
+
lastEventAt: session.lastEventAt,
|
|
1840
|
+
lastMessage: session.lastMessage,
|
|
1841
|
+
state: session.state,
|
|
1842
|
+
stateReason: session.stateReason
|
|
1843
|
+
}]))
|
|
1844
|
+
};
|
|
1845
|
+
const dir = path.dirname(getStatePath());
|
|
1846
|
+
fs.mkdirSync(dir, {
|
|
1847
|
+
recursive: true,
|
|
1848
|
+
mode: 448
|
|
1849
|
+
});
|
|
1850
|
+
fs.writeFileSync(getStatePath(), `${JSON.stringify(data, null, 2)}\n`, {
|
|
1851
|
+
encoding: "utf8",
|
|
1852
|
+
mode: 384
|
|
1853
|
+
});
|
|
1854
|
+
};
|
|
1855
|
+
const restoreSessions = () => {
|
|
1856
|
+
const state = loadState();
|
|
1857
|
+
if (!state) return /* @__PURE__ */ new Map();
|
|
1858
|
+
return new Map(Object.entries(state.sessions));
|
|
1859
|
+
};
|
|
1860
|
+
|
|
1861
|
+
//#endregion
|
|
1862
|
+
//#region apps/server/src/monitor.ts
|
|
1863
|
+
const baseDir = path.join(os.homedir(), ".tmux-agent-monitor");
|
|
1864
|
+
const execFileAsync = promisify(execFile);
|
|
1865
|
+
const buildAgent = (hint) => {
|
|
1866
|
+
const normalized = hint.toLowerCase();
|
|
1867
|
+
if (normalized.includes("codex")) return "codex";
|
|
1868
|
+
if (normalized.includes("claude")) return "claude";
|
|
1869
|
+
return "unknown";
|
|
1870
|
+
};
|
|
1871
|
+
const mergeHints = (...parts) => parts.filter((part) => Boolean(part && part.trim().length > 0)).join(" ");
|
|
1872
|
+
const processCacheTtlMs = 5e3;
|
|
1873
|
+
const processCommandCache = /* @__PURE__ */ new Map();
|
|
1874
|
+
const ttyAgentCache = /* @__PURE__ */ new Map();
|
|
1875
|
+
const processSnapshotCache = {
|
|
1876
|
+
at: 0,
|
|
1877
|
+
byPid: /* @__PURE__ */ new Map(),
|
|
1878
|
+
children: /* @__PURE__ */ new Map()
|
|
1879
|
+
};
|
|
1880
|
+
const normalizeTty = (tty) => tty.replace(/^\/dev\//, "");
|
|
1881
|
+
const normalizeFingerprint = (text) => text.replace(/\r/g, "").split("\n").map((line) => line.replace(/\s+$/, "")).join("\n").trimEnd();
|
|
1882
|
+
const normalizeTitle = (value) => {
|
|
1883
|
+
if (!value) return null;
|
|
1884
|
+
const trimmed = value.trim();
|
|
1885
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
1886
|
+
};
|
|
1887
|
+
const buildDefaultTitle = (currentPath, paneId, sessionName) => {
|
|
1888
|
+
if (!currentPath) return `${sessionName}:${paneId}`;
|
|
1889
|
+
return `${currentPath.replace(/\/+$/, "").split("/").pop() || "unknown"}:${paneId}`;
|
|
1890
|
+
};
|
|
1891
|
+
const hostCandidates = (() => {
|
|
1892
|
+
const host = os.hostname();
|
|
1893
|
+
const short = host.split(".")[0] ?? host;
|
|
1894
|
+
return new Set([
|
|
1895
|
+
host,
|
|
1896
|
+
short,
|
|
1897
|
+
`${host}.local`,
|
|
1898
|
+
`${short}.local`
|
|
1899
|
+
]);
|
|
1900
|
+
})();
|
|
1901
|
+
const toIsoFromEpochSeconds = (value) => {
|
|
1902
|
+
if (!value) return null;
|
|
1903
|
+
const date = /* @__PURE__ */ new Date(value * 1e3);
|
|
1904
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
1905
|
+
return date.toISOString();
|
|
1906
|
+
};
|
|
1907
|
+
const getProcessCommand = async (pid) => {
|
|
1908
|
+
if (!pid) return null;
|
|
1909
|
+
const cached = processCommandCache.get(pid);
|
|
1910
|
+
const nowMs = Date.now();
|
|
1911
|
+
if (cached && nowMs - cached.at < processCacheTtlMs) return cached.command;
|
|
1912
|
+
try {
|
|
1913
|
+
const command = ((await execFileAsync("ps", [
|
|
1914
|
+
"-p",
|
|
1915
|
+
String(pid),
|
|
1916
|
+
"-o",
|
|
1917
|
+
"command="
|
|
1918
|
+
], {
|
|
1919
|
+
encoding: "utf8",
|
|
1920
|
+
timeout: 1e3
|
|
1921
|
+
})).stdout ?? "").trim();
|
|
1922
|
+
if (command.length === 0) return null;
|
|
1923
|
+
processCommandCache.set(pid, {
|
|
1924
|
+
command,
|
|
1925
|
+
at: nowMs
|
|
1926
|
+
});
|
|
1927
|
+
return command;
|
|
1928
|
+
} catch {
|
|
1929
|
+
return null;
|
|
1930
|
+
}
|
|
1931
|
+
};
|
|
1932
|
+
const loadProcessSnapshot = async () => {
|
|
1933
|
+
const nowMs = Date.now();
|
|
1934
|
+
if (nowMs - processSnapshotCache.at < processCacheTtlMs) return processSnapshotCache;
|
|
1935
|
+
try {
|
|
1936
|
+
const result = await execFileAsync("ps", [
|
|
1937
|
+
"-ax",
|
|
1938
|
+
"-o",
|
|
1939
|
+
"pid=,ppid=,command="
|
|
1940
|
+
], {
|
|
1941
|
+
encoding: "utf8",
|
|
1942
|
+
timeout: 2e3
|
|
1943
|
+
});
|
|
1944
|
+
const byPid = /* @__PURE__ */ new Map();
|
|
1945
|
+
const children = /* @__PURE__ */ new Map();
|
|
1946
|
+
const lines = (result.stdout ?? "").split("\n").filter((line) => line.trim().length > 0);
|
|
1947
|
+
for (const line of lines) {
|
|
1948
|
+
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
1949
|
+
if (!match) continue;
|
|
1950
|
+
const pid = Number.parseInt(match[1] ?? "", 10);
|
|
1951
|
+
const ppid = Number.parseInt(match[2] ?? "", 10);
|
|
1952
|
+
if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;
|
|
1953
|
+
const command = match[3] ?? "";
|
|
1954
|
+
byPid.set(pid, {
|
|
1955
|
+
pid,
|
|
1956
|
+
ppid,
|
|
1957
|
+
command
|
|
1958
|
+
});
|
|
1959
|
+
const list = children.get(ppid) ?? [];
|
|
1960
|
+
list.push(pid);
|
|
1961
|
+
children.set(ppid, list);
|
|
1962
|
+
}
|
|
1963
|
+
processSnapshotCache.at = nowMs;
|
|
1964
|
+
processSnapshotCache.byPid = byPid;
|
|
1965
|
+
processSnapshotCache.children = children;
|
|
1966
|
+
} catch {}
|
|
1967
|
+
return processSnapshotCache;
|
|
1968
|
+
};
|
|
1969
|
+
const findAgentFromPidTree = async (pid) => {
|
|
1970
|
+
if (!pid) return "unknown";
|
|
1971
|
+
const snapshot = await loadProcessSnapshot();
|
|
1972
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1973
|
+
const stack = [pid];
|
|
1974
|
+
while (stack.length > 0) {
|
|
1975
|
+
const current = stack.pop();
|
|
1976
|
+
if (!current || visited.has(current)) continue;
|
|
1977
|
+
visited.add(current);
|
|
1978
|
+
const entry = snapshot.byPid.get(current);
|
|
1979
|
+
if (entry) {
|
|
1980
|
+
const agent = buildAgent(entry.command);
|
|
1981
|
+
if (agent !== "unknown") return agent;
|
|
1982
|
+
}
|
|
1983
|
+
(snapshot.children.get(current) ?? []).forEach((child) => {
|
|
1984
|
+
if (!visited.has(child)) stack.push(child);
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
return "unknown";
|
|
1988
|
+
};
|
|
1989
|
+
const getAgentFromTty = async (tty) => {
|
|
1990
|
+
if (!tty) return "unknown";
|
|
1991
|
+
const normalized = normalizeTty(tty);
|
|
1992
|
+
const cached = ttyAgentCache.get(normalized);
|
|
1993
|
+
const nowMs = Date.now();
|
|
1994
|
+
if (cached && nowMs - cached.at < processCacheTtlMs) return cached.agent;
|
|
1995
|
+
try {
|
|
1996
|
+
const agent = buildAgent(((await execFileAsync("ps", [
|
|
1997
|
+
"-o",
|
|
1998
|
+
"command=",
|
|
1999
|
+
"-t",
|
|
2000
|
+
normalized
|
|
2001
|
+
], {
|
|
2002
|
+
encoding: "utf8",
|
|
2003
|
+
timeout: 1e3
|
|
2004
|
+
})).stdout ?? "").split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join(" "));
|
|
2005
|
+
ttyAgentCache.set(normalized, {
|
|
2006
|
+
agent,
|
|
2007
|
+
at: nowMs
|
|
2008
|
+
});
|
|
2009
|
+
return agent;
|
|
2010
|
+
} catch {
|
|
2011
|
+
return "unknown";
|
|
2012
|
+
}
|
|
2013
|
+
};
|
|
2014
|
+
const deriveHookState = (hookEventName, notificationType) => {
|
|
2015
|
+
if (hookEventName === "Notification" && notificationType === "permission_prompt") return {
|
|
2016
|
+
state: "WAITING_PERMISSION",
|
|
2017
|
+
reason: "hook:permission_prompt"
|
|
2018
|
+
};
|
|
2019
|
+
if (hookEventName === "Stop") return {
|
|
2020
|
+
state: "WAITING_INPUT",
|
|
2021
|
+
reason: "hook:stop"
|
|
2022
|
+
};
|
|
2023
|
+
if (hookEventName === "UserPromptSubmit" || hookEventName === "PreToolUse" || hookEventName === "PostToolUse") return {
|
|
2024
|
+
state: "RUNNING",
|
|
2025
|
+
reason: `hook:${hookEventName}`
|
|
2026
|
+
};
|
|
2027
|
+
return null;
|
|
2028
|
+
};
|
|
2029
|
+
const mapHookToPane = (panes, hook) => {
|
|
2030
|
+
if (hook.tmux_pane) return hook.tmux_pane;
|
|
2031
|
+
if (hook.tty) {
|
|
2032
|
+
const matches = panes.filter((pane) => pane.paneTty === hook.tty);
|
|
2033
|
+
if (matches.length === 1) return matches[0]?.paneId ?? null;
|
|
2034
|
+
return null;
|
|
2035
|
+
}
|
|
2036
|
+
if (hook.cwd) {
|
|
2037
|
+
const matches = panes.filter((pane) => pane.currentPath === hook.cwd);
|
|
2038
|
+
if (matches.length === 1) return matches[0]?.paneId ?? null;
|
|
2039
|
+
}
|
|
2040
|
+
return null;
|
|
2041
|
+
};
|
|
2042
|
+
const createSessionMonitor = (adapter, config) => {
|
|
2043
|
+
const inspector = createInspector(adapter);
|
|
2044
|
+
const pipeManager = createPipeManager(adapter);
|
|
2045
|
+
const screenCapture = createScreenCapture(adapter);
|
|
2046
|
+
const registry = createSessionRegistry();
|
|
2047
|
+
const hookStates = /* @__PURE__ */ new Map();
|
|
2048
|
+
const lastOutputAt = /* @__PURE__ */ new Map();
|
|
2049
|
+
const lastEventAt = /* @__PURE__ */ new Map();
|
|
2050
|
+
const lastMessage = /* @__PURE__ */ new Map();
|
|
2051
|
+
const lastFingerprint = /* @__PURE__ */ new Map();
|
|
2052
|
+
const restored = restoreSessions();
|
|
2053
|
+
const restoredReason = /* @__PURE__ */ new Set();
|
|
2054
|
+
const serverKey = resolveServerKey(config.tmux.socketName, config.tmux.socketPath);
|
|
2055
|
+
const eventsDir = path.join(baseDir, "events", serverKey);
|
|
2056
|
+
const eventLogPath = path.join(eventsDir, "claude.jsonl");
|
|
2057
|
+
const logActivity = createLogActivityPoller(config.activity.pollIntervalMs);
|
|
2058
|
+
const jsonlTailer = createJsonlTailer(config.activity.pollIntervalMs);
|
|
2059
|
+
let timer = null;
|
|
2060
|
+
restored.forEach((session, paneId) => {
|
|
2061
|
+
lastOutputAt.set(paneId, session.lastOutputAt ?? null);
|
|
2062
|
+
lastEventAt.set(paneId, session.lastEventAt ?? null);
|
|
2063
|
+
lastMessage.set(paneId, session.lastMessage ?? null);
|
|
2064
|
+
});
|
|
2065
|
+
const getPaneLogPath = (paneId) => {
|
|
2066
|
+
return resolveLogPaths(baseDir, serverKey, paneId).paneLogPath;
|
|
2067
|
+
};
|
|
2068
|
+
const ensureLogFiles = async (paneId) => {
|
|
2069
|
+
const { panesDir, paneLogPath } = resolveLogPaths(baseDir, serverKey, paneId);
|
|
2070
|
+
await ensureDir(panesDir);
|
|
2071
|
+
await fs$1.open(paneLogPath, "a").then((handle) => handle.close());
|
|
2072
|
+
};
|
|
2073
|
+
const applyRestored = (paneId) => {
|
|
2074
|
+
if (restored.has(paneId) && !restoredReason.has(paneId)) {
|
|
2075
|
+
restoredReason.add(paneId);
|
|
2076
|
+
return restored.get(paneId) ?? null;
|
|
2077
|
+
}
|
|
2078
|
+
return null;
|
|
2079
|
+
};
|
|
2080
|
+
const capturePaneFingerprint = async (paneId, useAlt) => {
|
|
2081
|
+
const args = [
|
|
2082
|
+
"capture-pane",
|
|
2083
|
+
"-p",
|
|
2084
|
+
"-t",
|
|
2085
|
+
paneId,
|
|
2086
|
+
"-S",
|
|
2087
|
+
"-5",
|
|
2088
|
+
"-E",
|
|
2089
|
+
"-1"
|
|
2090
|
+
];
|
|
2091
|
+
if (useAlt) args.push("-a");
|
|
2092
|
+
const result = await adapter.run(args);
|
|
2093
|
+
if (result.exitCode !== 0) return null;
|
|
2094
|
+
return normalizeFingerprint(result.stdout ?? "");
|
|
2095
|
+
};
|
|
2096
|
+
const updateFromPanes = async () => {
|
|
2097
|
+
const panes = await inspector.listPanes();
|
|
2098
|
+
const activePaneIds = /* @__PURE__ */ new Set();
|
|
2099
|
+
for (const pane of panes) {
|
|
2100
|
+
if (pane.pipeTagValue === null) pane.pipeTagValue = await inspector.readUserOption(pane.paneId, "@tmux-agent-monitor_pipe");
|
|
2101
|
+
let agent = buildAgent(mergeHints(pane.currentCommand, pane.paneStartCommand, pane.paneTitle));
|
|
2102
|
+
if (agent === "unknown") {
|
|
2103
|
+
const processCommand = await getProcessCommand(pane.panePid);
|
|
2104
|
+
if (processCommand) agent = buildAgent(processCommand);
|
|
2105
|
+
}
|
|
2106
|
+
if (agent === "unknown") agent = await findAgentFromPidTree(pane.panePid);
|
|
2107
|
+
if (agent === "unknown") agent = await getAgentFromTty(pane.paneTty);
|
|
2108
|
+
const monitored = agent !== "unknown";
|
|
2109
|
+
if (!monitored) continue;
|
|
2110
|
+
activePaneIds.add(pane.paneId);
|
|
2111
|
+
const pipeState = {
|
|
2112
|
+
panePipe: pane.panePipe,
|
|
2113
|
+
pipeTagValue: pane.pipeTagValue
|
|
2114
|
+
};
|
|
2115
|
+
let pipeAttached = pane.pipeTagValue === "1";
|
|
2116
|
+
let pipeConflict = pipeManager.hasConflict(pipeState);
|
|
2117
|
+
if (config.attachOnServe && monitored && !pipeConflict) {
|
|
2118
|
+
await ensureLogFiles(pane.paneId);
|
|
2119
|
+
const attachResult = await pipeManager.attachPipe(pane.paneId, getPaneLogPath(pane.paneId), pipeState);
|
|
2120
|
+
pipeAttached = pipeAttached || attachResult.attached;
|
|
2121
|
+
pipeConflict = attachResult.conflict;
|
|
2122
|
+
}
|
|
2123
|
+
if (config.attachOnServe && monitored) logActivity.register(pane.paneId, getPaneLogPath(pane.paneId));
|
|
2124
|
+
await rotateLogIfNeeded(getPaneLogPath(pane.paneId), config.logs.maxPaneLogBytes, config.logs.retainRotations);
|
|
2125
|
+
const hookState = hookStates.get(pane.paneId) ?? null;
|
|
2126
|
+
let outputAt = lastOutputAt.get(pane.paneId) ?? null;
|
|
2127
|
+
const updateOutputAt = (next) => {
|
|
2128
|
+
if (!next) return;
|
|
2129
|
+
const nextTs = Date.parse(next);
|
|
2130
|
+
if (Number.isNaN(nextTs)) return;
|
|
2131
|
+
const prevTs = outputAt ? Date.parse(outputAt) : null;
|
|
2132
|
+
if (!prevTs || Number.isNaN(prevTs) || nextTs > prevTs) {
|
|
2133
|
+
outputAt = new Date(nextTs).toISOString();
|
|
2134
|
+
lastOutputAt.set(pane.paneId, outputAt);
|
|
2135
|
+
}
|
|
2136
|
+
};
|
|
2137
|
+
const logPath = getPaneLogPath(pane.paneId);
|
|
2138
|
+
const stat = await fs$1.stat(logPath).catch(() => null);
|
|
2139
|
+
if (stat && stat.size > 0) updateOutputAt(stat.mtime.toISOString());
|
|
2140
|
+
const windowActivityAt = toIsoFromEpochSeconds(pane.windowActivity);
|
|
2141
|
+
if (windowActivityAt && !shouldSuppressActivity(pane.paneId, windowActivityAt)) updateOutputAt(windowActivityAt);
|
|
2142
|
+
if (agent === "codex" && !pane.paneDead) {
|
|
2143
|
+
const fingerprint = await capturePaneFingerprint(pane.paneId, pane.alternateOn);
|
|
2144
|
+
if (fingerprint) {
|
|
2145
|
+
if (lastFingerprint.get(pane.paneId) !== fingerprint) {
|
|
2146
|
+
lastFingerprint.set(pane.paneId, fingerprint);
|
|
2147
|
+
updateOutputAt((/* @__PURE__ */ new Date()).toISOString());
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
if (!outputAt) updateOutputAt((/* @__PURE__ */ new Date(Date.now() - config.activity.inactiveThresholdMs - 1e3)).toISOString());
|
|
2152
|
+
const eventAt = lastEventAt.get(pane.paneId) ?? null;
|
|
2153
|
+
const message = lastMessage.get(pane.paneId) ?? null;
|
|
2154
|
+
const restoredSession = applyRestored(pane.paneId);
|
|
2155
|
+
const estimated = estimateState({
|
|
2156
|
+
paneDead: pane.paneDead,
|
|
2157
|
+
lastOutputAt: outputAt,
|
|
2158
|
+
hookState,
|
|
2159
|
+
thresholds: {
|
|
2160
|
+
runningThresholdMs: agent === "codex" ? Math.min(config.activity.runningThresholdMs, 1e4) : config.activity.runningThresholdMs,
|
|
2161
|
+
inactiveThresholdMs: config.activity.inactiveThresholdMs
|
|
2162
|
+
}
|
|
2163
|
+
});
|
|
2164
|
+
const finalState = restoredSession ? restoredSession.state : estimated.state;
|
|
2165
|
+
const finalReason = restoredSession ? "restored" : estimated.reason;
|
|
2166
|
+
const paneTitle = normalizeTitle(pane.paneTitle);
|
|
2167
|
+
const defaultTitle = buildDefaultTitle(pane.currentPath, pane.paneId, pane.sessionName);
|
|
2168
|
+
const title = paneTitle && !hostCandidates.has(paneTitle) ? paneTitle : defaultTitle;
|
|
2169
|
+
const detail = {
|
|
2170
|
+
paneId: pane.paneId,
|
|
2171
|
+
sessionName: pane.sessionName,
|
|
2172
|
+
windowIndex: pane.windowIndex,
|
|
2173
|
+
paneIndex: pane.paneIndex,
|
|
2174
|
+
windowActivity: pane.windowActivity,
|
|
2175
|
+
paneActive: pane.paneActive,
|
|
2176
|
+
currentCommand: pane.currentCommand,
|
|
2177
|
+
currentPath: pane.currentPath,
|
|
2178
|
+
paneTty: pane.paneTty,
|
|
2179
|
+
title,
|
|
2180
|
+
agent,
|
|
2181
|
+
state: finalState,
|
|
2182
|
+
stateReason: finalReason,
|
|
2183
|
+
lastMessage: message,
|
|
2184
|
+
lastOutputAt: outputAt,
|
|
2185
|
+
lastEventAt: eventAt,
|
|
2186
|
+
paneDead: pane.paneDead,
|
|
2187
|
+
alternateOn: pane.alternateOn,
|
|
2188
|
+
pipeAttached,
|
|
2189
|
+
pipeConflict,
|
|
2190
|
+
startCommand: pane.paneStartCommand,
|
|
2191
|
+
panePid: pane.panePid
|
|
2192
|
+
};
|
|
2193
|
+
registry.update(detail);
|
|
2194
|
+
}
|
|
2195
|
+
registry.removeMissing(activePaneIds);
|
|
2196
|
+
lastOutputAt.forEach((_, paneId) => {
|
|
2197
|
+
if (!activePaneIds.has(paneId)) {
|
|
2198
|
+
lastOutputAt.delete(paneId);
|
|
2199
|
+
lastEventAt.delete(paneId);
|
|
2200
|
+
lastMessage.delete(paneId);
|
|
2201
|
+
lastFingerprint.delete(paneId);
|
|
2202
|
+
hookStates.delete(paneId);
|
|
2203
|
+
}
|
|
2204
|
+
});
|
|
2205
|
+
saveState(registry.values());
|
|
2206
|
+
};
|
|
2207
|
+
const handleHookEvent = (context) => {
|
|
2208
|
+
hookStates.set(context.paneId, context.hookState);
|
|
2209
|
+
lastEventAt.set(context.paneId, context.hookState.at);
|
|
2210
|
+
};
|
|
2211
|
+
const startHookTailer = async () => {
|
|
2212
|
+
await ensureDir(eventsDir);
|
|
2213
|
+
await fs$1.open(eventLogPath, "a").then((handle) => handle.close());
|
|
2214
|
+
jsonlTailer.onLine((line) => {
|
|
2215
|
+
const parsed = claudeHookEventSchema.safeParse(JSON.parse(line));
|
|
2216
|
+
if (!parsed.success) return;
|
|
2217
|
+
const event = parsed.data;
|
|
2218
|
+
const hookState = deriveHookState(event.hook_event_name, event.notification_type);
|
|
2219
|
+
if (!hookState) return;
|
|
2220
|
+
const paneId = mapHookToPane(registry.values(), {
|
|
2221
|
+
tmux_pane: event.tmux_pane ?? null,
|
|
2222
|
+
tty: event.tty,
|
|
2223
|
+
cwd: event.cwd
|
|
2224
|
+
});
|
|
2225
|
+
if (!paneId) return;
|
|
2226
|
+
handleHookEvent({
|
|
2227
|
+
paneId,
|
|
2228
|
+
hookState: {
|
|
2229
|
+
...hookState,
|
|
2230
|
+
at: event.ts
|
|
2231
|
+
}
|
|
2232
|
+
});
|
|
2233
|
+
});
|
|
2234
|
+
jsonlTailer.start(eventLogPath);
|
|
2235
|
+
};
|
|
2236
|
+
const start = async () => {
|
|
2237
|
+
logActivity.onActivity((paneId, at) => {
|
|
2238
|
+
lastOutputAt.set(paneId, at);
|
|
2239
|
+
});
|
|
2240
|
+
logActivity.start();
|
|
2241
|
+
await startHookTailer();
|
|
2242
|
+
timer = setInterval(() => {
|
|
2243
|
+
updateFromPanes().catch(() => null);
|
|
2244
|
+
rotateLogIfNeeded(eventLogPath, config.logs.maxEventLogBytes, config.logs.retainRotations).catch(() => null);
|
|
2245
|
+
}, config.activity.pollIntervalMs);
|
|
2246
|
+
await updateFromPanes();
|
|
2247
|
+
};
|
|
2248
|
+
const stop = () => {
|
|
2249
|
+
if (timer) {
|
|
2250
|
+
clearInterval(timer);
|
|
2251
|
+
timer = null;
|
|
2252
|
+
}
|
|
2253
|
+
logActivity.stop();
|
|
2254
|
+
jsonlTailer.stop();
|
|
2255
|
+
};
|
|
2256
|
+
const getScreenCapture = () => screenCapture;
|
|
2257
|
+
return {
|
|
2258
|
+
registry,
|
|
2259
|
+
start,
|
|
2260
|
+
stop,
|
|
2261
|
+
handleHookEvent,
|
|
2262
|
+
getScreenCapture
|
|
2263
|
+
};
|
|
2264
|
+
};
|
|
2265
|
+
|
|
2266
|
+
//#endregion
|
|
2267
|
+
//#region apps/server/src/network.ts
|
|
2268
|
+
const isValidOctets = (parts) => {
|
|
2269
|
+
return parts.every((value) => !Number.isNaN(value) && value >= 0 && value <= 255);
|
|
2270
|
+
};
|
|
2271
|
+
const isPrivateIP = (address) => {
|
|
2272
|
+
const parts = address.split(".").map(Number);
|
|
2273
|
+
if (parts.length !== 4 || !isValidOctets(parts)) return false;
|
|
2274
|
+
const [first, second] = parts;
|
|
2275
|
+
if (first === void 0 || second === void 0) return false;
|
|
2276
|
+
if (first === 10) return true;
|
|
2277
|
+
if (first === 172 && second >= 16 && second <= 31) return true;
|
|
2278
|
+
return first === 192 && second === 168;
|
|
2279
|
+
};
|
|
2280
|
+
const isTailscaleIP = (address) => {
|
|
2281
|
+
const parts = address.split(".").map(Number);
|
|
2282
|
+
if (parts.length !== 4 || !isValidOctets(parts)) return false;
|
|
2283
|
+
const [first, second] = parts;
|
|
2284
|
+
if (first === void 0 || second === void 0) return false;
|
|
2285
|
+
return first === 100 && second >= 64 && second <= 127;
|
|
2286
|
+
};
|
|
2287
|
+
const getTailscaleFromCLI = () => {
|
|
2288
|
+
for (const bin of ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]) try {
|
|
2289
|
+
const ip = execFileSync(bin, ["ip", "-4"], {
|
|
2290
|
+
encoding: "utf8",
|
|
2291
|
+
timeout: 2e3,
|
|
2292
|
+
stdio: [
|
|
2293
|
+
"pipe",
|
|
2294
|
+
"pipe",
|
|
2295
|
+
"ignore"
|
|
2296
|
+
]
|
|
2297
|
+
}).trim();
|
|
2298
|
+
if (ip && isTailscaleIP(ip)) return ip;
|
|
2299
|
+
} catch {}
|
|
2300
|
+
return null;
|
|
2301
|
+
};
|
|
2302
|
+
const getTailscaleFromInterfaces = () => {
|
|
2303
|
+
const interfaces = networkInterfaces();
|
|
2304
|
+
return Object.values(interfaces).flat().filter((info) => Boolean(info)).find((info) => info.family === "IPv4" && isTailscaleIP(info.address))?.address ?? null;
|
|
2305
|
+
};
|
|
2306
|
+
const getTailscaleIP = () => {
|
|
2307
|
+
return getTailscaleFromCLI() ?? getTailscaleFromInterfaces();
|
|
2308
|
+
};
|
|
2309
|
+
const getLocalIP = () => {
|
|
2310
|
+
const interfaces = networkInterfaces();
|
|
2311
|
+
const candidates = Object.values(interfaces).flat().filter((info) => Boolean(info)).filter((info) => info.family === "IPv4" && !info.internal && !isTailscaleIP(info.address));
|
|
2312
|
+
const privateMatch = candidates.find((info) => isPrivateIP(info.address));
|
|
2313
|
+
if (privateMatch) return privateMatch.address;
|
|
2314
|
+
return candidates[0]?.address ?? "localhost";
|
|
2315
|
+
};
|
|
2316
|
+
|
|
2317
|
+
//#endregion
|
|
2318
|
+
//#region apps/server/src/ports.ts
|
|
2319
|
+
const isPortAvailable = (port, host) => new Promise((resolve) => {
|
|
2320
|
+
const server = createServer();
|
|
2321
|
+
server.once("error", () => {
|
|
2322
|
+
server.close();
|
|
2323
|
+
resolve(false);
|
|
2324
|
+
});
|
|
2325
|
+
server.once("listening", () => {
|
|
2326
|
+
server.close(() => resolve(true));
|
|
2327
|
+
});
|
|
2328
|
+
server.listen(port, host);
|
|
2329
|
+
});
|
|
2330
|
+
const findAvailablePort = async (startPort, host, attempts) => {
|
|
2331
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
2332
|
+
const port = startPort + i;
|
|
2333
|
+
if (await isPortAvailable(port, host)) return port;
|
|
2334
|
+
}
|
|
2335
|
+
throw new Error(`No available port found in range ${startPort}-${startPort + attempts - 1}`);
|
|
2336
|
+
};
|
|
2337
|
+
|
|
2338
|
+
//#endregion
|
|
2339
|
+
//#region apps/server/src/tmux-actions.ts
|
|
2340
|
+
const buildError = (code, message) => ({
|
|
2341
|
+
code,
|
|
2342
|
+
message
|
|
2343
|
+
});
|
|
2344
|
+
const createTmuxActions = (adapter, config) => {
|
|
2345
|
+
const dangerPatterns = compileDangerPatterns(config.dangerCommandPatterns);
|
|
2346
|
+
const enterKey = config.input.enterKey || "C-m";
|
|
2347
|
+
const enterDelayMs = config.input.enterDelayMs ?? 0;
|
|
2348
|
+
const bracketedPaste = (value) => `\u001b[200~${value}\u001b[201~`;
|
|
2349
|
+
const sendText = async (paneId, text, enter = true) => {
|
|
2350
|
+
if (!text || text.trim().length === 0) return {
|
|
2351
|
+
ok: false,
|
|
2352
|
+
error: buildError("INVALID_PAYLOAD", "text is required")
|
|
2353
|
+
};
|
|
2354
|
+
if (text.length > config.input.maxTextLength) return {
|
|
2355
|
+
ok: false,
|
|
2356
|
+
error: buildError("INVALID_PAYLOAD", "text too long")
|
|
2357
|
+
};
|
|
2358
|
+
if (isDangerousCommand(text, dangerPatterns)) return {
|
|
2359
|
+
ok: false,
|
|
2360
|
+
error: buildError("DANGEROUS_COMMAND", "dangerous command blocked")
|
|
2361
|
+
};
|
|
2362
|
+
await adapter.run([
|
|
2363
|
+
"if-shell",
|
|
2364
|
+
"-t",
|
|
2365
|
+
paneId,
|
|
2366
|
+
"[ \"#{pane_in_mode}\" = \"1\" ]",
|
|
2367
|
+
`copy-mode -q -t ${paneId}`
|
|
2368
|
+
]);
|
|
2369
|
+
const normalized = text.replace(/\r\n/g, "\n");
|
|
2370
|
+
if (normalized.includes("\n")) {
|
|
2371
|
+
const result = await adapter.run([
|
|
2372
|
+
"send-keys",
|
|
2373
|
+
"-l",
|
|
2374
|
+
"-t",
|
|
2375
|
+
paneId,
|
|
2376
|
+
bracketedPaste(normalized)
|
|
2377
|
+
]);
|
|
2378
|
+
if (result.exitCode !== 0) return {
|
|
2379
|
+
ok: false,
|
|
2380
|
+
error: buildError("INTERNAL", result.stderr || "send-keys failed")
|
|
2381
|
+
};
|
|
2382
|
+
if (enter) {
|
|
2383
|
+
if (enterDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, enterDelayMs));
|
|
2384
|
+
const enterResult = await adapter.run([
|
|
2385
|
+
"send-keys",
|
|
2386
|
+
"-t",
|
|
2387
|
+
paneId,
|
|
2388
|
+
enterKey
|
|
2389
|
+
]);
|
|
2390
|
+
if (enterResult.exitCode !== 0) return {
|
|
2391
|
+
ok: false,
|
|
2392
|
+
error: buildError("INTERNAL", enterResult.stderr || "send-keys Enter failed")
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
return { ok: true };
|
|
2396
|
+
}
|
|
2397
|
+
const result = await adapter.run([
|
|
2398
|
+
"send-keys",
|
|
2399
|
+
"-l",
|
|
2400
|
+
"-t",
|
|
2401
|
+
paneId,
|
|
2402
|
+
normalized
|
|
2403
|
+
]);
|
|
2404
|
+
if (result.exitCode !== 0) return {
|
|
2405
|
+
ok: false,
|
|
2406
|
+
error: buildError("INTERNAL", result.stderr || "send-keys failed")
|
|
2407
|
+
};
|
|
2408
|
+
if (enter) {
|
|
2409
|
+
if (enterDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, enterDelayMs));
|
|
2410
|
+
const enterResult = await adapter.run([
|
|
2411
|
+
"send-keys",
|
|
2412
|
+
"-t",
|
|
2413
|
+
paneId,
|
|
2414
|
+
enterKey
|
|
2415
|
+
]);
|
|
2416
|
+
if (enterResult.exitCode !== 0) return {
|
|
2417
|
+
ok: false,
|
|
2418
|
+
error: buildError("INTERNAL", enterResult.stderr || "send-keys Enter failed")
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
return { ok: true };
|
|
2422
|
+
};
|
|
2423
|
+
const sendKeys = async (paneId, keys) => {
|
|
2424
|
+
const allowed = new Set(allowedKeys);
|
|
2425
|
+
if (keys.length === 0 || keys.some((key) => !allowed.has(key))) return {
|
|
2426
|
+
ok: false,
|
|
2427
|
+
error: buildError("INVALID_PAYLOAD", "invalid keys")
|
|
2428
|
+
};
|
|
2429
|
+
for (const key of keys) {
|
|
2430
|
+
const result = await adapter.run([
|
|
2431
|
+
"send-keys",
|
|
2432
|
+
"-t",
|
|
2433
|
+
paneId,
|
|
2434
|
+
key
|
|
2435
|
+
]);
|
|
2436
|
+
if (result.exitCode !== 0) return {
|
|
2437
|
+
ok: false,
|
|
2438
|
+
error: buildError("INTERNAL", result.stderr || "send-keys failed")
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
return { ok: true };
|
|
2442
|
+
};
|
|
2443
|
+
return {
|
|
2444
|
+
sendText,
|
|
2445
|
+
sendKeys
|
|
2446
|
+
};
|
|
2447
|
+
};
|
|
2448
|
+
|
|
2449
|
+
//#endregion
|
|
2450
|
+
//#region apps/server/src/index.ts
|
|
2451
|
+
const parseArgs = () => {
|
|
2452
|
+
const args = process.argv.slice(2);
|
|
2453
|
+
const flags = /* @__PURE__ */ new Map();
|
|
2454
|
+
let command = null;
|
|
2455
|
+
const positional = [];
|
|
2456
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
2457
|
+
const arg = args[i];
|
|
2458
|
+
if (!arg) continue;
|
|
2459
|
+
if (arg.startsWith("--")) {
|
|
2460
|
+
const next = args[i + 1];
|
|
2461
|
+
if (next && !next.startsWith("--")) {
|
|
2462
|
+
flags.set(arg, next);
|
|
2463
|
+
i += 1;
|
|
2464
|
+
} else flags.set(arg, true);
|
|
2465
|
+
} else if (!command) command = arg;
|
|
2466
|
+
else positional.push(arg);
|
|
2467
|
+
}
|
|
2468
|
+
return {
|
|
2469
|
+
command,
|
|
2470
|
+
flags,
|
|
2471
|
+
positional
|
|
2472
|
+
};
|
|
2473
|
+
};
|
|
2474
|
+
const printHooksSnippet = () => {
|
|
2475
|
+
console.log(JSON.stringify({ hooks: {
|
|
2476
|
+
PreToolUse: [{
|
|
2477
|
+
matcher: "*",
|
|
2478
|
+
hooks: [{
|
|
2479
|
+
type: "command",
|
|
2480
|
+
command: "tmux-agent-monitor-hook PreToolUse"
|
|
2481
|
+
}]
|
|
2482
|
+
}],
|
|
2483
|
+
PostToolUse: [{
|
|
2484
|
+
matcher: "*",
|
|
2485
|
+
hooks: [{
|
|
2486
|
+
type: "command",
|
|
2487
|
+
command: "tmux-agent-monitor-hook PostToolUse"
|
|
2488
|
+
}]
|
|
2489
|
+
}],
|
|
2490
|
+
Notification: [{ hooks: [{
|
|
2491
|
+
type: "command",
|
|
2492
|
+
command: "tmux-agent-monitor-hook Notification"
|
|
2493
|
+
}] }],
|
|
2494
|
+
Stop: [{ hooks: [{
|
|
2495
|
+
type: "command",
|
|
2496
|
+
command: "tmux-agent-monitor-hook Stop"
|
|
2497
|
+
}] }],
|
|
2498
|
+
UserPromptSubmit: [{ hooks: [{
|
|
2499
|
+
type: "command",
|
|
2500
|
+
command: "tmux-agent-monitor-hook UserPromptSubmit"
|
|
2501
|
+
}] }]
|
|
2502
|
+
} }, null, 2));
|
|
2503
|
+
};
|
|
2504
|
+
const ensureTmuxAvailable = async (adapter) => {
|
|
2505
|
+
if ((await adapter.run(["-V"])).exitCode !== 0) throw new Error("tmux not available");
|
|
2506
|
+
if ((await adapter.run(["list-sessions"])).exitCode !== 0) throw new Error("tmux server not running");
|
|
2507
|
+
};
|
|
2508
|
+
const parsePort = (value) => {
|
|
2509
|
+
if (typeof value !== "string") return null;
|
|
2510
|
+
const parsed = Number.parseInt(value, 10);
|
|
2511
|
+
if (Number.isNaN(parsed) || parsed <= 0) return null;
|
|
2512
|
+
return parsed;
|
|
2513
|
+
};
|
|
2514
|
+
const runServe = async (flags) => {
|
|
2515
|
+
const config = ensureConfig();
|
|
2516
|
+
const publicBind = flags.has("--public");
|
|
2517
|
+
const tailscale = flags.has("--tailscale");
|
|
2518
|
+
const noAttach = flags.has("--no-attach");
|
|
2519
|
+
const portFlag = flags.get("--port");
|
|
2520
|
+
const webPortFlag = flags.get("--web-port");
|
|
2521
|
+
const socketName = flags.get("--socket-name");
|
|
2522
|
+
const socketPath = flags.get("--socket-path");
|
|
2523
|
+
config.bind = publicBind ? "0.0.0.0" : config.bind;
|
|
2524
|
+
config.attachOnServe = !noAttach;
|
|
2525
|
+
const parsedPort = parsePort(portFlag);
|
|
2526
|
+
if (parsedPort) config.port = parsedPort;
|
|
2527
|
+
if (typeof socketName === "string") config.tmux.socketName = socketName;
|
|
2528
|
+
if (typeof socketPath === "string") config.tmux.socketPath = socketPath;
|
|
2529
|
+
const host = config.bind;
|
|
2530
|
+
const port = await findAvailablePort(config.port, host, 10);
|
|
2531
|
+
const adapter = createTmuxAdapter({
|
|
2532
|
+
socketName: config.tmux.socketName,
|
|
2533
|
+
socketPath: config.tmux.socketPath
|
|
2534
|
+
});
|
|
2535
|
+
await ensureTmuxAvailable(adapter);
|
|
2536
|
+
const monitor = createSessionMonitor(adapter, config);
|
|
2537
|
+
await monitor.start();
|
|
2538
|
+
const { app, injectWebSocket } = createApp({
|
|
2539
|
+
config,
|
|
2540
|
+
monitor,
|
|
2541
|
+
tmuxActions: createTmuxActions(adapter, config)
|
|
2542
|
+
});
|
|
2543
|
+
injectWebSocket(serve({
|
|
2544
|
+
fetch: app.fetch,
|
|
2545
|
+
port,
|
|
2546
|
+
hostname: host
|
|
2547
|
+
}));
|
|
2548
|
+
const url = `http://${tailscale ? getTailscaleIP() ?? getLocalIP() : host === "0.0.0.0" ? getLocalIP() : "localhost"}:${parsePort(webPortFlag) ?? port}/?token=${config.token}`;
|
|
2549
|
+
console.log(`tmux-agent-monitor: ${url}`);
|
|
2550
|
+
qrcode.generate(url, { small: true });
|
|
2551
|
+
process.on("SIGINT", () => {
|
|
2552
|
+
monitor.stop();
|
|
2553
|
+
process.exit(0);
|
|
2554
|
+
});
|
|
2555
|
+
};
|
|
2556
|
+
const main = async () => {
|
|
2557
|
+
const { command, positional, flags } = parseArgs();
|
|
2558
|
+
if (command === "token" && positional[0] === "rotate") {
|
|
2559
|
+
const next = rotateToken();
|
|
2560
|
+
console.log(next.token);
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2563
|
+
if (command === "claude" && positional[0] === "hooks" && positional[1] === "print") {
|
|
2564
|
+
printHooksSnippet();
|
|
2565
|
+
return;
|
|
2566
|
+
}
|
|
2567
|
+
await runServe(flags);
|
|
2568
|
+
};
|
|
2569
|
+
main().catch((error) => {
|
|
2570
|
+
console.error(error instanceof Error ? error.message : error);
|
|
2571
|
+
process.exit(1);
|
|
2572
|
+
});
|
|
2573
|
+
|
|
2574
|
+
//#endregion
|
|
2575
|
+
export { };
|