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/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 resolve4 } from "path";
5
- import { existsSync as existsSync7, readFileSync as readFileSync6, copyFileSync, mkdirSync as mkdirSync7 } from "fs";
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/claude-cli.ts
297
- import { spawn } from "child_process";
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((resolve5) => {
538
+ return new Promise((resolve6) => {
550
539
  waiters.push(() => {
551
540
  activeProcesses++;
552
- resolve5();
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 runClaude(
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 runClaude(session.cwd, effectiveArgs, item.prompt, void 0, item.systemPrompt, item.timeoutMs);
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((resolve5, reject) => {
763
- session.queue.push({ prompt, systemPrompt: opts?.systemPrompt, timeoutMs: opts?.timeoutMs, extraArgs: opts?.extraArgs, resolve: resolve5, reject });
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 mkdirSync2 } from "fs";
1477
- import { dirname as dirname2, join as join3 } from "path";
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 = join3(homedir(), ".pulse", "events", "mpg-sessions.jsonl");
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
- mkdirSync2(dirname2(target), { recursive: true });
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 existsSync2 } from "fs";
1558
- import { join as join4 } from "path";
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 = join4(homedir2(), ".pulse", "events", "mpg-sessions.jsonl");
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 (!existsSync2(filePath)) return [];
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 === "hour") {
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 key = e.project_key;
1623
- const row = map.get(key) ?? { project_key: key, project_dir: e.project_dir, input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cost_usd: 0, message_count: 0 };
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(key, row);
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: totalInput > 0 ? cacheRead / totalInput : 0
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/health-server.ts
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
- // Sessions table
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>Project</th><th>Session ID</th><th>Last Activity</th><th>Queue</th></tr>';
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
- h += '<tr><td>' + escapeHtml(s.projectKey) + '</td><td>' + escapeHtml(s.sessionId ? s.sessionId.slice(0, 12) + '...' : '\u2014') + '</td><td>' + formatAgo(s.lastActivity) + '</td><td>' + s.queueLength + '</td></tr>';
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 = '7d';
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 = totalTok > 1e6 ? (totalTok / 1e6).toFixed(1) + 'M' : totalTok > 1e3 ? (totalTok / 1e3).toFixed(1) + 'k' : String(totalTok);
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
- // Messages Over Time (bar)
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: 'bar',
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; }), backgroundColor: '#58a6ff' }] },
1947
- options: { scales: { y: { beginAtZero: true, ticks: { color: '#8b949e' }, grid: { color: '#30363d' } }, x: { ticks: { color: '#8b949e' }, grid: { color: '#30363d' } } }, plugins: { legend: { display: false } } }
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: { ticks: { color: '#8b949e' }, grid: { color: '#30363d' } } }, plugins: { legend: { display: false } } }
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 (bar)
2852
+ // Sessions Over Time (line)
1959
2853
  destroyChart('sessions');
