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/README.md +3 -1
- package/dist/cli.js +1532 -93
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { resolve as
|
|
5
|
-
import { existsSync as
|
|
4
|
+
import { resolve as 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
|
-
"
|
|
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
|
-
...
|
|
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 =
|
|
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((
|
|
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
|
-
|
|
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((
|
|
549
|
+
return new Promise((resolve5) => {
|
|
406
550
|
waiters.push(() => {
|
|
407
551
|
activeProcesses++;
|
|
408
|
-
|
|
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
|
-
|
|
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,
|
|
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((
|
|
541
|
-
session.queue.push({ prompt, systemPrompt: opts?.systemPrompt, timeoutMs: opts?.timeoutMs, resolve:
|
|
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]) => `-
|
|
1169
|
+
([name, agent]) => `- \`${name}\` \u2014 ${agent.role}`
|
|
831
1170
|
);
|
|
832
1171
|
return `**${context.projectName} agents**
|
|
833
1172
|
${lines.join("\n")}
|
|
834
1173
|
|
|
835
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1058
|
-
|
|
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
|
-
|
|
1061
|
-
|
|
1062
|
-
sessions:
|
|
1063
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
|
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
|
|
1117
|
-
import { existsSync as
|
|
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(
|
|
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 (
|
|
2201
|
+
if (existsSync3(mpgEnv)) {
|
|
1129
2202
|
return mpgEnv;
|
|
1130
2203
|
}
|
|
1131
2204
|
const cwdEnv = resolve(process.cwd(), ".env");
|
|
1132
|
-
if (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
2230
|
+
if (existsSync3(defaultConfig)) {
|
|
1158
2231
|
return defaultConfig;
|
|
1159
2232
|
}
|
|
1160
2233
|
const cwdConfig = resolve(process.cwd(), "config.json");
|
|
1161
|
-
if (
|
|
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(
|
|
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((
|
|
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
|
-
|
|
1198
|
-
|
|
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 (
|
|
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 (!
|
|
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
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
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>
|
|
1360
|
-
--config <path>
|
|
1361
|
-
--migrate
|
|
1362
|
-
|
|
1363
|
-
|
|
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(
|
|
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 || !
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2765
|
+
const activityEngine = createActivityEngine();
|
|
2766
|
+
healthServer = await createHealthServer(config.defaults.httpPort, sessionManager, bot, config, { activityEngine });
|
|
1438
2767
|
} catch (err) {
|
|
1439
|
-
|
|
2768
|
+
log.warn(`Health server failed to start on port ${config.defaults.httpPort}: ${err}`);
|
|
1440
2769
|
}
|
|
1441
2770
|
}
|
|
1442
2771
|
}).catch((err) => {
|
|
1443
|
-
|
|
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) :
|
|
1453
|
-
if (!
|
|
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 &&
|
|
2787
|
+
if (configPath && existsSync7(configPath)) {
|
|
1459
2788
|
try {
|
|
1460
|
-
const raw = JSON.parse(
|
|
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(
|
|
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 =
|
|
1489
|
-
const cwdConfig =
|
|
1490
|
-
const cwdSessions =
|
|
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
|
-
|
|
1493
|
-
if (
|
|
1494
|
-
const dest =
|
|
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 (
|
|
1499
|
-
const dest =
|
|
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 (
|
|
1504
|
-
const dest =
|
|
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);
|