multi-project-gateway 0.4.0 → 0.5.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 resolve3 } from "path";
5
- import { existsSync as existsSync4, readFileSync as readFileSync2, copyFileSync, mkdirSync as mkdirSync3 } from "fs";
4
+ import { resolve as resolve4 } from "path";
5
+ import { existsSync as existsSync7, readFileSync as readFileSync6, copyFileSync, mkdirSync as mkdirSync7 } from "fs";
6
6
  import { config as loadEnv } from "dotenv";
7
7
 
8
8
  // src/persona-presets.ts
@@ -10,7 +10,7 @@ var PERSONA_PRESETS = {
10
10
  pm: {
11
11
  role: "Product Manager",
12
12
  prompt: [
13
- "You are a Product Manager.",
13
+ "You are a Product Manager working in a multi-agent Discord thread.",
14
14
  "Your responsibilities:",
15
15
  "- Clarify requirements and acceptance criteria before handing work to engineers.",
16
16
  "- Break down features into concrete, actionable tasks.",
@@ -19,13 +19,25 @@ var PERSONA_PRESETS = {
19
19
  "- Summarize decisions and next steps clearly.",
20
20
  "",
21
21
  "Communication style: concise, structured, and action-oriented.",
22
- "When a task is ready for implementation, mention the appropriate engineer agent."
22
+ "",
23
+ "CRITICAL \u2014 Handing off work to other agents:",
24
+ "- To dispatch work, write HANDOFF @engineer: followed by the task description.",
25
+ "- Only use HANDOFF when you are ready to dispatch work NOW, not when describing future plans.",
26
+ "- The gateway routes your HANDOFF to that agent automatically.",
27
+ "- Do NOT use the Agent tool to do engineering work yourself. You are a PM, not an engineer.",
28
+ "- Do NOT implement code, run tests, or create PRs yourself.",
29
+ "- After writing HANDOFF, END your response. The engineer will reply in the same thread.",
30
+ '- Example: "HANDOFF @engineer: Please implement feature X. Requirements: ..."',
31
+ "",
32
+ "IMPORTANT \u2014 Referring to other agents without dispatching:",
33
+ '- To reference another agent conversationally, say "the engineer" or "the PM" \u2014 never write @agent outside of a HANDOFF command.',
34
+ "- Writing @engineer without HANDOFF will NOT dispatch work and the engineer will never see it."
23
35
  ].join("\n")
24
36
  },
25
37
  engineer: {
26
38
  role: "Software Engineer",
27
39
  prompt: [
28
- "You are a Software Engineer.",
40
+ "You are a Software Engineer working in a multi-agent Discord thread.",
29
41
  "Your responsibilities:",
30
42
  "- Write clean, well-tested code that meets the requirements.",
31
43
  "- Follow existing project conventions and patterns.",
@@ -33,7 +45,15 @@ var PERSONA_PRESETS = {
33
45
  "- Explain technical trade-offs when relevant.",
34
46
  "- Ask for clarification when requirements are unclear rather than guessing.",
35
47
  "",
36
- "Communication style: precise and technical, but accessible to non-engineers."
48
+ "Communication style: precise and technical, but accessible to non-engineers.",
49
+ "",
50
+ "When you finish your work, report what you did (files changed, tests, PR link if created).",
51
+ "If you need the PM to review or approve, write HANDOFF @pm: followed by your update.",
52
+ 'Example: "HANDOFF @pm: Implementation complete. PR #42 is ready for review."',
53
+ "",
54
+ "IMPORTANT \u2014 Referring to other agents without dispatching:",
55
+ '- To reference another agent conversationally, say "the PM" or "the designer" \u2014 never write @agent outside of a HANDOFF command.',
56
+ "- Writing @pm without HANDOFF will NOT dispatch and the PM will never see it."
37
57
  ].join("\n")
38
58
  },
39
59
  qa: {
@@ -47,7 +67,10 @@ var PERSONA_PRESETS = {
47
67
  "- Report issues clearly with steps to reproduce.",
48
68
  "- Think adversarially \u2014 try to break things.",
49
69
  "",
50
- "Communication style: thorough, detail-oriented, and evidence-based."
70
+ "Communication style: thorough, detail-oriented, and evidence-based.",
71
+ "",
72
+ "To dispatch work to another agent, write HANDOFF @agent: followed by the task.",
73
+ 'To reference another agent conversationally, say "the engineer" or "the PM" \u2014 never write @agent outside of a HANDOFF command.'
51
74
  ].join("\n")
52
75
  },
53
76
  designer: {
@@ -60,7 +83,10 @@ var PERSONA_PRESETS = {
60
83
  "- Provide clear specifications for engineers to implement.",
61
84
  "- Challenge assumptions about user needs when appropriate.",
62
85
  "",
63
- "Communication style: visual-thinking, user-centric, and practical."
86
+ "Communication style: visual-thinking, user-centric, and practical.",
87
+ "",
88
+ "To dispatch work to another agent, write HANDOFF @agent: followed by the task.",
89
+ 'To reference another agent conversationally, say "the engineer" or "the PM" \u2014 never write @agent outside of a HANDOFF command.'
64
90
  ].join("\n")
65
91
  },
66
92
  devops: {
@@ -73,7 +99,10 @@ var PERSONA_PRESETS = {
73
99
  "- Advise on architecture decisions that affect operability.",
74
100
  "- Automate repetitive operational tasks.",
75
101
  "",
76
- "Communication style: systematic, risk-aware, and automation-focused."
102
+ "Communication style: systematic, risk-aware, and automation-focused.",
103
+ "",
104
+ "To dispatch work to another agent, write HANDOFF @agent: followed by the task.",
105
+ 'To reference another agent conversationally, say "the engineer" or "the PM" \u2014 never write @agent outside of a HANDOFF command.'
77
106
  ].join("\n")
78
107
  }
79
108
  };
@@ -81,7 +110,74 @@ function resolvePreset(presetName) {
81
110
  return PERSONA_PRESETS[presetName.toLowerCase()];
82
111
  }
83
112
 
113
+ // src/logger.ts
114
+ var LEVEL_ORDER = {
115
+ debug: 0,
116
+ info: 1,
117
+ warn: 2,
118
+ error: 3
119
+ };
120
+ function shouldLog(entryLevel, minLevel) {
121
+ return LEVEL_ORDER[entryLevel] >= LEVEL_ORDER[minLevel];
122
+ }
123
+ function formatLogEntry(entry) {
124
+ return JSON.stringify(entry);
125
+ }
126
+ function parseLogEntry(line) {
127
+ try {
128
+ const parsed = JSON.parse(line);
129
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.timestamp === "string" && typeof parsed.level === "string" && typeof parsed.message === "string" && parsed.level in LEVEL_ORDER) {
130
+ return parsed;
131
+ }
132
+ return null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+ function filterLogEntries(entries, opts) {
138
+ return entries.filter((entry) => {
139
+ if (opts?.level && !shouldLog(entry.level, opts.level)) {
140
+ return false;
141
+ }
142
+ if (opts?.project && entry.project !== opts.project) {
143
+ return false;
144
+ }
145
+ return true;
146
+ });
147
+ }
148
+ function createLogger(minLevel = "info", writer = (line) => process.stderr.write(line + "\n")) {
149
+ function log(level, message, ctx) {
150
+ if (!shouldLog(level, minLevel)) return;
151
+ const entry = {
152
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
153
+ level,
154
+ message,
155
+ ...ctx?.project && { project: ctx.project },
156
+ ...ctx?.session && { session: ctx.session }
157
+ };
158
+ writer(formatLogEntry(entry));
159
+ }
160
+ return {
161
+ debug: (message, ctx) => log("debug", message, ctx),
162
+ info: (message, ctx) => log("info", message, ctx),
163
+ warn: (message, ctx) => log("warn", message, ctx),
164
+ error: (message, ctx) => log("error", message, ctx)
165
+ };
166
+ }
167
+ function isValidLogLevel(value) {
168
+ return typeof value === "string" && value in LEVEL_ORDER;
169
+ }
170
+
84
171
  // src/config.ts
172
+ var DEFAULT_ALLOWED_TOOLS = [
173
+ "Read",
174
+ "Edit",
175
+ "Write",
176
+ "Glob",
177
+ "Grep",
178
+ "Bash(git:*)",
179
+ "TodoWrite"
180
+ ];
85
181
  function loadConfig(raw) {
86
182
  if (!raw || typeof raw !== "object") {
87
183
  throw new Error("Config must be an object");
@@ -135,15 +231,31 @@ ${extra}` : basePrompt;
135
231
  }
136
232
  if (Object.keys(agents).length === 0) agents = void 0;
137
233
  }
234
+ const projectAllowed = Array.isArray(p.allowedTools) ? p.allowedTools : void 0;
235
+ const projectDisallowed = Array.isArray(p.disallowedTools) ? p.disallowedTools : void 0;
236
+ if (projectAllowed && projectDisallowed) {
237
+ console.warn(`Warning: project "${typeof p.name === "string" ? p.name : channelId}" sets both allowedTools and disallowedTools \u2014 they conflict. allowedTools takes precedence.`);
238
+ }
239
+ const allowedRoles = Array.isArray(p.allowedRoles) ? p.allowedRoles.filter((r) => typeof r === "string") : void 0;
240
+ const rateLimitPerUser = typeof p.rateLimitPerUser === "number" && p.rateLimitPerUser > 0 ? p.rateLimitPerUser : void 0;
138
241
  validated[channelId] = {
139
242
  name: typeof p.name === "string" ? p.name : channelId,
140
243
  directory: p.directory,
141
244
  ...p.idleTimeoutMs !== void 0 && { idleTimeoutMs: Number(p.idleTimeoutMs) },
142
245
  ...Array.isArray(p.claudeArgs) && { claudeArgs: p.claudeArgs },
143
- ...agents && { agents }
246
+ ...projectAllowed && { allowedTools: projectAllowed },
247
+ ...projectDisallowed && { disallowedTools: projectDisallowed },
248
+ ...agents && { agents },
249
+ ...allowedRoles && allowedRoles.length > 0 && { allowedRoles },
250
+ ...rateLimitPerUser !== void 0 && { rateLimitPerUser }
144
251
  };
145
252
  }
146
253
  const defaults = obj.defaults ?? {};
254
+ const defaultAllowed = Array.isArray(defaults.allowedTools) ? defaults.allowedTools : DEFAULT_ALLOWED_TOOLS;
255
+ const defaultDisallowed = Array.isArray(defaults.disallowedTools) ? defaults.disallowedTools : [];
256
+ if (Array.isArray(defaults.allowedTools) && Array.isArray(defaults.disallowedTools)) {
257
+ console.warn("Warning: gateway defaults set both allowedTools and disallowedTools \u2014 they conflict. allowedTools takes precedence.");
258
+ }
147
259
  return {
148
260
  defaults: {
149
261
  idleTimeoutMs: typeof defaults.idleTimeoutMs === "number" ? defaults.idleTimeoutMs : 18e5,
@@ -151,9 +263,12 @@ ${extra}` : basePrompt;
151
263
  sessionTtlMs: typeof defaults.sessionTtlMs === "number" ? defaults.sessionTtlMs : 7 * 24 * 60 * 60 * 1e3,
152
264
  maxPersistedSessions: typeof defaults.maxPersistedSessions === "number" ? defaults.maxPersistedSessions : 50,
153
265
  claudeArgs: Array.isArray(defaults.claudeArgs) ? defaults.claudeArgs : ["--permission-mode", "acceptEdits", "--output-format", "json"],
266
+ allowedTools: defaultAllowed,
267
+ disallowedTools: defaultDisallowed,
154
268
  maxTurnsPerAgent: typeof defaults.maxTurnsPerAgent === "number" ? defaults.maxTurnsPerAgent : 5,
155
269
  agentTimeoutMs: typeof defaults.agentTimeoutMs === "number" ? defaults.agentTimeoutMs : 3 * 60 * 1e3,
156
- httpPort: defaults.httpPort === false ? false : typeof defaults.httpPort === "number" ? defaults.httpPort : 3100
270
+ httpPort: defaults.httpPort === false ? false : typeof defaults.httpPort === "number" ? defaults.httpPort : 3100,
271
+ logLevel: isValidLogLevel(defaults.logLevel) ? defaults.logLevel : "info"
157
272
  },
158
273
  projects: validated
159
274
  };
@@ -182,21 +297,50 @@ function createRouter(config) {
182
297
  import { spawn } from "child_process";
183
298
  function parseClaudeJsonOutput(raw) {
184
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
+ }
185
315
  return {
186
316
  text: data.result ?? "",
187
317
  sessionId: data.session_id ?? "",
188
- isError: Boolean(data.is_error)
318
+ isError: Boolean(data.is_error),
319
+ usage
189
320
  };
190
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
+ }
191
336
  function buildClaudeArgs(baseArgs, prompt, sessionId, systemPrompt) {
192
- const args2 = ["--print", ...baseArgs];
337
+ const args2 = ["--print", prompt, ...baseArgs];
193
338
  if (sessionId) {
194
339
  args2.push("--resume", sessionId);
195
340
  }
196
341
  if (systemPrompt) {
197
342
  args2.push("--append-system-prompt", systemPrompt);
198
343
  }
199
- args2.push(prompt);
200
344
  return args2;
201
345
  }
202
346
  function friendlyError(stderr) {
@@ -215,9 +359,9 @@ function friendlyError(stderr) {
215
359
  }
216
360
  return `Claude error: ${stderr.slice(0, 500)}`;
217
361
  }
218
- var DEFAULT_TIMEOUT_MS = 10 * 60 * 1e3;
362
+ var DEFAULT_TIMEOUT_MS = 20 * 60 * 1e3;
219
363
  function runClaude(cwd, baseArgs, prompt, sessionId, systemPrompt, timeoutMs = DEFAULT_TIMEOUT_MS) {
220
- return new Promise((resolve4, reject) => {
364
+ return new Promise((resolve5, reject) => {
221
365
  const args2 = buildClaudeArgs(baseArgs, prompt, sessionId, systemPrompt);
222
366
  const proc = spawn("claude", args2, {
223
367
  cwd,
@@ -249,7 +393,7 @@ function runClaude(cwd, baseArgs, prompt, sessionId, systemPrompt, timeoutMs = D
249
393
  }
250
394
  try {
251
395
  const result = parseClaudeJsonOutput(stdout.trim());
252
- resolve4(result);
396
+ resolve5(result);
253
397
  } catch (err) {
254
398
  reject(new Error(`Failed to parse claude output: ${stdout.slice(0, 200)}`));
255
399
  }
@@ -354,7 +498,7 @@ function reconcileWorktrees(projectDir, knownKeys) {
354
498
  }
355
499
 
356
500
  // src/session-manager.ts
357
- function createSessionManager(defaults, store) {
501
+ function createSessionManager(defaults, store, pulseEmitter) {
358
502
  const sessions = /* @__PURE__ */ new Map();
359
503
  const sessionTtlMs = defaults.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1e3;
360
504
  const maxPersistedSessions = defaults.maxPersistedSessions ?? 50;
@@ -402,10 +546,10 @@ function createSessionManager(defaults, store) {
402
546
  activeProcesses++;
403
547
  return;
404
548
  }
405
- return new Promise((resolve4) => {
549
+ return new Promise((resolve5) => {
406
550
  waiters.push(() => {
407
551
  activeProcesses++;
408
- resolve4();
552
+ resolve5();
409
553
  });
410
554
  });
411
555
  }
@@ -418,6 +562,15 @@ function createSessionManager(defaults, store) {
418
562
  if (session.idleTimer) clearTimeout(session.idleTimer);
419
563
  if (session.queue.length > 0) return;
420
564
  session.idleTimer = setTimeout(() => {
565
+ if (pulseEmitter && session.sessionId) {
566
+ pulseEmitter.sessionIdle(
567
+ session.sessionId,
568
+ session.projectKey,
569
+ session.cwd,
570
+ Date.now() - session.createdAt,
571
+ session.messageCount
572
+ );
573
+ }
421
574
  sessions.delete(session.projectKey);
422
575
  }, defaults.idleTimeoutMs);
423
576
  }
@@ -426,11 +579,21 @@ function createSessionManager(defaults, store) {
426
579
  session.processing = true;
427
580
  while (session.queue.length > 0) {
428
581
  const item = session.queue.shift();
582
+ const effectiveArgs = item.extraArgs ? [...defaults.claudeArgs, ...item.extraArgs] : defaults.claudeArgs;
429
583
  await acquireSlot();
584
+ if (pulseEmitter) {
585
+ const agentTarget = session.projectKey.includes(":") ? session.projectKey.split(":").pop() : void 0;
586
+ pulseEmitter.messageRouted(
587
+ session.sessionId ?? session.projectKey,
588
+ session.projectKey,
589
+ session.cwd,
590
+ { agentTarget, queueDepth: session.queue.length }
591
+ );
592
+ }
430
593
  try {
431
594
  const result = await runClaude(
432
595
  session.cwd,
433
- defaults.claudeArgs,
596
+ effectiveArgs,
434
597
  item.prompt,
435
598
  session.sessionId,
436
599
  item.systemPrompt,
@@ -439,6 +602,17 @@ function createSessionManager(defaults, store) {
439
602
  const sessionChanged = !!(session.sessionId && result.sessionId && result.sessionId !== session.sessionId);
440
603
  session.sessionId = result.sessionId || session.sessionId;
441
604
  session.lastActivity = Date.now();
605
+ session.messageCount++;
606
+ if (pulseEmitter && session.sessionId && result.usage) {
607
+ const agentTarget = session.projectKey.includes(":") ? session.projectKey.split(":").pop() : void 0;
608
+ pulseEmitter.messageCompleted(
609
+ session.sessionId,
610
+ session.projectKey,
611
+ session.cwd,
612
+ result.usage,
613
+ { agentTarget }
614
+ );
615
+ }
442
616
  resetIdleTimer(session);
443
617
  persistSessions();
444
618
  if (sessionChanged) {
@@ -450,9 +624,20 @@ function createSessionManager(defaults, store) {
450
624
  if (session.sessionId) {
451
625
  session.sessionId = void 0;
452
626
  try {
453
- const result = await runClaude(session.cwd, defaults.claudeArgs, item.prompt, void 0, item.systemPrompt, item.timeoutMs);
627
+ const result = await runClaude(session.cwd, effectiveArgs, item.prompt, void 0, item.systemPrompt, item.timeoutMs);
454
628
  session.sessionId = result.sessionId || void 0;
455
629
  session.lastActivity = Date.now();
630
+ session.messageCount++;
631
+ if (pulseEmitter && session.sessionId && result.usage) {
632
+ const agentTarget = session.projectKey.includes(":") ? session.projectKey.split(":").pop() : void 0;
633
+ pulseEmitter.messageCompleted(
634
+ session.sessionId,
635
+ session.projectKey,
636
+ session.cwd,
637
+ result.usage,
638
+ { agentTarget }
639
+ );
640
+ }
456
641
  resetIdleTimer(session);
457
642
  persistSessions();
458
643
  item.resolve({ ...result, sessionReset: true });
@@ -470,15 +655,26 @@ function createSessionManager(defaults, store) {
470
655
  }
471
656
  function getOrCreateSession(projectKey, cwd, useWorktree) {
472
657
  let session = sessions.get(projectKey);
658
+ if (session && session.restored && !session.resumeEmitted && pulseEmitter && session.sessionId) {
659
+ session.resumeEmitted = true;
660
+ pulseEmitter.sessionResume(
661
+ session.sessionId,
662
+ session.projectKey,
663
+ session.cwd,
664
+ Date.now() - session.lastActivity
665
+ );
666
+ }
473
667
  if (!session) {
474
668
  let restoredSessionId;
475
669
  let restoredWorktreePath;
670
+ let restoredLastActivity;
476
671
  if (store) {
477
672
  const persisted = store.load();
478
673
  const entry = persisted.get(projectKey);
479
674
  if (entry?.sessionId) {
480
675
  restoredSessionId = entry.sessionId;
481
676
  restoredWorktreePath = entry.worktreePath;
677
+ restoredLastActivity = entry.lastActivity;
482
678
  }
483
679
  }
484
680
  let effectiveCwd = cwd;
@@ -498,12 +694,34 @@ function createSessionManager(defaults, store) {
498
694
  projectDir,
499
695
  worktreePath: worktreePath2,
500
696
  lastActivity: Date.now(),
697
+ createdAt: Date.now(),
698
+ messageCount: 0,
699
+ restored: !!restoredSessionId,
700
+ resumeEmitted: false,
501
701
  processing: false,
502
702
  queue: [],
503
703
  idleTimer: null
504
704
  };
505
705
  sessions.set(projectKey, session);
506
706
  resetIdleTimer(session);
707
+ if (pulseEmitter) {
708
+ if (restoredSessionId) {
709
+ session.resumeEmitted = true;
710
+ pulseEmitter.sessionResume(
711
+ restoredSessionId,
712
+ projectKey,
713
+ effectiveCwd,
714
+ Date.now() - (restoredLastActivity ?? Date.now())
715
+ );
716
+ } else {
717
+ pulseEmitter.sessionStart(
718
+ session.sessionId ?? projectKey,
719
+ projectKey,
720
+ effectiveCwd,
721
+ { triggerSource: "discord" }
722
+ );
723
+ }
724
+ }
507
725
  }
508
726
  return session;
509
727
  }
@@ -522,6 +740,10 @@ function createSessionManager(defaults, store) {
522
740
  projectDir: entry.projectDir,
523
741
  worktreePath: entry.worktreePath,
524
742
  lastActivity: entry.lastActivity,
743
+ createdAt: entry.lastActivity,
744
+ messageCount: 0,
745
+ restored: true,
746
+ resumeEmitted: false,
525
747
  processing: false,
526
748
  queue: [],
527
749
  idleTimer: null
@@ -537,8 +759,8 @@ function createSessionManager(defaults, store) {
537
759
  return {
538
760
  send(projectKey, cwd, prompt, opts) {
539
761
  const session = getOrCreateSession(projectKey, cwd, opts?.worktree);
540
- return new Promise((resolve4, reject) => {
541
- session.queue.push({ prompt, systemPrompt: opts?.systemPrompt, timeoutMs: opts?.timeoutMs, resolve: resolve4, reject });
762
+ return new Promise((resolve5, reject) => {
763
+ session.queue.push({ prompt, systemPrompt: opts?.systemPrompt, timeoutMs: opts?.timeoutMs, extraArgs: opts?.extraArgs, resolve: resolve5, reject });
542
764
  processQueue(session);
543
765
  });
544
766
  },
@@ -564,6 +786,15 @@ function createSessionManager(defaults, store) {
564
786
  const session = sessions.get(projectKey);
565
787
  if (!session) return false;
566
788
  if (session.idleTimer) clearTimeout(session.idleTimer);
789
+ if (pulseEmitter && session.sessionId) {
790
+ pulseEmitter.sessionEnd(
791
+ session.sessionId,
792
+ session.projectKey,
793
+ session.cwd,
794
+ Date.now() - session.createdAt,
795
+ session.messageCount
796
+ );
797
+ }
567
798
  if (session.worktreePath && session.projectDir) {
568
799
  removeWorktree(session.projectDir, session.projectKey);
569
800
  }
@@ -623,9 +854,44 @@ function createFileSessionStore(filePath) {
623
854
  }
624
855
 
625
856
  // src/discord.ts
857
+ import { readdirSync, readFileSync as readFileSync2 } from "fs";
858
+ import { join as join2 } from "path";
626
859
  import { Client, GatewayIntentBits, Events, Status } from "discord.js";
627
860
 
628
861
  // src/agent-dispatch.ts
862
+ var BUILT_IN_COMMANDS = /* @__PURE__ */ new Set([
863
+ "help",
864
+ "sessions",
865
+ "session",
866
+ "kill",
867
+ "restart",
868
+ "agents",
869
+ "ask"
870
+ ]);
871
+ function parseAgentCommand(text, agents) {
872
+ const askMatch = text.match(/^!ask\s+(\S+)(?:\s+([\s\S]*))?$/i);
873
+ if (askMatch) {
874
+ const name = askMatch[1].toLowerCase();
875
+ const agent = agents[name];
876
+ if (!agent) return null;
877
+ const prompt = (askMatch[2] ?? "").trim();
878
+ return { agentName: name, agent, prompt };
879
+ }
880
+ const shortMatch = text.match(/^!(\S+)(?:\s+([\s\S]*))?$/i);
881
+ if (shortMatch) {
882
+ const name = shortMatch[1].toLowerCase();
883
+ if (BUILT_IN_COMMANDS.has(name)) return null;
884
+ const agent = agents[name];
885
+ if (!agent) return null;
886
+ const prompt = (shortMatch[2] ?? "").trim();
887
+ return { agentName: name, agent, prompt };
888
+ }
889
+ return null;
890
+ }
891
+ function extractAskTarget(text) {
892
+ const askMatch = text.match(/^!ask\s+(\S+)/i);
893
+ return askMatch ? askMatch[1].toLowerCase() : null;
894
+ }
629
895
  function parseAgentMention(text, agents) {
630
896
  const agentNames = Object.keys(agents);
631
897
  if (agentNames.length === 0) return null;
@@ -644,6 +910,18 @@ function parseAgentMention(text, agents) {
644
910
  }
645
911
  return { agentName: matchedName, agent, prompt };
646
912
  }
913
+ function parseHandoffCommand(text, agents) {
914
+ const agentNames = Object.keys(agents);
915
+ if (agentNames.length === 0) return null;
916
+ const escaped = agentNames.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
917
+ const pattern = new RegExp(`^HANDOFF\\s+@(${escaped.join("|")})\\s*:\\s*(.*)$`, "im");
918
+ const match = text.match(pattern);
919
+ if (!match) return null;
920
+ const matchedName = match[1].toLowerCase();
921
+ const agent = agents[matchedName];
922
+ if (!agent) return null;
923
+ return { agentName: matchedName, agent, prompt: match[2].trim() };
924
+ }
647
925
 
648
926
  // src/embed-format.ts
649
927
  import { EmbedBuilder } from "discord.js";
@@ -689,6 +967,9 @@ function buildAgentEmbeds(text, agentName, agentRole) {
689
967
  return embed;
690
968
  });
691
969
  }
970
+ function buildHandoffEmbed(agentName, agentRole) {
971
+ return new EmbedBuilder().setAuthor({ name: agentRole }).setDescription(`Handing off to **@${agentName}**...`).setColor(agentColor(agentName));
972
+ }
692
973
  var PLAIN_TEXT_LIMIT = 2e3;
693
974
  async function sendAgentMessage(channel, text, agentName, agentRole) {
694
975
  if (agentName && agentRole) {
@@ -704,6 +985,64 @@ async function sendAgentMessage(channel, text, agentName, agentRole) {
704
985
  }
705
986
  }
706
987
 
988
+ // src/role-check.ts
989
+ function hasAllowedRole(member, allowedRoles) {
990
+ if (!allowedRoles || allowedRoles.length === 0) return true;
991
+ if (!member) return false;
992
+ return member.roles.cache.some(
993
+ (role) => allowedRoles.includes(role.name) || allowedRoles.includes(role.id)
994
+ );
995
+ }
996
+
997
+ // src/rate-limiter.ts
998
+ var WINDOW_MS = 6e4;
999
+ var CLEANUP_INTERVAL_MS = 5 * 6e4;
1000
+ function createRateLimiter() {
1001
+ const timestamps = /* @__PURE__ */ new Map();
1002
+ function pruneUser(userId, now) {
1003
+ const ts = timestamps.get(userId);
1004
+ if (!ts) return [];
1005
+ const valid = ts.filter((t) => now - t < WINDOW_MS);
1006
+ if (valid.length === 0) {
1007
+ timestamps.delete(userId);
1008
+ return [];
1009
+ }
1010
+ timestamps.set(userId, valid);
1011
+ return valid;
1012
+ }
1013
+ const cleanupTimer = setInterval(() => {
1014
+ const now = Date.now();
1015
+ for (const userId of timestamps.keys()) {
1016
+ pruneUser(userId, now);
1017
+ }
1018
+ }, CLEANUP_INTERVAL_MS);
1019
+ if (cleanupTimer.unref) cleanupTimer.unref();
1020
+ return {
1021
+ check(userId, limit) {
1022
+ const now = Date.now();
1023
+ const valid = pruneUser(userId, now);
1024
+ if (valid.length >= limit) {
1025
+ const oldest = valid[0];
1026
+ const retryAfterMs = WINDOW_MS - (now - oldest);
1027
+ return {
1028
+ allowed: false,
1029
+ retryAfterSeconds: Math.ceil(retryAfterMs / 1e3)
1030
+ };
1031
+ }
1032
+ if (!timestamps.has(userId)) {
1033
+ timestamps.set(userId, [now]);
1034
+ } else {
1035
+ timestamps.get(userId).push(now);
1036
+ }
1037
+ return { allowed: true };
1038
+ },
1039
+ dispose() {
1040
+ clearInterval(cleanupTimer);
1041
+ timestamps.clear();
1042
+ }
1043
+ };
1044
+ }
1045
+
707
1046
  // src/discord.ts
708
1047
  function chunkMessage(text, limit) {
709
1048
  if (text.length <= limit) return [text];
@@ -827,21 +1166,52 @@ ${lines.join("\n")}`;
827
1166
  return `**${context.projectName}** \u2014 No agents configured. Messages go to the default session.`;
828
1167
  }
829
1168
  const lines = Object.entries(project.agents).map(
830
- ([name, agent]) => `- \`@${name}\` \u2014 ${agent.role}`
1169
+ ([name, agent]) => `- \`${name}\` \u2014 ${agent.role}`
831
1170
  );
832
1171
  return `**${context.projectName} agents**
833
1172
  ${lines.join("\n")}
834
1173
 
835
- Mention an agent to dispatch: \`@pm review this\``;
1174
+ Dispatch: \`!ask <agent> <message>\` or shorthand \`!<agent> <message>\``;
1175
+ }
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
+ }
836
1203
  }
837
1204
  if (cmd === "!help") {
838
1205
  return [
839
1206
  "**Gateway commands**",
1207
+ "`!ask <agent> <message>` \u2014 dispatch a message to a named agent",
1208
+ "`!<agent> <message>` \u2014 shorthand for `!ask`",
840
1209
  "`!sessions` \u2014 list all active sessions",
841
1210
  "`!session` \u2014 show session for the current thread (or use `!session <name>`)",
842
1211
  "`!restart <name>` \u2014 reset a session (fresh context, keeps worktree)",
843
1212
  "`!kill <name>` \u2014 force-close a project session",
844
1213
  "`!agents` \u2014 list available agents for the current project",
1214
+ "`!apo` \u2014 show latest pulse interaction report",
845
1215
  "`!help` \u2014 show this message"
846
1216
  ].join("\n");
847
1217
  }
@@ -868,9 +1238,11 @@ function createDiscordBot(router, sessionManager, config, turnCounter) {
868
1238
  intents: [
869
1239
  GatewayIntentBits.Guilds,
870
1240
  GatewayIntentBits.GuildMessages,
871
- GatewayIntentBits.MessageContent
1241
+ GatewayIntentBits.MessageContent,
1242
+ GatewayIntentBits.GuildMembers
872
1243
  ]
873
1244
  });
1245
+ const rateLimiter = createRateLimiter();
874
1246
  const lastActiveAgent = /* @__PURE__ */ new Map();
875
1247
  client.on(Events.MessageCreate, async (message) => {
876
1248
  if (message.author.bot) return;
@@ -888,11 +1260,48 @@ function createDiscordBot(router, sessionManager, config, turnCounter) {
888
1260
  await message.channel.send(response);
889
1261
  return;
890
1262
  }
1263
+ const projectChannelId2 = parentId2 || resolved2.channelId;
1264
+ const project2 = config.projects[projectChannelId2];
1265
+ const agents2 = project2?.agents;
1266
+ if (agents2) {
1267
+ const askMention = parseAgentCommand(message.content, agents2);
1268
+ if (askMention) {
1269
+ } else {
1270
+ const target = extractAskTarget(message.content);
1271
+ if (target) {
1272
+ const agentList = Object.entries(agents2).map(([name, a]) => `\`${name}\` \u2014 ${a.role}`).join("\n- ");
1273
+ await message.channel.send(
1274
+ `Unknown agent \`${target}\`. Available agents:
1275
+ - ${agentList}
1276
+
1277
+ Usage: \`!ask <agent> <message>\``
1278
+ );
1279
+ return;
1280
+ }
1281
+ }
1282
+ }
891
1283
  }
892
1284
  }
893
1285
  const parentId = message.channel.isThread() ? message.channel.parentId ?? void 0 : void 0;
894
1286
  const resolved = router.resolve(message.channelId, parentId);
895
1287
  if (!resolved) return;
1288
+ const projectChannelIdForAcl = parentId || resolved.channelId;
1289
+ const projectForAcl = config.projects[projectChannelIdForAcl];
1290
+ if (projectForAcl?.allowedRoles && projectForAcl.allowedRoles.length > 0) {
1291
+ if (!hasAllowedRole(message.member, projectForAcl.allowedRoles)) {
1292
+ await message.reply("You don't have permission to use this bot.");
1293
+ return;
1294
+ }
1295
+ }
1296
+ if (projectForAcl?.rateLimitPerUser) {
1297
+ const result = rateLimiter.check(`${message.author.id}:${projectChannelIdForAcl}`, projectForAcl.rateLimitPerUser);
1298
+ if (!result.allowed) {
1299
+ await message.reply(
1300
+ `You're sending messages too quickly. Please wait ${result.retryAfterSeconds}s before trying again.`
1301
+ );
1302
+ return;
1303
+ }
1304
+ }
896
1305
  try {
897
1306
  await message.react("\u{1F440}");
898
1307
  } catch {
@@ -922,8 +1331,14 @@ function createDiscordBot(router, sessionManager, config, turnCounter) {
922
1331
  const projectChannelId = parentId || resolved.channelId;
923
1332
  const project = config.projects[projectChannelId];
924
1333
  const agents = project?.agents;
1334
+ const allClaudeArgs = project?.claudeArgs ? [...config.defaults.claudeArgs, ...project.claudeArgs] : config.defaults.claudeArgs;
1335
+ const toolArgs = buildToolArgs(
1336
+ config.defaults,
1337
+ project ? { allowedTools: project.allowedTools, disallowedTools: project.disallowedTools } : void 0,
1338
+ allClaudeArgs
1339
+ );
925
1340
  if (turnCounter) turnCounter.reset(replyChannel.id);
926
- const mention = agents ? parseAgentMention(message.content, agents) : null;
1341
+ const mention = agents ? parseAgentCommand(message.content, agents) ?? parseAgentMention(message.content, agents) : null;
927
1342
  const activeAgent = mention ?? (message.channel.isThread() ? lastActiveAgent.get(replyChannel.id) ?? null : null);
928
1343
  const threadId = replyChannel.id;
929
1344
  const sessionKey = activeAgent ? `${threadId}:${activeAgent.agentName}` : threadId;
@@ -936,13 +1351,18 @@ ${activeAgent.agent.prompt}` : void 0;
936
1351
  const history = await fetchThreadHistory(replyChannel, message.id);
937
1352
  if (history) userPrompt = `${history}${userPrompt}`;
938
1353
  }
1354
+ if (!userPrompt.trim()) {
1355
+ await replyChannel.send("Please include a message with your request.");
1356
+ return;
1357
+ }
939
1358
  const result = await sessionManager.send(
940
1359
  sessionKey,
941
1360
  resolved.directory,
942
1361
  userPrompt,
943
1362
  {
944
1363
  worktree: replyChannel.isThread() ? true : void 0,
945
- systemPrompt
1364
+ systemPrompt,
1365
+ extraArgs: toolArgs.length > 0 ? toolArgs : void 0
946
1366
  }
947
1367
  );
948
1368
  if (result.sessionReset) {
@@ -964,7 +1384,7 @@ ${activeAgent.agent.prompt}` : void 0;
964
1384
  let currentAgentName = activeAgent?.agentName;
965
1385
  const maxTurns = config.defaults.maxTurnsPerAgent;
966
1386
  while (true) {
967
- const handoff = parseAgentMention(responseText, agents);
1387
+ const handoff = parseHandoffCommand(responseText, agents);
968
1388
  if (!handoff || handoff.agentName === currentAgentName) break;
969
1389
  turnCounter.increment(replyChannel.id);
970
1390
  const turn = turnCounter.getTurns(replyChannel.id);
@@ -980,6 +1400,9 @@ ${activeAgent.agent.prompt}` : void 0;
980
1400
  const handoffPrompt = `Your role: ${handoff.agent.role}
981
1401
 
982
1402
  ${handoff.agent.prompt}`;
1403
+ replyChannel.sendTyping().catch(() => {
1404
+ });
1405
+ await replyChannel.send({ embeds: [buildHandoffEmbed(handoff.agentName, handoff.agent.role)] }).catch(() => null);
983
1406
  replyChannel.sendTyping().catch(() => {
984
1407
  });
985
1408
  console.log(`[handoff] thread=${replyChannel.id} sending to ${handoff.agentName} (key=${handoffKey}, prompt length=${responseText.length})`);
@@ -990,7 +1413,7 @@ ${handoff.agent.prompt}`;
990
1413
  handoffKey,
991
1414
  resolved.directory,
992
1415
  responseText,
993
- { worktree: replyChannel.isThread() ? true : void 0, systemPrompt: handoffPrompt, timeoutMs: config.defaults.agentTimeoutMs }
1416
+ { worktree: replyChannel.isThread() ? true : void 0, systemPrompt: handoffPrompt, timeoutMs: config.defaults.agentTimeoutMs, extraArgs: toolArgs.length > 0 ? toolArgs : void 0 }
994
1417
  );
995
1418
  } catch (handoffErr) {
996
1419
  const msg = handoffErr instanceof Error ? handoffErr.message : String(handoffErr);
@@ -1028,6 +1451,7 @@ ${handoff.agent.prompt}`;
1028
1451
  console.log(`Gateway connected as ${client.user?.tag}`);
1029
1452
  },
1030
1453
  stop() {
1454
+ rateLimiter.dispose();
1031
1455
  client.destroy();
1032
1456
  },
1033
1457
  getStatus() {
@@ -1048,35 +1472,684 @@ ${handoff.agent.prompt}`;
1048
1472
  };
1049
1473
  }
1050
1474
 
1475
+ // src/pulse-events.ts
1476
+ import { appendFileSync, mkdirSync as mkdirSync2 } from "fs";
1477
+ import { dirname as dirname2, join as join3 } from "path";
1478
+ import { homedir } from "os";
1479
+ var DEFAULT_PATH = join3(homedir(), ".pulse", "events", "mpg-sessions.jsonl");
1480
+ function baseEvent(eventType, sessionId, projectKey, projectDir) {
1481
+ return {
1482
+ schema_version: 1,
1483
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1484
+ event_type: eventType,
1485
+ session_id: sessionId,
1486
+ project_key: projectKey,
1487
+ project_dir: projectDir
1488
+ };
1489
+ }
1490
+ function createPulseEmitter(filePath) {
1491
+ const target = filePath ?? DEFAULT_PATH;
1492
+ let dirCreated = false;
1493
+ function emit(event) {
1494
+ try {
1495
+ if (!dirCreated) {
1496
+ mkdirSync2(dirname2(target), { recursive: true });
1497
+ dirCreated = true;
1498
+ }
1499
+ appendFileSync(target, JSON.stringify(event) + "\n");
1500
+ } catch {
1501
+ }
1502
+ }
1503
+ return {
1504
+ sessionStart(sessionId, projectKey, projectDir, opts) {
1505
+ emit({
1506
+ ...baseEvent("session_start", sessionId, projectKey, projectDir),
1507
+ agent_name: opts?.agentName,
1508
+ trigger_source: opts?.triggerSource ?? "unknown"
1509
+ });
1510
+ },
1511
+ sessionEnd(sessionId, projectKey, projectDir, durationMs, messageCount) {
1512
+ emit({
1513
+ ...baseEvent("session_end", sessionId, projectKey, projectDir),
1514
+ duration_ms: durationMs,
1515
+ message_count: messageCount
1516
+ });
1517
+ },
1518
+ sessionIdle(sessionId, projectKey, projectDir, durationMs, messageCount) {
1519
+ emit({
1520
+ ...baseEvent("session_idle", sessionId, projectKey, projectDir),
1521
+ duration_ms: durationMs,
1522
+ message_count: messageCount
1523
+ });
1524
+ },
1525
+ sessionResume(sessionId, projectKey, projectDir, idleDurationMs) {
1526
+ emit({
1527
+ ...baseEvent("session_resume", sessionId, projectKey, projectDir),
1528
+ idle_duration_ms: idleDurationMs
1529
+ });
1530
+ },
1531
+ messageRouted(sessionId, projectKey, projectDir, opts) {
1532
+ emit({
1533
+ ...baseEvent("message_routed", sessionId, projectKey, projectDir),
1534
+ agent_target: opts?.agentTarget,
1535
+ queue_depth: opts?.queueDepth ?? 0
1536
+ });
1537
+ },
1538
+ messageCompleted(sessionId, projectKey, projectDir, usage, opts) {
1539
+ emit({
1540
+ ...baseEvent("message_completed", sessionId, projectKey, projectDir),
1541
+ agent_target: opts?.agentTarget,
1542
+ input_tokens: usage.input_tokens,
1543
+ output_tokens: usage.output_tokens,
1544
+ cache_creation_input_tokens: usage.cache_creation_input_tokens,
1545
+ cache_read_input_tokens: usage.cache_read_input_tokens,
1546
+ total_cost_usd: usage.total_cost_usd,
1547
+ duration_ms: usage.duration_ms,
1548
+ duration_api_ms: usage.duration_api_ms,
1549
+ num_turns: usage.num_turns,
1550
+ model: usage.model
1551
+ });
1552
+ }
1553
+ };
1554
+ }
1555
+
1556
+ // src/activity-engine.ts
1557
+ import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
1558
+ import { join as join4 } from "path";
1559
+ import { homedir as homedir2 } from "os";
1560
+ var DEFAULT_PATH2 = join4(homedir2(), ".pulse", "events", "mpg-sessions.jsonl");
1561
+ var RANGE_MS = {
1562
+ "24h": 24 * 60 * 60 * 1e3,
1563
+ "7d": 7 * 24 * 60 * 60 * 1e3,
1564
+ "30d": 30 * 24 * 60 * 60 * 1e3
1565
+ };
1566
+ function readEvents(filePath, range) {
1567
+ if (!existsSync2(filePath)) return [];
1568
+ try {
1569
+ const content = readFileSync3(filePath, "utf-8").trim();
1570
+ if (!content) return [];
1571
+ const cutoff = Date.now() - RANGE_MS[range];
1572
+ const events = [];
1573
+ for (const line of content.split("\n")) {
1574
+ try {
1575
+ const e = JSON.parse(line);
1576
+ if (new Date(e.timestamp).getTime() >= cutoff) {
1577
+ events.push(e);
1578
+ }
1579
+ } catch {
1580
+ }
1581
+ }
1582
+ return events;
1583
+ } catch {
1584
+ return [];
1585
+ }
1586
+ }
1587
+ function bucketKey(timestamp, bucket) {
1588
+ const d = new Date(timestamp);
1589
+ if (bucket === "hour") {
1590
+ d.setMinutes(0, 0, 0);
1591
+ } else {
1592
+ d.setHours(0, 0, 0, 0);
1593
+ }
1594
+ return d.toISOString();
1595
+ }
1596
+ function createActivityEngine(filePath) {
1597
+ const target = filePath ?? DEFAULT_PATH2;
1598
+ function getEvents(range, eventType) {
1599
+ const events = readEvents(target, range);
1600
+ return eventType ? events.filter((e) => e.event_type === eventType) : events;
1601
+ }
1602
+ return {
1603
+ computeSummary(range) {
1604
+ const events = readEvents(target, range);
1605
+ const sessions = events.filter((e) => e.event_type === "session_start");
1606
+ const messages = events.filter((e) => e.event_type === "message_completed");
1607
+ const endings = events.filter((e) => e.event_type === "session_end" || e.event_type === "session_idle");
1608
+ const totalDuration = endings.reduce((s, e) => s + (Number(e.duration_ms) || 0), 0);
1609
+ return {
1610
+ total_cost_usd: messages.reduce((s, e) => s + (Number(e.total_cost_usd) || 0), 0),
1611
+ total_input_tokens: messages.reduce((s, e) => s + (Number(e.input_tokens) || 0), 0),
1612
+ total_output_tokens: messages.reduce((s, e) => s + (Number(e.output_tokens) || 0), 0),
1613
+ total_sessions: sessions.length,
1614
+ total_messages: messages.length,
1615
+ avg_session_duration_ms: endings.length > 0 ? totalDuration / endings.length : 0
1616
+ };
1617
+ },
1618
+ tokensByProject(range) {
1619
+ const messages = getEvents(range, "message_completed");
1620
+ const map = /* @__PURE__ */ new Map();
1621
+ 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 };
1624
+ row.input_tokens += Number(e.input_tokens) || 0;
1625
+ row.output_tokens += Number(e.output_tokens) || 0;
1626
+ row.cache_read_input_tokens += Number(e.cache_read_input_tokens) || 0;
1627
+ row.cost_usd += Number(e.total_cost_usd) || 0;
1628
+ row.message_count++;
1629
+ map.set(key, row);
1630
+ }
1631
+ return Array.from(map.values());
1632
+ },
1633
+ tokensBySession(range) {
1634
+ const messages = getEvents(range, "message_completed");
1635
+ const map = /* @__PURE__ */ new Map();
1636
+ for (const e of messages) {
1637
+ const key = e.session_id;
1638
+ const row = map.get(key) ?? { session_id: key, project_key: e.project_key, input_tokens: 0, output_tokens: 0, cost_usd: 0, message_count: 0, duration_ms: 0 };
1639
+ row.input_tokens += Number(e.input_tokens) || 0;
1640
+ row.output_tokens += Number(e.output_tokens) || 0;
1641
+ row.cost_usd += Number(e.total_cost_usd) || 0;
1642
+ row.duration_ms += Number(e.duration_ms) || 0;
1643
+ row.message_count++;
1644
+ map.set(key, row);
1645
+ }
1646
+ return Array.from(map.values());
1647
+ },
1648
+ bucketed(range, bucket, eventType, valueField) {
1649
+ const events = getEvents(range, eventType);
1650
+ const map = /* @__PURE__ */ new Map();
1651
+ for (const e of events) {
1652
+ const key = bucketKey(e.timestamp, bucket);
1653
+ const val = valueField ? Number(e[valueField]) || 0 : 1;
1654
+ map.set(key, (map.get(key) ?? 0) + val);
1655
+ }
1656
+ return Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0])).map(([b, value]) => ({ bucket: b, value }));
1657
+ },
1658
+ sessionDurations(range) {
1659
+ const endings = getEvents(range).filter((e) => e.event_type === "session_end" || e.event_type === "session_idle");
1660
+ return endings.map((e) => ({
1661
+ session_id: e.session_id,
1662
+ project_key: e.project_key,
1663
+ duration_ms: Number(e.duration_ms) || 0
1664
+ }));
1665
+ },
1666
+ modelBreakdown(range) {
1667
+ const messages = getEvents(range, "message_completed");
1668
+ const map = /* @__PURE__ */ new Map();
1669
+ for (const e of messages) {
1670
+ const model = String(e.model ?? "unknown");
1671
+ const row = map.get(model) ?? { model, input_tokens: 0, output_tokens: 0, cost_usd: 0 };
1672
+ row.input_tokens += Number(e.input_tokens) || 0;
1673
+ row.output_tokens += Number(e.output_tokens) || 0;
1674
+ row.cost_usd += Number(e.total_cost_usd) || 0;
1675
+ map.set(model, row);
1676
+ }
1677
+ return Array.from(map.values());
1678
+ },
1679
+ personaBreakdown(range) {
1680
+ const routed = getEvents(range, "message_routed");
1681
+ const map = /* @__PURE__ */ new Map();
1682
+ for (const e of routed) {
1683
+ const agent = String(e.agent_target ?? "default");
1684
+ map.set(agent, (map.get(agent) ?? 0) + 1);
1685
+ }
1686
+ return Array.from(map.entries()).map(([agent, count]) => ({ agent, count }));
1687
+ },
1688
+ cacheEfficiency(range) {
1689
+ const messages = getEvents(range, "message_completed");
1690
+ const totalInput = messages.reduce((s, e) => s + (Number(e.input_tokens) || 0), 0);
1691
+ const cacheRead = messages.reduce((s, e) => s + (Number(e.cache_read_input_tokens) || 0), 0);
1692
+ return {
1693
+ total_input_tokens: totalInput,
1694
+ cache_read_tokens: cacheRead,
1695
+ cache_hit_ratio: totalInput > 0 ? cacheRead / totalInput : 0
1696
+ };
1697
+ }
1698
+ };
1699
+ }
1700
+
1051
1701
  // src/health-server.ts
1052
1702
  import { createServer } from "http";
1053
- function createHealthServer(port, sessionManager, bot) {
1703
+ import { readFileSync as readFileSync4 } from "fs";
1704
+ function getVersion() {
1705
+ try {
1706
+ const pkg = JSON.parse(readFileSync4(new URL("../package.json", import.meta.url), "utf-8"));
1707
+ return pkg.version ?? "unknown";
1708
+ } catch {
1709
+ return "unknown";
1710
+ }
1711
+ }
1712
+ function buildDashboardHtml() {
1713
+ return `<!DOCTYPE html>
1714
+ <html lang="en">
1715
+ <head>
1716
+ <meta charset="utf-8">
1717
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1718
+ <title>MPG Dashboard</title>
1719
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
1720
+ <style>
1721
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1722
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f1117; color: #e1e4e8; padding: 24px; }
1723
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
1724
+ .subtitle { color: #8b949e; font-size: 13px; margin-bottom: 8px; }
1725
+ .tabs { display: flex; gap: 8px; margin-bottom: 24px; }
1726
+ .tab { background: #161b22; border: 1px solid #30363d; border-radius: 6px; color: #8b949e; padding: 8px 16px; cursor: pointer; font-size: 14px; }
1727
+ .tab.active { color: #e1e4e8; border-color: #58a6ff; background: #1c2333; }
1728
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
1729
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
1730
+ .card-label { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; }
1731
+ .card-value { font-size: 28px; font-weight: 700; margin-top: 4px; }
1732
+ .status-ok { color: #3fb950; }
1733
+ .status-warn { color: #d29922; }
1734
+ .status-err { color: #f85149; }
1735
+ h2 { font-size: 16px; font-weight: 600; margin-bottom: 12px; }
1736
+ h3 { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: #8b949e; }
1737
+ table { width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; margin-bottom: 24px; }
1738
+ th { text-align: left; padding: 10px 14px; font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid #30363d; }
1739
+ td { padding: 10px 14px; font-size: 14px; border-bottom: 1px solid #21262d; }
1740
+ tr:last-child td { border-bottom: none; }
1741
+ .empty { color: #8b949e; font-style: italic; padding: 24px; text-align: center; }
1742
+ .refresh-info { color: #484f58; font-size: 12px; text-align: right; }
1743
+ .range-selector { display: flex; gap: 8px; margin-bottom: 16px; }
1744
+ .range-btn { background: #161b22; border: 1px solid #30363d; border-radius: 4px; color: #8b949e; padding: 6px 12px; cursor: pointer; font-size: 13px; }
1745
+ .range-btn.active { color: #e1e4e8; border-color: #58a6ff; }
1746
+ .chart-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 16px; margin-bottom: 24px; }
1747
+ .chart-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
1748
+ .chart-card h3 { margin-bottom: 12px; }
1749
+ .summary-cards { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 24px; }
1750
+ .summary-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; text-align: center; }
1751
+ .summary-value { font-size: 24px; font-weight: bold; color: #e1e4e8; }
1752
+ .summary-label { font-size: 12px; color: #8b949e; margin-top: 4px; }
1753
+ </style>
1754
+ </head>
1755
+ <body>
1756
+ <h1>Multi-Project Gateway</h1>
1757
+ <p class="subtitle" id="version"></p>
1758
+ <div class="tabs">
1759
+ <button class="tab active" onclick="switchTab('overview')">Overview</button>
1760
+ <button class="tab" onclick="switchTab('activity')">Activity</button>
1761
+ </div>
1762
+
1763
+ <div id="tab-overview">
1764
+ <div class="grid">
1765
+ <div class="card">
1766
+ <div class="card-label">Status</div>
1767
+ <div class="card-value" id="status">\u2014</div>
1768
+ </div>
1769
+ <div class="card">
1770
+ <div class="card-label">Uptime</div>
1771
+ <div class="card-value" id="uptime">\u2014</div>
1772
+ </div>
1773
+ <div class="card">
1774
+ <div class="card-label">Active Sessions</div>
1775
+ <div class="card-value" id="sessions-active">\u2014</div>
1776
+ </div>
1777
+ <div class="card">
1778
+ <div class="card-label">Queued Messages</div>
1779
+ <div class="card-value" id="sessions-queued">\u2014</div>
1780
+ </div>
1781
+ <div class="card">
1782
+ <div class="card-label">Discord</div>
1783
+ <div class="card-value" id="discord">\u2014</div>
1784
+ </div>
1785
+ </div>
1786
+
1787
+ <h2>Sessions</h2>
1788
+ <div id="sessions-table"></div>
1789
+
1790
+ <h2>Projects</h2>
1791
+ <div id="projects-table"></div>
1792
+
1793
+ <p class="refresh-info">Auto-refreshes every 5s</p>
1794
+ </div>
1795
+
1796
+ <div id="tab-activity" style="display:none">
1797
+ <div class="range-selector">
1798
+ <button class="range-btn active" data-range="24h">24h</button>
1799
+ <button class="range-btn" data-range="7d">7d</button>
1800
+ <button class="range-btn" data-range="30d">30d</button>
1801
+ </div>
1802
+ <div class="summary-cards">
1803
+ <div class="summary-card"><div class="summary-value" id="total-cost">$0.00</div><div class="summary-label">Total Cost</div></div>
1804
+ <div class="summary-card"><div class="summary-value" id="total-tokens">0</div><div class="summary-label">Total Tokens</div></div>
1805
+ <div class="summary-card"><div class="summary-value" id="total-sessions-card">0</div><div class="summary-label">Sessions</div></div>
1806
+ <div class="summary-card"><div class="summary-value" id="total-messages">0</div><div class="summary-label">Messages</div></div>
1807
+ <div class="summary-card"><div class="summary-value" id="avg-duration">0m</div><div class="summary-label">Avg Duration</div></div>
1808
+ </div>
1809
+ <div class="chart-grid">
1810
+ <div class="chart-card"><h3>Messages Over Time</h3><canvas id="messages-chart"></canvas></div>
1811
+ <div class="chart-card"><h3>Cost Over Time</h3><canvas id="cost-chart"></canvas></div>
1812
+ <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>
1814
+ <div class="chart-card"><h3>Persona Breakdown</h3><canvas id="persona-chart"></canvas></div>
1815
+ <div class="chart-card"><h3>Model Breakdown</h3><canvas id="model-chart"></canvas></div>
1816
+ </div>
1817
+ <h3 style="margin:16px 0 8px">Token Usage by Project</h3>
1818
+ <div id="project-table"></div>
1819
+ <h3 style="margin:16px 0 8px">Token Usage by Session</h3>
1820
+ <div id="session-table"></div>
1821
+ <h3 style="margin:16px 0 8px">Cache Efficiency</h3>
1822
+ <div id="cache-table"></div>
1823
+ </div>
1824
+
1825
+ <script>
1826
+ function formatUptime(s) {
1827
+ if (s < 60) return s + 's';
1828
+ if (s < 3600) return Math.floor(s / 60) + 'm';
1829
+ var h = Math.floor(s / 3600);
1830
+ var m = Math.floor((s % 3600) / 60);
1831
+ return h + 'h ' + m + 'm';
1832
+ }
1833
+ function formatAgo(ts) {
1834
+ var diff = Math.floor((Date.now() - ts) / 1000);
1835
+ if (diff < 60) return diff + 's ago';
1836
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
1837
+ return Math.floor(diff / 3600) + 'h ago';
1838
+ }
1839
+ function statusClass(v) {
1840
+ if (v === 'ok' || v === 'connected') return 'status-ok';
1841
+ if (v === 'reconnecting') return 'status-warn';
1842
+ return 'status-err';
1843
+ }
1844
+ function escapeHtml(s) {
1845
+ var d = document.createElement('div');
1846
+ d.textContent = s;
1847
+ return d.innerHTML;
1848
+ }
1849
+
1850
+ function refresh() {
1851
+ fetch('/api/status')
1852
+ .then(function(r) { return r.json(); })
1853
+ .then(function(d) {
1854
+ document.getElementById('version').textContent = 'v' + d.version;
1855
+ var statusEl = document.getElementById('status');
1856
+ statusEl.textContent = d.health.status;
1857
+ statusEl.className = 'card-value ' + statusClass(d.health.status);
1858
+ document.getElementById('uptime').textContent = formatUptime(d.health.uptime);
1859
+ document.getElementById('sessions-active').textContent = d.health.sessions.active;
1860
+ document.getElementById('sessions-queued').textContent = d.health.sessions.queued;
1861
+ var discordEl = document.getElementById('discord');
1862
+ discordEl.textContent = d.health.discord;
1863
+ discordEl.className = 'card-value ' + statusClass(d.health.discord);
1864
+
1865
+ // Sessions table
1866
+ var st = document.getElementById('sessions-table');
1867
+ if (d.sessions.length === 0) {
1868
+ st.innerHTML = '<div class="empty">No active sessions</div>';
1869
+ } else {
1870
+ var h = '<table><tr><th>Project</th><th>Session ID</th><th>Last Activity</th><th>Queue</th></tr>';
1871
+ for (var i = 0; i < d.sessions.length; i++) {
1872
+ 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>';
1874
+ }
1875
+ h += '</table>';
1876
+ st.innerHTML = h;
1877
+ }
1878
+
1879
+ // Projects table
1880
+ var pt = document.getElementById('projects-table');
1881
+ if (d.projects.length === 0) {
1882
+ pt.innerHTML = '<div class="empty">No projects configured</div>';
1883
+ } else {
1884
+ var h2 = '<table><tr><th>Name</th><th>Directory</th><th>Agents</th></tr>';
1885
+ for (var j = 0; j < d.projects.length; j++) {
1886
+ var p = d.projects[j];
1887
+ h2 += '<tr><td>' + escapeHtml(p.name) + '</td><td>' + escapeHtml(p.directory) + '</td><td>' + escapeHtml(p.agents.join(', ') || '\u2014') + '</td></tr>';
1888
+ }
1889
+ h2 += '</table>';
1890
+ pt.innerHTML = h2;
1891
+ }
1892
+ })
1893
+ .catch(function() {
1894
+ document.getElementById('status').textContent = 'error';
1895
+ document.getElementById('status').className = 'card-value status-err';
1896
+ });
1897
+ }
1898
+
1899
+ refresh();
1900
+ setInterval(refresh, 5000);
1901
+
1902
+ var chartInstances = {};
1903
+ var currentRange = '7d';
1904
+ var CHART_COLORS = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#79c0ff'];
1905
+
1906
+ function switchTab(tab) {
1907
+ document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
1908
+ document.querySelectorAll('.tab').forEach(function(t) {
1909
+ if (t.textContent.toLowerCase() === tab) t.classList.add('active');
1910
+ });
1911
+ document.getElementById('tab-overview').style.display = tab === 'overview' ? '' : 'none';
1912
+ document.getElementById('tab-activity').style.display = tab === 'activity' ? '' : 'none';
1913
+ if (tab === 'activity') refreshActivity();
1914
+ }
1915
+
1916
+ document.querySelectorAll('.range-btn').forEach(function(btn) {
1917
+ btn.addEventListener('click', function() {
1918
+ document.querySelectorAll('.range-btn').forEach(function(b) { b.classList.remove('active'); });
1919
+ btn.classList.add('active');
1920
+ currentRange = btn.dataset.range;
1921
+ refreshActivity();
1922
+ });
1923
+ });
1924
+
1925
+ function destroyChart(key) {
1926
+ if (chartInstances[key]) { chartInstances[key].destroy(); chartInstances[key] = null; }
1927
+ }
1928
+
1929
+ function refreshActivity() {
1930
+ fetch('/api/activity/summary?range=' + currentRange)
1931
+ .then(function(r) { return r.json(); })
1932
+ .then(function(d) {
1933
+ // Summary cards
1934
+ var s = d.summary;
1935
+ document.getElementById('total-cost').textContent = '$' + s.total_cost_usd.toFixed(2);
1936
+ 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);
1938
+ document.getElementById('total-sessions-card').textContent = String(s.total_sessions);
1939
+ document.getElementById('total-messages').textContent = String(s.total_messages);
1940
+ document.getElementById('avg-duration').textContent = Math.round(s.avg_session_duration_ms / 60000) + 'm';
1941
+
1942
+ // Messages Over Time (bar)
1943
+ destroyChart('messages');
1944
+ 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 } } }
1948
+ });
1949
+
1950
+ // Cost Over Time (line)
1951
+ destroyChart('cost');
1952
+ chartInstances['cost'] = new Chart(document.getElementById('cost-chart'), {
1953
+ type: 'line',
1954
+ 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 } } }
1956
+ });
1957
+
1958
+ // Sessions Over Time (bar)
1959
+ destroyChart('sessions');
1960
+ 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 } } }
1964
+ });
1965
+
1966
+ // Token Usage Over Time (stacked bar)
1967
+ destroyChart('tokens');
1968
+ chartInstances['tokens'] = new Chart(document.getElementById('tokens-chart'), {
1969
+ 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' } } } }
1972
+ });
1973
+
1974
+ // Persona Breakdown (doughnut)
1975
+ destroyChart('persona');
1976
+ if (d.persona_breakdown.length > 0) {
1977
+ chartInstances['persona'] = new Chart(document.getElementById('persona-chart'), {
1978
+ type: 'doughnut',
1979
+ data: { labels: d.persona_breakdown.map(function(p) { return p.agent; }), datasets: [{ data: d.persona_breakdown.map(function(p) { return p.count; }), backgroundColor: CHART_COLORS.slice(0, d.persona_breakdown.length) }] },
1980
+ options: { plugins: { legend: { labels: { color: '#8b949e' } } } }
1981
+ });
1982
+ }
1983
+
1984
+ // Model Breakdown (doughnut)
1985
+ destroyChart('model');
1986
+ if (d.model_breakdown.length > 0) {
1987
+ chartInstances['model'] = new Chart(document.getElementById('model-chart'), {
1988
+ type: 'doughnut',
1989
+ data: { labels: d.model_breakdown.map(function(m) { return m.model; }), datasets: [{ data: d.model_breakdown.map(function(m) { return m.cost_usd; }), backgroundColor: CHART_COLORS.slice(0, d.model_breakdown.length) }] },
1990
+ options: { plugins: { legend: { labels: { color: '#8b949e' } } } }
1991
+ });
1992
+ }
1993
+
1994
+ // Token Usage by Project table
1995
+ var pt = document.getElementById('project-table');
1996
+ if (d.tokens_by_project.length === 0) { pt.innerHTML = '<div class="empty">No data</div>'; }
1997
+ else {
1998
+ 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>'; });
2000
+ pt.innerHTML = h + '</table>';
2001
+ }
2002
+
2003
+ // Token Usage by Session table
2004
+ var st = document.getElementById('session-table');
2005
+ if (d.tokens_by_session.length === 0) { st.innerHTML = '<div class="empty">No data</div>'; }
2006
+ else {
2007
+ 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>'; });
2009
+ st.innerHTML = h2 + '</table>';
2010
+ }
2011
+
2012
+ // Cache Efficiency table
2013
+ var ct = document.getElementById('cache-table');
2014
+ 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>';
2016
+ })
2017
+ .catch(function(err) { console.error('Activity fetch error:', err); });
2018
+ }
2019
+
2020
+ setInterval(function() {
2021
+ if (document.getElementById('tab-activity').style.display !== 'none') {
2022
+ refreshActivity();
2023
+ }
2024
+ }, 30000);
2025
+ </script>
2026
+ </body>
2027
+ </html>`;
2028
+ }
2029
+ function createHealthServer(port, sessionManager, bot, config, options) {
1054
2030
  const startTime = Date.now();
2031
+ const version2 = getVersion();
2032
+ const dashboardHtml = buildDashboardHtml();
2033
+ function getHealthData() {
2034
+ const sessions = sessionManager.listSessions();
2035
+ return {
2036
+ status: "ok",
2037
+ uptime: Math.floor((Date.now() - startTime) / 1e3),
2038
+ sessions: {
2039
+ active: sessions.length,
2040
+ queued: sessions.reduce((sum, s) => sum + s.queueLength, 0)
2041
+ },
2042
+ discord: bot.getStatus()
2043
+ };
2044
+ }
2045
+ function getProjectsData() {
2046
+ if (!config) return [];
2047
+ return Object.entries(config.projects).map(([channelId, project]) => ({
2048
+ channelId,
2049
+ name: project.name,
2050
+ directory: project.directory,
2051
+ agents: project.agents ? Object.keys(project.agents) : []
2052
+ }));
2053
+ }
1055
2054
  const server = createServer((req, res) => {
1056
2055
  const { pathname } = new URL(req.url ?? "/", `http://localhost`);
1057
- if (req.method === "GET" && pathname === "/health") {
1058
- const sessions = sessionManager.listSessions();
2056
+ if (req.method !== "GET") {
2057
+ res.writeHead(404, { "Content-Type": "application/json" });
2058
+ res.end(JSON.stringify({ error: "Not Found" }));
2059
+ return;
2060
+ }
2061
+ if (pathname === "/health") {
2062
+ const body = JSON.stringify(getHealthData());
2063
+ res.writeHead(200, { "Content-Type": "application/json" });
2064
+ res.end(body);
2065
+ return;
2066
+ }
2067
+ if (pathname === "/api/sessions") {
2068
+ const body = JSON.stringify(sessionManager.listSessions());
2069
+ res.writeHead(200, { "Content-Type": "application/json" });
2070
+ res.end(body);
2071
+ return;
2072
+ }
2073
+ if (pathname === "/api/projects") {
2074
+ const body = JSON.stringify(getProjectsData());
2075
+ res.writeHead(200, { "Content-Type": "application/json" });
2076
+ res.end(body);
2077
+ return;
2078
+ }
2079
+ if (pathname === "/api/status") {
1059
2080
  const body = JSON.stringify({
1060
- status: "ok",
1061
- uptime: Math.floor((Date.now() - startTime) / 1e3),
1062
- sessions: {
1063
- active: sessions.length,
1064
- queued: sessions.reduce((sum, s) => sum + s.queueLength, 0)
1065
- },
1066
- discord: bot.getStatus()
2081
+ version: version2,
2082
+ health: getHealthData(),
2083
+ sessions: sessionManager.listSessions(),
2084
+ projects: getProjectsData()
1067
2085
  });
1068
2086
  res.writeHead(200, { "Content-Type": "application/json" });
1069
2087
  res.end(body);
1070
2088
  return;
1071
2089
  }
2090
+ if (pathname === "/api/activity/summary") {
2091
+ const url = new URL(req.url ?? "/", `http://localhost`);
2092
+ const rangeParam = url.searchParams.get("range") || "7d";
2093
+ if (rangeParam !== "24h" && rangeParam !== "7d" && rangeParam !== "30d") {
2094
+ res.writeHead(400, { "Content-Type": "application/json" });
2095
+ res.end(JSON.stringify({ error: "Invalid range. Must be 24h, 7d, or 30d" }));
2096
+ return;
2097
+ }
2098
+ const range = rangeParam;
2099
+ const bucket = range === "24h" ? "hour" : "day";
2100
+ const engine = options?.activityEngine;
2101
+ if (!engine) {
2102
+ res.writeHead(200, { "Content-Type": "application/json" });
2103
+ res.end(JSON.stringify({
2104
+ summary: { total_cost_usd: 0, total_input_tokens: 0, total_output_tokens: 0, total_sessions: 0, total_messages: 0, avg_session_duration_ms: 0 },
2105
+ tokens_by_project: [],
2106
+ tokens_by_session: [],
2107
+ sessions_over_time: [],
2108
+ messages_over_time: [],
2109
+ cost_over_time: [],
2110
+ tokens_over_time: [],
2111
+ session_durations: [],
2112
+ model_breakdown: [],
2113
+ persona_breakdown: [],
2114
+ cache_efficiency: { total_input_tokens: 0, cache_read_tokens: 0, cache_hit_ratio: 0 }
2115
+ }));
2116
+ return;
2117
+ }
2118
+ try {
2119
+ const data = {
2120
+ summary: engine.computeSummary(range),
2121
+ tokens_by_project: engine.tokensByProject(range),
2122
+ tokens_by_session: engine.tokensBySession(range),
2123
+ sessions_over_time: engine.bucketed(range, bucket, "session_start"),
2124
+ messages_over_time: engine.bucketed(range, bucket, "message_completed"),
2125
+ cost_over_time: engine.bucketed(range, bucket, "message_completed", "total_cost_usd"),
2126
+ tokens_over_time: engine.bucketed(range, bucket, "message_completed", "input_tokens"),
2127
+ session_durations: engine.sessionDurations(range),
2128
+ model_breakdown: engine.modelBreakdown(range),
2129
+ persona_breakdown: engine.personaBreakdown(range),
2130
+ cache_efficiency: engine.cacheEfficiency(range)
2131
+ };
2132
+ res.writeHead(200, { "Content-Type": "application/json" });
2133
+ res.end(JSON.stringify(data));
2134
+ } catch {
2135
+ res.writeHead(500, { "Content-Type": "application/json" });
2136
+ res.end(JSON.stringify({ error: "Failed to compute activity data" }));
2137
+ }
2138
+ return;
2139
+ }
2140
+ if (pathname === "/") {
2141
+ res.writeHead(200, { "Content-Type": "text/html" });
2142
+ res.end(dashboardHtml);
2143
+ return;
2144
+ }
1072
2145
  res.writeHead(404, { "Content-Type": "application/json" });
1073
2146
  res.end(JSON.stringify({ error: "Not Found" }));
1074
2147
  });
1075
- return new Promise((resolve4, reject) => {
2148
+ return new Promise((resolve5, reject) => {
1076
2149
  server.on("error", reject);
1077
2150
  server.listen(port, () => {
1078
2151
  console.log(`Health endpoint listening on http://localhost:${port}/health`);
1079
- resolve4({
2152
+ resolve5({
1080
2153
  close() {
1081
2154
  return new Promise((res, rej) => {
1082
2155
  server.close((err) => err ? rej(err) : res());
@@ -1108,16 +2181,16 @@ function createTurnCounter() {
1108
2181
 
1109
2182
  // src/init.ts
1110
2183
  import { createInterface } from "readline";
1111
- import { writeFileSync as writeFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync2 } from "fs";
2184
+ import { writeFileSync as writeFileSync2, existsSync as existsSync4, mkdirSync as mkdirSync3 } from "fs";
1112
2185
  import { resolve as resolve2 } from "path";
1113
2186
  import { execSync } from "child_process";
1114
2187
 
1115
2188
  // src/resolve-home.ts
1116
- import { resolve, dirname as dirname2 } from "path";
1117
- import { existsSync as existsSync2 } from "fs";
1118
- import { homedir } from "os";
2189
+ import { resolve, dirname as dirname3 } from "path";
2190
+ import { existsSync as existsSync3 } from "fs";
2191
+ import { homedir as homedir3 } from "os";
1119
2192
  function resolveMpgHome() {
1120
- return process.env.MPG_HOME ?? resolve(homedir(), ".mpg");
2193
+ return process.env.MPG_HOME ?? resolve(homedir3(), ".mpg");
1121
2194
  }
1122
2195
  function resolveProfileDir(profile) {
1123
2196
  return resolve(resolveMpgHome(), "profiles", profile);
@@ -1125,11 +2198,11 @@ function resolveProfileDir(profile) {
1125
2198
  function resolveEnvPath() {
1126
2199
  const mpgHome = resolveMpgHome();
1127
2200
  const mpgEnv = resolve(mpgHome, ".env");
1128
- if (existsSync2(mpgEnv)) {
2201
+ if (existsSync3(mpgEnv)) {
1129
2202
  return mpgEnv;
1130
2203
  }
1131
2204
  const cwdEnv = resolve(process.cwd(), ".env");
1132
- if (existsSync2(cwdEnv)) {
2205
+ if (existsSync3(cwdEnv)) {
1133
2206
  return cwdEnv;
1134
2207
  }
1135
2208
  return void 0;
@@ -1137,7 +2210,7 @@ function resolveEnvPath() {
1137
2210
  function resolveConfigPath(options) {
1138
2211
  if (options?.configFlag) {
1139
2212
  const explicit = resolve(options.configFlag);
1140
- if (existsSync2(explicit)) {
2213
+ if (existsSync3(explicit)) {
1141
2214
  return explicit;
1142
2215
  }
1143
2216
  return explicit;
@@ -1147,24 +2220,35 @@ function resolveConfigPath(options) {
1147
2220
  resolveProfileDir(options.profileFlag),
1148
2221
  "config.json"
1149
2222
  );
1150
- if (existsSync2(profileConfig)) {
2223
+ if (existsSync3(profileConfig)) {
1151
2224
  return profileConfig;
1152
2225
  }
1153
2226
  return profileConfig;
1154
2227
  }
1155
2228
  const mpgHome = resolveMpgHome();
1156
2229
  const defaultConfig = resolve(mpgHome, "profiles", "default", "config.json");
1157
- if (existsSync2(defaultConfig)) {
2230
+ if (existsSync3(defaultConfig)) {
1158
2231
  return defaultConfig;
1159
2232
  }
1160
2233
  const cwdConfig = resolve(process.cwd(), "config.json");
1161
- if (existsSync2(cwdConfig)) {
2234
+ if (existsSync3(cwdConfig)) {
1162
2235
  return cwdConfig;
1163
2236
  }
1164
2237
  return void 0;
1165
2238
  }
1166
2239
  function resolveSessionsPath(configPath) {
1167
- return resolve(dirname2(configPath), "sessions.json");
2240
+ return resolve(dirname3(configPath), "sessions.json");
2241
+ }
2242
+ function resolvePidPath(profile) {
2243
+ const name = !profile || profile === "default" ? "mpg" : `mpg-${profile}`;
2244
+ return resolve(resolveMpgHome(), `${name}.pid`);
2245
+ }
2246
+ function resolveLogDir() {
2247
+ return resolve(resolveMpgHome(), "logs");
2248
+ }
2249
+ function resolveLogPath(profile) {
2250
+ const name = !profile || profile === "default" ? "mpg" : `mpg-${profile}`;
2251
+ return resolve(resolveLogDir(), `${name}.log`);
1168
2252
  }
1169
2253
  function parseFlags(argv) {
1170
2254
  const result = {};
@@ -1175,8 +2259,16 @@ function parseFlags(argv) {
1175
2259
  } else if (argv[i] === "--profile" && i + 1 < argv.length) {
1176
2260
  result.profileFlag = argv[i + 1];
1177
2261
  i++;
2262
+ } else if (argv[i] === "--project" && i + 1 < argv.length) {
2263
+ result.project = argv[i + 1];
2264
+ i++;
2265
+ } else if (argv[i] === "--level" && i + 1 < argv.length) {
2266
+ result.level = argv[i + 1];
2267
+ i++;
1178
2268
  } else if (argv[i] === "--migrate") {
1179
2269
  result.migrate = true;
2270
+ } else if (argv[i] === "--follow" || argv[i] === "-f") {
2271
+ result.follow = true;
1180
2272
  }
1181
2273
  }
1182
2274
  return result;
@@ -1185,7 +2277,7 @@ function parseFlags(argv) {
1185
2277
  // src/init.ts
1186
2278
  function createPrompt() {
1187
2279
  const rl = createInterface({ input: process.stdin, output: process.stdout });
1188
- return (question) => new Promise((resolve4) => rl.question(question, (answer) => resolve4(answer.trim())));
2280
+ return (question) => new Promise((resolve5) => rl.question(question, (answer) => resolve5(answer.trim())));
1189
2281
  }
1190
2282
  async function runInit(profile) {
1191
2283
  const ask = createPrompt();
@@ -1194,8 +2286,8 @@ async function runInit(profile) {
1194
2286
  if (profile) {
1195
2287
  configDir = resolveProfileDir(profile);
1196
2288
  envDir = resolveMpgHome();
1197
- mkdirSync2(configDir, { recursive: true });
1198
- mkdirSync2(envDir, { recursive: true });
2289
+ mkdirSync3(configDir, { recursive: true });
2290
+ mkdirSync3(envDir, { recursive: true });
1199
2291
  console.log(`
1200
2292
  mpg init \u2014 set up profile "${profile}"
1201
2293
  `);
@@ -1226,7 +2318,7 @@ mpg init \u2014 set up profile "${profile}"
1226
2318
  console.log(`Wrote ${envPath}`);
1227
2319
  const projects = [];
1228
2320
  const configPath = resolve2(configDir, "config.json");
1229
- if (existsSync3(configPath)) {
2321
+ if (existsSync4(configPath)) {
1230
2322
  try {
1231
2323
  const existing = JSON.parse(
1232
2324
  (await import("fs")).readFileSync(configPath, "utf-8")
@@ -1256,7 +2348,7 @@ Existing projects (${projects.length}):`);
1256
2348
  console.log("Directory is required, skipping.");
1257
2349
  continue;
1258
2350
  }
1259
- if (!existsSync3(directory)) {
2351
+ if (!existsSync4(directory)) {
1260
2352
  console.warn(`Warning: ${directory} does not exist.`);
1261
2353
  }
1262
2354
  const channelId = await ask("Discord channel ID: ");
@@ -1315,6 +2407,212 @@ function runHealthChecks(config) {
1315
2407
  }
1316
2408
  }
1317
2409
 
2410
+ // src/pid.ts
2411
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync3, unlinkSync, mkdirSync as mkdirSync4 } from "fs";
2412
+ import { dirname as dirname4 } from "path";
2413
+ function writePid(pidPath, pid = process.pid) {
2414
+ mkdirSync4(dirname4(pidPath), { recursive: true });
2415
+ writeFileSync3(pidPath, `${pid}
2416
+ `);
2417
+ }
2418
+ function readPid(pidPath) {
2419
+ try {
2420
+ const content = readFileSync5(pidPath, "utf-8").trim();
2421
+ const pid = Number(content);
2422
+ return Number.isFinite(pid) && pid > 0 ? pid : void 0;
2423
+ } catch {
2424
+ return void 0;
2425
+ }
2426
+ }
2427
+ function removePid(pidPath) {
2428
+ try {
2429
+ unlinkSync(pidPath);
2430
+ } catch {
2431
+ }
2432
+ }
2433
+ function isProcessRunning(pid) {
2434
+ try {
2435
+ process.kill(pid, 0);
2436
+ return true;
2437
+ } catch {
2438
+ return false;
2439
+ }
2440
+ }
2441
+ function checkPidFile(pidPath) {
2442
+ const pid = readPid(pidPath);
2443
+ if (pid === void 0) {
2444
+ return { status: "none" };
2445
+ }
2446
+ if (isProcessRunning(pid)) {
2447
+ return { status: "running", pid };
2448
+ }
2449
+ removePid(pidPath);
2450
+ return { status: "stale", pid };
2451
+ }
2452
+
2453
+ // src/file-logger.ts
2454
+ import { appendFileSync as appendFileSync2, renameSync, unlinkSync as unlinkSync2, existsSync as existsSync5, mkdirSync as mkdirSync5, statSync as statSync2 } from "fs";
2455
+ import { dirname as dirname5 } from "path";
2456
+ var DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
2457
+ var DEFAULT_MAX_FILES = 5;
2458
+ function rotateLog(logPath, maxFiles = DEFAULT_MAX_FILES) {
2459
+ if (!existsSync5(logPath)) return;
2460
+ const oldest = `${logPath}.${maxFiles}`;
2461
+ if (existsSync5(oldest)) {
2462
+ try {
2463
+ unlinkSync2(oldest);
2464
+ } catch {
2465
+ }
2466
+ }
2467
+ for (let i = maxFiles - 1; i >= 1; i--) {
2468
+ const from = `${logPath}.${i}`;
2469
+ const to = `${logPath}.${i + 1}`;
2470
+ if (existsSync5(from)) {
2471
+ try {
2472
+ renameSync(from, to);
2473
+ } catch {
2474
+ }
2475
+ }
2476
+ }
2477
+ try {
2478
+ renameSync(logPath, `${logPath}.1`);
2479
+ } catch {
2480
+ }
2481
+ }
2482
+ function createFileWriter(logPath, opts) {
2483
+ const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;
2484
+ const maxFiles = opts?.maxFiles ?? DEFAULT_MAX_FILES;
2485
+ let currentBytes = 0;
2486
+ const dir = dirname5(logPath);
2487
+ mkdirSync5(dir, { recursive: true });
2488
+ try {
2489
+ currentBytes = statSync2(logPath).size;
2490
+ } catch {
2491
+ currentBytes = 0;
2492
+ }
2493
+ return (line) => {
2494
+ const data = line + "\n";
2495
+ const byteLength = Buffer.byteLength(data);
2496
+ if (currentBytes + byteLength > maxBytes && currentBytes > 0) {
2497
+ rotateLog(logPath, maxFiles);
2498
+ currentBytes = 0;
2499
+ }
2500
+ appendFileSync2(logPath, data);
2501
+ currentBytes += byteLength;
2502
+ };
2503
+ }
2504
+
2505
+ // src/daemon.ts
2506
+ import { resolve as resolve3 } from "path";
2507
+ 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";
2510
+
2511
+ // src/systemd.ts
2512
+ import { dirname as dirname6 } from "path";
2513
+ function unitFileName(profile) {
2514
+ if (!profile || profile === "default") return "mpg.service";
2515
+ return `mpg-${profile}.service`;
2516
+ }
2517
+ function generateUnitFile(opts) {
2518
+ const profileArg = opts.profile && opts.profile !== "default" ? ` --profile ${opts.profile}` : "";
2519
+ const nodeDir = dirname6(opts.nodePath);
2520
+ const mpgDir = dirname6(opts.mpgPath);
2521
+ const pathDirs = /* @__PURE__ */ new Set([nodeDir, mpgDir, "/usr/local/bin", "/usr/bin", "/bin"]);
2522
+ const pathValue = [...pathDirs].join(":");
2523
+ return `[Unit]
2524
+ Description=Multi-Project Gateway for Claude Code
2525
+ After=network-online.target
2526
+ Wants=network-online.target
2527
+
2528
+ [Service]
2529
+ Type=simple
2530
+ ExecStart=${opts.mpgPath} start${profileArg}
2531
+ Restart=on-failure
2532
+ RestartSec=10
2533
+ Environment=PATH=${pathValue}
2534
+ Environment=NODE_ENV=production
2535
+
2536
+ [Install]
2537
+ WantedBy=default.target
2538
+ `;
2539
+ }
2540
+
2541
+ // src/daemon.ts
2542
+ function resolveServiceDir() {
2543
+ return resolve3(homedir4(), ".config", "systemd", "user");
2544
+ }
2545
+ function resolveServicePath(profile) {
2546
+ return resolve3(resolveServiceDir(), unitFileName(profile));
2547
+ }
2548
+ function daemonInstall(profile) {
2549
+ const serviceDir = resolveServiceDir();
2550
+ mkdirSync6(serviceDir, { recursive: true });
2551
+ const nodePath = process.execPath;
2552
+ const mpgPath = resolveOwnBinary();
2553
+ const unit = generateUnitFile({ nodePath, mpgPath, profile });
2554
+ const servicePath = resolveServicePath(profile);
2555
+ writeFileSync4(servicePath, unit);
2556
+ const name = unitFileName(profile);
2557
+ try {
2558
+ execFileSync3("loginctl", ["enable-linger"], { stdio: "ignore" });
2559
+ } catch {
2560
+ }
2561
+ execFileSync3("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
2562
+ execFileSync3("systemctl", ["--user", "enable", "--now", name], { stdio: "inherit" });
2563
+ console.log(`Installed and started ${name}`);
2564
+ console.log(` Unit file: ${servicePath}`);
2565
+ console.log(` Status: systemctl --user status ${name}`);
2566
+ console.log(` Logs: mpg daemon logs`);
2567
+ }
2568
+ function daemonUninstall(profile) {
2569
+ const name = unitFileName(profile);
2570
+ const servicePath = resolveServicePath(profile);
2571
+ try {
2572
+ execFileSync3("systemctl", ["--user", "stop", name], { stdio: "inherit" });
2573
+ } catch {
2574
+ }
2575
+ try {
2576
+ execFileSync3("systemctl", ["--user", "disable", name], { stdio: "inherit" });
2577
+ } catch {
2578
+ }
2579
+ if (existsSync6(servicePath)) {
2580
+ unlinkSync3(servicePath);
2581
+ }
2582
+ try {
2583
+ execFileSync3("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
2584
+ } catch {
2585
+ }
2586
+ console.log(`Uninstalled ${name}`);
2587
+ }
2588
+ function daemonStatus(profile) {
2589
+ const name = unitFileName(profile);
2590
+ try {
2591
+ execFileSync3("systemctl", ["--user", "status", name], { stdio: "inherit" });
2592
+ } catch {
2593
+ }
2594
+ }
2595
+ function daemonLogs(profile, follow) {
2596
+ const name = unitFileName(profile);
2597
+ const args2 = ["--user", "-u", name, "--no-pager"];
2598
+ if (follow) args2.push("-f");
2599
+ try {
2600
+ execFileSync3("journalctl", args2, { stdio: "inherit" });
2601
+ } catch {
2602
+ console.error(`journalctl not available. View logs at: ${resolveLogPath(profile)}`);
2603
+ }
2604
+ }
2605
+ function resolveOwnBinary() {
2606
+ try {
2607
+ const which = execSync2("which mpg", { encoding: "utf-8" }).trim();
2608
+ if (which) return which;
2609
+ } catch {
2610
+ }
2611
+ const fallback = resolve3(process.argv[1] ?? ".");
2612
+ console.warn(`Warning: 'mpg' not found on PATH. Using ${fallback} in service file.`);
2613
+ return fallback;
2614
+ }
2615
+
1318
2616
  // src/cli.ts
1319
2617
  var args = process.argv.slice(2);
1320
2618
  var command = args[0] ?? "start";
@@ -1330,6 +2628,12 @@ async function main() {
1330
2628
  return runInit(flags.profileFlag);
1331
2629
  case "status":
1332
2630
  return status();
2631
+ case "stop":
2632
+ return stop();
2633
+ case "daemon":
2634
+ return daemon();
2635
+ case "logs":
2636
+ return logs();
1333
2637
  case "help":
1334
2638
  case "--help":
1335
2639
  case "-h":
@@ -1350,24 +2654,33 @@ mpg \u2014 multi-project gateway for Claude Code
1350
2654
  Usage: mpg <command>
1351
2655
 
1352
2656
  Commands:
1353
- start Start the gateway (default)
1354
- init Interactive setup wizard
1355
- status Show session status
1356
- help Show this message
2657
+ start Start the gateway (default)
2658
+ stop Stop a running gateway instance
2659
+ init Interactive setup wizard
2660
+ status Show session status
2661
+ logs Filter structured log output (reads stdin)
2662
+ daemon install Install systemd user service
2663
+ daemon uninstall Remove systemd user service
2664
+ daemon status Show systemd service status
2665
+ daemon logs Show service logs (journalctl)
2666
+ help Show this message
1357
2667
 
1358
2668
  Options:
1359
- --profile <name> Use a named profile (default: "default")
1360
- --config <path> Use a specific config.json path
1361
- --migrate Copy CWD config files into ~/.mpg/profiles/default/
1362
- -v, --version Show version
1363
- -h, --help Show this message
2669
+ --profile <name> Use a named profile (default: "default")
2670
+ --config <path> Use a specific config.json path
2671
+ --migrate Copy CWD config files into ~/.mpg/profiles/default/
2672
+ --project <name> (logs) Filter by project name
2673
+ --level <level> (logs) Filter by minimum log level (debug|info|warn|error)
2674
+ --follow, -f (daemon logs) Follow log output
2675
+ -v, --version Show version
2676
+ -h, --help Show this message
1364
2677
 
1365
2678
  Environment:
1366
2679
  MPG_HOME Override config home (default: ~/.mpg)
1367
2680
  `.trim());
1368
2681
  }
1369
2682
  function version() {
1370
- const pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf-8"));
2683
+ const pkg = JSON.parse(readFileSync6(new URL("../package.json", import.meta.url), "utf-8"));
1371
2684
  console.log(`mpg v${pkg.version}`);
1372
2685
  }
1373
2686
  function start() {
@@ -1380,27 +2693,41 @@ function start() {
1380
2693
  console.error("DISCORD_BOT_TOKEN is not set. Run `mpg init` or set it in .env");
1381
2694
  process.exit(1);
1382
2695
  }
2696
+ const pidPath = resolvePidPath(flags.profileFlag);
2697
+ const pidCheck = checkPidFile(pidPath);
2698
+ if (pidCheck.status === "running") {
2699
+ console.error(`MPG is already running (PID ${pidCheck.pid}). Use \`mpg stop\` first.`);
2700
+ process.exit(1);
2701
+ }
2702
+ writePid(pidPath);
1383
2703
  const configPath = resolveConfigPath({
1384
2704
  configFlag: flags.configFlag,
1385
2705
  profileFlag: flags.profileFlag
1386
2706
  });
1387
- if (!configPath || !existsSync4(configPath)) {
2707
+ if (!configPath || !existsSync7(configPath)) {
1388
2708
  console.error("config.json not found. Run `mpg init` to create one.");
1389
2709
  process.exit(1);
1390
2710
  }
1391
- const rawConfig = JSON.parse(readFileSync2(configPath, "utf-8"));
2711
+ const rawConfig = JSON.parse(readFileSync6(configPath, "utf-8"));
1392
2712
  const config = loadConfig(rawConfig);
2713
+ const logPath = resolveLogPath(flags.profileFlag);
2714
+ const fileWriter = createFileWriter(logPath);
2715
+ const log = createLogger(config.defaults.logLevel, (line) => {
2716
+ fileWriter(line);
2717
+ process.stderr.write(line + "\n");
2718
+ });
1393
2719
  const projectCount = Object.keys(config.projects).length;
1394
2720
  if (projectCount === 0) {
1395
2721
  console.error("No projects configured in config.json");
1396
2722
  process.exit(1);
1397
2723
  }
1398
2724
  runHealthChecks(config);
1399
- console.log(`Loaded ${projectCount} project(s) from ${configPath}`);
2725
+ log.info(`Loaded ${projectCount} project(s) from ${configPath}`);
1400
2726
  const router = createRouter(config);
1401
2727
  const sessionsPath = resolveSessionsPath(configPath);
1402
2728
  const sessionStore = createFileSessionStore(sessionsPath);
1403
- const sessionManager = createSessionManager(config.defaults, sessionStore);
2729
+ const pulseEmitter = createPulseEmitter();
2730
+ const sessionManager = createSessionManager(config.defaults, sessionStore, pulseEmitter);
1404
2731
  const persistedSessions = sessionStore.load();
1405
2732
  const knownKeysByProject = /* @__PURE__ */ new Map();
1406
2733
  for (const [key, entry] of persistedSessions) {
@@ -1420,7 +2747,8 @@ function start() {
1420
2747
  const bot = createDiscordBot(router, sessionManager, config, turnCounter);
1421
2748
  let healthServer;
1422
2749
  function shutdown() {
1423
- console.log("Shutting down...");
2750
+ log.info("Shutting down...");
2751
+ removePid(pidPath);
1424
2752
  if (healthServer) {
1425
2753
  healthServer.close().catch(() => {
1426
2754
  });
@@ -1434,13 +2762,14 @@ function start() {
1434
2762
  bot.start(token).then(async () => {
1435
2763
  if (config.defaults.httpPort !== false) {
1436
2764
  try {
1437
- healthServer = await createHealthServer(config.defaults.httpPort, sessionManager, bot);
2765
+ const activityEngine = createActivityEngine();
2766
+ healthServer = await createHealthServer(config.defaults.httpPort, sessionManager, bot, config, { activityEngine });
1438
2767
  } catch (err) {
1439
- console.warn(`Health server failed to start on port ${config.defaults.httpPort}:`, err);
2768
+ log.warn(`Health server failed to start on port ${config.defaults.httpPort}: ${err}`);
1440
2769
  }
1441
2770
  }
1442
2771
  }).catch((err) => {
1443
- console.error("Failed to start bot:", err);
2772
+ log.error(`Failed to start bot: ${err}`);
1444
2773
  process.exit(1);
1445
2774
  });
1446
2775
  }
@@ -1449,15 +2778,15 @@ function status() {
1449
2778
  configFlag: flags.configFlag,
1450
2779
  profileFlag: flags.profileFlag
1451
2780
  });
1452
- const sessionsPath = configPath ? resolveSessionsPath(configPath) : resolve3(process.cwd(), ".sessions.json");
1453
- if (!existsSync4(sessionsPath)) {
2781
+ const sessionsPath = configPath ? resolveSessionsPath(configPath) : resolve4(process.cwd(), ".sessions.json");
2782
+ if (!existsSync7(sessionsPath)) {
1454
2783
  console.log("No sessions file found. Is the gateway running?");
1455
2784
  return;
1456
2785
  }
1457
2786
  let projectNames = {};
1458
- if (configPath && existsSync4(configPath)) {
2787
+ if (configPath && existsSync7(configPath)) {
1459
2788
  try {
1460
- const raw = JSON.parse(readFileSync2(configPath, "utf-8"));
2789
+ const raw = JSON.parse(readFileSync6(configPath, "utf-8"));
1461
2790
  const config = loadConfig(raw);
1462
2791
  for (const [channelId, project] of Object.entries(config.projects)) {
1463
2792
  projectNames[channelId] = project.name;
@@ -1465,7 +2794,7 @@ function status() {
1465
2794
  } catch {
1466
2795
  }
1467
2796
  }
1468
- const sessions = JSON.parse(readFileSync2(sessionsPath, "utf-8"));
2797
+ const sessions = JSON.parse(readFileSync6(sessionsPath, "utf-8"));
1469
2798
  if (sessions.length === 0) {
1470
2799
  console.log("No active sessions.");
1471
2800
  return;
@@ -1485,23 +2814,23 @@ function status() {
1485
2814
  function migrate() {
1486
2815
  const mpgHome = resolveMpgHome();
1487
2816
  const profileDir = resolveProfileDir("default");
1488
- const cwdEnv = resolve3(process.cwd(), ".env");
1489
- const cwdConfig = resolve3(process.cwd(), "config.json");
1490
- const cwdSessions = resolve3(process.cwd(), ".sessions.json");
2817
+ const cwdEnv = resolve4(process.cwd(), ".env");
2818
+ const cwdConfig = resolve4(process.cwd(), "config.json");
2819
+ const cwdSessions = resolve4(process.cwd(), ".sessions.json");
1491
2820
  const copied = [];
1492
- mkdirSync3(profileDir, { recursive: true });
1493
- if (existsSync4(cwdEnv)) {
1494
- const dest = resolve3(mpgHome, ".env");
2821
+ mkdirSync7(profileDir, { recursive: true });
2822
+ if (existsSync7(cwdEnv)) {
2823
+ const dest = resolve4(mpgHome, ".env");
1495
2824
  copyFileSync(cwdEnv, dest);
1496
2825
  copied.push(` ${cwdEnv} \u2192 ${dest}`);
1497
2826
  }
1498
- if (existsSync4(cwdConfig)) {
1499
- const dest = resolve3(profileDir, "config.json");
2827
+ if (existsSync7(cwdConfig)) {
2828
+ const dest = resolve4(profileDir, "config.json");
1500
2829
  copyFileSync(cwdConfig, dest);
1501
2830
  copied.push(` ${cwdConfig} \u2192 ${dest}`);
1502
2831
  }
1503
- if (existsSync4(cwdSessions)) {
1504
- const dest = resolve3(profileDir, "sessions.json");
2832
+ if (existsSync7(cwdSessions)) {
2833
+ const dest = resolve4(profileDir, "sessions.json");
1505
2834
  copyFileSync(cwdSessions, dest);
1506
2835
  copied.push(` ${cwdSessions} \u2192 ${dest}`);
1507
2836
  }
@@ -1518,6 +2847,116 @@ function migrate() {
1518
2847
  Profile directory: ${profileDir}`);
1519
2848
  console.log("You can now run `mpg start` from any directory.");
1520
2849
  }
2850
+ function stop() {
2851
+ const pidPath = resolvePidPath(flags.profileFlag);
2852
+ const check = checkPidFile(pidPath);
2853
+ if (check.status === "none") {
2854
+ console.log("No running MPG instance found.");
2855
+ return;
2856
+ }
2857
+ if (check.status === "stale") {
2858
+ console.log(`Removed stale PID file (process ${check.pid} was not running).`);
2859
+ return;
2860
+ }
2861
+ const { pid } = check;
2862
+ console.log(`Sending SIGTERM to MPG (PID ${pid})...`);
2863
+ try {
2864
+ process.kill(pid, "SIGTERM");
2865
+ } catch (err) {
2866
+ console.error(`Failed to send signal: ${err}`);
2867
+ process.exit(1);
2868
+ }
2869
+ const deadline = Date.now() + 1e4;
2870
+ const poll = setInterval(() => {
2871
+ try {
2872
+ process.kill(pid, 0);
2873
+ if (Date.now() > deadline) {
2874
+ clearInterval(poll);
2875
+ console.log("Graceful shutdown timed out. Sending SIGKILL...");
2876
+ try {
2877
+ process.kill(pid, "SIGKILL");
2878
+ } catch {
2879
+ }
2880
+ removePid(pidPath);
2881
+ console.log("Killed.");
2882
+ process.exit(0);
2883
+ }
2884
+ } catch {
2885
+ clearInterval(poll);
2886
+ removePid(pidPath);
2887
+ console.log("Stopped.");
2888
+ process.exit(0);
2889
+ }
2890
+ }, 200);
2891
+ }
2892
+ function daemon() {
2893
+ const subcommand = args[1];
2894
+ const daemonFlags = parseFlags(args.slice(2));
2895
+ const profile = daemonFlags.profileFlag ?? flags.profileFlag;
2896
+ switch (subcommand) {
2897
+ case "install":
2898
+ return daemonInstall(profile);
2899
+ case "uninstall":
2900
+ return daemonUninstall(profile);
2901
+ case "status":
2902
+ return daemonStatus(profile);
2903
+ case "logs":
2904
+ return daemonLogs(profile, daemonFlags.follow);
2905
+ default:
2906
+ console.error(`Unknown daemon subcommand: ${subcommand}`);
2907
+ console.error("Usage: mpg daemon <install|uninstall|status|logs>");
2908
+ process.exit(1);
2909
+ }
2910
+ }
2911
+ function logs() {
2912
+ const levelFlag = flags.level;
2913
+ const projectFlag = flags.project;
2914
+ if (levelFlag && !isValidLogLevel(levelFlag)) {
2915
+ console.error(`Invalid log level: ${levelFlag}. Must be one of: debug, info, warn, error`);
2916
+ process.exit(1);
2917
+ }
2918
+ const minLevel = levelFlag;
2919
+ let buffer = "";
2920
+ process.stdin.setEncoding("utf-8");
2921
+ process.stdin.on("data", (chunk) => {
2922
+ buffer += chunk;
2923
+ const lines = buffer.split("\n");
2924
+ buffer = lines.pop() ?? "";
2925
+ for (const line of lines) {
2926
+ if (!line.trim()) continue;
2927
+ const entry = parseLogEntry(line);
2928
+ if (!entry) {
2929
+ process.stdout.write(line + "\n");
2930
+ continue;
2931
+ }
2932
+ const [filtered] = filterLogEntries([entry], { project: projectFlag, level: minLevel });
2933
+ if (filtered) {
2934
+ const ts = entry.timestamp.replace("T", " ").replace("Z", "");
2935
+ const proj = entry.project ? ` [${entry.project}]` : "";
2936
+ const sess = entry.session ? ` (${entry.session.slice(0, 8)})` : "";
2937
+ process.stdout.write(`${ts} ${entry.level.toUpperCase().padEnd(5)}${proj}${sess} ${entry.message}
2938
+ `);
2939
+ }
2940
+ }
2941
+ });
2942
+ process.stdin.on("end", () => {
2943
+ if (buffer.trim()) {
2944
+ const entry = parseLogEntry(buffer);
2945
+ if (!entry) {
2946
+ process.stdout.write(buffer + "\n");
2947
+ } else {
2948
+ const [filtered] = filterLogEntries([entry], { project: projectFlag, level: minLevel });
2949
+ if (filtered) {
2950
+ const ts = entry.timestamp.replace("T", " ").replace("Z", "");
2951
+ const proj = entry.project ? ` [${entry.project}]` : "";
2952
+ const sess = entry.session ? ` (${entry.session.slice(0, 8)})` : "";
2953
+ process.stdout.write(`${ts} ${entry.level.toUpperCase().padEnd(5)}${proj}${sess} ${entry.message}
2954
+ `);
2955
+ }
2956
+ }
2957
+ }
2958
+ });
2959
+ }
1521
2960
  main().catch((err) => {
1522
2961
  console.error(err);
1523
2962
  process.exit(1);