1960
2854
  chartInstances['sessions'] = new Chart(document.getElementById('sessions-chart'), {
1961
- type: 'bar',
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; }), backgroundColor: '#d29922' }] },
1963
- options: { scales: { y: { beginAtZero: true, ticks: { color: '#8b949e', stepSize: 1 }, grid: { color: '#30363d' } }, x: { ticks: { color: '#8b949e' }, grid: { color: '#30363d' } } }, plugins: { legend: { display: false } } }
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 (stacked bar)
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: { labels: d.tokens_over_time.map(function(e) { return e.bucket; }), datasets: [{ label: 'Input Tokens', data: d.tokens_over_time.map(function(e) { return e.value; }), backgroundColor: '#58a6ff' }] },
1971
- options: { scales: { y: { beginAtZero: true, ticks: { color: '#8b949e' }, grid: { color: '#30363d' } }, x: { ticks: { color: '#8b949e' }, grid: { color: '#30363d' } } }, plugins: { legend: { labels: { color: '#8b949e' } } } }
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.project_key) + '</td><td>' + p.input_tokens.toLocaleString() + '</td><td>' + p.output_tokens.toLocaleString() + '</td><td>' + p.cache_read_input_tokens.toLocaleString() + '</td><td>$' + p.cost_usd.toFixed(3) + '</td><td>' + p.message_count + '</td></tr>'; });
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) { h2 += '<tr><td>' + escapeHtml(row.session_id.substring(0, 8)) + '</td><td>' + escapeHtml(row.project_key) + '</td><td>' + row.input_tokens.toLocaleString() + '</td><td>' + row.output_tokens.toLocaleString() + '</td><td>$' + row.cost_usd.toFixed(3) + '</td><td>' + row.message_count + '</td><td>' + Math.round(row.duration_ms / 60000) + 'm</td></tr>'; });
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.toLocaleString() + '</td><td>' + ce.cache_read_tokens.toLocaleString() + '</td><td>' + (ce.cache_hit_ratio * 100).toFixed(1) + '%</td></tr></table>';
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 createHealthServer(port, sessionManager, bot, config, options) {
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
- tokens_over_time: [],
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
- tokens_over_time: engine.bucketed(range, bucket, "message_completed", "input_tokens"),
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((resolve5, reject) => {
3116
+ return new Promise((resolve6, reject) => {
2149
3117
  server.on("error", reject);
2150
3118
  server.listen(port, () => {
2151
- console.log(`Health endpoint listening on http://localhost:${port}/health`);
2152
- resolve5({
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 existsSync4, mkdirSync as mkdirSync3 } from "fs";
2185
- import { resolve as resolve2 } from "path";
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 existsSync3 } from "fs";
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 ?? resolve(homedir3(), ".mpg");
3161
+ return process.env.MPG_HOME ?? resolve2(homedir3(), ".mpg");
2194
3162
  }
2195
3163
  function resolveProfileDir(profile) {
2196
- return resolve(resolveMpgHome(), "profiles", profile);
3164
+ return resolve2(resolveMpgHome(), "profiles", profile);
2197
3165
  }
2198
3166
  function resolveEnvPath() {
2199
3167
  const mpgHome = resolveMpgHome();
2200
- const mpgEnv = resolve(mpgHome, ".env");
2201
- if (existsSync3(mpgEnv)) {
3168
+ const mpgEnv = resolve2(mpgHome, ".env");
3169
+ if (existsSync5(mpgEnv)) {
2202
3170
  return mpgEnv;
2203
3171
  }
2204
- const cwdEnv = resolve(process.cwd(), ".env");
2205
- if (existsSync3(cwdEnv)) {
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 = resolve(options.configFlag);
2213
- if (existsSync3(explicit)) {
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 = resolve(
3187
+ const profileConfig = resolve2(
2220
3188
  resolveProfileDir(options.profileFlag),
2221
3189
  "config.json"
2222
3190
  );
2223
- if (existsSync3(profileConfig)) {
3191
+ if (existsSync5(profileConfig)) {
2224
3192
  return profileConfig;
2225
3193
  }
2226
3194
  return profileConfig;
2227
3195
  }
2228
3196
  const mpgHome = resolveMpgHome();
2229
- const defaultConfig = resolve(mpgHome, "profiles", "default", "config.json");
2230
- if (existsSync3(defaultConfig)) {
3197
+ const defaultConfig = resolve2(mpgHome, "profiles", "default", "config.json");
3198
+ if (existsSync5(defaultConfig)) {
2231
3199
  return defaultConfig;
2232
3200
  }
2233
- const cwdConfig = resolve(process.cwd(), "config.json");
2234
- if (existsSync3(cwdConfig)) {
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 resolve(dirname3(configPath), "sessions.json");
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 resolve(resolveMpgHome(), `${name}.pid`);
3212
+ return resolve2(resolveMpgHome(), `${name}.pid`);
2245
3213
  }
2246
3214
  function resolveLogDir() {
2247
- return resolve(resolveMpgHome(), "logs");
3215
+ return resolve2(resolveMpgHome(), "logs");
2248
3216
  }
2249
3217
  function resolveLogPath(profile) {
2250
3218
  const name = !profile || profile === "default" ? "mpg" : `mpg-${profile}`;
2251
- return resolve(resolveLogDir(), `${name}.log`);
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((resolve5) => rl.question(question, (answer) => resolve5(answer.trim())));
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
- mkdirSync3(configDir, { recursive: true });
2290
- mkdirSync3(envDir, { recursive: true });
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 = resolve2(envDir, ".env");
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 = resolve2(configDir, "config.json");
2321
- if (existsSync4(configPath)) {
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 (!existsSync4(directory)) {
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 execFileSync2 } from "child_process";
3350
+ import { statSync as statSync2 } from "fs";
3351
+ import { execFileSync as execFileSync3 } from "child_process";
2384
3352
  function runHealthChecks(config) {
2385
3353
  try {
2386
- execFileSync2("claude", ["--version"], { timeout: 5e3, stdio: "ignore" });
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 (!statSync(project.directory).isDirectory()) {
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 mkdirSync4 } from "fs";
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
- mkdirSync4(dirname4(pidPath), { recursive: true });
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 existsSync5, mkdirSync as mkdirSync5, statSync as statSync2 } from "fs";
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 (!existsSync5(logPath)) return;
3427
+ if (!existsSync7(logPath)) return;
2460
3428
  const oldest = `${logPath}.${maxFiles}`;
2461
- if (existsSync5(oldest)) {
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 (existsSync5(from)) {
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
- mkdirSync5(dir, { recursive: true });
3455
+ mkdirSync6(dir, { recursive: true });
2488
3456
  try {
2489
- currentBytes = statSync2(logPath).size;
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 resolve3 } from "path";
3474
+ import { resolve as resolve4 } from "path";
2507
3475
  import { homedir as homedir4 } from "os";
2508
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync4, unlinkSync as unlinkSync3, existsSync as existsSync6 } from "fs";
2509
- import { execFileSync as execFileSync3, execSync as execSync2 } from "child_process";
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 resolve3(homedir4(), ".config", "systemd", "user");
3511
+ return resolve4(homedir4(), ".config", "systemd", "user");
2544
3512
  }
2545
3513
  function resolveServicePath(profile) {
2546
- return resolve3(resolveServiceDir(), unitFileName(profile));
3514
+ return resolve4(resolveServiceDir(), unitFileName(profile));
2547
3515
  }
2548
3516
  function daemonInstall(profile) {
2549
3517
  const serviceDir = resolveServiceDir();
2550
- mkdirSync6(serviceDir, { recursive: true });
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
- execFileSync3("loginctl", ["enable-linger"], { stdio: "ignore" });
3526
+ execFileSync4("loginctl", ["enable-linger"], { stdio: "ignore" });
2559
3527
  } catch {
2560
3528
  }
2561
- execFileSync3("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
2562
- execFileSync3("systemctl", ["--user", "enable", "--now", name], { stdio: "inherit" });
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
- execFileSync3("systemctl", ["--user", "stop", name], { stdio: "inherit" });
3540
+ execFileSync4("systemctl", ["--user", "stop", name], { stdio: "inherit" });
2573
3541
  } catch {
2574
3542
  }
2575
3543
  try {
2576
- execFileSync3("systemctl", ["--user", "disable", name], { stdio: "inherit" });
3544
+ execFileSync4("systemctl", ["--user", "disable", name], { stdio: "inherit" });
2577
3545
  } catch {
2578
3546
  }
2579
- if (existsSync6(servicePath)) {
3547
+ if (existsSync8(servicePath)) {
2580
3548
  unlinkSync3(servicePath);
2581
3549
  }
2582
3550
  try {
2583
- execFileSync3("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
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
- execFileSync3("systemctl", ["--user", "status", name], { stdio: "inherit" });
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
- execFileSync3("journalctl", args2, { stdio: "inherit" });
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 = resolve3(process.argv[1] ?? ".");
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 || !existsSync7(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
- const sessionManager = createSessionManager(config.defaults, sessionStore, pulseEmitter);
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 healthServer;
3742
+ let dashboardServer;
2749
3743
  function shutdown() {
2750
3744
  log.info("Shutting down...");
2751
3745
  removePid(pidPath);
2752
- if (healthServer) {
2753
- healthServer.close().catch(() => {
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
- healthServer = await createHealthServer(config.defaults.httpPort, sessionManager, bot, config, { activityEngine });
3760
+ dashboardServer = await createDashboardServer(config.defaults.httpPort, sessionManager, bot, config, { activityEngine });
2767
3761
  } catch (err) {
2768
- log.warn(`Health server failed to start on port ${config.defaults.httpPort}: ${err}`);
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) : resolve4(process.cwd(), ".sessions.json");
2782
- if (!existsSync7(sessionsPath)) {
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 && existsSync7(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 = resolve4(process.cwd(), ".env");
2818
- const cwdConfig = resolve4(process.cwd(), "config.json");
2819
- const cwdSessions = resolve4(process.cwd(), ".sessions.json");
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
- mkdirSync7(profileDir, { recursive: true });
2822
- if (existsSync7(cwdEnv)) {
2823
- const dest = resolve4(mpgHome, ".env");
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 (existsSync7(cwdConfig)) {
2828
- const dest = resolve4(profileDir, "config.json");
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 (existsSync7(cwdSessions)) {
2833
- const dest = resolve4(profileDir, "sessions.json");
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
  }