multi-project-gateway 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/dist/cli.js +1292 -291
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { resolve as
|
|
5
|
-
import { existsSync as
|
|
4
|
+
import { resolve as resolve5 } from "path";
|
|
5
|
+
import { existsSync as existsSync9, readFileSync as readFileSync6, copyFileSync, mkdirSync as mkdirSync8 } from "fs";
|
|
6
6
|
import { config as loadEnv } from "dotenv";
|
|
7
7
|
|
|
8
8
|
// src/persona-presets.ts
|
|
@@ -247,7 +247,10 @@ ${extra}` : basePrompt;
|
|
|
247
247
|
...projectDisallowed && { disallowedTools: projectDisallowed },
|
|
248
248
|
...agents && { agents },
|
|
249
249
|
...allowedRoles && allowedRoles.length > 0 && { allowedRoles },
|
|
250
|
-
...rateLimitPerUser !== void 0 && { rateLimitPerUser }
|
|
250
|
+
...rateLimitPerUser !== void 0 && { rateLimitPerUser },
|
|
251
|
+
...typeof p.maxAttachmentSizeMb === "number" && { maxAttachmentSizeMb: p.maxAttachmentSizeMb },
|
|
252
|
+
...Array.isArray(p.allowedMimeTypes) && { allowedMimeTypes: p.allowedMimeTypes },
|
|
253
|
+
...typeof p.maxAttachmentsPerMessage === "number" && { maxAttachmentsPerMessage: p.maxAttachmentsPerMessage }
|
|
251
254
|
};
|
|
252
255
|
}
|
|
253
256
|
const defaults = obj.defaults ?? {};
|
|
@@ -267,8 +270,13 @@ ${extra}` : basePrompt;
|
|
|
267
270
|
disallowedTools: defaultDisallowed,
|
|
268
271
|
maxTurnsPerAgent: typeof defaults.maxTurnsPerAgent === "number" ? defaults.maxTurnsPerAgent : 5,
|
|
269
272
|
agentTimeoutMs: typeof defaults.agentTimeoutMs === "number" ? defaults.agentTimeoutMs : 3 * 60 * 1e3,
|
|
273
|
+
stuckNotifyMs: typeof defaults.stuckNotifyMs === "number" ? defaults.stuckNotifyMs : 3e5,
|
|
270
274
|
httpPort: defaults.httpPort === false ? false : typeof defaults.httpPort === "number" ? defaults.httpPort : 3100,
|
|
271
|
-
logLevel: isValidLogLevel(defaults.logLevel) ? defaults.logLevel : "info"
|
|
275
|
+
logLevel: isValidLogLevel(defaults.logLevel) ? defaults.logLevel : "info",
|
|
276
|
+
maxAttachmentSizeMb: typeof defaults.maxAttachmentSizeMb === "number" ? defaults.maxAttachmentSizeMb : 10,
|
|
277
|
+
allowedMimeTypes: Array.isArray(defaults.allowedMimeTypes) ? defaults.allowedMimeTypes : ["image/*", "text/*", "application/pdf", "application/json"],
|
|
278
|
+
maxAttachmentsPerMessage: typeof defaults.maxAttachmentsPerMessage === "number" ? defaults.maxAttachmentsPerMessage : 5,
|
|
279
|
+
persistence: defaults.persistence === "tmux" ? "tmux" : "direct"
|
|
272
280
|
},
|
|
273
281
|
projects: validated
|
|
274
282
|
};
|
|
@@ -293,119 +301,8 @@ function createRouter(config) {
|
|
|
293
301
|
};
|
|
294
302
|
}
|
|
295
303
|
|
|
296
|
-
// src/
|
|
297
|
-
import {
|
|
298
|
-
function parseClaudeJsonOutput(raw) {
|
|
299
|
-
const data = JSON.parse(raw);
|
|
300
|
-
let usage;
|
|
301
|
-
if (data.total_cost_usd != null || data.usage) {
|
|
302
|
-
const model = data.model ?? (data.modelUsage ? Object.keys(data.modelUsage)[0] : void 0);
|
|
303
|
-
usage = {
|
|
304
|
-
input_tokens: data.usage?.input_tokens ?? 0,
|
|
305
|
-
output_tokens: data.usage?.output_tokens ?? 0,
|
|
306
|
-
cache_creation_input_tokens: data.usage?.cache_creation_input_tokens ?? 0,
|
|
307
|
-
cache_read_input_tokens: data.usage?.cache_read_input_tokens ?? 0,
|
|
308
|
-
total_cost_usd: data.total_cost_usd ?? 0,
|
|
309
|
-
duration_ms: data.duration_ms ?? 0,
|
|
310
|
-
duration_api_ms: data.duration_api_ms ?? 0,
|
|
311
|
-
num_turns: data.num_turns ?? 0,
|
|
312
|
-
model
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
return {
|
|
316
|
-
text: data.result ?? "",
|
|
317
|
-
sessionId: data.session_id ?? "",
|
|
318
|
-
isError: Boolean(data.is_error),
|
|
319
|
-
usage
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
function buildToolArgs(defaults, projectOverrides, existingArgs) {
|
|
323
|
-
if (existingArgs?.includes("--allowed-tools") || existingArgs?.includes("--disallowed-tools")) {
|
|
324
|
-
return [];
|
|
325
|
-
}
|
|
326
|
-
const allowed = projectOverrides?.allowedTools ?? defaults.allowedTools;
|
|
327
|
-
const disallowed = projectOverrides?.disallowedTools ?? defaults.disallowedTools;
|
|
328
|
-
const args2 = [];
|
|
329
|
-
if (allowed && allowed.length > 0) {
|
|
330
|
-
args2.push("--allowed-tools", ...allowed);
|
|
331
|
-
} else if (disallowed && disallowed.length > 0) {
|
|
332
|
-
args2.push("--disallowed-tools", ...disallowed);
|
|
333
|
-
}
|
|
334
|
-
return args2;
|
|
335
|
-
}
|
|
336
|
-
function buildClaudeArgs(baseArgs, prompt, sessionId, systemPrompt) {
|
|
337
|
-
const args2 = ["--print", prompt, ...baseArgs];
|
|
338
|
-
if (sessionId) {
|
|
339
|
-
args2.push("--resume", sessionId);
|
|
340
|
-
}
|
|
341
|
-
if (systemPrompt) {
|
|
342
|
-
args2.push("--append-system-prompt", systemPrompt);
|
|
343
|
-
}
|
|
344
|
-
return args2;
|
|
345
|
-
}
|
|
346
|
-
function friendlyError(stderr) {
|
|
347
|
-
const combined = stderr.toLowerCase();
|
|
348
|
-
if (combined.includes("rate limit") || combined.includes("rate_limit_error")) {
|
|
349
|
-
return "Claude usage limit reached \u2014 please wait a few minutes and try again.";
|
|
350
|
-
}
|
|
351
|
-
if (combined.includes("overloaded") || combined.includes("overloaded_error")) {
|
|
352
|
-
return "Claude API is temporarily overloaded \u2014 please try again shortly.";
|
|
353
|
-
}
|
|
354
|
-
if (combined.includes("invalid api key") || combined.includes("authentication_error") || combined.includes("authentication failed")) {
|
|
355
|
-
return "Claude authentication failed \u2014 check your API key or CLI login.";
|
|
356
|
-
}
|
|
357
|
-
if (combined.includes("no messages returned")) {
|
|
358
|
-
return "Claude returned an empty response \u2014 try sending your message again.";
|
|
359
|
-
}
|
|
360
|
-
return `Claude error: ${stderr.slice(0, 500)}`;
|
|
361
|
-
}
|
|
362
|
-
var DEFAULT_TIMEOUT_MS = 20 * 60 * 1e3;
|
|
363
|
-
function runClaude(cwd, baseArgs, prompt, sessionId, systemPrompt, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
364
|
-
return new Promise((resolve5, reject) => {
|
|
365
|
-
const args2 = buildClaudeArgs(baseArgs, prompt, sessionId, systemPrompt);
|
|
366
|
-
const proc = spawn("claude", args2, {
|
|
367
|
-
cwd,
|
|
368
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
369
|
-
});
|
|
370
|
-
let stdout = "";
|
|
371
|
-
let stderr = "";
|
|
372
|
-
let settled = false;
|
|
373
|
-
const timer = setTimeout(() => {
|
|
374
|
-
if (!settled) {
|
|
375
|
-
settled = true;
|
|
376
|
-
proc.kill("SIGTERM");
|
|
377
|
-
reject(new Error(`Claude CLI timed out after ${timeoutMs / 1e3}s`));
|
|
378
|
-
}
|
|
379
|
-
}, timeoutMs);
|
|
380
|
-
proc.stdout.on("data", (chunk) => {
|
|
381
|
-
stdout += chunk.toString();
|
|
382
|
-
});
|
|
383
|
-
proc.stderr.on("data", (chunk) => {
|
|
384
|
-
stderr += chunk.toString();
|
|
385
|
-
});
|
|
386
|
-
proc.on("close", (code) => {
|
|
387
|
-
clearTimeout(timer);
|
|
388
|
-
if (settled) return;
|
|
389
|
-
settled = true;
|
|
390
|
-
if (code !== 0) {
|
|
391
|
-
reject(new Error(friendlyError(stderr)));
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
try {
|
|
395
|
-
const result = parseClaudeJsonOutput(stdout.trim());
|
|
396
|
-
resolve5(result);
|
|
397
|
-
} catch (err) {
|
|
398
|
-
reject(new Error(`Failed to parse claude output: ${stdout.slice(0, 200)}`));
|
|
399
|
-
}
|
|
400
|
-
});
|
|
401
|
-
proc.on("error", (err) => {
|
|
402
|
-
clearTimeout(timer);
|
|
403
|
-
if (settled) return;
|
|
404
|
-
settled = true;
|
|
405
|
-
reject(new Error(`Failed to spawn claude: ${err.message}`));
|
|
406
|
-
});
|
|
407
|
-
});
|
|
408
|
-
}
|
|
304
|
+
// src/session-manager.ts
|
|
305
|
+
import { existsSync as existsSync2 } from "fs";
|
|
409
306
|
|
|
410
307
|
// src/worktree.ts
|
|
411
308
|
import { execFileSync } from "child_process";
|
|
@@ -497,8 +394,100 @@ function reconcileWorktrees(projectDir, knownKeys) {
|
|
|
497
394
|
}
|
|
498
395
|
}
|
|
499
396
|
|
|
397
|
+
// src/attachments.ts
|
|
398
|
+
import { mkdir, writeFile, rm } from "fs/promises";
|
|
399
|
+
import { basename, join as join2, resolve } from "path";
|
|
400
|
+
function matchesMimeType(contentType, patterns) {
|
|
401
|
+
if (!contentType) return false;
|
|
402
|
+
const mime = contentType.split(";")[0].trim().toLowerCase();
|
|
403
|
+
return patterns.some((pattern) => {
|
|
404
|
+
if (pattern.endsWith("/*")) {
|
|
405
|
+
return mime.startsWith(pattern.slice(0, -1));
|
|
406
|
+
}
|
|
407
|
+
return mime === pattern;
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
async function downloadAttachments(attachments, messageId, baseDir, config) {
|
|
411
|
+
const warnings = [];
|
|
412
|
+
const downloaded = [];
|
|
413
|
+
const items = [...attachments.values()];
|
|
414
|
+
if (items.length > config.maxAttachmentsPerMessage) {
|
|
415
|
+
warnings.push(
|
|
416
|
+
`Only processing first ${config.maxAttachmentsPerMessage} of ${items.length} attachments.`
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
const toProcess = items.slice(0, config.maxAttachmentsPerMessage);
|
|
420
|
+
const maxBytes = config.maxAttachmentSizeMb * 1024 * 1024;
|
|
421
|
+
const dir = join2(baseDir, ".mpg-attachments", messageId);
|
|
422
|
+
let dirCreated = false;
|
|
423
|
+
for (const att of toProcess) {
|
|
424
|
+
const rawName = att.name ?? `attachment-${att.id}`;
|
|
425
|
+
const name = basename(rawName).replace(/^\.+/, "") || `attachment-${att.id}`;
|
|
426
|
+
if (att.size > maxBytes) {
|
|
427
|
+
warnings.push(`Skipped \`${name}\` \u2014 exceeds ${config.maxAttachmentSizeMb}MB limit.`);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (!matchesMimeType(att.contentType, config.allowedMimeTypes)) {
|
|
431
|
+
warnings.push(`Skipped \`${name}\` \u2014 type \`${att.contentType ?? "unknown"}\` not allowed.`);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
const parsedUrl = new URL(att.url);
|
|
436
|
+
if (!parsedUrl.hostname.endsWith(".discordapp.com") && !parsedUrl.hostname.endsWith(".discord.com")) {
|
|
437
|
+
warnings.push(`Skipped \`${name}\` \u2014 untrusted URL host.`);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const response = await fetch(att.url);
|
|
441
|
+
if (!response.ok) {
|
|
442
|
+
warnings.push(`Failed to download \`${name}\` \u2014 HTTP ${response.status}.`);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (!dirCreated) {
|
|
446
|
+
await mkdir(dir, { recursive: true });
|
|
447
|
+
dirCreated = true;
|
|
448
|
+
}
|
|
449
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
450
|
+
const filePath = join2(dir, name);
|
|
451
|
+
if (!resolve(filePath).startsWith(resolve(dir) + "/")) {
|
|
452
|
+
warnings.push(`Skipped \`${rawName}\` \u2014 unsafe filename.`);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
await writeFile(filePath, buffer);
|
|
456
|
+
downloaded.push({ path: filePath, name });
|
|
457
|
+
} catch (err) {
|
|
458
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
459
|
+
warnings.push(`Failed to download \`${name}\` \u2014 ${msg}.`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return { downloaded, warnings };
|
|
463
|
+
}
|
|
464
|
+
function buildAttachmentPrompt(attachments) {
|
|
465
|
+
if (attachments.length === 0) return "";
|
|
466
|
+
const paths = attachments.map((a) => a.path).join("\n ");
|
|
467
|
+
return `[Attached files \u2014 use the Read tool to view these:
|
|
468
|
+
${paths}]
|
|
469
|
+
|
|
470
|
+
`;
|
|
471
|
+
}
|
|
472
|
+
async function reconcileAttachments(projectDir) {
|
|
473
|
+
const dir = join2(projectDir, ".mpg-attachments");
|
|
474
|
+
try {
|
|
475
|
+
await rm(dir, { recursive: true });
|
|
476
|
+
return true;
|
|
477
|
+
} catch {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
async function cleanupAttachments(baseDir) {
|
|
482
|
+
const dir = join2(baseDir, ".mpg-attachments");
|
|
483
|
+
try {
|
|
484
|
+
await rm(dir, { recursive: true, force: true });
|
|
485
|
+
} catch {
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
500
489
|
// src/session-manager.ts
|
|
501
|
-
function createSessionManager(defaults, store, pulseEmitter) {
|
|
490
|
+
function createSessionManager(defaults, runtime, store, pulseEmitter) {
|
|
502
491
|
const sessions = /* @__PURE__ */ new Map();
|
|
503
492
|
const sessionTtlMs = defaults.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1e3;
|
|
504
493
|
const maxPersistedSessions = defaults.maxPersistedSessions ?? 50;
|
|
@@ -546,10 +535,10 @@ function createSessionManager(defaults, store, pulseEmitter) {
|
|
|
546
535
|
activeProcesses++;
|
|
547
536
|
return;
|
|
548
537
|
}
|
|
549
|
-
return new Promise((
|
|
538
|
+
return new Promise((resolve6) => {
|
|
550
539
|
waiters.push(() => {
|
|
551
540
|
activeProcesses++;
|
|
552
|
-
|
|
541
|
+
resolve6();
|
|
553
542
|
});
|
|
554
543
|
});
|
|
555
544
|
}
|
|
@@ -571,6 +560,9 @@ function createSessionManager(defaults, store, pulseEmitter) {
|
|
|
571
560
|
session.messageCount
|
|
572
561
|
);
|
|
573
562
|
}
|
|
563
|
+
cleanupAttachments(session.projectDir ?? session.cwd).catch(() => {
|
|
564
|
+
});
|
|
565
|
+
if (runtime.cleanup) runtime.cleanup(session.projectKey);
|
|
574
566
|
sessions.delete(session.projectKey);
|
|
575
567
|
}, defaults.idleTimeoutMs);
|
|
576
568
|
}
|
|
@@ -591,14 +583,15 @@ function createSessionManager(defaults, store, pulseEmitter) {
|
|
|
591
583
|
);
|
|
592
584
|
}
|
|
593
585
|
try {
|
|
594
|
-
const result = await
|
|
595
|
-
session.cwd,
|
|
596
|
-
effectiveArgs,
|
|
597
|
-
item.prompt,
|
|
598
|
-
session.sessionId,
|
|
599
|
-
item.systemPrompt,
|
|
600
|
-
item.timeoutMs
|
|
601
|
-
|
|
586
|
+
const result = await runtime.spawn({
|
|
587
|
+
cwd: session.cwd,
|
|
588
|
+
baseArgs: effectiveArgs,
|
|
589
|
+
prompt: item.prompt,
|
|
590
|
+
sessionId: session.sessionId,
|
|
591
|
+
systemPrompt: item.systemPrompt,
|
|
592
|
+
timeoutMs: item.timeoutMs,
|
|
593
|
+
projectKey: session.projectKey
|
|
594
|
+
});
|
|
602
595
|
const sessionChanged = !!(session.sessionId && result.sessionId && result.sessionId !== session.sessionId);
|
|
603
596
|
session.sessionId = result.sessionId || session.sessionId;
|
|
604
597
|
session.lastActivity = Date.now();
|
|
@@ -624,7 +617,7 @@ function createSessionManager(defaults, store, pulseEmitter) {
|
|
|
624
617
|
if (session.sessionId) {
|
|
625
618
|
session.sessionId = void 0;
|
|
626
619
|
try {
|
|
627
|
-
const result = await
|
|
620
|
+
const result = await runtime.spawn({ cwd: session.cwd, baseArgs: effectiveArgs, prompt: item.prompt, sessionId: void 0, systemPrompt: item.systemPrompt, timeoutMs: item.timeoutMs, projectKey: session.projectKey });
|
|
628
621
|
session.sessionId = result.sessionId || void 0;
|
|
629
622
|
session.lastActivity = Date.now();
|
|
630
623
|
session.messageCount++;
|
|
@@ -678,7 +671,7 @@ function createSessionManager(defaults, store, pulseEmitter) {
|
|
|
678
671
|
}
|
|
679
672
|
}
|
|
680
673
|
let effectiveCwd = cwd;
|
|
681
|
-
let worktreePath2 = restoredWorktreePath;
|
|
674
|
+
let worktreePath2 = restoredWorktreePath && existsSync2(restoredWorktreePath) ? restoredWorktreePath : void 0;
|
|
682
675
|
let projectDir;
|
|
683
676
|
if (useWorktree && !worktreePath2) {
|
|
684
677
|
worktreePath2 = createWorktree(cwd, projectKey);
|
|
@@ -759,8 +752,8 @@ function createSessionManager(defaults, store, pulseEmitter) {
|
|
|
759
752
|
return {
|
|
760
753
|
send(projectKey, cwd, prompt, opts) {
|
|
761
754
|
const session = getOrCreateSession(projectKey, cwd, opts?.worktree);
|
|
762
|
-
return new Promise((
|
|
763
|
-
session.queue.push({ prompt, systemPrompt: opts?.systemPrompt, timeoutMs: opts?.timeoutMs, extraArgs: opts?.extraArgs, resolve:
|
|
755
|
+
return new Promise((resolve6, reject) => {
|
|
756
|
+
session.queue.push({ prompt, systemPrompt: opts?.systemPrompt, timeoutMs: opts?.timeoutMs, extraArgs: opts?.extraArgs, resolve: resolve6, reject });
|
|
764
757
|
processQueue(session);
|
|
765
758
|
});
|
|
766
759
|
},
|
|
@@ -770,16 +763,24 @@ function createSessionManager(defaults, store, pulseEmitter) {
|
|
|
770
763
|
return {
|
|
771
764
|
sessionId: session.sessionId ?? "",
|
|
772
765
|
projectKey: session.projectKey,
|
|
766
|
+
cwd: session.cwd,
|
|
767
|
+
projectDir: session.projectDir,
|
|
773
768
|
lastActivity: session.lastActivity,
|
|
774
|
-
queueLength: session.queue.length
|
|
769
|
+
queueLength: session.queue.length,
|
|
770
|
+
createdAt: session.createdAt,
|
|
771
|
+
processing: session.processing
|
|
775
772
|
};
|
|
776
773
|
},
|
|
777
774
|
listSessions() {
|
|
778
775
|
return Array.from(sessions.values()).map((s) => ({
|
|
779
776
|
sessionId: s.sessionId ?? "",
|
|
780
777
|
projectKey: s.projectKey,
|
|
778
|
+
cwd: s.cwd,
|
|
779
|
+
projectDir: s.projectDir,
|
|
781
780
|
lastActivity: s.lastActivity,
|
|
782
|
-
queueLength: s.queue.length
|
|
781
|
+
queueLength: s.queue.length,
|
|
782
|
+
createdAt: s.createdAt,
|
|
783
|
+
processing: s.processing
|
|
783
784
|
}));
|
|
784
785
|
},
|
|
785
786
|
clearSession(projectKey) {
|
|
@@ -798,6 +799,9 @@ function createSessionManager(defaults, store, pulseEmitter) {
|
|
|
798
799
|
if (session.worktreePath && session.projectDir) {
|
|
799
800
|
removeWorktree(session.projectDir, session.projectKey);
|
|
800
801
|
}
|
|
802
|
+
cleanupAttachments(session.projectDir ?? session.cwd).catch(() => {
|
|
803
|
+
});
|
|
804
|
+
if (runtime.cleanup) runtime.cleanup(projectKey);
|
|
801
805
|
sessions.delete(projectKey);
|
|
802
806
|
persistSessions();
|
|
803
807
|
return true;
|
|
@@ -811,10 +815,71 @@ function createSessionManager(defaults, store, pulseEmitter) {
|
|
|
811
815
|
persistSessions();
|
|
812
816
|
return true;
|
|
813
817
|
},
|
|
818
|
+
async recoverOrphanedSessions(onResult) {
|
|
819
|
+
if (!runtime.canResume) {
|
|
820
|
+
console.log("[recovery] Skipping: runtime.canResume is false");
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
console.log("[recovery] Checking for orphaned tmux sessions...");
|
|
824
|
+
let orphanedKeys;
|
|
825
|
+
try {
|
|
826
|
+
orphanedKeys = await runtime.listOrphanedSessions();
|
|
827
|
+
} catch (err) {
|
|
828
|
+
console.error("Failed to list orphaned sessions:", err);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
console.log(`[recovery] Found ${orphanedKeys.length} orphaned tmux session(s): ${JSON.stringify(orphanedKeys)}`);
|
|
832
|
+
if (orphanedKeys.length === 0) return;
|
|
833
|
+
console.log(`Discovered ${orphanedKeys.length} orphaned tmux session(s)`);
|
|
834
|
+
const persisted = store ? store.load() : /* @__PURE__ */ new Map();
|
|
835
|
+
console.log(`[recovery] Persisted store has ${persisted.size} entries. Keys: ${JSON.stringify([...persisted.keys()].slice(0, 20))}`);
|
|
836
|
+
const sanitizedLookup = /* @__PURE__ */ new Map();
|
|
837
|
+
for (const [key, entry] of persisted) {
|
|
838
|
+
const sanitized = key.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
839
|
+
sanitizedLookup.set(sanitized, entry);
|
|
840
|
+
}
|
|
841
|
+
const reattachPromises = [];
|
|
842
|
+
for (const key of orphanedKeys) {
|
|
843
|
+
const entry = sanitizedLookup.get(key);
|
|
844
|
+
console.log(`[recovery] Orphan key "${key}" \u2192 match: ${entry ? `yes (projectKey=${entry.projectKey})` : "NO"}`);
|
|
845
|
+
if (!entry) {
|
|
846
|
+
console.log(`Cleaning up unmatched orphan: ${key}`);
|
|
847
|
+
if (runtime.cleanup) runtime.cleanup(key);
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
reattachPromises.push(
|
|
851
|
+
(async () => {
|
|
852
|
+
try {
|
|
853
|
+
if (pulseEmitter) {
|
|
854
|
+
pulseEmitter.sessionResume(
|
|
855
|
+
entry.sessionId,
|
|
856
|
+
entry.projectKey,
|
|
857
|
+
entry.cwd,
|
|
858
|
+
Date.now() - entry.lastActivity
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
const result = await runtime.reattach(key);
|
|
862
|
+
console.log(`Reattached orphan ${key}: ${result.text.length} chars`);
|
|
863
|
+
onResult(entry.projectKey, result);
|
|
864
|
+
} catch (err) {
|
|
865
|
+
console.error(`Failed to reattach orphan ${key}:`, err);
|
|
866
|
+
} finally {
|
|
867
|
+
if (runtime.cleanup) runtime.cleanup(key);
|
|
868
|
+
}
|
|
869
|
+
})()
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
await Promise.all(reattachPromises);
|
|
873
|
+
},
|
|
814
874
|
shutdown() {
|
|
815
875
|
persistSessions();
|
|
876
|
+
const activeKeys = [...sessions.values()].map((s) => s.projectKey);
|
|
877
|
+
console.log(`[shutdown] Persisted ${activeKeys.length} session(s). canResume=${runtime.canResume}. Keys: ${JSON.stringify(activeKeys.slice(0, 10))}`);
|
|
816
878
|
for (const session of sessions.values()) {
|
|
817
879
|
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
880
|
+
if (!runtime.canResume && runtime.cleanup) runtime.cleanup(session.projectKey);
|
|
881
|
+
cleanupAttachments(session.projectDir ?? session.cwd).catch(() => {
|
|
882
|
+
});
|
|
818
883
|
}
|
|
819
884
|
sessions.clear();
|
|
820
885
|
}
|
|
@@ -853,9 +918,344 @@ function createFileSessionStore(filePath) {
|
|
|
853
918
|
};
|
|
854
919
|
}
|
|
855
920
|
|
|
921
|
+
// src/claude-cli.ts
|
|
922
|
+
import { spawn } from "child_process";
|
|
923
|
+
function parseClaudeJsonOutput(raw) {
|
|
924
|
+
const data = JSON.parse(raw);
|
|
925
|
+
let usage;
|
|
926
|
+
if (data.total_cost_usd != null || data.usage) {
|
|
927
|
+
const model = data.model ?? (data.modelUsage ? Object.keys(data.modelUsage)[0] : void 0);
|
|
928
|
+
usage = {
|
|
929
|
+
input_tokens: data.usage?.input_tokens ?? 0,
|
|
930
|
+
output_tokens: data.usage?.output_tokens ?? 0,
|
|
931
|
+
cache_creation_input_tokens: data.usage?.cache_creation_input_tokens ?? 0,
|
|
932
|
+
cache_read_input_tokens: data.usage?.cache_read_input_tokens ?? 0,
|
|
933
|
+
total_cost_usd: data.total_cost_usd ?? 0,
|
|
934
|
+
duration_ms: data.duration_ms ?? 0,
|
|
935
|
+
duration_api_ms: data.duration_api_ms ?? 0,
|
|
936
|
+
num_turns: data.num_turns ?? 0,
|
|
937
|
+
model
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
return {
|
|
941
|
+
text: data.result ?? "",
|
|
942
|
+
sessionId: data.session_id ?? "",
|
|
943
|
+
isError: Boolean(data.is_error),
|
|
944
|
+
usage
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
function buildToolArgs(defaults, projectOverrides, existingArgs) {
|
|
948
|
+
if (existingArgs?.includes("--allowed-tools") || existingArgs?.includes("--disallowed-tools")) {
|
|
949
|
+
return [];
|
|
950
|
+
}
|
|
951
|
+
const allowed = projectOverrides?.allowedTools ?? defaults.allowedTools;
|
|
952
|
+
const disallowed = projectOverrides?.disallowedTools ?? defaults.disallowedTools;
|
|
953
|
+
const args2 = [];
|
|
954
|
+
if (allowed && allowed.length > 0) {
|
|
955
|
+
args2.push("--allowed-tools", ...allowed);
|
|
956
|
+
} else if (disallowed && disallowed.length > 0) {
|
|
957
|
+
args2.push("--disallowed-tools", ...disallowed);
|
|
958
|
+
}
|
|
959
|
+
return args2;
|
|
960
|
+
}
|
|
961
|
+
function buildClaudeArgs(baseArgs, prompt, sessionId, systemPrompt) {
|
|
962
|
+
const args2 = ["--print", prompt, ...baseArgs];
|
|
963
|
+
if (sessionId) {
|
|
964
|
+
args2.push("--resume", sessionId);
|
|
965
|
+
}
|
|
966
|
+
if (systemPrompt) {
|
|
967
|
+
args2.push("--append-system-prompt", systemPrompt);
|
|
968
|
+
}
|
|
969
|
+
return args2;
|
|
970
|
+
}
|
|
971
|
+
function friendlyError(stderr) {
|
|
972
|
+
const combined = stderr.toLowerCase();
|
|
973
|
+
if (combined.includes("rate limit") || combined.includes("rate_limit_error")) {
|
|
974
|
+
return "Claude usage limit reached \u2014 please wait a few minutes and try again.";
|
|
975
|
+
}
|
|
976
|
+
if (combined.includes("overloaded") || combined.includes("overloaded_error")) {
|
|
977
|
+
return "Claude API is temporarily overloaded \u2014 please try again shortly.";
|
|
978
|
+
}
|
|
979
|
+
if (combined.includes("invalid api key") || combined.includes("authentication_error") || combined.includes("authentication failed")) {
|
|
980
|
+
return "Claude authentication failed \u2014 check your API key or CLI login.";
|
|
981
|
+
}
|
|
982
|
+
if (combined.includes("no messages returned")) {
|
|
983
|
+
return "Claude returned an empty response \u2014 try sending your message again.";
|
|
984
|
+
}
|
|
985
|
+
return `Claude error: ${stderr.slice(0, 500)}`;
|
|
986
|
+
}
|
|
987
|
+
var DEFAULT_TIMEOUT_MS = 20 * 60 * 1e3;
|
|
988
|
+
function runClaude(cwd, baseArgs, prompt, sessionId, systemPrompt, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
989
|
+
return new Promise((resolve6, reject) => {
|
|
990
|
+
const args2 = buildClaudeArgs(baseArgs, prompt, sessionId, systemPrompt);
|
|
991
|
+
const proc = spawn("claude", args2, {
|
|
992
|
+
cwd,
|
|
993
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
994
|
+
});
|
|
995
|
+
let stdout = "";
|
|
996
|
+
let stderr = "";
|
|
997
|
+
let settled = false;
|
|
998
|
+
const timer = setTimeout(() => {
|
|
999
|
+
if (!settled) {
|
|
1000
|
+
settled = true;
|
|
1001
|
+
proc.kill("SIGTERM");
|
|
1002
|
+
reject(new Error(`Claude CLI timed out after ${timeoutMs / 1e3}s`));
|
|
1003
|
+
}
|
|
1004
|
+
}, timeoutMs);
|
|
1005
|
+
proc.stdout.on("data", (chunk) => {
|
|
1006
|
+
stdout += chunk.toString();
|
|
1007
|
+
});
|
|
1008
|
+
proc.stderr.on("data", (chunk) => {
|
|
1009
|
+
stderr += chunk.toString();
|
|
1010
|
+
});
|
|
1011
|
+
proc.on("close", (code) => {
|
|
1012
|
+
clearTimeout(timer);
|
|
1013
|
+
if (settled) return;
|
|
1014
|
+
settled = true;
|
|
1015
|
+
if (code !== 0) {
|
|
1016
|
+
reject(new Error(friendlyError(stderr)));
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
try {
|
|
1020
|
+
const result = parseClaudeJsonOutput(stdout.trim());
|
|
1021
|
+
resolve6(result);
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
reject(new Error(`Failed to parse claude output: ${stdout.slice(0, 200)}`));
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
proc.on("error", (err) => {
|
|
1027
|
+
clearTimeout(timer);
|
|
1028
|
+
if (settled) return;
|
|
1029
|
+
settled = true;
|
|
1030
|
+
reject(new Error(`Failed to spawn claude: ${err.message}`));
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// src/runtimes/claude-cli-runtime.ts
|
|
1036
|
+
var ClaudeCliRuntime = class {
|
|
1037
|
+
name = "claude-cli";
|
|
1038
|
+
canResume = false;
|
|
1039
|
+
spawn(opts) {
|
|
1040
|
+
return runClaude(
|
|
1041
|
+
opts.cwd,
|
|
1042
|
+
opts.baseArgs,
|
|
1043
|
+
opts.prompt,
|
|
1044
|
+
opts.sessionId,
|
|
1045
|
+
opts.systemPrompt,
|
|
1046
|
+
opts.timeoutMs
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
async listOrphanedSessions() {
|
|
1050
|
+
return [];
|
|
1051
|
+
}
|
|
1052
|
+
async reattach(_sessionId) {
|
|
1053
|
+
throw new Error("ClaudeCliRuntime does not support session reattachment");
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
// src/runtimes/tmux-runtime.ts
|
|
1058
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, existsSync as existsSync3, watch } from "fs";
|
|
1059
|
+
import { join as join3 } from "path";
|
|
1060
|
+
|
|
1061
|
+
// src/tmux.ts
|
|
1062
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
1063
|
+
var TIMEOUT2 = 1e4;
|
|
1064
|
+
function ensureTmux() {
|
|
1065
|
+
try {
|
|
1066
|
+
execFileSync2("tmux", ["-V"], { timeout: TIMEOUT2, stdio: "pipe" });
|
|
1067
|
+
} catch {
|
|
1068
|
+
throw new Error(
|
|
1069
|
+
"tmux is not installed or not on PATH. Install tmux to use persistent sessions (e.g. `apt install tmux`)."
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
function createSession(name, command2, opts) {
|
|
1074
|
+
ensureTmux();
|
|
1075
|
+
execFileSync2("tmux", ["new-session", "-d", "-s", name, command2], {
|
|
1076
|
+
cwd: opts?.cwd,
|
|
1077
|
+
timeout: TIMEOUT2,
|
|
1078
|
+
stdio: "pipe"
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
function sessionExists(name) {
|
|
1082
|
+
try {
|
|
1083
|
+
execFileSync2("tmux", ["has-session", "-t", name], {
|
|
1084
|
+
timeout: TIMEOUT2,
|
|
1085
|
+
stdio: "pipe"
|
|
1086
|
+
});
|
|
1087
|
+
return true;
|
|
1088
|
+
} catch {
|
|
1089
|
+
return false;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
function listSessions(prefix) {
|
|
1093
|
+
try {
|
|
1094
|
+
const raw = execFileSync2("tmux", ["ls", "-F", "#{session_name}"], {
|
|
1095
|
+
timeout: TIMEOUT2,
|
|
1096
|
+
stdio: "pipe"
|
|
1097
|
+
}).toString();
|
|
1098
|
+
return raw.split("\n").map((l) => l.trim()).filter((l) => l.startsWith(prefix));
|
|
1099
|
+
} catch {
|
|
1100
|
+
return [];
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
function killSession(name) {
|
|
1104
|
+
try {
|
|
1105
|
+
execFileSync2("tmux", ["kill-session", "-t", name], {
|
|
1106
|
+
timeout: TIMEOUT2,
|
|
1107
|
+
stdio: "pipe"
|
|
1108
|
+
});
|
|
1109
|
+
} catch {
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// src/runtimes/tmux-runtime.ts
|
|
1114
|
+
var SESSION_PREFIX = "mpg-";
|
|
1115
|
+
var OUTPUT_BASE_DIR = "/tmp/mpg-sessions";
|
|
1116
|
+
var DEFAULT_TIMEOUT_MS2 = 20 * 60 * 1e3;
|
|
1117
|
+
var HEALTH_CHECK_DELAY_MS = 2 * 60 * 1e3;
|
|
1118
|
+
var POLL_INTERVAL_MS = 500;
|
|
1119
|
+
function sanitizeSessionName(key) {
|
|
1120
|
+
return key.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
1121
|
+
}
|
|
1122
|
+
function outputDir(sessionKey) {
|
|
1123
|
+
return join3(OUTPUT_BASE_DIR, sanitizeSessionName(sessionKey));
|
|
1124
|
+
}
|
|
1125
|
+
function outputFile(sessionKey) {
|
|
1126
|
+
return join3(outputDir(sessionKey), "output.json");
|
|
1127
|
+
}
|
|
1128
|
+
function stderrFile(sessionKey) {
|
|
1129
|
+
return join3(outputDir(sessionKey), "stderr.log");
|
|
1130
|
+
}
|
|
1131
|
+
var TmuxRuntime = class {
|
|
1132
|
+
name = "tmux";
|
|
1133
|
+
canResume = true;
|
|
1134
|
+
constructor() {
|
|
1135
|
+
ensureTmux();
|
|
1136
|
+
}
|
|
1137
|
+
async spawn(opts) {
|
|
1138
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
|
|
1139
|
+
const sessionKey = opts.projectKey ?? opts.sessionId ?? `spawn-${Date.now()}`;
|
|
1140
|
+
const tmuxName = SESSION_PREFIX + sanitizeSessionName(sessionKey);
|
|
1141
|
+
const outDir = outputDir(sessionKey);
|
|
1142
|
+
mkdirSync2(outDir, { recursive: true });
|
|
1143
|
+
const outPath = outputFile(sessionKey);
|
|
1144
|
+
const errPath = stderrFile(sessionKey);
|
|
1145
|
+
const args2 = buildClaudeArgs(opts.baseArgs, opts.prompt, opts.sessionId, opts.systemPrompt);
|
|
1146
|
+
const escapedArgs = args2.map((a) => shellEscape(a));
|
|
1147
|
+
const bufferMs = 5 * 60 * 1e3;
|
|
1148
|
+
const timeoutSec = Math.ceil((timeoutMs + bufferMs) / 1e3);
|
|
1149
|
+
const command2 = `timeout ${timeoutSec} claude ${escapedArgs.join(" ")} > ${shellEscape(outPath)} 2> ${shellEscape(errPath)}`;
|
|
1150
|
+
if (sessionExists(tmuxName)) {
|
|
1151
|
+
killSession(tmuxName);
|
|
1152
|
+
}
|
|
1153
|
+
createSession(tmuxName, command2, { cwd: opts.cwd });
|
|
1154
|
+
return this._waitForResult(tmuxName, sessionKey, outPath, errPath, timeoutMs);
|
|
1155
|
+
}
|
|
1156
|
+
async listOrphanedSessions() {
|
|
1157
|
+
const sessions = listSessions(SESSION_PREFIX);
|
|
1158
|
+
console.log(`[tmux] listOrphanedSessions: raw tmux sessions with prefix '${SESSION_PREFIX}': ${JSON.stringify(sessions)}`);
|
|
1159
|
+
return sessions.map((name) => name.slice(SESSION_PREFIX.length));
|
|
1160
|
+
}
|
|
1161
|
+
async reattach(sessionKey) {
|
|
1162
|
+
const tmuxName = SESSION_PREFIX + sanitizeSessionName(sessionKey);
|
|
1163
|
+
const outPath = outputFile(sessionKey);
|
|
1164
|
+
const errPath = stderrFile(sessionKey);
|
|
1165
|
+
if (!sessionExists(tmuxName)) {
|
|
1166
|
+
if (existsSync3(outPath)) {
|
|
1167
|
+
return this._readResult(outPath, errPath);
|
|
1168
|
+
}
|
|
1169
|
+
throw new Error(`tmux session ${tmuxName} does not exist and no output file found`);
|
|
1170
|
+
}
|
|
1171
|
+
return this._waitForResult(tmuxName, sessionKey, outPath, errPath, DEFAULT_TIMEOUT_MS2);
|
|
1172
|
+
}
|
|
1173
|
+
/** Clean up tmux session and temp files for a given session key. */
|
|
1174
|
+
cleanup(sessionKey) {
|
|
1175
|
+
const tmuxName = SESSION_PREFIX + sanitizeSessionName(sessionKey);
|
|
1176
|
+
killSession(tmuxName);
|
|
1177
|
+
const dir = outputDir(sessionKey);
|
|
1178
|
+
try {
|
|
1179
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1180
|
+
} catch {
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
_readResult(outPath, errPath) {
|
|
1184
|
+
const stdout = existsSync3(outPath) ? readFileSync2(outPath, "utf-8").trim() : "";
|
|
1185
|
+
const stderr = existsSync3(errPath) ? readFileSync2(errPath, "utf-8").trim() : "";
|
|
1186
|
+
if (!stdout && stderr) {
|
|
1187
|
+
throw new Error(friendlyError(stderr));
|
|
1188
|
+
}
|
|
1189
|
+
if (!stdout) {
|
|
1190
|
+
throw new Error("Claude produced no output");
|
|
1191
|
+
}
|
|
1192
|
+
try {
|
|
1193
|
+
return parseClaudeJsonOutput(stdout);
|
|
1194
|
+
} catch {
|
|
1195
|
+
throw new Error(`Failed to parse claude output: ${stdout.slice(0, 200)}`);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
_waitForResult(tmuxName, sessionKey, outPath, errPath, timeoutMs) {
|
|
1199
|
+
return new Promise((resolve6, reject) => {
|
|
1200
|
+
let settled = false;
|
|
1201
|
+
let healthCheckDone = false;
|
|
1202
|
+
const timer = setTimeout(() => {
|
|
1203
|
+
if (settled) return;
|
|
1204
|
+
settled = true;
|
|
1205
|
+
killSession(tmuxName);
|
|
1206
|
+
reject(new Error(`Claude CLI timed out after ${timeoutMs / 1e3}s`));
|
|
1207
|
+
}, timeoutMs);
|
|
1208
|
+
const healthTimer = setTimeout(() => {
|
|
1209
|
+
if (settled) return;
|
|
1210
|
+
healthCheckDone = true;
|
|
1211
|
+
if (!sessionExists(tmuxName)) {
|
|
1212
|
+
tryResolve();
|
|
1213
|
+
}
|
|
1214
|
+
}, Math.min(HEALTH_CHECK_DELAY_MS, timeoutMs));
|
|
1215
|
+
let watcher;
|
|
1216
|
+
try {
|
|
1217
|
+
const dir = outputDir(sessionKey);
|
|
1218
|
+
watcher = watch(dir, () => {
|
|
1219
|
+
if (!settled) tryResolve();
|
|
1220
|
+
});
|
|
1221
|
+
} catch {
|
|
1222
|
+
}
|
|
1223
|
+
const pollTimer = setInterval(() => {
|
|
1224
|
+
if (!settled) tryResolve();
|
|
1225
|
+
}, POLL_INTERVAL_MS);
|
|
1226
|
+
function tryResolve() {
|
|
1227
|
+
if (sessionExists(tmuxName)) return;
|
|
1228
|
+
if (settled) return;
|
|
1229
|
+
settled = true;
|
|
1230
|
+
clearTimeout(timer);
|
|
1231
|
+
clearTimeout(healthTimer);
|
|
1232
|
+
clearInterval(pollTimer);
|
|
1233
|
+
if (watcher) watcher.close();
|
|
1234
|
+
try {
|
|
1235
|
+
const stdout = existsSync3(outPath) ? readFileSync2(outPath, "utf-8").trim() : "";
|
|
1236
|
+
const stderr = existsSync3(errPath) ? readFileSync2(errPath, "utf-8").trim() : "";
|
|
1237
|
+
if (!stdout && stderr) {
|
|
1238
|
+
reject(new Error(friendlyError(stderr)));
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
if (!stdout) {
|
|
1242
|
+
reject(new Error("Claude produced no output"));
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
const result = parseClaudeJsonOutput(stdout);
|
|
1246
|
+
resolve6(result);
|
|
1247
|
+
} catch (err) {
|
|
1248
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
function shellEscape(s) {
|
|
1255
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
1256
|
+
}
|
|
1257
|
+
|
|
856
1258
|
// src/discord.ts
|
|
857
|
-
import { readdirSync, readFileSync as readFileSync2 } from "fs";
|
|
858
|
-
import { join as join2 } from "path";
|
|
859
1259
|
import { Client, GatewayIntentBits, Events, Status } from "discord.js";
|
|
860
1260
|
|
|
861
1261
|
// src/agent-dispatch.ts
|
|
@@ -1173,34 +1573,6 @@ ${lines.join("\n")}
|
|
|
1173
1573
|
|
|
1174
1574
|
Dispatch: \`!ask <agent> <message>\` or shorthand \`!<agent> <message>\``;
|
|
1175
1575
|
}
|
|
1176
|
-
if (cmd === "!apo") {
|
|
1177
|
-
if (!context) return "Run `!apo` in a project channel or thread.";
|
|
1178
|
-
const match = findProjectByName(config, context.projectName);
|
|
1179
|
-
const projectDir = match ? config.projects[match.channelId]?.directory : void 0;
|
|
1180
|
-
if (!projectDir) return "Run `!apo` in a project channel or thread.";
|
|
1181
|
-
const pulseDir = join2(projectDir, ".pulse");
|
|
1182
|
-
let files;
|
|
1183
|
-
try {
|
|
1184
|
-
files = readdirSync(pulseDir).filter((f) => f.endsWith(".json")).sort();
|
|
1185
|
-
} catch {
|
|
1186
|
-
return `No pulse reports found for **${context.projectName}**.`;
|
|
1187
|
-
}
|
|
1188
|
-
if (files.length === 0) return `No pulse reports found for **${context.projectName}**.`;
|
|
1189
|
-
try {
|
|
1190
|
-
const raw = readFileSync2(join2(pulseDir, files[files.length - 1]), "utf-8");
|
|
1191
|
-
const report = JSON.parse(raw);
|
|
1192
|
-
const c = report.convergence ?? {};
|
|
1193
|
-
return [
|
|
1194
|
-
`Pulse \u2014 ${report.project ?? context.projectName}`,
|
|
1195
|
-
"\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550",
|
|
1196
|
-
`${c.exchanges ?? "?"} exchanges | ${c.outcomes ?? "?"} outcomes | rate ${c.rate ?? "?"}`,
|
|
1197
|
-
`Rework: ${c.reworkPercent ?? "?"}%`,
|
|
1198
|
-
`Reported: ${report.timestamp ?? "unknown"}`
|
|
1199
|
-
].join("\n");
|
|
1200
|
-
} catch {
|
|
1201
|
-
return `Failed to read pulse report for **${context.projectName}**.`;
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
1576
|
if (cmd === "!help") {
|
|
1205
1577
|
return [
|
|
1206
1578
|
"**Gateway commands**",
|
|
@@ -1211,7 +1583,6 @@ Dispatch: \`!ask <agent> <message>\` or shorthand \`!<agent> <message>\``;
|
|
|
1211
1583
|
"`!restart <name>` \u2014 reset a session (fresh context, keeps worktree)",
|
|
1212
1584
|
"`!kill <name>` \u2014 force-close a project session",
|
|
1213
1585
|
"`!agents` \u2014 list available agents for the current project",
|
|
1214
|
-
"`!apo` \u2014 show latest pulse interaction report",
|
|
1215
1586
|
"`!help` \u2014 show this message"
|
|
1216
1587
|
].join("\n");
|
|
1217
1588
|
}
|
|
@@ -1328,6 +1699,17 @@ Usage: \`!ask <agent> <message>\``
|
|
|
1328
1699
|
}, 7e3);
|
|
1329
1700
|
replyChannel.sendTyping().catch(() => {
|
|
1330
1701
|
});
|
|
1702
|
+
const stuckNotifyMs = config.defaults.stuckNotifyMs;
|
|
1703
|
+
const isWorktreeSession = replyChannel.isThread();
|
|
1704
|
+
const sendStartTime = Date.now();
|
|
1705
|
+
let stuckNotifyInterval = null;
|
|
1706
|
+
if (stuckNotifyMs > 0 && isWorktreeSession) {
|
|
1707
|
+
stuckNotifyInterval = setInterval(() => {
|
|
1708
|
+
const elapsed = Math.floor((Date.now() - sendStartTime) / 6e4);
|
|
1709
|
+
replyChannel.send(`\u23F3 Still working\u2026 (${elapsed}m elapsed)`).catch(() => {
|
|
1710
|
+
});
|
|
1711
|
+
}, stuckNotifyMs);
|
|
1712
|
+
}
|
|
1331
1713
|
const projectChannelId = parentId || resolved.channelId;
|
|
1332
1714
|
const project = config.projects[projectChannelId];
|
|
1333
1715
|
const agents = project?.agents;
|
|
@@ -1346,11 +1728,35 @@ Usage: \`!ask <agent> <message>\``
|
|
|
1346
1728
|
|
|
1347
1729
|
${activeAgent.agent.prompt}` : void 0;
|
|
1348
1730
|
try {
|
|
1731
|
+
let attachmentPrefix = "";
|
|
1732
|
+
if (message.attachments.size > 0) {
|
|
1733
|
+
const attachmentConfig = {
|
|
1734
|
+
maxAttachmentSizeMb: project?.maxAttachmentSizeMb ?? config.defaults.maxAttachmentSizeMb,
|
|
1735
|
+
allowedMimeTypes: project?.allowedMimeTypes ?? config.defaults.allowedMimeTypes,
|
|
1736
|
+
maxAttachmentsPerMessage: project?.maxAttachmentsPerMessage ?? config.defaults.maxAttachmentsPerMessage
|
|
1737
|
+
};
|
|
1738
|
+
const attachmentResult = await downloadAttachments(
|
|
1739
|
+
message.attachments,
|
|
1740
|
+
message.id,
|
|
1741
|
+
resolved.directory,
|
|
1742
|
+
attachmentConfig
|
|
1743
|
+
);
|
|
1744
|
+
if (attachmentResult.warnings.length > 0) {
|
|
1745
|
+
await replyChannel.send(`\u26A0\uFE0F ${attachmentResult.warnings.join("\n")}`);
|
|
1746
|
+
}
|
|
1747
|
+
attachmentPrefix = buildAttachmentPrompt(attachmentResult.downloaded);
|
|
1748
|
+
}
|
|
1349
1749
|
let userPrompt = mention ? mention.prompt : message.content;
|
|
1350
1750
|
if (activeAgent && message.channel.isThread()) {
|
|
1351
1751
|
const history = await fetchThreadHistory(replyChannel, message.id);
|
|
1352
1752
|
if (history) userPrompt = `${history}${userPrompt}`;
|
|
1353
1753
|
}
|
|
1754
|
+
if (!userPrompt.trim() && attachmentPrefix) {
|
|
1755
|
+
userPrompt = "Please review the attached files.";
|
|
1756
|
+
}
|
|
1757
|
+
if (attachmentPrefix) {
|
|
1758
|
+
userPrompt = `${attachmentPrefix}${userPrompt}`;
|
|
1759
|
+
}
|
|
1354
1760
|
if (!userPrompt.trim()) {
|
|
1355
1761
|
await replyChannel.send("Please include a message with your request.");
|
|
1356
1762
|
return;
|
|
@@ -1443,6 +1849,7 @@ ${handoff.agent.prompt}`;
|
|
|
1443
1849
|
);
|
|
1444
1850
|
} finally {
|
|
1445
1851
|
clearInterval(typingInterval);
|
|
1852
|
+
if (stuckNotifyInterval) clearInterval(stuckNotifyInterval);
|
|
1446
1853
|
}
|
|
1447
1854
|
});
|
|
1448
1855
|
return {
|
|
@@ -1468,15 +1875,35 @@ ${handoff.agent.prompt}`;
|
|
|
1468
1875
|
[Status.Resuming]: "resuming"
|
|
1469
1876
|
};
|
|
1470
1877
|
return statusMap[ws.status] ?? "unknown";
|
|
1878
|
+
},
|
|
1879
|
+
async deliverOrphanResult(projectKey, result) {
|
|
1880
|
+
const threadId = projectKey.includes(":") ? projectKey.split(":")[0] : projectKey;
|
|
1881
|
+
const agentName = projectKey.includes(":") ? projectKey.split(":").pop() : void 0;
|
|
1882
|
+
try {
|
|
1883
|
+
const channel = await client.channels.fetch(threadId);
|
|
1884
|
+
if (!channel || !("send" in channel)) {
|
|
1885
|
+
console.error(`Cannot deliver orphan result: channel ${threadId} not found or not sendable`);
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
let agentRole;
|
|
1889
|
+
if (agentName && channel.isThread() && channel.parentId) {
|
|
1890
|
+
const project = config.projects[channel.parentId];
|
|
1891
|
+
agentRole = project?.agents?.[agentName]?.role;
|
|
1892
|
+
}
|
|
1893
|
+
await channel.send("\u{1F504} Resumed after gateway restart \u2014 here is the pending response:");
|
|
1894
|
+
await sendAgentMessage(channel, result.text, agentName, agentRole);
|
|
1895
|
+
} catch (err) {
|
|
1896
|
+
console.error(`Failed to deliver orphan result to ${threadId}:`, err);
|
|
1897
|
+
}
|
|
1471
1898
|
}
|
|
1472
1899
|
};
|
|
1473
1900
|
}
|
|
1474
1901
|
|
|
1475
1902
|
// src/pulse-events.ts
|
|
1476
|
-
import { appendFileSync, mkdirSync as
|
|
1477
|
-
import { dirname as dirname2, join as
|
|
1903
|
+
import { appendFileSync, mkdirSync as mkdirSync3 } from "fs";
|
|
1904
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
1478
1905
|
import { homedir } from "os";
|
|
1479
|
-
var DEFAULT_PATH =
|
|
1906
|
+
var DEFAULT_PATH = join4(homedir(), ".pulse", "events", "mpg-sessions.jsonl");
|
|
1480
1907
|
function baseEvent(eventType, sessionId, projectKey, projectDir) {
|
|
1481
1908
|
return {
|
|
1482
1909
|
schema_version: 1,
|
|
@@ -1493,7 +1920,7 @@ function createPulseEmitter(filePath) {
|
|
|
1493
1920
|
function emit(event) {
|
|
1494
1921
|
try {
|
|
1495
1922
|
if (!dirCreated) {
|
|
1496
|
-
|
|
1923
|
+
mkdirSync3(dirname2(target), { recursive: true });
|
|
1497
1924
|
dirCreated = true;
|
|
1498
1925
|
}
|
|
1499
1926
|
appendFileSync(target, JSON.stringify(event) + "\n");
|
|
@@ -1554,17 +1981,19 @@ function createPulseEmitter(filePath) {
|
|
|
1554
1981
|
}
|
|
1555
1982
|
|
|
1556
1983
|
// src/activity-engine.ts
|
|
1557
|
-
import { readFileSync as readFileSync3, existsSync as
|
|
1558
|
-
import { join as
|
|
1984
|
+
import { readFileSync as readFileSync3, existsSync as existsSync4 } from "fs";
|
|
1985
|
+
import { join as join5 } from "path";
|
|
1559
1986
|
import { homedir as homedir2 } from "os";
|
|
1560
|
-
var DEFAULT_PATH2 =
|
|
1987
|
+
var DEFAULT_PATH2 = join5(homedir2(), ".pulse", "events", "mpg-sessions.jsonl");
|
|
1561
1988
|
var RANGE_MS = {
|
|
1989
|
+
"3h": 3 * 60 * 60 * 1e3,
|
|
1990
|
+
"12h": 12 * 60 * 60 * 1e3,
|
|
1562
1991
|
"24h": 24 * 60 * 60 * 1e3,
|
|
1563
1992
|
"7d": 7 * 24 * 60 * 60 * 1e3,
|
|
1564
1993
|
"30d": 30 * 24 * 60 * 60 * 1e3
|
|
1565
1994
|
};
|
|
1566
1995
|
function readEvents(filePath, range) {
|
|
1567
|
-
if (!
|
|
1996
|
+
if (!existsSync4(filePath)) return [];
|
|
1568
1997
|
try {
|
|
1569
1998
|
const content = readFileSync3(filePath, "utf-8").trim();
|
|
1570
1999
|
if (!content) return [];
|
|
@@ -1586,13 +2015,23 @@ function readEvents(filePath, range) {
|
|
|
1586
2015
|
}
|
|
1587
2016
|
function bucketKey(timestamp, bucket) {
|
|
1588
2017
|
const d = new Date(timestamp);
|
|
1589
|
-
if (bucket === "
|
|
2018
|
+
if (bucket === "15min") {
|
|
2019
|
+
d.setMinutes(Math.floor(d.getMinutes() / 15) * 15, 0, 0);
|
|
2020
|
+
} else if (bucket === "hour") {
|
|
1590
2021
|
d.setMinutes(0, 0, 0);
|
|
1591
2022
|
} else {
|
|
1592
2023
|
d.setHours(0, 0, 0, 0);
|
|
1593
2024
|
}
|
|
1594
2025
|
return d.toISOString();
|
|
1595
2026
|
}
|
|
2027
|
+
function resolveNameFromDir(projectDir, dirToNameMap) {
|
|
2028
|
+
if (!dirToNameMap || !projectDir) return void 0;
|
|
2029
|
+
if (dirToNameMap[projectDir]) return dirToNameMap[projectDir];
|
|
2030
|
+
for (const [dir, name] of Object.entries(dirToNameMap)) {
|
|
2031
|
+
if (projectDir.startsWith(dir + "/")) return name;
|
|
2032
|
+
}
|
|
2033
|
+
return void 0;
|
|
2034
|
+
}
|
|
1596
2035
|
function createActivityEngine(filePath) {
|
|
1597
2036
|
const target = filePath ?? DEFAULT_PATH2;
|
|
1598
2037
|
function getEvents(range, eventType) {
|
|
@@ -1615,18 +2054,18 @@ function createActivityEngine(filePath) {
|
|
|
1615
2054
|
avg_session_duration_ms: endings.length > 0 ? totalDuration / endings.length : 0
|
|
1616
2055
|
};
|
|
1617
2056
|
},
|
|
1618
|
-
tokensByProject(range) {
|
|
2057
|
+
tokensByProject(range, dirToNameMap) {
|
|
1619
2058
|
const messages = getEvents(range, "message_completed");
|
|
1620
2059
|
const map = /* @__PURE__ */ new Map();
|
|
1621
2060
|
for (const e of messages) {
|
|
1622
|
-
const
|
|
1623
|
-
const row = map.get(
|
|
2061
|
+
const name = resolveNameFromDir(e.project_dir, dirToNameMap) ?? e.project_key;
|
|
2062
|
+
const row = map.get(name) ?? { project_name: name, input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cost_usd: 0, message_count: 0 };
|
|
1624
2063
|
row.input_tokens += Number(e.input_tokens) || 0;
|
|
1625
2064
|
row.output_tokens += Number(e.output_tokens) || 0;
|
|
1626
2065
|
row.cache_read_input_tokens += Number(e.cache_read_input_tokens) || 0;
|
|
1627
2066
|
row.cost_usd += Number(e.total_cost_usd) || 0;
|
|
1628
2067
|
row.message_count++;
|
|
1629
|
-
map.set(
|
|
2068
|
+
map.set(name, row);
|
|
1630
2069
|
}
|
|
1631
2070
|
return Array.from(map.values());
|
|
1632
2071
|
},
|
|
@@ -1653,6 +2092,17 @@ function createActivityEngine(filePath) {
|
|
|
1653
2092
|
const val = valueField ? Number(e[valueField]) || 0 : 1;
|
|
1654
2093
|
map.set(key, (map.get(key) ?? 0) + val);
|
|
1655
2094
|
}
|
|
2095
|
+
const now = /* @__PURE__ */ new Date();
|
|
2096
|
+
const start2 = new Date(now.getTime() - RANGE_MS[range]);
|
|
2097
|
+
const stepMs = bucket === "15min" ? 15 * 60 * 1e3 : bucket === "hour" ? 60 * 60 * 1e3 : 24 * 60 * 60 * 1e3;
|
|
2098
|
+
const startKey = bucketKey(start2.toISOString(), bucket);
|
|
2099
|
+
const cursor = new Date(startKey);
|
|
2100
|
+
const endTime = now.getTime();
|
|
2101
|
+
while (cursor.getTime() <= endTime) {
|
|
2102
|
+
const key = cursor.toISOString();
|
|
2103
|
+
if (!map.has(key)) map.set(key, 0);
|
|
2104
|
+
cursor.setTime(cursor.getTime() + stepMs);
|
|
2105
|
+
}
|
|
1656
2106
|
return Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0])).map(([b, value]) => ({ bucket: b, value }));
|
|
1657
2107
|
},
|
|
1658
2108
|
sessionDurations(range) {
|
|
@@ -1689,16 +2139,109 @@ function createActivityEngine(filePath) {
|
|
|
1689
2139
|
const messages = getEvents(range, "message_completed");
|
|
1690
2140
|
const totalInput = messages.reduce((s, e) => s + (Number(e.input_tokens) || 0), 0);
|
|
1691
2141
|
const cacheRead = messages.reduce((s, e) => s + (Number(e.cache_read_input_tokens) || 0), 0);
|
|
2142
|
+
const denominator = cacheRead + totalInput;
|
|
1692
2143
|
return {
|
|
1693
2144
|
total_input_tokens: totalInput,
|
|
1694
2145
|
cache_read_tokens: cacheRead,
|
|
1695
|
-
cache_hit_ratio:
|
|
2146
|
+
cache_hit_ratio: denominator > 0 ? cacheRead / denominator : 0
|
|
1696
2147
|
};
|
|
2148
|
+
},
|
|
2149
|
+
sessionTimeline(range, projectNameMap, dirToNameMap) {
|
|
2150
|
+
const events = readEvents(target, range);
|
|
2151
|
+
const TIMELINE_TYPES = /* @__PURE__ */ new Set(["session_start", "session_resume", "message_routed", "message_completed", "session_end", "session_idle"]);
|
|
2152
|
+
const relevant = events.filter((e) => TIMELINE_TYPES.has(e.event_type));
|
|
2153
|
+
const completedBySession = /* @__PURE__ */ new Map();
|
|
2154
|
+
for (const e of events) {
|
|
2155
|
+
if (e.event_type === "message_completed") {
|
|
2156
|
+
const list = completedBySession.get(e.session_id);
|
|
2157
|
+
if (list) list.push(e);
|
|
2158
|
+
else completedBySession.set(e.session_id, [e]);
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
const sessionMap = /* @__PURE__ */ new Map();
|
|
2162
|
+
for (const e of relevant) {
|
|
2163
|
+
const list = sessionMap.get(e.session_id);
|
|
2164
|
+
if (list) list.push(e);
|
|
2165
|
+
else sessionMap.set(e.session_id, [e]);
|
|
2166
|
+
}
|
|
2167
|
+
const result = [];
|
|
2168
|
+
for (const [sessionId, sessionEvents] of sessionMap) {
|
|
2169
|
+
sessionEvents.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
2170
|
+
let persona = "default";
|
|
2171
|
+
const startEvent = sessionEvents.find((e) => e.event_type === "session_start");
|
|
2172
|
+
if (startEvent && startEvent.agent_name) {
|
|
2173
|
+
persona = String(startEvent.agent_name);
|
|
2174
|
+
} else {
|
|
2175
|
+
const routedEvent = sessionEvents.find((e) => e.event_type === "message_routed");
|
|
2176
|
+
if (routedEvent && routedEvent.agent_target) {
|
|
2177
|
+
persona = String(routedEvent.agent_target);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
const projectDir = sessionEvents[0].project_dir;
|
|
2181
|
+
const projectKey = sessionEvents[0].project_key;
|
|
2182
|
+
const channelId = projectKey?.includes(":") ? projectKey.split(":")[0] : projectKey;
|
|
2183
|
+
const projectName = resolveNameFromDir(projectDir, dirToNameMap) ?? (projectNameMap && channelId ? projectNameMap[channelId] : void 0) ?? "unknown";
|
|
2184
|
+
const shortId = channelId ? channelId.slice(-8) : sessionId.substring(0, 8);
|
|
2185
|
+
const label = `${projectName}/${shortId}/${persona}`;
|
|
2186
|
+
const segments = [];
|
|
2187
|
+
let currentState = "idle";
|
|
2188
|
+
let segmentStart = sessionEvents[0].timestamp;
|
|
2189
|
+
for (let i = 1; i < sessionEvents.length; i++) {
|
|
2190
|
+
const e = sessionEvents[i];
|
|
2191
|
+
if (e.event_type === "message_routed" && currentState === "idle") {
|
|
2192
|
+
segments.push({ start: segmentStart, end: e.timestamp, state: "idle" });
|
|
2193
|
+
segmentStart = e.timestamp;
|
|
2194
|
+
currentState = "processing";
|
|
2195
|
+
} else if (e.event_type === "message_completed" && currentState === "processing") {
|
|
2196
|
+
segments.push({ start: segmentStart, end: e.timestamp, state: "processing" });
|
|
2197
|
+
segmentStart = e.timestamp;
|
|
2198
|
+
currentState = "idle";
|
|
2199
|
+
} else if (e.event_type === "session_resume") {
|
|
2200
|
+
if (i > 0) {
|
|
2201
|
+
const prevEvent = sessionEvents[i - 1];
|
|
2202
|
+
if (segmentStart !== prevEvent.timestamp) {
|
|
2203
|
+
segments.push({ start: segmentStart, end: prevEvent.timestamp, state: currentState });
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
segmentStart = e.timestamp;
|
|
2207
|
+
currentState = "idle";
|
|
2208
|
+
} else if (e.event_type === "session_end" || e.event_type === "session_idle") {
|
|
2209
|
+
segments.push({ start: segmentStart, end: e.timestamp, state: currentState });
|
|
2210
|
+
segmentStart = e.timestamp;
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
const lastEvent = sessionEvents[sessionEvents.length - 1];
|
|
2214
|
+
if (lastEvent.event_type !== "session_end" && lastEvent.event_type !== "session_idle") {
|
|
2215
|
+
if (segmentStart !== lastEvent.timestamp || segments.length === 0) {
|
|
2216
|
+
if (segments.length > 0 || sessionEvents.length > 1) {
|
|
2217
|
+
segments.push({ start: segmentStart, end: lastEvent.timestamp, state: currentState });
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
const completed = completedBySession.get(sessionId) || [];
|
|
2222
|
+
for (const seg of segments) {
|
|
2223
|
+
if (seg.state !== "processing") continue;
|
|
2224
|
+
const segStartMs = new Date(seg.start).getTime();
|
|
2225
|
+
const segEndMs = new Date(seg.end).getTime();
|
|
2226
|
+
const durationSec = (segEndMs - segStartMs) / 1e3;
|
|
2227
|
+
let tokenCount = 0;
|
|
2228
|
+
for (const ev of completed) {
|
|
2229
|
+
const evMs = new Date(ev.timestamp).getTime();
|
|
2230
|
+
if (evMs >= segStartMs && evMs <= segEndMs) {
|
|
2231
|
+
tokenCount += (Number(ev.input_tokens) || 0) + (Number(ev.output_tokens) || 0);
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
seg.token_count = tokenCount;
|
|
2235
|
+
seg.token_rate = durationSec > 0 ? Math.round(tokenCount / durationSec) : 0;
|
|
2236
|
+
}
|
|
2237
|
+
result.push({ session_id: sessionId, thread_id: channelId ?? "", label, segments });
|
|
2238
|
+
}
|
|
2239
|
+
return result;
|
|
1697
2240
|
}
|
|
1698
2241
|
};
|
|
1699
2242
|
}
|
|
1700
2243
|
|
|
1701
|
-
// src/
|
|
2244
|
+
// src/dashboard-server.ts
|
|
1702
2245
|
import { createServer } from "http";
|
|
1703
2246
|
import { readFileSync as readFileSync4 } from "fs";
|
|
1704
2247
|
function getVersion() {
|
|
@@ -1758,6 +2301,7 @@ function buildDashboardHtml() {
|
|
|
1758
2301
|
<div class="tabs">
|
|
1759
2302
|
<button class="tab active" onclick="switchTab('overview')">Overview</button>
|
|
1760
2303
|
<button class="tab" onclick="switchTab('activity')">Activity</button>
|
|
2304
|
+
<button class="tab" onclick="switchTab('timeline')">Timeline</button>
|
|
1761
2305
|
</div>
|
|
1762
2306
|
|
|
1763
2307
|
<div id="tab-overview">
|
|
@@ -1810,7 +2354,8 @@ function buildDashboardHtml() {
|
|
|
1810
2354
|
<div class="chart-card"><h3>Messages Over Time</h3><canvas id="messages-chart"></canvas></div>
|
|
1811
2355
|
<div class="chart-card"><h3>Cost Over Time</h3><canvas id="cost-chart"></canvas></div>
|
|
1812
2356
|
<div class="chart-card"><h3>Sessions Over Time</h3><canvas id="sessions-chart"></canvas></div>
|
|
1813
|
-
<div class="chart-card"><h3>Token Usage Over Time</h3><canvas id="tokens-chart"></canvas></div>
|
|
2357
|
+
<div class="chart-card"><h3>Token Usage Over Time (Input / Output)</h3><canvas id="tokens-chart"></canvas></div>
|
|
2358
|
+
<div class="chart-card"><h3>Cache Read Tokens Over Time</h3><canvas id="cache-chart-time"></canvas></div>
|
|
1814
2359
|
<div class="chart-card"><h3>Persona Breakdown</h3><canvas id="persona-chart"></canvas></div>
|
|
1815
2360
|
<div class="chart-card"><h3>Model Breakdown</h3><canvas id="model-chart"></canvas></div>
|
|
1816
2361
|
</div>
|
|
@@ -1822,6 +2367,17 @@ function buildDashboardHtml() {
|
|
|
1822
2367
|
<div id="cache-table"></div>
|
|
1823
2368
|
</div>
|
|
1824
2369
|
|
|
2370
|
+
<div id="tab-timeline" style="display:none">
|
|
2371
|
+
<div class="range-selector timeline-range">
|
|
2372
|
+
<button class="tl-range-btn range-btn active" data-range="3h">3h</button>
|
|
2373
|
+
<button class="tl-range-btn range-btn" data-range="12h">12h</button>
|
|
2374
|
+
<button class="tl-range-btn range-btn" data-range="24h">24h</button>
|
|
2375
|
+
<button class="tl-range-btn range-btn" data-range="7d">7d</button>
|
|
2376
|
+
<button class="tl-range-btn range-btn" data-range="30d">30d</button>
|
|
2377
|
+
</div>
|
|
2378
|
+
<div class="chart-card"><h3>Session Timeline</h3><canvas id="timeline-chart"></canvas><div id="timeline-tooltip" style="display:none;position:fixed;background:#161b22;border:1px solid #30363d;border-radius:6px;padding:8px 12px;color:#e1e4e8;font-size:12px;pointer-events:none;z-index:100;white-space:nowrap"></div></div>
|
|
2379
|
+
</div>
|
|
2380
|
+
|
|
1825
2381
|
<script>
|
|
1826
2382
|
function formatUptime(s) {
|
|
1827
2383
|
if (s < 60) return s + 's';
|
|
@@ -1836,6 +2392,19 @@ function formatAgo(ts) {
|
|
|
1836
2392
|
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
|
1837
2393
|
return Math.floor(diff / 3600) + 'h ago';
|
|
1838
2394
|
}
|
|
2395
|
+
function formatDuration(startTs) {
|
|
2396
|
+
var diff = Math.floor((Date.now() - startTs) / 1000);
|
|
2397
|
+
if (diff < 60) return diff + 's';
|
|
2398
|
+
if (diff < 3600) return Math.floor(diff / 60) + 'm';
|
|
2399
|
+
var h = Math.floor(diff / 3600);
|
|
2400
|
+
var m = Math.floor((diff % 3600) / 60);
|
|
2401
|
+
return h + 'h ' + m + 'm';
|
|
2402
|
+
}
|
|
2403
|
+
function sessionStatus(s) {
|
|
2404
|
+
if (s.processing) return '<span style="color:#3fb950">processing</span>';
|
|
2405
|
+
if (s.queueLength > 0) return '<span style="color:#d29922">waiting</span>';
|
|
2406
|
+
return '<span style="color:#8b949e">idle</span>';
|
|
2407
|
+
}
|
|
1839
2408
|
function statusClass(v) {
|
|
1840
2409
|
if (v === 'ok' || v === 'connected') return 'status-ok';
|
|
1841
2410
|
if (v === 'reconnecting') return 'status-warn';
|
|
@@ -1846,6 +2415,44 @@ function escapeHtml(s) {
|
|
|
1846
2415
|
d.textContent = s;
|
|
1847
2416
|
return d.innerHTML;
|
|
1848
2417
|
}
|
|
2418
|
+
function compactTokens(n) {
|
|
2419
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
2420
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
|
2421
|
+
return String(n);
|
|
2422
|
+
}
|
|
2423
|
+
function formatLocalTime(isoStr, isHourBucket) {
|
|
2424
|
+
var d = new Date(isoStr);
|
|
2425
|
+
if (isHourBucket) return String(d.getHours()).padStart(2, '0') + ':00';
|
|
2426
|
+
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
2427
|
+
return months[d.getMonth()] + ' ' + d.getDate();
|
|
2428
|
+
}
|
|
2429
|
+
function resolveProjectName(key, nameMap) {
|
|
2430
|
+
if (!key) return '\u2014';
|
|
2431
|
+
var parts = key.split(':');
|
|
2432
|
+
var channelId = parts[0];
|
|
2433
|
+
var agent = parts.length > 1 ? parts[1] : null;
|
|
2434
|
+
var name = (nameMap && nameMap[channelId]) ? nameMap[channelId] : channelId;
|
|
2435
|
+
return agent ? name + ':' + agent : name;
|
|
2436
|
+
}
|
|
2437
|
+
function resolveProjectNameFromDir(dir, dirMap) {
|
|
2438
|
+
if (!dir || !dirMap) return null;
|
|
2439
|
+
if (dirMap[dir]) return dirMap[dir];
|
|
2440
|
+
var keys = Object.keys(dirMap);
|
|
2441
|
+
for (var i = 0; i < keys.length; i++) {
|
|
2442
|
+
if (dir.indexOf(keys[i] + '/') === 0) return dirMap[keys[i]];
|
|
2443
|
+
}
|
|
2444
|
+
return null;
|
|
2445
|
+
}
|
|
2446
|
+
function copyToClipboard(text, el) {
|
|
2447
|
+
navigator.clipboard.writeText(text).then(function() {
|
|
2448
|
+
var orig = el.textContent;
|
|
2449
|
+
el.textContent = 'copied!';
|
|
2450
|
+
el.style.color = '#3fb950';
|
|
2451
|
+
setTimeout(function() { el.textContent = orig; el.style.color = ''; }, 1200);
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
var projectNameMap = {};
|
|
1849
2456
|
|
|
1850
2457
|
function refresh() {
|
|
1851
2458
|
fetch('/api/status')
|
|
@@ -1862,15 +2469,33 @@ function refresh() {
|
|
|
1862
2469
|
discordEl.textContent = d.health.discord;
|
|
1863
2470
|
discordEl.className = 'card-value ' + statusClass(d.health.discord);
|
|
1864
2471
|
|
|
1865
|
-
//
|
|
2472
|
+
// Build name maps from projects list
|
|
2473
|
+
var dirToNameMap = {};
|
|
2474
|
+
if (d.projects) {
|
|
2475
|
+
d.projects.forEach(function(p) {
|
|
2476
|
+
if (p.channelId && p.name) projectNameMap[p.channelId] = p.name;
|
|
2477
|
+
if (p.directory && p.name) dirToNameMap[p.directory] = p.name;
|
|
2478
|
+
});
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
// Sessions table \u2014 sort by most recent last activity first
|
|
2482
|
+
d.sessions.sort(function(a, b) {
|
|
2483
|
+
return (b.lastActivity || 0) - (a.lastActivity || 0);
|
|
2484
|
+
});
|
|
1866
2485
|
var st = document.getElementById('sessions-table');
|
|
1867
2486
|
if (d.sessions.length === 0) {
|
|
1868
2487
|
st.innerHTML = '<div class="empty">No active sessions</div>';
|
|
1869
2488
|
} else {
|
|
1870
|
-
var h = '<table><tr><th>
|
|
2489
|
+
var h = '<table><tr><th>Session</th><th>Status</th><th>Duration</th><th>Last Activity</th></tr>';
|
|
1871
2490
|
for (var i = 0; i < d.sessions.length; i++) {
|
|
1872
2491
|
var s = d.sessions[i];
|
|
1873
|
-
|
|
2492
|
+
var sid = s.sessionId || '';
|
|
2493
|
+
var shortId = sid ? sid.slice(0, 8) : '';
|
|
2494
|
+
var pkParts = (s.projectKey || '').split(':');
|
|
2495
|
+
var projName = resolveProjectNameFromDir(s.projectDir || s.cwd, dirToNameMap) || (projectNameMap && projectNameMap[pkParts[0]] ? projectNameMap[pkParts[0]] : null) || pkParts[0];
|
|
2496
|
+
var role = pkParts.length > 1 ? pkParts[1] : '';
|
|
2497
|
+
var sessionLabel = projName && shortId ? (role ? projName + '/' + shortId + '/' + role : projName + '/' + shortId) : (shortId || '\u2014');
|
|
2498
|
+
h += '<tr><td><span class="clickable-id" style="cursor:pointer;text-decoration:underline dotted" title="Click to copy: ' + escapeHtml(sid) + '" onclick="copyToClipboard(\\'' + escapeHtml(sid) + '\\', this)">' + escapeHtml(sessionLabel) + '</span></td><td>' + sessionStatus(s) + '</td><td>' + formatDuration(s.createdAt) + '</td><td>' + formatAgo(s.lastActivity) + '</td></tr>';
|
|
1874
2499
|
}
|
|
1875
2500
|
h += '</table>';
|
|
1876
2501
|
st.innerHTML = h;
|
|
@@ -1900,7 +2525,8 @@ refresh();
|
|
|
1900
2525
|
setInterval(refresh, 5000);
|
|
1901
2526
|
|
|
1902
2527
|
var chartInstances = {};
|
|
1903
|
-
var currentRange = '
|
|
2528
|
+
var currentRange = '24h';
|
|
2529
|
+
var timelineRange = '3h';
|
|
1904
2530
|
var CHART_COLORS = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#79c0ff'];
|
|
1905
2531
|
|
|
1906
2532
|
function switchTab(tab) {
|
|
@@ -1910,41 +2536,309 @@ function switchTab(tab) {
|
|
|
1910
2536
|
});
|
|
1911
2537
|
document.getElementById('tab-overview').style.display = tab === 'overview' ? '' : 'none';
|
|
1912
2538
|
document.getElementById('tab-activity').style.display = tab === 'activity' ? '' : 'none';
|
|
2539
|
+
document.getElementById('tab-timeline').style.display = tab === 'timeline' ? '' : 'none';
|
|
1913
2540
|
if (tab === 'activity') refreshActivity();
|
|
2541
|
+
if (tab === 'timeline') refreshTimeline();
|
|
1914
2542
|
}
|
|
1915
2543
|
|
|
1916
|
-
document.querySelectorAll('.range-btn').forEach(function(btn) {
|
|
2544
|
+
document.querySelectorAll('#tab-activity .range-btn').forEach(function(btn) {
|
|
1917
2545
|
btn.addEventListener('click', function() {
|
|
1918
|
-
document.querySelectorAll('.range-btn').forEach(function(b) { b.classList.remove('active'); });
|
|
2546
|
+
document.querySelectorAll('#tab-activity .range-btn').forEach(function(b) { b.classList.remove('active'); });
|
|
1919
2547
|
btn.classList.add('active');
|
|
1920
2548
|
currentRange = btn.dataset.range;
|
|
1921
2549
|
refreshActivity();
|
|
1922
2550
|
});
|
|
1923
2551
|
});
|
|
1924
2552
|
|
|
2553
|
+
document.querySelectorAll('.tl-range-btn').forEach(function(btn) {
|
|
2554
|
+
btn.addEventListener('click', function() {
|
|
2555
|
+
document.querySelectorAll('.tl-range-btn').forEach(function(b) { b.classList.remove('active'); });
|
|
2556
|
+
btn.classList.add('active');
|
|
2557
|
+
timelineRange = btn.dataset.range;
|
|
2558
|
+
refreshTimeline();
|
|
2559
|
+
});
|
|
2560
|
+
});
|
|
2561
|
+
|
|
1925
2562
|
function destroyChart(key) {
|
|
1926
2563
|
if (chartInstances[key]) { chartInstances[key].destroy(); chartInstances[key] = null; }
|
|
1927
2564
|
}
|
|
1928
2565
|
|
|
2566
|
+
function timeAxisOptions(isHourBucket) {
|
|
2567
|
+
return {
|
|
2568
|
+
ticks: {
|
|
2569
|
+
color: '#8b949e',
|
|
2570
|
+
callback: function(value, index, ticks) {
|
|
2571
|
+
var label = this.getLabelForValue(value);
|
|
2572
|
+
return formatLocalTime(label, isHourBucket);
|
|
2573
|
+
}
|
|
2574
|
+
},
|
|
2575
|
+
grid: { color: '#30363d' }
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
function formatSegmentDuration(startIso, endIso) {
|
|
2580
|
+
var ms = new Date(endIso).getTime() - new Date(startIso).getTime();
|
|
2581
|
+
if (ms < 1000) return ms + 'ms';
|
|
2582
|
+
var s = Math.floor(ms / 1000);
|
|
2583
|
+
if (s < 60) return s + 's';
|
|
2584
|
+
var m = Math.floor(s / 60);
|
|
2585
|
+
s = s % 60;
|
|
2586
|
+
if (m < 60) return m + 'm ' + s + 's';
|
|
2587
|
+
var h = Math.floor(m / 60);
|
|
2588
|
+
m = m % 60;
|
|
2589
|
+
return h + 'h ' + m + 'm';
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
var _tlHitRects = [];
|
|
2593
|
+
|
|
2594
|
+
function refreshTimeline() {
|
|
2595
|
+
var RANGE_MS = { '3h': 10800000, '12h': 43200000, '24h': 86400000, '7d': 604800000, '30d': 2592000000 };
|
|
2596
|
+
var now = Date.now();
|
|
2597
|
+
var rangeMs = RANGE_MS[timelineRange] || RANGE_MS['3h'];
|
|
2598
|
+
var xMin = now - rangeMs;
|
|
2599
|
+
var xMax = now;
|
|
2600
|
+
|
|
2601
|
+
fetch('/api/activity/timeline?range=' + timelineRange)
|
|
2602
|
+
.then(function(r) { return r.json(); })
|
|
2603
|
+
.then(function(sessions) {
|
|
2604
|
+
var canvas = document.getElementById('timeline-chart');
|
|
2605
|
+
var dpr = window.devicePixelRatio || 1;
|
|
2606
|
+
var containerW = canvas.parentElement.clientWidth - 32;
|
|
2607
|
+
|
|
2608
|
+
// Filter out sessions with only idle segments (no processing)
|
|
2609
|
+
var activeSessions = sessions ? sessions.filter(function(s) {
|
|
2610
|
+
return s.segments.some(function(seg) { return seg.state === 'processing'; });
|
|
2611
|
+
}) : [];
|
|
2612
|
+
|
|
2613
|
+
var LABEL_W = 160;
|
|
2614
|
+
var ROW_H = 32;
|
|
2615
|
+
var HEADER_H = 28;
|
|
2616
|
+
var FOOTER_H = 8;
|
|
2617
|
+
var chartW = containerW;
|
|
2618
|
+
|
|
2619
|
+
if (!activeSessions.length) {
|
|
2620
|
+
var h = 60;
|
|
2621
|
+
canvas.width = Math.floor(chartW * dpr);
|
|
2622
|
+
canvas.height = Math.floor(h * dpr);
|
|
2623
|
+
canvas.style.width = chartW + 'px';
|
|
2624
|
+
canvas.style.height = h + 'px';
|
|
2625
|
+
var c = canvas.getContext('2d');
|
|
2626
|
+
c.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
2627
|
+
c.fillStyle = '#0d1117';
|
|
2628
|
+
c.fillRect(0, 0, chartW, h);
|
|
2629
|
+
c.fillStyle = '#8b949e';
|
|
2630
|
+
c.font = '13px -apple-system, sans-serif';
|
|
2631
|
+
c.textAlign = 'center';
|
|
2632
|
+
c.fillText('No active sessions in range', chartW / 2, h / 2 + 4);
|
|
2633
|
+
_tlHitRects = [];
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
var LEGEND_H = 28;
|
|
2638
|
+
var totalH = HEADER_H + activeSessions.length * ROW_H + FOOTER_H + LEGEND_H;
|
|
2639
|
+
canvas.width = Math.floor(chartW * dpr);
|
|
2640
|
+
canvas.height = Math.floor(totalH * dpr);
|
|
2641
|
+
canvas.style.width = chartW + 'px';
|
|
2642
|
+
canvas.style.height = totalH + 'px';
|
|
2643
|
+
var ctx = canvas.getContext('2d');
|
|
2644
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
2645
|
+
|
|
2646
|
+
// Background
|
|
2647
|
+
ctx.fillStyle = '#0d1117';
|
|
2648
|
+
ctx.fillRect(0, 0, chartW, totalH);
|
|
2649
|
+
|
|
2650
|
+
var plotLeft = LABEL_W;
|
|
2651
|
+
var plotRight = chartW - 12;
|
|
2652
|
+
var plotW = plotRight - plotLeft;
|
|
2653
|
+
|
|
2654
|
+
function timeToX(t) {
|
|
2655
|
+
return plotLeft + ((t - xMin) / (xMax - xMin)) * plotW;
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
// X-axis time labels at top
|
|
2659
|
+
ctx.fillStyle = '#8b949e';
|
|
2660
|
+
ctx.font = '10px -apple-system, sans-serif';
|
|
2661
|
+
ctx.textAlign = 'center';
|
|
2662
|
+
var tickCount = Math.max(2, Math.min(8, Math.floor(plotW / 90)));
|
|
2663
|
+
for (var ti = 0; ti <= tickCount; ti++) {
|
|
2664
|
+
var t = xMin + (ti / tickCount) * (xMax - xMin);
|
|
2665
|
+
var tx = timeToX(t);
|
|
2666
|
+
var d = new Date(t);
|
|
2667
|
+
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
2668
|
+
var lbl = months[d.getMonth()] + ' ' + d.getDate() + ' ' + String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0');
|
|
2669
|
+
ctx.fillText(lbl, tx, 14);
|
|
2670
|
+
// Grid line
|
|
2671
|
+
ctx.strokeStyle = '#30363d';
|
|
2672
|
+
ctx.lineWidth = 0.5;
|
|
2673
|
+
ctx.beginPath();
|
|
2674
|
+
ctx.moveTo(tx, HEADER_H);
|
|
2675
|
+
ctx.lineTo(tx, totalH - FOOTER_H);
|
|
2676
|
+
ctx.stroke();
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
_tlHitRects = [];
|
|
2680
|
+
|
|
2681
|
+
// Compute maxTokenRate across all visible processing segments for normalization
|
|
2682
|
+
var maxTokenRate = 0;
|
|
2683
|
+
for (var mi = 0; mi < activeSessions.length; mi++) {
|
|
2684
|
+
for (var mj = 0; mj < activeSessions[mi].segments.length; mj++) {
|
|
2685
|
+
var mseg = activeSessions[mi].segments[mj];
|
|
2686
|
+
if (mseg.state === 'processing' && mseg.token_rate > maxTokenRate) {
|
|
2687
|
+
maxTokenRate = mseg.token_rate;
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
function tokenRateColor(rate) {
|
|
2693
|
+
if (!maxTokenRate || !rate) return 'hsl(220, 40%, 55%)';
|
|
2694
|
+
var t = Math.min(rate / maxTokenRate, 1);
|
|
2695
|
+
// Interpolate: low rate \u2192 cool blue, high rate \u2192 warm orange
|
|
2696
|
+
var hue = 220 - t * 190; // 220 (blue) \u2192 30 (orange)
|
|
2697
|
+
var sat = 40 + t * 40; // 40% \u2192 80%
|
|
2698
|
+
var light = 55 - t * 15; // 55% \u2192 40%
|
|
2699
|
+
return 'hsl(' + hue + ', ' + sat + '%, ' + light + '%)';
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
for (var ri = 0; ri < activeSessions.length; ri++) {
|
|
2703
|
+
var sess = activeSessions[ri];
|
|
2704
|
+
var rowY = HEADER_H + ri * ROW_H;
|
|
2705
|
+
var barY = rowY + 6;
|
|
2706
|
+
var barH = ROW_H - 12;
|
|
2707
|
+
|
|
2708
|
+
// Y-axis label
|
|
2709
|
+
ctx.fillStyle = '#8b949e';
|
|
2710
|
+
ctx.font = '11px monospace';
|
|
2711
|
+
ctx.textAlign = 'right';
|
|
2712
|
+
ctx.fillText(sess.label, LABEL_W - 8, rowY + ROW_H / 2 + 4);
|
|
2713
|
+
|
|
2714
|
+
// Row separator
|
|
2715
|
+
ctx.strokeStyle = '#21262d';
|
|
2716
|
+
ctx.lineWidth = 0.5;
|
|
2717
|
+
ctx.beginPath();
|
|
2718
|
+
ctx.moveTo(plotLeft, rowY + ROW_H);
|
|
2719
|
+
ctx.lineTo(plotRight, rowY + ROW_H);
|
|
2720
|
+
ctx.stroke();
|
|
2721
|
+
|
|
2722
|
+
// Draw segments
|
|
2723
|
+
for (var si = 0; si < sess.segments.length; si++) {
|
|
2724
|
+
var seg = sess.segments[si];
|
|
2725
|
+
var segStart = Math.max(new Date(seg.start).getTime(), xMin);
|
|
2726
|
+
var segEnd = Math.min(new Date(seg.end).getTime(), xMax);
|
|
2727
|
+
if (segStart >= segEnd) continue;
|
|
2728
|
+
|
|
2729
|
+
var x1 = timeToX(segStart);
|
|
2730
|
+
var x2 = timeToX(segEnd);
|
|
2731
|
+
var w = Math.max(x2 - x1, 1);
|
|
2732
|
+
|
|
2733
|
+
ctx.fillStyle = seg.state === 'processing' ? tokenRateColor(seg.token_rate || 0) : '#484f58';
|
|
2734
|
+
ctx.fillRect(x1, barY, w, barH);
|
|
2735
|
+
|
|
2736
|
+
_tlHitRects.push({ x: x1, y: barY, w: w, h: barH, label: sess.label, state: seg.state, start: seg.start, end: seg.end, token_count: seg.token_count || 0, token_rate: seg.token_rate || 0 });
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
// Draw intensity legend below the chart
|
|
2741
|
+
if (maxTokenRate > 0) {
|
|
2742
|
+
var lgX = plotLeft;
|
|
2743
|
+
var lgY = totalH - LEGEND_H + 4;
|
|
2744
|
+
var lgW = 120;
|
|
2745
|
+
var lgH = 10;
|
|
2746
|
+
|
|
2747
|
+
ctx.fillStyle = '#8b949e';
|
|
2748
|
+
ctx.font = '10px -apple-system, sans-serif';
|
|
2749
|
+
ctx.textAlign = 'left';
|
|
2750
|
+
ctx.fillText('Token rate:', lgX, lgY + 9);
|
|
2751
|
+
|
|
2752
|
+
var gradX = lgX + 68;
|
|
2753
|
+
// Draw gradient bar
|
|
2754
|
+
for (var gi = 0; gi < lgW; gi++) {
|
|
2755
|
+
var gt = gi / lgW;
|
|
2756
|
+
var gHue = 220 - gt * 190; // blue \u2192 orange
|
|
2757
|
+
var gSat = 40 + gt * 40;
|
|
2758
|
+
var gLight = 55 - gt * 15;
|
|
2759
|
+
ctx.fillStyle = 'hsl(' + gHue + ', ' + gSat + '%, ' + gLight + '%)';
|
|
2760
|
+
ctx.fillRect(gradX + gi, lgY + 1, 1, lgH);
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
ctx.fillStyle = '#8b949e';
|
|
2764
|
+
ctx.font = '9px -apple-system, sans-serif';
|
|
2765
|
+
ctx.textAlign = 'left';
|
|
2766
|
+
ctx.fillText('0', gradX, lgY + 22);
|
|
2767
|
+
ctx.textAlign = 'right';
|
|
2768
|
+
ctx.fillText(compactTokens(maxTokenRate) + '/s', gradX + lgW, lgY + 22);
|
|
2769
|
+
|
|
2770
|
+
// Idle swatch
|
|
2771
|
+
var idleX = gradX + lgW + 16;
|
|
2772
|
+
ctx.fillStyle = '#484f58';
|
|
2773
|
+
ctx.fillRect(idleX, lgY + 1, lgH, lgH);
|
|
2774
|
+
ctx.fillStyle = '#8b949e';
|
|
2775
|
+
ctx.font = '9px -apple-system, sans-serif';
|
|
2776
|
+
ctx.textAlign = 'left';
|
|
2777
|
+
ctx.fillText('Idle', idleX + lgH + 4, lgY + 9);
|
|
2778
|
+
}
|
|
2779
|
+
})
|
|
2780
|
+
.catch(function(err) { console.error('Timeline fetch error:', err); });
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
// Timeline tooltip via mousemove
|
|
2784
|
+
(function() {
|
|
2785
|
+
var canvas = document.getElementById('timeline-chart');
|
|
2786
|
+
var tooltip = document.getElementById('timeline-tooltip');
|
|
2787
|
+
canvas.addEventListener('mousemove', function(e) {
|
|
2788
|
+
var rect = canvas.getBoundingClientRect();
|
|
2789
|
+
var dpr = window.devicePixelRatio || 1;
|
|
2790
|
+
var mx = (e.clientX - rect.left);
|
|
2791
|
+
var my = (e.clientY - rect.top);
|
|
2792
|
+
var hit = null;
|
|
2793
|
+
for (var i = _tlHitRects.length - 1; i >= 0; i--) {
|
|
2794
|
+
var r = _tlHitRects[i];
|
|
2795
|
+
if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) { hit = r; break; }
|
|
2796
|
+
}
|
|
2797
|
+
if (hit) {
|
|
2798
|
+
var state = hit.state.charAt(0).toUpperCase() + hit.state.slice(1);
|
|
2799
|
+
var tokenInfo = '';
|
|
2800
|
+
if (hit.state === 'processing' && hit.token_count > 0) {
|
|
2801
|
+
tokenInfo = '<br>Tokens: ' + compactTokens(hit.token_count) + ' (' + compactTokens(hit.token_rate) + ' tok/s)';
|
|
2802
|
+
}
|
|
2803
|
+
tooltip.innerHTML = '<strong>' + hit.label + '</strong><br>' + state + ': ' + formatSegmentDuration(hit.start, hit.end) + tokenInfo;
|
|
2804
|
+
tooltip.style.display = 'block';
|
|
2805
|
+
tooltip.style.left = (e.clientX + 12) + 'px';
|
|
2806
|
+
tooltip.style.top = (e.clientY - 10) + 'px';
|
|
2807
|
+
} else {
|
|
2808
|
+
tooltip.style.display = 'none';
|
|
2809
|
+
}
|
|
2810
|
+
});
|
|
2811
|
+
canvas.addEventListener('mouseleave', function() {
|
|
2812
|
+
tooltip.style.display = 'none';
|
|
2813
|
+
});
|
|
2814
|
+
})();
|
|
2815
|
+
|
|
1929
2816
|
function refreshActivity() {
|
|
2817
|
+
var isHourBucket = currentRange === '24h';
|
|
1930
2818
|
fetch('/api/activity/summary?range=' + currentRange)
|
|
1931
2819
|
.then(function(r) { return r.json(); })
|
|
1932
2820
|
.then(function(d) {
|
|
2821
|
+
var nameMap = d.project_name_map || {};
|
|
2822
|
+
// Merge into global map
|
|
2823
|
+
Object.keys(nameMap).forEach(function(k) { projectNameMap[k] = nameMap[k]; });
|
|
2824
|
+
|
|
1933
2825
|
// Summary cards
|
|
1934
2826
|
var s = d.summary;
|
|
1935
2827
|
document.getElementById('total-cost').textContent = '$' + s.total_cost_usd.toFixed(2);
|
|
1936
2828
|
var totalTok = s.total_input_tokens + s.total_output_tokens;
|
|
1937
|
-
document.getElementById('total-tokens').textContent =
|
|
2829
|
+
document.getElementById('total-tokens').textContent = compactTokens(totalTok);
|
|
1938
2830
|
document.getElementById('total-sessions-card').textContent = String(s.total_sessions);
|
|
1939
2831
|
document.getElementById('total-messages').textContent = String(s.total_messages);
|
|
1940
2832
|
document.getElementById('avg-duration').textContent = Math.round(s.avg_session_duration_ms / 60000) + 'm';
|
|
1941
2833
|
|
|
1942
|
-
|
|
2834
|
+
var xOpts = timeAxisOptions(isHourBucket);
|
|
2835
|
+
|
|
2836
|
+
// Messages Over Time (line)
|
|
1943
2837
|
destroyChart('messages');
|
|
1944
2838
|
chartInstances['messages'] = new Chart(document.getElementById('messages-chart'), {
|
|
1945
|
-
type: '
|
|
1946
|
-
data: { labels: d.messages_over_time.map(function(e) { return e.bucket; }), datasets: [{ label: 'Messages', data: d.messages_over_time.map(function(e) { return e.value; }),
|
|
1947
|
-
options: { scales: { y: { beginAtZero: true, ticks: { color: '#8b949e' }, grid: { color: '#30363d' } }, x:
|
|
2839
|
+
type: 'line',
|
|
2840
|
+
data: { labels: d.messages_over_time.map(function(e) { return e.bucket; }), datasets: [{ label: 'Messages', data: d.messages_over_time.map(function(e) { return e.value; }), borderColor: '#58a6ff', tension: 0.3 }] },
|
|
2841
|
+
options: { scales: { y: { beginAtZero: true, ticks: { color: '#8b949e' }, grid: { color: '#30363d' } }, x: xOpts }, plugins: { legend: { display: false } } }
|
|
1948
2842
|
});
|
|
1949
2843
|
|
|
1950
2844
|
// Cost Over Time (line)
|
|
@@ -1952,23 +2846,43 @@ function refreshActivity() {
|
|
|
1952
2846
|
chartInstances['cost'] = new Chart(document.getElementById('cost-chart'), {
|
|
1953
2847
|
type: 'line',
|
|
1954
2848
|
data: { labels: d.cost_over_time.map(function(e) { return e.bucket; }), datasets: [{ label: 'Cost ($)', data: d.cost_over_time.map(function(e) { return e.value; }), borderColor: '#3fb950', tension: 0.3 }] },
|
|
1955
|
-
options: { scales: { y: { beginAtZero: true, ticks: { color: '#8b949e' }, grid: { color: '#30363d' } }, x:
|
|
2849
|
+
options: { scales: { y: { beginAtZero: true, ticks: { color: '#8b949e' }, grid: { color: '#30363d' } }, x: xOpts }, plugins: { legend: { display: false } } }
|
|
1956
2850
|
});
|
|
1957
2851
|
|
|
1958
|
-
// Sessions Over Time (
|
|
2852
|
+
// Sessions Over Time (line)
|
|
1959
2853
|
destroyChart('sessions');
|
|
1960
2854
|
chartInstances['sessions'] = new Chart(document.getElementById('sessions-chart'), {
|
|
1961
|
-
type: '
|
|
1962
|
-
data: { labels: d.sessions_over_time.map(function(e) { return e.bucket; }), datasets: [{ label: 'Sessions', data: d.sessions_over_time.map(function(e) { return e.value; }),
|
|
1963
|
-
options: { scales: { y: { beginAtZero: true, ticks: { color: '#8b949e', stepSize: 1 }, grid: { color: '#30363d' } }, x:
|
|
2855
|
+
type: 'line',
|
|
2856
|
+
data: { labels: d.sessions_over_time.map(function(e) { return e.bucket; }), datasets: [{ label: 'Sessions', data: d.sessions_over_time.map(function(e) { return e.value; }), borderColor: '#d29922', tension: 0.3 }] },
|
|
2857
|
+
options: { scales: { y: { beginAtZero: true, ticks: { color: '#8b949e', stepSize: 1 }, grid: { color: '#30363d' } }, x: xOpts }, plugins: { legend: { display: false } } }
|
|
1964
2858
|
});
|
|
1965
2859
|
|
|
1966
|
-
// Token Usage Over Time
|
|
2860
|
+
// Token Usage Over Time \u2014 Input + Output stacked (NO cache reads)
|
|
2861
|
+
var allBuckets = {};
|
|
2862
|
+
(d.input_tokens_over_time || []).forEach(function(e) { allBuckets[e.bucket] = true; });
|
|
2863
|
+
(d.output_tokens_over_time || []).forEach(function(e) { allBuckets[e.bucket] = true; });
|
|
2864
|
+
var bucketKeys = Object.keys(allBuckets).sort();
|
|
2865
|
+
var inputMap = {}; (d.input_tokens_over_time || []).forEach(function(e) { inputMap[e.bucket] = e.value; });
|
|
2866
|
+
var outputMap = {}; (d.output_tokens_over_time || []).forEach(function(e) { outputMap[e.bucket] = e.value; });
|
|
1967
2867
|
destroyChart('tokens');
|
|
1968
2868
|
chartInstances['tokens'] = new Chart(document.getElementById('tokens-chart'), {
|
|
1969
2869
|
type: 'bar',
|
|
1970
|
-
data: {
|
|
1971
|
-
|
|
2870
|
+
data: {
|
|
2871
|
+
labels: bucketKeys,
|
|
2872
|
+
datasets: [
|
|
2873
|
+
{ label: 'Input', data: bucketKeys.map(function(k) { return inputMap[k] || 0; }), backgroundColor: '#58a6ff' },
|
|
2874
|
+
{ label: 'Output', data: bucketKeys.map(function(k) { return outputMap[k] || 0; }), backgroundColor: '#3fb950' }
|
|
2875
|
+
]
|
|
2876
|
+
},
|
|
2877
|
+
options: { scales: { y: { beginAtZero: true, stacked: true, ticks: { color: '#8b949e', callback: function(v) { return compactTokens(v); } }, grid: { color: '#30363d' } }, x: Object.assign({}, xOpts, { stacked: true }) }, plugins: { legend: { labels: { color: '#8b949e' } } } }
|
|
2878
|
+
});
|
|
2879
|
+
|
|
2880
|
+
// Cache Read Tokens Over Time \u2014 separate chart
|
|
2881
|
+
destroyChart('cache-time');
|
|
2882
|
+
chartInstances['cache-time'] = new Chart(document.getElementById('cache-chart-time'), {
|
|
2883
|
+
type: 'bar',
|
|
2884
|
+
data: { labels: (d.cache_read_over_time || []).map(function(e) { return e.bucket; }), datasets: [{ label: 'Cache Read', data: (d.cache_read_over_time || []).map(function(e) { return e.value; }), backgroundColor: '#bc8cff' }] },
|
|
2885
|
+
options: { scales: { y: { beginAtZero: true, ticks: { color: '#8b949e', callback: function(v) { return compactTokens(v); } }, grid: { color: '#30363d' } }, x: xOpts }, plugins: { legend: { display: false } } }
|
|
1972
2886
|
});
|
|
1973
2887
|
|
|
1974
2888
|
// Persona Breakdown (doughnut)
|
|
@@ -1991,28 +2905,31 @@ function refreshActivity() {
|
|
|
1991
2905
|
});
|
|
1992
2906
|
}
|
|
1993
2907
|
|
|
1994
|
-
// Token Usage by Project table
|
|
2908
|
+
// Token Usage by Project table \u2014 resolve names, compact token notation
|
|
1995
2909
|
var pt = document.getElementById('project-table');
|
|
1996
2910
|
if (d.tokens_by_project.length === 0) { pt.innerHTML = '<div class="empty">No data</div>'; }
|
|
1997
2911
|
else {
|
|
1998
2912
|
var h = '<table><tr><th>Project</th><th>Input</th><th>Output</th><th>Cache Read</th><th>Cost</th><th>Messages</th></tr>';
|
|
1999
|
-
d.tokens_by_project.forEach(function(p) { h += '<tr><td>' + escapeHtml(p.
|
|
2913
|
+
d.tokens_by_project.forEach(function(p) { h += '<tr><td>' + escapeHtml(p.project_name || 'unknown') + '</td><td>' + compactTokens(p.input_tokens) + '</td><td>' + compactTokens(p.output_tokens) + '</td><td>' + compactTokens(p.cache_read_input_tokens) + '</td><td>$' + p.cost_usd.toFixed(3) + '</td><td>' + p.message_count + '</td></tr>'; });
|
|
2000
2914
|
pt.innerHTML = h + '</table>';
|
|
2001
2915
|
}
|
|
2002
2916
|
|
|
2003
|
-
// Token Usage by Session table
|
|
2917
|
+
// Token Usage by Session table \u2014 resolve names, copy-to-clipboard session IDs
|
|
2004
2918
|
var st = document.getElementById('session-table');
|
|
2005
2919
|
if (d.tokens_by_session.length === 0) { st.innerHTML = '<div class="empty">No data</div>'; }
|
|
2006
2920
|
else {
|
|
2007
2921
|
var h2 = '<table><tr><th>Session</th><th>Project</th><th>Input</th><th>Output</th><th>Cost</th><th>Msgs</th><th>Duration</th></tr>';
|
|
2008
|
-
d.tokens_by_session.forEach(function(row) {
|
|
2922
|
+
d.tokens_by_session.forEach(function(row) {
|
|
2923
|
+
var shortId = row.session_id.substring(0, 8) + '...';
|
|
2924
|
+
h2 += '<tr><td><span class="clickable-id" style="cursor:pointer;text-decoration:underline dotted" title="Click to copy: ' + escapeHtml(row.session_id) + '" onclick="copyToClipboard(\\'' + escapeHtml(row.session_id) + '\\', this)">' + escapeHtml(shortId) + '</span></td><td>' + escapeHtml(resolveProjectName(row.project_key, nameMap)) + '</td><td>' + compactTokens(row.input_tokens) + '</td><td>' + compactTokens(row.output_tokens) + '</td><td>$' + row.cost_usd.toFixed(3) + '</td><td>' + row.message_count + '</td><td>' + Math.round(row.duration_ms / 60000) + 'm</td></tr>';
|
|
2925
|
+
});
|
|
2009
2926
|
st.innerHTML = h2 + '</table>';
|
|
2010
2927
|
}
|
|
2011
2928
|
|
|
2012
2929
|
// Cache Efficiency table
|
|
2013
2930
|
var ct = document.getElementById('cache-table');
|
|
2014
2931
|
var ce = d.cache_efficiency;
|
|
2015
|
-
ct.innerHTML = '<table><tr><th>Total Input</th><th>Cache Read</th><th>Hit Ratio</th></tr><tr><td>' + ce.total_input_tokens
|
|
2932
|
+
ct.innerHTML = '<table><tr><th>Total Input</th><th>Cache Read</th><th>Hit Ratio</th></tr><tr><td>' + compactTokens(ce.total_input_tokens) + '</td><td>' + compactTokens(ce.cache_read_tokens) + '</td><td>' + (ce.cache_hit_ratio * 100).toFixed(1) + '%</td></tr></table>';
|
|
2016
2933
|
})
|
|
2017
2934
|
.catch(function(err) { console.error('Activity fetch error:', err); });
|
|
2018
2935
|
}
|
|
@@ -2021,12 +2938,15 @@ setInterval(function() {
|
|
|
2021
2938
|
if (document.getElementById('tab-activity').style.display !== 'none') {
|
|
2022
2939
|
refreshActivity();
|
|
2023
2940
|
}
|
|
2941
|
+
if (document.getElementById('tab-timeline').style.display !== 'none') {
|
|
2942
|
+
refreshTimeline();
|
|
2943
|
+
}
|
|
2024
2944
|
}, 30000);
|
|
2025
2945
|
</script>
|
|
2026
2946
|
</body>
|
|
2027
2947
|
</html>`;
|
|
2028
2948
|
}
|
|
2029
|
-
function
|
|
2949
|
+
function createDashboardServer(port, sessionManager, bot, config, options) {
|
|
2030
2950
|
const startTime = Date.now();
|
|
2031
2951
|
const version2 = getVersion();
|
|
2032
2952
|
const dashboardHtml = buildDashboardHtml();
|
|
@@ -2087,6 +3007,38 @@ function createHealthServer(port, sessionManager, bot, config, options) {
|
|
|
2087
3007
|
res.end(body);
|
|
2088
3008
|
return;
|
|
2089
3009
|
}
|
|
3010
|
+
if (pathname === "/api/activity/timeline") {
|
|
3011
|
+
const url = new URL(req.url ?? "/", `http://localhost`);
|
|
3012
|
+
const rangeParam = url.searchParams.get("range") || "7d";
|
|
3013
|
+
if (rangeParam !== "3h" && rangeParam !== "12h" && rangeParam !== "24h" && rangeParam !== "7d" && rangeParam !== "30d") {
|
|
3014
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
3015
|
+
res.end(JSON.stringify({ error: "Invalid range. Must be 3h, 12h, 24h, 7d, or 30d" }));
|
|
3016
|
+
return;
|
|
3017
|
+
}
|
|
3018
|
+
const engine = options?.activityEngine;
|
|
3019
|
+
if (!engine) {
|
|
3020
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3021
|
+
res.end(JSON.stringify([]));
|
|
3022
|
+
return;
|
|
3023
|
+
}
|
|
3024
|
+
try {
|
|
3025
|
+
const projectNameMap = {};
|
|
3026
|
+
const dirToNameMap = {};
|
|
3027
|
+
if (config) {
|
|
3028
|
+
for (const [channelId, project] of Object.entries(config.projects)) {
|
|
3029
|
+
projectNameMap[channelId] = project.name;
|
|
3030
|
+
dirToNameMap[project.directory] = project.name;
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
const data = engine.sessionTimeline(rangeParam, projectNameMap, dirToNameMap);
|
|
3034
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3035
|
+
res.end(JSON.stringify(data));
|
|
3036
|
+
} catch {
|
|
3037
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3038
|
+
res.end(JSON.stringify({ error: "Failed to compute timeline data" }));
|
|
3039
|
+
}
|
|
3040
|
+
return;
|
|
3041
|
+
}
|
|
2090
3042
|
if (pathname === "/api/activity/summary") {
|
|
2091
3043
|
const url = new URL(req.url ?? "/", `http://localhost`);
|
|
2092
3044
|
const rangeParam = url.searchParams.get("range") || "7d";
|
|
@@ -2107,27 +3059,43 @@ function createHealthServer(port, sessionManager, bot, config, options) {
|
|
|
2107
3059
|
sessions_over_time: [],
|
|
2108
3060
|
messages_over_time: [],
|
|
2109
3061
|
cost_over_time: [],
|
|
2110
|
-
|
|
3062
|
+
input_tokens_over_time: [],
|
|
3063
|
+
output_tokens_over_time: [],
|
|
3064
|
+
cache_read_over_time: [],
|
|
2111
3065
|
session_durations: [],
|
|
2112
3066
|
model_breakdown: [],
|
|
2113
3067
|
persona_breakdown: [],
|
|
2114
|
-
cache_efficiency: { total_input_tokens: 0, cache_read_tokens: 0, cache_hit_ratio: 0 }
|
|
3068
|
+
cache_efficiency: { total_input_tokens: 0, cache_read_tokens: 0, cache_hit_ratio: 0 },
|
|
3069
|
+
project_name_map: {},
|
|
3070
|
+
dir_to_name_map: {}
|
|
2115
3071
|
}));
|
|
2116
3072
|
return;
|
|
2117
3073
|
}
|
|
2118
3074
|
try {
|
|
3075
|
+
const projectNameMap = {};
|
|
3076
|
+
const dirToNameMap = {};
|
|
3077
|
+
if (config) {
|
|
3078
|
+
for (const [channelId, project] of Object.entries(config.projects)) {
|
|
3079
|
+
projectNameMap[channelId] = project.name;
|
|
3080
|
+
dirToNameMap[project.directory] = project.name;
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
2119
3083
|
const data = {
|
|
2120
3084
|
summary: engine.computeSummary(range),
|
|
2121
|
-
tokens_by_project: engine.tokensByProject(range),
|
|
3085
|
+
tokens_by_project: engine.tokensByProject(range, dirToNameMap),
|
|
2122
3086
|
tokens_by_session: engine.tokensBySession(range),
|
|
2123
3087
|
sessions_over_time: engine.bucketed(range, bucket, "session_start"),
|
|
2124
3088
|
messages_over_time: engine.bucketed(range, bucket, "message_completed"),
|
|
2125
3089
|
cost_over_time: engine.bucketed(range, bucket, "message_completed", "total_cost_usd"),
|
|
2126
|
-
|
|
3090
|
+
input_tokens_over_time: engine.bucketed(range, bucket, "message_completed", "input_tokens"),
|
|
3091
|
+
output_tokens_over_time: engine.bucketed(range, bucket, "message_completed", "output_tokens"),
|
|
3092
|
+
cache_read_over_time: engine.bucketed(range, bucket, "message_completed", "cache_read_input_tokens"),
|
|
2127
3093
|
session_durations: engine.sessionDurations(range),
|
|
2128
3094
|
model_breakdown: engine.modelBreakdown(range),
|
|
2129
3095
|
persona_breakdown: engine.personaBreakdown(range),
|
|
2130
|
-
cache_efficiency: engine.cacheEfficiency(range)
|
|
3096
|
+
cache_efficiency: engine.cacheEfficiency(range),
|
|
3097
|
+
project_name_map: projectNameMap,
|
|
3098
|
+
dir_to_name_map: dirToNameMap
|
|
2131
3099
|
};
|
|
2132
3100
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2133
3101
|
res.end(JSON.stringify(data));
|
|
@@ -2145,11 +3113,11 @@ function createHealthServer(port, sessionManager, bot, config, options) {
|
|
|
2145
3113
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2146
3114
|
res.end(JSON.stringify({ error: "Not Found" }));
|
|
2147
3115
|
});
|
|
2148
|
-
return new Promise((
|
|
3116
|
+
return new Promise((resolve6, reject) => {
|
|
2149
3117
|
server.on("error", reject);
|
|
2150
3118
|
server.listen(port, () => {
|
|
2151
|
-
console.log(`
|
|
2152
|
-
|
|
3119
|
+
console.log(`Dashboard server listening on http://localhost:${port}/`);
|
|
3120
|
+
resolve6({
|
|
2153
3121
|
close() {
|
|
2154
3122
|
return new Promise((res, rej) => {
|
|
2155
3123
|
server.close((err) => err ? rej(err) : res());
|
|
@@ -2181,74 +3149,74 @@ function createTurnCounter() {
|
|
|
2181
3149
|
|
|
2182
3150
|
// src/init.ts
|
|
2183
3151
|
import { createInterface } from "readline";
|
|
2184
|
-
import { writeFileSync as writeFileSync2, existsSync as
|
|
2185
|
-
import { resolve as
|
|
3152
|
+
import { writeFileSync as writeFileSync2, existsSync as existsSync6, mkdirSync as mkdirSync4 } from "fs";
|
|
3153
|
+
import { resolve as resolve3 } from "path";
|
|
2186
3154
|
import { execSync } from "child_process";
|
|
2187
3155
|
|
|
2188
3156
|
// src/resolve-home.ts
|
|
2189
|
-
import { resolve, dirname as dirname3 } from "path";
|
|
2190
|
-
import { existsSync as
|
|
3157
|
+
import { resolve as resolve2, dirname as dirname3 } from "path";
|
|
3158
|
+
import { existsSync as existsSync5 } from "fs";
|
|
2191
3159
|
import { homedir as homedir3 } from "os";
|
|
2192
3160
|
function resolveMpgHome() {
|
|
2193
|
-
return process.env.MPG_HOME ??
|
|
3161
|
+
return process.env.MPG_HOME ?? resolve2(homedir3(), ".mpg");
|
|
2194
3162
|
}
|
|
2195
3163
|
function resolveProfileDir(profile) {
|
|
2196
|
-
return
|
|
3164
|
+
return resolve2(resolveMpgHome(), "profiles", profile);
|
|
2197
3165
|
}
|
|
2198
3166
|
function resolveEnvPath() {
|
|
2199
3167
|
const mpgHome = resolveMpgHome();
|
|
2200
|
-
const mpgEnv =
|
|
2201
|
-
if (
|
|
3168
|
+
const mpgEnv = resolve2(mpgHome, ".env");
|
|
3169
|
+
if (existsSync5(mpgEnv)) {
|
|
2202
3170
|
return mpgEnv;
|
|
2203
3171
|
}
|
|
2204
|
-
const cwdEnv =
|
|
2205
|
-
if (
|
|
3172
|
+
const cwdEnv = resolve2(process.cwd(), ".env");
|
|
3173
|
+
if (existsSync5(cwdEnv)) {
|
|
2206
3174
|
return cwdEnv;
|
|
2207
3175
|
}
|
|
2208
3176
|
return void 0;
|
|
2209
3177
|
}
|
|
2210
3178
|
function resolveConfigPath(options) {
|
|
2211
3179
|
if (options?.configFlag) {
|
|
2212
|
-
const explicit =
|
|
2213
|
-
if (
|
|
3180
|
+
const explicit = resolve2(options.configFlag);
|
|
3181
|
+
if (existsSync5(explicit)) {
|
|
2214
3182
|
return explicit;
|
|
2215
3183
|
}
|
|
2216
3184
|
return explicit;
|
|
2217
3185
|
}
|
|
2218
3186
|
if (options?.profileFlag) {
|
|
2219
|
-
const profileConfig =
|
|
3187
|
+
const profileConfig = resolve2(
|
|
2220
3188
|
resolveProfileDir(options.profileFlag),
|
|
2221
3189
|
"config.json"
|
|
2222
3190
|
);
|
|
2223
|
-
if (
|
|
3191
|
+
if (existsSync5(profileConfig)) {
|
|
2224
3192
|
return profileConfig;
|
|
2225
3193
|
}
|
|
2226
3194
|
return profileConfig;
|
|
2227
3195
|
}
|
|
2228
3196
|
const mpgHome = resolveMpgHome();
|
|
2229
|
-
const defaultConfig =
|
|
2230
|
-
if (
|
|
3197
|
+
const defaultConfig = resolve2(mpgHome, "profiles", "default", "config.json");
|
|
3198
|
+
if (existsSync5(defaultConfig)) {
|
|
2231
3199
|
return defaultConfig;
|
|
2232
3200
|
}
|
|
2233
|
-
const cwdConfig =
|
|
2234
|
-
if (
|
|
3201
|
+
const cwdConfig = resolve2(process.cwd(), "config.json");
|
|
3202
|
+
if (existsSync5(cwdConfig)) {
|
|
2235
3203
|
return cwdConfig;
|
|
2236
3204
|
}
|
|
2237
3205
|
return void 0;
|
|
2238
3206
|
}
|
|
2239
3207
|
function resolveSessionsPath(configPath) {
|
|
2240
|
-
return
|
|
3208
|
+
return resolve2(dirname3(configPath), "sessions.json");
|
|
2241
3209
|
}
|
|
2242
3210
|
function resolvePidPath(profile) {
|
|
2243
3211
|
const name = !profile || profile === "default" ? "mpg" : `mpg-${profile}`;
|
|
2244
|
-
return
|
|
3212
|
+
return resolve2(resolveMpgHome(), `${name}.pid`);
|
|
2245
3213
|
}
|
|
2246
3214
|
function resolveLogDir() {
|
|
2247
|
-
return
|
|
3215
|
+
return resolve2(resolveMpgHome(), "logs");
|
|
2248
3216
|
}
|
|
2249
3217
|
function resolveLogPath(profile) {
|
|
2250
3218
|
const name = !profile || profile === "default" ? "mpg" : `mpg-${profile}`;
|
|
2251
|
-
return
|
|
3219
|
+
return resolve2(resolveLogDir(), `${name}.log`);
|
|
2252
3220
|
}
|
|
2253
3221
|
function parseFlags(argv) {
|
|
2254
3222
|
const result = {};
|
|
@@ -2277,7 +3245,7 @@ function parseFlags(argv) {
|
|
|
2277
3245
|
// src/init.ts
|
|
2278
3246
|
function createPrompt() {
|
|
2279
3247
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2280
|
-
return (question) => new Promise((
|
|
3248
|
+
return (question) => new Promise((resolve6) => rl.question(question, (answer) => resolve6(answer.trim())));
|
|
2281
3249
|
}
|
|
2282
3250
|
async function runInit(profile) {
|
|
2283
3251
|
const ask = createPrompt();
|
|
@@ -2286,8 +3254,8 @@ async function runInit(profile) {
|
|
|
2286
3254
|
if (profile) {
|
|
2287
3255
|
configDir = resolveProfileDir(profile);
|
|
2288
3256
|
envDir = resolveMpgHome();
|
|
2289
|
-
|
|
2290
|
-
|
|
3257
|
+
mkdirSync4(configDir, { recursive: true });
|
|
3258
|
+
mkdirSync4(envDir, { recursive: true });
|
|
2291
3259
|
console.log(`
|
|
2292
3260
|
mpg init \u2014 set up profile "${profile}"
|
|
2293
3261
|
`);
|
|
@@ -2312,13 +3280,13 @@ mpg init \u2014 set up profile "${profile}"
|
|
|
2312
3280
|
console.error("A Discord bot token is required. Create one at https://discord.com/developers/applications");
|
|
2313
3281
|
process.exit(1);
|
|
2314
3282
|
}
|
|
2315
|
-
const envPath =
|
|
3283
|
+
const envPath = resolve3(envDir, ".env");
|
|
2316
3284
|
writeFileSync2(envPath, `DISCORD_BOT_TOKEN=${token}
|
|
2317
3285
|
`);
|
|
2318
3286
|
console.log(`Wrote ${envPath}`);
|
|
2319
3287
|
const projects = [];
|
|
2320
|
-
const configPath =
|
|
2321
|
-
if (
|
|
3288
|
+
const configPath = resolve3(configDir, "config.json");
|
|
3289
|
+
if (existsSync6(configPath)) {
|
|
2322
3290
|
try {
|
|
2323
3291
|
const existing = JSON.parse(
|
|
2324
3292
|
(await import("fs")).readFileSync(configPath, "utf-8")
|
|
@@ -2348,7 +3316,7 @@ Existing projects (${projects.length}):`);
|
|
|
2348
3316
|
console.log("Directory is required, skipping.");
|
|
2349
3317
|
continue;
|
|
2350
3318
|
}
|
|
2351
|
-
if (!
|
|
3319
|
+
if (!existsSync6(directory)) {
|
|
2352
3320
|
console.warn(`Warning: ${directory} does not exist.`);
|
|
2353
3321
|
}
|
|
2354
3322
|
const channelId = await ask("Discord channel ID: ");
|
|
@@ -2379,11 +3347,11 @@ Existing projects (${projects.length}):`);
|
|
|
2379
3347
|
}
|
|
2380
3348
|
|
|
2381
3349
|
// src/health.ts
|
|
2382
|
-
import { statSync } from "fs";
|
|
2383
|
-
import { execFileSync as
|
|
3350
|
+
import { statSync as statSync2 } from "fs";
|
|
3351
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
2384
3352
|
function runHealthChecks(config) {
|
|
2385
3353
|
try {
|
|
2386
|
-
|
|
3354
|
+
execFileSync3("claude", ["--version"], { timeout: 5e3, stdio: "ignore" });
|
|
2387
3355
|
} catch {
|
|
2388
3356
|
console.error(
|
|
2389
3357
|
'Health check failed:\n \u2717 "claude" CLI not found on PATH. Install: https://docs.anthropic.com/en/docs/claude-code'
|
|
@@ -2394,7 +3362,7 @@ function runHealthChecks(config) {
|
|
|
2394
3362
|
const missing = [];
|
|
2395
3363
|
for (const project of Object.values(config.projects)) {
|
|
2396
3364
|
try {
|
|
2397
|
-
if (!
|
|
3365
|
+
if (!statSync2(project.directory).isDirectory()) {
|
|
2398
3366
|
missing.push(` \u2717 Project "${project.name}" path is not a directory: ${project.directory}`);
|
|
2399
3367
|
}
|
|
2400
3368
|
} catch {
|
|
@@ -2408,10 +3376,10 @@ function runHealthChecks(config) {
|
|
|
2408
3376
|
}
|
|
2409
3377
|
|
|
2410
3378
|
// src/pid.ts
|
|
2411
|
-
import { readFileSync as readFileSync5, writeFileSync as writeFileSync3, unlinkSync, mkdirSync as
|
|
3379
|
+
import { readFileSync as readFileSync5, writeFileSync as writeFileSync3, unlinkSync, mkdirSync as mkdirSync5 } from "fs";
|
|
2412
3380
|
import { dirname as dirname4 } from "path";
|
|
2413
3381
|
function writePid(pidPath, pid = process.pid) {
|
|
2414
|
-
|
|
3382
|
+
mkdirSync5(dirname4(pidPath), { recursive: true });
|
|
2415
3383
|
writeFileSync3(pidPath, `${pid}
|
|
2416
3384
|
`);
|
|
2417
3385
|
}
|
|
@@ -2451,14 +3419,14 @@ function checkPidFile(pidPath) {
|
|
|
2451
3419
|
}
|
|
2452
3420
|
|
|
2453
3421
|
// src/file-logger.ts
|
|
2454
|
-
import { appendFileSync as appendFileSync2, renameSync, unlinkSync as unlinkSync2, existsSync as
|
|
3422
|
+
import { appendFileSync as appendFileSync2, renameSync, unlinkSync as unlinkSync2, existsSync as existsSync7, mkdirSync as mkdirSync6, statSync as statSync3 } from "fs";
|
|
2455
3423
|
import { dirname as dirname5 } from "path";
|
|
2456
3424
|
var DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
2457
3425
|
var DEFAULT_MAX_FILES = 5;
|
|
2458
3426
|
function rotateLog(logPath, maxFiles = DEFAULT_MAX_FILES) {
|
|
2459
|
-
if (!
|
|
3427
|
+
if (!existsSync7(logPath)) return;
|
|
2460
3428
|
const oldest = `${logPath}.${maxFiles}`;
|
|
2461
|
-
if (
|
|
3429
|
+
if (existsSync7(oldest)) {
|
|
2462
3430
|
try {
|
|
2463
3431
|
unlinkSync2(oldest);
|
|
2464
3432
|
} catch {
|
|
@@ -2467,7 +3435,7 @@ function rotateLog(logPath, maxFiles = DEFAULT_MAX_FILES) {
|
|
|
2467
3435
|
for (let i = maxFiles - 1; i >= 1; i--) {
|
|
2468
3436
|
const from = `${logPath}.${i}`;
|
|
2469
3437
|
const to = `${logPath}.${i + 1}`;
|
|
2470
|
-
if (
|
|
3438
|
+
if (existsSync7(from)) {
|
|
2471
3439
|
try {
|
|
2472
3440
|
renameSync(from, to);
|
|
2473
3441
|
} catch {
|
|
@@ -2484,9 +3452,9 @@ function createFileWriter(logPath, opts) {
|
|
|
2484
3452
|
const maxFiles = opts?.maxFiles ?? DEFAULT_MAX_FILES;
|
|
2485
3453
|
let currentBytes = 0;
|
|
2486
3454
|
const dir = dirname5(logPath);
|
|
2487
|
-
|
|
3455
|
+
mkdirSync6(dir, { recursive: true });
|
|
2488
3456
|
try {
|
|
2489
|
-
currentBytes =
|
|
3457
|
+
currentBytes = statSync3(logPath).size;
|
|
2490
3458
|
} catch {
|
|
2491
3459
|
currentBytes = 0;
|
|
2492
3460
|
}
|
|
@@ -2503,10 +3471,10 @@ function createFileWriter(logPath, opts) {
|
|
|
2503
3471
|
}
|
|
2504
3472
|
|
|
2505
3473
|
// src/daemon.ts
|
|
2506
|
-
import { resolve as
|
|
3474
|
+
import { resolve as resolve4 } from "path";
|
|
2507
3475
|
import { homedir as homedir4 } from "os";
|
|
2508
|
-
import { mkdirSync as
|
|
2509
|
-
import { execFileSync as
|
|
3476
|
+
import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync4, unlinkSync as unlinkSync3, existsSync as existsSync8 } from "fs";
|
|
3477
|
+
import { execFileSync as execFileSync4, execSync as execSync2 } from "child_process";
|
|
2510
3478
|
|
|
2511
3479
|
// src/systemd.ts
|
|
2512
3480
|
import { dirname as dirname6 } from "path";
|
|
@@ -2540,14 +3508,14 @@ WantedBy=default.target
|
|
|
2540
3508
|
|
|
2541
3509
|
// src/daemon.ts
|
|
2542
3510
|
function resolveServiceDir() {
|
|
2543
|
-
return
|
|
3511
|
+
return resolve4(homedir4(), ".config", "systemd", "user");
|
|
2544
3512
|
}
|
|
2545
3513
|
function resolveServicePath(profile) {
|
|
2546
|
-
return
|
|
3514
|
+
return resolve4(resolveServiceDir(), unitFileName(profile));
|
|
2547
3515
|
}
|
|
2548
3516
|
function daemonInstall(profile) {
|
|
2549
3517
|
const serviceDir = resolveServiceDir();
|
|
2550
|
-
|
|
3518
|
+
mkdirSync7(serviceDir, { recursive: true });
|
|
2551
3519
|
const nodePath = process.execPath;
|
|
2552
3520
|
const mpgPath = resolveOwnBinary();
|
|
2553
3521
|
const unit = generateUnitFile({ nodePath, mpgPath, profile });
|
|
@@ -2555,11 +3523,11 @@ function daemonInstall(profile) {
|
|
|
2555
3523
|
writeFileSync4(servicePath, unit);
|
|
2556
3524
|
const name = unitFileName(profile);
|
|
2557
3525
|
try {
|
|
2558
|
-
|
|
3526
|
+
execFileSync4("loginctl", ["enable-linger"], { stdio: "ignore" });
|
|
2559
3527
|
} catch {
|
|
2560
3528
|
}
|
|
2561
|
-
|
|
2562
|
-
|
|
3529
|
+
execFileSync4("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
|
|
3530
|
+
execFileSync4("systemctl", ["--user", "enable", "--now", name], { stdio: "inherit" });
|
|
2563
3531
|
console.log(`Installed and started ${name}`);
|
|
2564
3532
|
console.log(` Unit file: ${servicePath}`);
|
|
2565
3533
|
console.log(` Status: systemctl --user status ${name}`);
|
|
@@ -2569,18 +3537,18 @@ function daemonUninstall(profile) {
|
|
|
2569
3537
|
const name = unitFileName(profile);
|
|
2570
3538
|
const servicePath = resolveServicePath(profile);
|
|
2571
3539
|
try {
|
|
2572
|
-
|
|
3540
|
+
execFileSync4("systemctl", ["--user", "stop", name], { stdio: "inherit" });
|
|
2573
3541
|
} catch {
|
|
2574
3542
|
}
|
|
2575
3543
|
try {
|
|
2576
|
-
|
|
3544
|
+
execFileSync4("systemctl", ["--user", "disable", name], { stdio: "inherit" });
|
|
2577
3545
|
} catch {
|
|
2578
3546
|
}
|
|
2579
|
-
if (
|
|
3547
|
+
if (existsSync8(servicePath)) {
|
|
2580
3548
|
unlinkSync3(servicePath);
|
|
2581
3549
|
}
|
|
2582
3550
|
try {
|
|
2583
|
-
|
|
3551
|
+
execFileSync4("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
|
|
2584
3552
|
} catch {
|
|
2585
3553
|
}
|
|
2586
3554
|
console.log(`Uninstalled ${name}`);
|
|
@@ -2588,7 +3556,7 @@ function daemonUninstall(profile) {
|
|
|
2588
3556
|
function daemonStatus(profile) {
|
|
2589
3557
|
const name = unitFileName(profile);
|
|
2590
3558
|
try {
|
|
2591
|
-
|
|
3559
|
+
execFileSync4("systemctl", ["--user", "status", name], { stdio: "inherit" });
|
|
2592
3560
|
} catch {
|
|
2593
3561
|
}
|
|
2594
3562
|
}
|
|
@@ -2597,7 +3565,7 @@ function daemonLogs(profile, follow) {
|
|
|
2597
3565
|
const args2 = ["--user", "-u", name, "--no-pager"];
|
|
2598
3566
|
if (follow) args2.push("-f");
|
|
2599
3567
|
try {
|
|
2600
|
-
|
|
3568
|
+
execFileSync4("journalctl", args2, { stdio: "inherit" });
|
|
2601
3569
|
} catch {
|
|
2602
3570
|
console.error(`journalctl not available. View logs at: ${resolveLogPath(profile)}`);
|
|
2603
3571
|
}
|
|
@@ -2608,7 +3576,7 @@ function resolveOwnBinary() {
|
|
|
2608
3576
|
if (which) return which;
|
|
2609
3577
|
} catch {
|
|
2610
3578
|
}
|
|
2611
|
-
const fallback =
|
|
3579
|
+
const fallback = resolve4(process.argv[1] ?? ".");
|
|
2612
3580
|
console.warn(`Warning: 'mpg' not found on PATH. Using ${fallback} in service file.`);
|
|
2613
3581
|
return fallback;
|
|
2614
3582
|
}
|
|
@@ -2704,7 +3672,7 @@ function start() {
|
|
|
2704
3672
|
configFlag: flags.configFlag,
|
|
2705
3673
|
profileFlag: flags.profileFlag
|
|
2706
3674
|
});
|
|
2707
|
-
if (!configPath || !
|
|
3675
|
+
if (!configPath || !existsSync9(configPath)) {
|
|
2708
3676
|
console.error("config.json not found. Run `mpg init` to create one.");
|
|
2709
3677
|
process.exit(1);
|
|
2710
3678
|
}
|
|
@@ -2727,7 +3695,24 @@ function start() {
|
|
|
2727
3695
|
const sessionsPath = resolveSessionsPath(configPath);
|
|
2728
3696
|
const sessionStore = createFileSessionStore(sessionsPath);
|
|
2729
3697
|
const pulseEmitter = createPulseEmitter();
|
|
2730
|
-
|
|
3698
|
+
let runtime;
|
|
3699
|
+
if (config.defaults.persistence === "tmux") {
|
|
3700
|
+
runtime = new TmuxRuntime();
|
|
3701
|
+
log.info("Using tmux-based persistent runtime");
|
|
3702
|
+
} else {
|
|
3703
|
+
runtime = new ClaudeCliRuntime();
|
|
3704
|
+
try {
|
|
3705
|
+
const stale = listSessions("mpg-");
|
|
3706
|
+
for (const name of stale) {
|
|
3707
|
+
killSession(name);
|
|
3708
|
+
}
|
|
3709
|
+
if (stale.length > 0) {
|
|
3710
|
+
log.info(`Cleaned up ${stale.length} stale tmux session(s)`);
|
|
3711
|
+
}
|
|
3712
|
+
} catch {
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
3715
|
+
const sessionManager = createSessionManager(config.defaults, runtime, sessionStore, pulseEmitter);
|
|
2731
3716
|
const persistedSessions = sessionStore.load();
|
|
2732
3717
|
const knownKeysByProject = /* @__PURE__ */ new Map();
|
|
2733
3718
|
for (const [key, entry] of persistedSessions) {
|
|
@@ -2743,14 +3728,23 @@ function start() {
|
|
|
2743
3728
|
for (const [projectDir, keys] of knownKeysByProject) {
|
|
2744
3729
|
reconcileWorktrees(projectDir, keys);
|
|
2745
3730
|
}
|
|
3731
|
+
const seenDirs = /* @__PURE__ */ new Set();
|
|
3732
|
+
for (const project of Object.values(config.projects)) {
|
|
3733
|
+
if (seenDirs.has(project.directory)) continue;
|
|
3734
|
+
seenDirs.add(project.directory);
|
|
3735
|
+
reconcileAttachments(project.directory).then((removed) => {
|
|
3736
|
+
if (removed) log.info(`Removed orphaned attachments in ${project.directory}`);
|
|
3737
|
+
}).catch(() => {
|
|
3738
|
+
});
|
|
3739
|
+
}
|
|
2746
3740
|
const turnCounter = createTurnCounter();
|
|
2747
3741
|
const bot = createDiscordBot(router, sessionManager, config, turnCounter);
|
|
2748
|
-
let
|
|
3742
|
+
let dashboardServer;
|
|
2749
3743
|
function shutdown() {
|
|
2750
3744
|
log.info("Shutting down...");
|
|
2751
3745
|
removePid(pidPath);
|
|
2752
|
-
if (
|
|
2753
|
-
|
|
3746
|
+
if (dashboardServer) {
|
|
3747
|
+
dashboardServer.close().catch(() => {
|
|
2754
3748
|
});
|
|
2755
3749
|
}
|
|
2756
3750
|
sessionManager.shutdown();
|
|
@@ -2763,11 +3757,18 @@ function start() {
|
|
|
2763
3757
|
if (config.defaults.httpPort !== false) {
|
|
2764
3758
|
try {
|
|
2765
3759
|
const activityEngine = createActivityEngine();
|
|
2766
|
-
|
|
3760
|
+
dashboardServer = await createDashboardServer(config.defaults.httpPort, sessionManager, bot, config, { activityEngine });
|
|
2767
3761
|
} catch (err) {
|
|
2768
|
-
log.warn(`
|
|
3762
|
+
log.warn(`Dashboard server failed to start on port ${config.defaults.httpPort}: ${err}`);
|
|
2769
3763
|
}
|
|
2770
3764
|
}
|
|
3765
|
+
sessionManager.recoverOrphanedSessions((projectKey, result) => {
|
|
3766
|
+
bot.deliverOrphanResult(projectKey, result).catch((err) => {
|
|
3767
|
+
log.error(`Failed to deliver orphan result for ${projectKey}: ${err}`);
|
|
3768
|
+
});
|
|
3769
|
+
}).catch((err) => {
|
|
3770
|
+
log.error(`Orphan session recovery failed: ${err}`);
|
|
3771
|
+
});
|
|
2771
3772
|
}).catch((err) => {
|
|
2772
3773
|
log.error(`Failed to start bot: ${err}`);
|
|
2773
3774
|
process.exit(1);
|
|
@@ -2778,13 +3779,13 @@ function status() {
|
|
|
2778
3779
|
configFlag: flags.configFlag,
|
|
2779
3780
|
profileFlag: flags.profileFlag
|
|
2780
3781
|
});
|
|
2781
|
-
const sessionsPath = configPath ? resolveSessionsPath(configPath) :
|
|
2782
|
-
if (!
|
|
3782
|
+
const sessionsPath = configPath ? resolveSessionsPath(configPath) : resolve5(process.cwd(), ".sessions.json");
|
|
3783
|
+
if (!existsSync9(sessionsPath)) {
|
|
2783
3784
|
console.log("No sessions file found. Is the gateway running?");
|
|
2784
3785
|
return;
|
|
2785
3786
|
}
|
|
2786
3787
|
let projectNames = {};
|
|
2787
|
-
if (configPath &&
|
|
3788
|
+
if (configPath && existsSync9(configPath)) {
|
|
2788
3789
|
try {
|
|
2789
3790
|
const raw = JSON.parse(readFileSync6(configPath, "utf-8"));
|
|
2790
3791
|
const config = loadConfig(raw);
|
|
@@ -2814,23 +3815,23 @@ function status() {
|
|
|
2814
3815
|
function migrate() {
|
|
2815
3816
|
const mpgHome = resolveMpgHome();
|
|
2816
3817
|
const profileDir = resolveProfileDir("default");
|
|
2817
|
-
const cwdEnv =
|
|
2818
|
-
const cwdConfig =
|
|
2819
|
-
const cwdSessions =
|
|
3818
|
+
const cwdEnv = resolve5(process.cwd(), ".env");
|
|
3819
|
+
const cwdConfig = resolve5(process.cwd(), "config.json");
|
|
3820
|
+
const cwdSessions = resolve5(process.cwd(), ".sessions.json");
|
|
2820
3821
|
const copied = [];
|
|
2821
|
-
|
|
2822
|
-
if (
|
|
2823
|
-
const dest =
|
|
3822
|
+
mkdirSync8(profileDir, { recursive: true });
|
|
3823
|
+
if (existsSync9(cwdEnv)) {
|
|
3824
|
+
const dest = resolve5(mpgHome, ".env");
|
|
2824
3825
|
copyFileSync(cwdEnv, dest);
|
|
2825
3826
|
copied.push(` ${cwdEnv} \u2192 ${dest}`);
|
|
2826
3827
|
}
|
|
2827
|
-
if (
|
|
2828
|
-
const dest =
|
|
3828
|
+
if (existsSync9(cwdConfig)) {
|
|
3829
|
+
const dest = resolve5(profileDir, "config.json");
|
|
2829
3830
|
copyFileSync(cwdConfig, dest);
|
|
2830
3831
|
copied.push(` ${cwdConfig} \u2192 ${dest}`);
|
|
2831
3832
|
}
|
|
2832
|
-
if (
|
|
2833
|
-
const dest =
|
|
3833
|
+
if (existsSync9(cwdSessions)) {
|
|
3834
|
+
const dest = resolve5(profileDir, "sessions.json");
|
|
2834
3835
|
copyFileSync(cwdSessions, dest);
|
|
2835
3836
|
copied.push(` ${cwdSessions} \u2192 ${dest}`);
|
|
2836
3837
|
}
|