pi-orchestrator-config 4.2.0 → 4.3.2

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.
Files changed (100) hide show
  1. package/README.md +76 -67
  2. package/agents/code-reviewer-docs.md +11 -1
  3. package/agents/code-reviewer-guidelines.md +11 -1
  4. package/agents/code-reviewer-quality.md +11 -1
  5. package/agents/code-reviewer-security.md +11 -1
  6. package/agents/code-reviewer-spec.md +11 -1
  7. package/agents/git-expert.md +42 -3
  8. package/agents/github-expert.md +11 -0
  9. package/extensions/acpx-provider/runtime-models.ts +18 -6
  10. package/extensions/cli-provider/agents/claude.ts +3 -2
  11. package/extensions/cli-provider/agents/cursor.ts +3 -5
  12. package/extensions/cli-provider/agents/gemini.ts +3 -2
  13. package/extensions/cli-provider/discover.ts +7 -7
  14. package/extensions/cli-provider/index.ts +5 -0
  15. package/extensions/cli-provider/runtime-models.ts +18 -6
  16. package/extensions/cli-provider/session-reaper.ts +5 -7
  17. package/extensions/cli-provider/sessions.ts +4 -0
  18. package/extensions/cli-provider/shared/discover-cache.ts +17 -176
  19. package/extensions/coms/coms-p2p.ts +1719 -423
  20. package/extensions/coms/coms-shared.ts +261 -39
  21. package/extensions/coms/coms-wrapper.ts +100 -62
  22. package/extensions/coms/index.ts +8 -4
  23. package/extensions/coms/mixed-turn.ts +26 -0
  24. package/extensions/coms/probe-socket.ts +42 -0
  25. package/extensions/coms/themeMap.ts +0 -1
  26. package/extensions/image-gen/image-gen.ts +19 -1
  27. package/extensions/orchestrator/async-agents.ts +563 -102
  28. package/extensions/orchestrator/async-capability.ts +8 -8
  29. package/extensions/orchestrator/cron.ts +15 -9
  30. package/extensions/orchestrator/dreaming.ts +32 -25
  31. package/extensions/orchestrator/enforcement-helpers.ts +459 -17
  32. package/extensions/orchestrator/enforcement-rules.ts +5 -4
  33. package/extensions/orchestrator/enforcement.ts +80 -27
  34. package/extensions/orchestrator/extended-autocomplete.ts +7 -0
  35. package/extensions/orchestrator/git-helpers.ts +2 -2
  36. package/extensions/orchestrator/index.ts +78 -10
  37. package/extensions/orchestrator/markdown-transformer.ts +313 -0
  38. package/extensions/orchestrator/memory-embeddings.ts +36 -34
  39. package/extensions/orchestrator/memory-provenance.ts +29 -25
  40. package/extensions/orchestrator/memory-scoring.ts +23 -18
  41. package/extensions/orchestrator/memory-tools.ts +10 -3
  42. package/extensions/orchestrator/overlay-dashboard.ts +17 -5
  43. package/extensions/orchestrator/parse-model-override.ts +29 -0
  44. package/extensions/orchestrator/pi-config-review-state.ts +57 -33
  45. package/extensions/orchestrator/project-settings.ts +333 -127
  46. package/extensions/orchestrator/resolve-agent-model.ts +24 -13
  47. package/extensions/orchestrator/review-porcelain.ts +4 -0
  48. package/extensions/orchestrator/review-ui.ts +63 -15
  49. package/extensions/orchestrator/rule-placeholders.ts +411 -2
  50. package/extensions/orchestrator/rules.ts +80 -132
  51. package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
  52. package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
  53. package/extensions/orchestrator/settings-tui.ts +636 -0
  54. package/extensions/orchestrator/state-jsonl.ts +431 -0
  55. package/extensions/orchestrator/subagent-tool.ts +157 -59
  56. package/extensions/orchestrator/sync-limit.ts +23 -0
  57. package/extensions/orchestrator/task-lifecycle.ts +77 -67
  58. package/extensions/orchestrator/utils.ts +8 -5
  59. package/extensions/pidash/pidash.ts +193 -156
  60. package/extensions/pidiff/pidiff.ts +96 -38
  61. package/extensions/pidiff/spawn-lock.ts +62 -0
  62. package/extensions/pitasks/index.ts +430 -0
  63. package/extensions/pitasks/reminders.ts +37 -0
  64. package/extensions/pitasks/task-store.ts +467 -0
  65. package/extensions/pitasks/task-tools.ts +375 -0
  66. package/extensions/pitasks/task-widget.ts +181 -0
  67. package/extensions/pitasks/types.ts +38 -0
  68. package/extensions/providers/acpx-driver.ts +12 -2
  69. package/extensions/providers/built-in-drivers.ts +6 -6
  70. package/extensions/providers/claude-driver.ts +1 -1
  71. package/extensions/providers/cursor-cli-driver.ts +1 -1
  72. package/extensions/providers/gemini-driver.ts +1 -1
  73. package/extensions/providers/index.ts +144 -26
  74. package/extensions/providers/initialized-guard.ts +27 -0
  75. package/extensions/providers/restore-default-model.ts +596 -0
  76. package/extensions/providers/session-shutdown.ts +39 -0
  77. package/extensions/shared/coms-active.ts +23 -0
  78. package/extensions/shared/create-runtime-provider.ts +2 -0
  79. package/extensions/shared/daemon-manager.ts +0 -8
  80. package/extensions/shared/file-logger.ts +132 -25
  81. package/extensions/shared/logger.ts +50 -0
  82. package/extensions/shared/models-dev.ts +421 -0
  83. package/extensions/shared/oneshot.ts +116 -0
  84. package/extensions/shared/resolve-binary.ts +191 -0
  85. package/extensions/shared/types.ts +3 -0
  86. package/package.json +9 -3
  87. package/prompts/coderabbit-rate-limit.md +2 -0
  88. package/rules/00-orchestrator-core.md +3 -0
  89. package/rules/10-agent-routing.md +12 -1
  90. package/rules/20-code-review-loop.md +52 -25
  91. package/rules/30-prompt-templates.md +4 -0
  92. package/rules/35-memory.md +3 -3
  93. package/rules/40-critical-rules.md +3 -0
  94. package/rules/55-coms-protocol.md +84 -18
  95. package/rules/60-task-tracking.md +2 -0
  96. package/settings-keys.json +563 -0
  97. package/templates/coms-feature-manager-prompt.md +4 -6
  98. package/extensions/coms/coms-net-server.ts +0 -1642
  99. package/extensions/coms/coms-net-wrapper.ts +0 -521
  100. package/extensions/coms/coms-net.ts +0 -1589
@@ -1,1642 +0,0 @@
1
- // Forked from https://github.com/disler/pi-vs-claude-code (commit b93c3f1)
2
- // We own this code — check upstream periodically for relevant changes.
3
- // coms-net-server.ts
4
- //
5
- // coms-net Bun HTTP/SSE hub server (v1).
6
- //
7
- // Implements the protocol defined in specs/coms-net-v1.md.
8
- //
9
- // Hard rules:
10
- // - Entrypoint guard: Bun.serve boot lives inside main(); only fires when
11
- // `import.meta.main` is true. `bun -e "import('...')"` must NOT start the server.
12
- // - Token policy:
13
- // * PI_COMS_NET_AUTH_TOKEN set -> use it; do NOT write server.secret.json.
14
- // * Loopback bind w/o env token -> generate random, write server.secret.json (0600).
15
- // * Non-loopback bind w/o env token -> fail startup (exit 1).
16
- // - Never log the auth token. Print only the *path* to server.secret.json.
17
- // - crypto.timingSafeEqual is length-guarded.
18
- // - Atomic writes via .tmp + renameSync.
19
- // - SIGINT/SIGTERM unlinks server.json (always best-effort) and server.secret.json
20
- // only if TOKEN_FILE_OWNED_BY_US is true.
21
- // - Status state machine: queued | delivered | complete | error | timeout.
22
- // No `in_progress` (dropped from v1).
23
-
24
- import * as crypto from "node:crypto";
25
- import * as fs from "node:fs";
26
- import * as path from "node:path";
27
- import * as os from "node:os";
28
-
29
- // ─────────────────────────────────────────────────────────────────────────────
30
- // Env-var reads (module scope; all tunables here)
31
- // ─────────────────────────────────────────────────────────────────────────────
32
-
33
- const HOST = process.env.PI_COMS_NET_HOST ?? "127.0.0.1";
34
- const PORT = Number(process.env.PI_COMS_NET_PORT ?? 0);
35
- const PUBLIC_URL = process.env.PI_COMS_NET_PUBLIC_URL;
36
- const PROJECT = process.env.PI_COMS_NET_PROJECT ?? "default";
37
- const ENV_TOKEN = process.env.PI_COMS_NET_AUTH_TOKEN;
38
- const REG_ROOT = path.join(os.homedir(), ".pi", "coms-net");
39
-
40
- const MAX_HOPS = Number(process.env.PI_COMS_NET_MAX_HOPS ?? 5);
41
- const MESSAGE_TTL_MS = Number(process.env.PI_COMS_NET_MESSAGE_TTL_MS ?? 1_800_000);
42
- const MAX_INBOX = Number(process.env.PI_COMS_NET_MAX_INBOX ?? 100);
43
- const HEARTBEAT_MS = Number(process.env.PI_COMS_NET_HEARTBEAT_MS ?? 10_000);
44
- const STALE_AFTER_MS = Number(process.env.PI_COMS_NET_STALE_AFTER_MS ?? 30_000);
45
- const OFFLINE_AFTER_MS = Number(process.env.PI_COMS_NET_OFFLINE_AFTER_MS ?? 60_000);
46
-
47
- const STALE_SCAN_INTERVAL_MS = 5_000;
48
- const TTL_SCAN_INTERVAL_MS = 10_000;
49
- const SSE_KEEPALIVE_MS = 15_000;
50
- const DEFAULT_AWAIT_TIMEOUT_MS = 30_000;
51
-
52
- let TOKEN: string = ENV_TOKEN ?? "";
53
- let TOKEN_FILE_OWNED_BY_US = false;
54
-
55
- // ─────────────────────────────────────────────────────────────────────────────
56
- // Console event logger
57
- // ─────────────────────────────────────────────────────────────────────────────
58
- // Concise, color-coded per-event lines so the operator can watch what's
59
- // flowing through the hub. Uses ANSI 24-bit colors when stdout is a TTY,
60
- // otherwise plain ASCII. Auth tokens NEVER appear here.
61
- //
62
- // Format: HH:MM:SS.sss <symbol> <kind:10> <detail>
63
- //
64
- // Set PI_COMS_NET_LOG_HEARTBEAT=1 to also see heartbeats (very chatty).
65
- // Set PI_COMS_NET_LOG_QUIET=1 to suppress everything except startup/shutdown.
66
-
67
- const LOG_TTY = process.stdout.isTTY === true;
68
- const LOG_QUIET = process.env.PI_COMS_NET_LOG_QUIET === "1";
69
- const LOG_HEARTBEAT = process.env.PI_COMS_NET_LOG_HEARTBEAT === "1";
70
-
71
- const C_DIM = LOG_TTY ? "\x1b[2m" : "";
72
- const C_RESET = LOG_TTY ? "\x1b[0m" : "";
73
- const C_GREEN = LOG_TTY ? "\x1b[32m" : "";
74
- const C_CYAN = LOG_TTY ? "\x1b[36m" : "";
75
- const C_YELLOW = LOG_TTY ? "\x1b[33m" : "";
76
- const C_RED = LOG_TTY ? "\x1b[31m" : "";
77
- const C_PINK = LOG_TTY ? "\x1b[95m" : "";
78
- const C_BLUE = LOG_TTY ? "\x1b[34m" : "";
79
-
80
- function logLine(symbol: string, color: string, kind: string, detail: string): void {
81
- if (LOG_QUIET) return;
82
- const t = new Date().toISOString().slice(11, 23); // HH:MM:SS.sss
83
- const padded = kind.padEnd(10);
84
- console.log(`${C_DIM}${t}${C_RESET} ${color}${symbol}${C_RESET} ${color}${padded}${C_RESET} ${detail}`);
85
- }
86
-
87
- const tail6 = (id: string) => id.length > 6 ? id.slice(-6) : id;
88
- const dim = (s: string) => `${C_DIM}${s}${C_RESET}`;
89
-
90
- function logRegister(name: string, project: string, sid: string, isReregister: boolean): void {
91
- const verb = isReregister ? "re-register" : "register";
92
- logLine(isReregister ? "↻" : "✓", C_GREEN, verb, `${name}@${project} ${dim("sid=…" + tail6(sid))}`);
93
- }
94
- function logUnregister(name: string, reason: string): void {
95
- logLine("✗", C_RED, "unregister", `${name} ${dim("reason=" + reason)}`);
96
- }
97
- function logSseOpen(name: string, totalStreams: number): void {
98
- logLine("⇄", C_CYAN, "sse-open", `${name} ${dim(`(${totalStreams} stream${totalStreams === 1 ? "" : "s"})`)}`);
99
- }
100
- function logSseClose(name: string, reason: string): void {
101
- logLine("⇄", C_DIM, "sse-close", `${name} ${dim("reason=" + reason)}`);
102
- }
103
- function logMessageSend(sender: string, target: string, msgId: string, prompt: string, hops: number, delivered: boolean): void {
104
- const preview = prompt.length > 50 ? prompt.slice(0, 47) + "…" : prompt;
105
- const safePreview = preview.replace(/\n/g, " ⏎ ");
106
- const status = delivered ? dim("delivered") : dim("queued");
107
- logLine("→", C_PINK, "message", `${sender} → ${target} ${dim(tail6(msgId))} "${safePreview}" ${dim(`hops=${hops}`)} ${status}`);
108
- }
109
- function logResponse(responder: string, sender: string, msgId: string, isError: boolean, error: string | null, size: number): void {
110
- const status = isError ? `${C_RED}error=${error}${C_RESET}` : dim(`${size}c`);
111
- logLine("←", isError ? C_RED : C_GREEN, "response", `${responder} → ${sender} ${dim(tail6(msgId))} ${status}`);
112
- }
113
- function logStale(name: string, dtSec: number): void {
114
- logLine("⚠", C_YELLOW, "stale", `${name} ${dim(`(${dtSec}s since last heartbeat)`)}`);
115
- }
116
- function logOffline(name: string): void {
117
- logLine("⌛", C_RED, "offline", `${name} ${dim("removed (no heartbeat)")}`);
118
- }
119
- function logExpired(msgId: string): void {
120
- logLine("⏱", C_YELLOW, "expired", dim(tail6(msgId)));
121
- }
122
- function logHeartbeat(name: string, pct: number, depth: number): void {
123
- if (!LOG_HEARTBEAT) return;
124
- logLine("♥", C_BLUE, "heartbeat", `${name} ${dim(`ctx=${pct}% queue=${depth}`)}`);
125
- }
126
- function logRejected(reason: string, detail: string): void {
127
- logLine("✗", C_YELLOW, "rejected", `${reason} ${dim(detail)}`);
128
- }
129
-
130
- // ─────────────────────────────────────────────────────────────────────────────
131
- // Shared types (paste into both server and client per spec)
132
- // ─────────────────────────────────────────────────────────────────────────────
133
-
134
- export type AgentStatus = "online" | "stale" | "offline";
135
- export type MessageStatus =
136
- | "queued"
137
- | "delivered"
138
- | "complete"
139
- | "error"
140
- | "timeout";
141
-
142
- export type AgentCard = {
143
- session_id: string;
144
- name: string;
145
- purpose: string;
146
- model: string;
147
- provider?: string;
148
- color: string;
149
- cwd: string;
150
- project: string;
151
- explicit: boolean;
152
- started_at: string;
153
- context_used_pct: number;
154
- queue_depth: number;
155
- status: AgentStatus;
156
- tasks_summary?: { total: number; completed: number; in_progress: number } | null;
157
- };
158
-
159
- export type RegistryEntry = AgentCard & {
160
- last_seen_at: string;
161
- registered_at: string;
162
- };
163
-
164
- export type ComsMessage = {
165
- msg_id: string;
166
- project: string;
167
- sender_session: string;
168
- target_session: string;
169
- prompt: string;
170
- conversation_id: string | null;
171
- response_schema: object | null;
172
- hops: number;
173
- tasks?: Array<{ subject: string; description: string }> | null;
174
- status: MessageStatus;
175
- response?: any;
176
- error?: string | null;
177
- created_at: string;
178
- delivered_at?: string;
179
- completed_at?: string;
180
- expires_at: string;
181
- };
182
-
183
- export type RegisterRequest = {
184
- project: string;
185
- session_id: string;
186
- name: string;
187
- purpose: string;
188
- model: string;
189
- provider?: string;
190
- color: string;
191
- cwd: string;
192
- explicit: boolean;
193
- };
194
-
195
- export type RegisterResponse = {
196
- ok: true;
197
- agent: AgentCard;
198
- heartbeat_interval_ms: number;
199
- sse_url: string;
200
- };
201
-
202
- export type HeartbeatRequest = {
203
- project: string;
204
- context_used_pct: number;
205
- queue_depth: number;
206
- model?: string;
207
- status?: AgentStatus;
208
- };
209
-
210
- export type SendRequest = {
211
- project: string;
212
- sender_session: string;
213
- target: string;
214
- target_session: string | null;
215
- prompt: string;
216
- conversation_id: string | null;
217
- response_schema: object | null;
218
- hops: number;
219
- tasks?: Array<{ subject: string; description: string }> | null;
220
- };
221
-
222
- export type SendResponse = {
223
- ok: true;
224
- msg_id: string;
225
- status: MessageStatus;
226
- target_session: string;
227
- };
228
-
229
- export type ResponseSubmitRequest = {
230
- project: string;
231
- responder_session: string;
232
- response: any;
233
- error: string | null;
234
- queued_msg_ids?: string[];
235
- };
236
-
237
- export type ErrorResponse = { ok: false; error: string; details?: any };
238
-
239
- // SSE writer & per-project state
240
- type Awaiter = {
241
- resolve: (m: ComsMessage) => void;
242
- timer: ReturnType<typeof setTimeout> | null;
243
- };
244
-
245
- type SseWriter = {
246
- session_id: string;
247
- enqueue: (frame: string) => void;
248
- close: () => void;
249
- lastId: number;
250
- };
251
-
252
- type ProjectState = {
253
- agents: Map<string, RegistryEntry>;
254
- nameIndex: Map<string, Set<string>>;
255
- messages: Map<string, ComsMessage>;
256
- streams: Map<string, SseWriter>;
257
- awaiters: Map<string, Set<Awaiter>>;
258
- };
259
-
260
- type ServerState = {
261
- server_id: string;
262
- started_at: string;
263
- projects: Map<string, ProjectState>;
264
- };
265
-
266
- // ─────────────────────────────────────────────────────────────────────────────
267
- // Helpers (module scope, all pure / deterministic)
268
- // ─────────────────────────────────────────────────────────────────────────────
269
-
270
- const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // pragma: allowlist secret
271
-
272
- export function ulid(): string {
273
- const time = Date.now();
274
- const rand = crypto.randomBytes(10);
275
- let timeStr = "";
276
- let t = time;
277
- for (let i = 9; i >= 0; i--) {
278
- timeStr = CROCKFORD[t % 32] + timeStr;
279
- t = Math.floor(t / 32);
280
- }
281
- let randStr = "";
282
- let bits = 0;
283
- let value = 0;
284
- for (const byte of rand) {
285
- value = (value << 8) | byte;
286
- bits += 8;
287
- while (bits >= 5) {
288
- bits -= 5;
289
- randStr += CROCKFORD[(value >> bits) & 31];
290
- }
291
- }
292
- return (timeStr + randStr).slice(0, 26);
293
- }
294
-
295
- export function nowIso(): string {
296
- return new Date().toISOString();
297
- }
298
-
299
- export function isLoopback(host: string): boolean {
300
- return host === "127.0.0.1" || host === "::1" || host === "localhost";
301
- }
302
-
303
- export function tokensEqual(a: string, b: string): boolean {
304
- const ab = Buffer.from(a, "utf-8");
305
- const bb = Buffer.from(b, "utf-8");
306
- if (ab.length !== bb.length) return false;
307
- return crypto.timingSafeEqual(ab, bb);
308
- }
309
-
310
- function authed(req: Request): boolean {
311
- if (!TOKEN) return false;
312
- const h = req.headers.get("authorization") ?? "";
313
- if (!h.startsWith("Bearer ")) return false;
314
- return tokensEqual(h.slice(7), TOKEN);
315
- }
316
-
317
- export function json(body: unknown, status = 200): Response {
318
- return new Response(JSON.stringify(body), {
319
- status,
320
- headers: { "content-type": "application/json" },
321
- });
322
- }
323
-
324
- function errorJson(error: string, status = 400, details?: any): Response {
325
- const body: ErrorResponse = { ok: false, error };
326
- if (details !== undefined) body.details = details;
327
- return json(body, status);
328
- }
329
-
330
- function unauthorized(): Response {
331
- return new Response(JSON.stringify({ ok: false, error: "unauthorized" }), {
332
- status: 401,
333
- headers: {
334
- "content-type": "application/json",
335
- "www-authenticate": 'Bearer realm="coms-net"',
336
- },
337
- });
338
- }
339
-
340
- function projectDir(project: string): string {
341
- return path.join(REG_ROOT, "projects", project);
342
- }
343
-
344
- function ensureDirSync(dir: string): void {
345
- fs.mkdirSync(dir, { recursive: true });
346
- }
347
-
348
- function atomicWriteSync(filePath: string, content: string, mode?: number): void {
349
- const dir = path.dirname(filePath);
350
- ensureDirSync(dir);
351
- const tmp = `${filePath}.tmp`;
352
- fs.writeFileSync(tmp, content);
353
- if (mode !== undefined) {
354
- try {
355
- fs.chmodSync(tmp, mode);
356
- } catch {
357
- // best-effort
358
- }
359
- }
360
- fs.renameSync(tmp, filePath);
361
- }
362
-
363
- export function sseFrame(event: string, data: unknown, id?: number): string {
364
- const lines = [`event: ${event}`];
365
- if (id !== undefined) lines.push(`id: ${id}`);
366
- lines.push(`data: ${JSON.stringify(data)}`);
367
- return lines.join("\n") + "\n\n";
368
- }
369
-
370
- export function resolveUniqueName(
371
- project: ProjectState,
372
- desiredName: string,
373
- ): string {
374
- const liveNames = new Set(
375
- [...project.agents.values()].map((a) => a.name),
376
- );
377
- if (!liveNames.has(desiredName)) return desiredName;
378
- let n = 2;
379
- while (liveNames.has(`${desiredName}${n}`)) n++;
380
- return `${desiredName}${n}`;
381
- }
382
-
383
- // ─────────────────────────────────────────────────────────────────────────────
384
- // State (module scope, single instance shared by router & loops)
385
- // ─────────────────────────────────────────────────────────────────────────────
386
-
387
- const state: ServerState = {
388
- server_id: ulid(),
389
- started_at: nowIso(),
390
- projects: new Map<string, ProjectState>(),
391
- };
392
-
393
- function getOrCreateProject(name: string): ProjectState {
394
- let p = state.projects.get(name);
395
- if (!p) {
396
- p = {
397
- agents: new Map(),
398
- nameIndex: new Map(),
399
- messages: new Map(),
400
- streams: new Map(),
401
- awaiters: new Map(),
402
- };
403
- state.projects.set(name, p);
404
- }
405
- return p;
406
- }
407
-
408
- function nameIndexAdd(p: ProjectState, name: string, sessionId: string): void {
409
- let bag = p.nameIndex.get(name);
410
- if (!bag) {
411
- bag = new Set();
412
- p.nameIndex.set(name, bag);
413
- }
414
- bag.add(sessionId);
415
- }
416
-
417
- function nameIndexRemove(
418
- p: ProjectState,
419
- name: string,
420
- sessionId: string,
421
- ): void {
422
- const bag = p.nameIndex.get(name);
423
- if (!bag) return;
424
- bag.delete(sessionId);
425
- if (bag.size === 0) p.nameIndex.delete(name);
426
- }
427
-
428
- function entryToCard(e: RegistryEntry): AgentCard {
429
- const {
430
- session_id,
431
- name,
432
- purpose,
433
- model,
434
- provider,
435
- color,
436
- cwd,
437
- project,
438
- explicit,
439
- started_at,
440
- context_used_pct,
441
- queue_depth,
442
- status,
443
- } = e;
444
- return {
445
- session_id,
446
- name,
447
- purpose,
448
- model,
449
- provider,
450
- color,
451
- cwd,
452
- project,
453
- explicit,
454
- started_at,
455
- context_used_pct,
456
- queue_depth,
457
- status,
458
- tasks_summary: e.tasks_summary ?? null,
459
- };
460
- }
461
-
462
- function broadcast(
463
- p: ProjectState,
464
- event: string,
465
- data: unknown,
466
- excludeSession?: string,
467
- ): void {
468
- for (const [sid, w] of p.streams) {
469
- if (excludeSession && sid === excludeSession) continue;
470
- const id = ++w.lastId;
471
- try {
472
- w.enqueue(sseFrame(event, data, id));
473
- } catch {
474
- // stream is dead; the abort handler will reap it
475
- }
476
- }
477
- }
478
-
479
- function sendToStream(
480
- p: ProjectState,
481
- sessionId: string,
482
- event: string,
483
- data: unknown,
484
- ): void {
485
- const w = p.streams.get(sessionId);
486
- if (!w) return;
487
- const id = ++w.lastId;
488
- try {
489
- w.enqueue(sseFrame(event, data, id));
490
- } catch {
491
- // dead; abort handler will reap
492
- }
493
- }
494
-
495
- function releaseAwaiters(p: ProjectState, msg_id: string): void {
496
- const set = p.awaiters.get(msg_id);
497
- if (!set) return;
498
- const message = p.messages.get(msg_id);
499
- for (const a of set) {
500
- if (a.timer) clearTimeout(a.timer);
501
- try {
502
- if (message) a.resolve(message);
503
- } catch {
504
- // noop
505
- }
506
- }
507
- p.awaiters.delete(msg_id);
508
- }
509
-
510
- function inboxDepthFor(p: ProjectState, targetSession: string): number {
511
- let n = 0;
512
- for (const m of p.messages.values()) {
513
- if (m.target_session !== targetSession) continue;
514
- if (m.status === "queued" || m.status === "delivered") n++;
515
- }
516
- return n;
517
- }
518
-
519
- // ─────────────────────────────────────────────────────────────────────────────
520
- // Route handlers
521
- // ─────────────────────────────────────────────────────────────────────────────
522
-
523
- async function handleHealth(_req: Request): Promise<Response> {
524
- return json({
525
- ok: true,
526
- version: 1,
527
- server_id: state.server_id,
528
- started_at: state.started_at,
529
- });
530
- }
531
-
532
- async function handleRegister(req: Request): Promise<Response> {
533
- let body: RegisterRequest;
534
- try {
535
- body = (await req.json()) as RegisterRequest;
536
- } catch {
537
- return errorJson("invalid_json", 400);
538
- }
539
- if (
540
- !body ||
541
- typeof body !== "object" ||
542
- typeof body.session_id !== "string" ||
543
- typeof body.project !== "string" ||
544
- typeof body.name !== "string"
545
- ) {
546
- return errorJson("invalid_request", 400);
547
- }
548
- const projectName = body.project || "default";
549
- const p = getOrCreateProject(projectName);
550
- const desiredName = body.name && body.name.length > 0 ? body.name : "agent";
551
- let resolvedName = desiredName;
552
- const existing = p.agents.get(body.session_id);
553
- const isReregister = !!existing;
554
- if (existing) {
555
- // upsert: keep their existing name unless they ask for a different one
556
- resolvedName =
557
- body.name && body.name !== existing.name
558
- ? resolveUniqueName(p, desiredName)
559
- : existing.name;
560
- } else {
561
- resolvedName = resolveUniqueName(p, desiredName);
562
- }
563
-
564
- const card: AgentCard = {
565
- session_id: body.session_id,
566
- name: resolvedName,
567
- purpose: body.purpose ?? "",
568
- model: body.model ?? "unknown",
569
- provider: body.provider,
570
- color: body.color ?? "#888888",
571
- cwd: body.cwd ?? "",
572
- project: projectName,
573
- explicit: body.explicit === true,
574
- started_at: existing?.started_at ?? nowIso(),
575
- context_used_pct: existing?.context_used_pct ?? 0,
576
- queue_depth: existing?.queue_depth ?? 0,
577
- status: "online",
578
- };
579
- const entry: RegistryEntry = {
580
- ...card,
581
- registered_at: existing?.registered_at ?? nowIso(),
582
- last_seen_at: nowIso(),
583
- };
584
-
585
- if (existing && existing.name !== entry.name) {
586
- nameIndexRemove(p, existing.name, body.session_id);
587
- }
588
- p.agents.set(body.session_id, entry);
589
- nameIndexAdd(p, entry.name, body.session_id);
590
-
591
- logRegister(entry.name, projectName, body.session_id, isReregister);
592
-
593
- // Emit agent_joined to OTHER streams (do not echo to a stream that may not
594
- // exist yet — the registering client opens SSE next).
595
- broadcast(
596
- p,
597
- "agent_joined",
598
- { project: projectName, agent: entryToCard(entry) },
599
- body.session_id,
600
- );
601
-
602
- const sse_url = `/v1/events?project=${encodeURIComponent(projectName)}&session_id=${encodeURIComponent(body.session_id)}`;
603
- const resp: RegisterResponse = {
604
- ok: true,
605
- agent: entryToCard(entry),
606
- heartbeat_interval_ms: HEARTBEAT_MS,
607
- sse_url,
608
- };
609
- return json(resp);
610
- }
611
-
612
- function handleEvents(req: Request, url: URL): Response {
613
- const projectName = url.searchParams.get("project") ?? "default";
614
- const session_id = url.searchParams.get("session_id") ?? "";
615
- if (!session_id) return errorJson("missing_session_id", 400);
616
- const p = getOrCreateProject(projectName);
617
- const entry = p.agents.get(session_id);
618
- if (!entry) return errorJson("agent_not_found", 404);
619
-
620
- const enc = new TextEncoder();
621
- let writer: SseWriter | null = null;
622
-
623
- const stream = new ReadableStream<Uint8Array>({
624
- start(controller) {
625
- let closed = false;
626
- let lastId = 0;
627
- writer = {
628
- session_id,
629
- lastId,
630
- enqueue(frame: string) {
631
- if (closed) return;
632
- try {
633
- controller.enqueue(enc.encode(frame));
634
- } catch {
635
- closed = true;
636
- }
637
- },
638
- close() {
639
- if (closed) return;
640
- closed = true;
641
- try {
642
- controller.close();
643
- } catch {
644
- // already closed
645
- }
646
- },
647
- };
648
- // Replace possibly-stale stream entry
649
- const old = p.streams.get(session_id);
650
- if (old && old !== writer) {
651
- try {
652
- old.close();
653
- } catch {
654
- // noop
655
- }
656
- }
657
- p.streams.set(session_id, writer);
658
- logSseOpen(entry.name, p.streams.size);
659
-
660
- // hello
661
- const helloId = ++writer.lastId;
662
- try {
663
- controller.enqueue(
664
- enc.encode(
665
- sseFrame(
666
- "hello",
667
- { server_time: nowIso(), server_id: state.server_id },
668
- helloId,
669
- ),
670
- ),
671
- );
672
- } catch {
673
- closed = true;
674
- }
675
-
676
- // pool_snapshot
677
- const agents: AgentCard[] = [];
678
- for (const a of p.agents.values()) {
679
- if (a.session_id === session_id) continue;
680
- if (a.explicit) continue;
681
- agents.push(entryToCard(a));
682
- }
683
- const snapId = ++writer.lastId;
684
- try {
685
- controller.enqueue(
686
- enc.encode(
687
- sseFrame(
688
- "pool_snapshot",
689
- { project: projectName, agents },
690
- snapId,
691
- ),
692
- ),
693
- );
694
- } catch {
695
- closed = true;
696
- }
697
-
698
- // abort handler
699
- const onAbort = () => {
700
- if (closed) return;
701
- closed = true;
702
- const cur = p.streams.get(session_id);
703
- if (cur === writer) p.streams.delete(session_id);
704
- try {
705
- controller.close();
706
- } catch {
707
- // noop
708
- }
709
- const left = p.agents.get(session_id);
710
- if (left) {
711
- logSseClose(left.name, "connection_closed");
712
- broadcast(
713
- p,
714
- "agent_left",
715
- {
716
- project: projectName,
717
- session_id,
718
- name: left.name,
719
- reason: "connection_closed",
720
- },
721
- session_id,
722
- );
723
- }
724
- };
725
- try {
726
- req.signal.addEventListener("abort", onAbort);
727
- } catch {
728
- // noop
729
- }
730
- },
731
- cancel() {
732
- const cur = p.streams.get(session_id);
733
- if (cur === writer) p.streams.delete(session_id);
734
- },
735
- });
736
-
737
- return new Response(stream, {
738
- status: 200,
739
- headers: {
740
- "content-type": "text/event-stream; charset=utf-8",
741
- "cache-control": "no-cache, no-transform",
742
- connection: "keep-alive",
743
- "x-accel-buffering": "no",
744
- },
745
- });
746
- }
747
-
748
- async function handleHeartbeat(
749
- req: Request,
750
- sessionId: string,
751
- ): Promise<Response> {
752
- let body: HeartbeatRequest;
753
- try {
754
- body = (await req.json()) as HeartbeatRequest;
755
- } catch {
756
- return errorJson("invalid_json", 400);
757
- }
758
- const projectName = body?.project ?? "default";
759
- const p = state.projects.get(projectName);
760
- if (!p) return errorJson("agent_not_found", 404);
761
- const entry = p.agents.get(sessionId);
762
- if (!entry) return errorJson("agent_not_found", 404);
763
-
764
- const before: Partial<AgentCard> = {
765
- context_used_pct: entry.context_used_pct,
766
- queue_depth: entry.queue_depth,
767
- model: entry.model,
768
- status: entry.status,
769
- tasks_summary: entry.tasks_summary,
770
- };
771
- if (typeof body.context_used_pct === "number")
772
- entry.context_used_pct = body.context_used_pct;
773
- if (typeof body.queue_depth === "number")
774
- entry.queue_depth = body.queue_depth;
775
- if (body.tasks_summary !== undefined) {
776
- const ts = body.tasks_summary;
777
- entry.tasks_summary = (ts && typeof ts === "object" && typeof ts.total === "number" && typeof ts.completed === "number" && typeof ts.in_progress === "number")
778
- ? { total: ts.total, completed: ts.completed, in_progress: ts.in_progress }
779
- : null;
780
- }
781
- if (typeof body.model === "string") entry.model = body.model;
782
- if (
783
- body.status === "online" ||
784
- body.status === "stale" ||
785
- body.status === "offline"
786
- ) {
787
- entry.status = body.status;
788
- } else {
789
- entry.status = "online";
790
- }
791
- entry.last_seen_at = nowIso();
792
-
793
- logHeartbeat(entry.name, entry.context_used_pct, entry.queue_depth);
794
-
795
- const changed =
796
- before.context_used_pct !== entry.context_used_pct ||
797
- before.queue_depth !== entry.queue_depth ||
798
- before.model !== entry.model ||
799
- before.status !== entry.status ||
800
- JSON.stringify(before.tasks_summary) !== JSON.stringify(entry.tasks_summary);
801
- if (changed) {
802
- broadcast(
803
- p,
804
- "agent_updated",
805
- {
806
- project: projectName,
807
- agent: {
808
- session_id: entry.session_id,
809
- name: entry.name,
810
- context_used_pct: entry.context_used_pct,
811
- queue_depth: entry.queue_depth,
812
- model: entry.model,
813
- status: entry.status,
814
- },
815
- },
816
- sessionId,
817
- );
818
- }
819
- return json({ ok: true });
820
- }
821
-
822
- function handleListAgents(_req: Request, url: URL): Response {
823
- const projectName = url.searchParams.get("project") ?? "default";
824
- const includeExplicit =
825
- (url.searchParams.get("include_explicit") ?? "false").toLowerCase() ===
826
- "true";
827
- const p = state.projects.get(projectName);
828
- const out: AgentCard[] = [];
829
- if (p) {
830
- for (const e of p.agents.values()) {
831
- if (!includeExplicit && e.explicit) continue;
832
- out.push(entryToCard(e));
833
- }
834
- }
835
- return json({ agents: out });
836
- }
837
-
838
- async function handleSendMessage(req: Request): Promise<Response> {
839
- let body: SendRequest;
840
- try {
841
- body = (await req.json()) as SendRequest;
842
- } catch {
843
- return errorJson("invalid_json", 400);
844
- }
845
- if (
846
- !body ||
847
- typeof body !== "object" ||
848
- typeof body.sender_session !== "string" ||
849
- typeof body.prompt !== "string"
850
- ) {
851
- return errorJson("invalid_request", 400);
852
- }
853
- const projectName = body.project ?? "default";
854
- const p = state.projects.get(projectName);
855
- if (!p) return errorJson("agent_not_found", 404);
856
-
857
- const sender = p.agents.get(body.sender_session);
858
- if (!sender) return errorJson("sender_not_registered", 404);
859
-
860
- const hops = typeof body.hops === "number" ? body.hops : 0;
861
- if (hops >= MAX_HOPS) {
862
- logRejected("hop_limit", `${sender.name} hops=${hops} max=${MAX_HOPS}`);
863
- return errorJson("hop_limit_exceeded", 409, { hops, max_hops: MAX_HOPS });
864
- }
865
-
866
- // Resolve target.
867
- let target: RegistryEntry | undefined;
868
- if (body.target_session && typeof body.target_session === "string") {
869
- target = p.agents.get(body.target_session);
870
- if (!target) {
871
- logRejected("target_not_found", `${sender.name} → ${body.target_session.slice(-6)}`);
872
- return errorJson("target_not_found", 404);
873
- }
874
- } else {
875
- const desired = (body.target ?? "").trim();
876
- if (!desired) return errorJson("missing_target", 400);
877
- // Direct session_id match first.
878
- const directSid = p.agents.get(desired);
879
- if (directSid) {
880
- target = directSid;
881
- } else {
882
- const bag = p.nameIndex.get(desired);
883
- if (!bag || bag.size === 0) {
884
- logRejected("target_not_found", `${sender.name} → "${desired}"`);
885
- return errorJson("target_not_found", 404, { target: desired });
886
- }
887
- if (bag.size > 1) {
888
- logRejected("ambiguous", `${sender.name} → "${desired}" matches ${bag.size}`);
889
- return errorJson("ambiguous_target", 409, {
890
- target: desired,
891
- candidates: [...bag],
892
- });
893
- }
894
- const onlySid = [...bag][0];
895
- target = p.agents.get(onlySid);
896
- if (!target) return errorJson("target_not_found", 404);
897
- }
898
- }
899
-
900
- // Inbox cap.
901
- const depth = inboxDepthFor(p, target.session_id);
902
- if (depth >= MAX_INBOX) {
903
- logRejected("inbox_full", `${sender.name} → ${target.name} depth=${depth}`);
904
- return errorJson("inbox_full", 429, { depth, max_inbox: MAX_INBOX });
905
- }
906
-
907
- const created = nowIso();
908
- const expires = new Date(Date.now() + MESSAGE_TTL_MS).toISOString();
909
- const msg: ComsMessage = {
910
- msg_id: ulid(),
911
- project: projectName,
912
- sender_session: body.sender_session,
913
- target_session: target.session_id,
914
- prompt: body.prompt,
915
- conversation_id:
916
- body.conversation_id && typeof body.conversation_id === "string"
917
- ? body.conversation_id
918
- : null,
919
- response_schema:
920
- body.response_schema && typeof body.response_schema === "object"
921
- ? body.response_schema
922
- : null,
923
- hops,
924
- tasks: Array.isArray(body.tasks) ? body.tasks : null,
925
- status: "queued",
926
- response: null,
927
- error: null,
928
- created_at: created,
929
- expires_at: expires,
930
- };
931
- p.messages.set(msg.msg_id, msg);
932
-
933
- // Notify sender: queued
934
- sendToStream(p, body.sender_session, "message_status", {
935
- msg_id: msg.msg_id,
936
- status: "queued",
937
- });
938
-
939
- // Emit prompt to target if its stream is open.
940
- const targetWriter = p.streams.get(target.session_id);
941
- if (targetWriter) {
942
- sendToStream(p, target.session_id, "prompt", {
943
- msg_id: msg.msg_id,
944
- project: projectName,
945
- sender: {
946
- session_id: sender.session_id,
947
- name: sender.name,
948
- cwd: sender.cwd,
949
- },
950
- prompt: msg.prompt,
951
- conversation_id: msg.conversation_id,
952
- response_schema: msg.response_schema,
953
- hops: msg.hops,
954
- tasks: msg.tasks ?? null,
955
- });
956
- msg.status = "delivered";
957
- msg.delivered_at = nowIso();
958
- // Notify sender: delivered
959
- sendToStream(p, body.sender_session, "message_status", {
960
- msg_id: msg.msg_id,
961
- status: "delivered",
962
- });
963
- }
964
-
965
- logMessageSend(
966
- sender.name,
967
- target.name,
968
- msg.msg_id,
969
- msg.prompt,
970
- hops,
971
- msg.status === "delivered",
972
- );
973
-
974
- const resp: SendResponse = {
975
- ok: true,
976
- msg_id: msg.msg_id,
977
- status: msg.status,
978
- target_session: target.session_id,
979
- };
980
- return json(resp);
981
- }
982
-
983
- function handleGetMessage(_req: Request, msg_id: string): Response {
984
- for (const p of state.projects.values()) {
985
- const m = p.messages.get(msg_id);
986
- if (m) {
987
- return json({
988
- msg_id: m.msg_id,
989
- status: m.status,
990
- response: m.response ?? null,
991
- error: m.error ?? null,
992
- });
993
- }
994
- }
995
- return errorJson("message_not_found", 404);
996
- }
997
-
998
- function handleAwaitMessage(req: Request, url: URL, msg_id: string): Response {
999
- let project: ProjectState | undefined;
1000
- let msg: ComsMessage | undefined;
1001
- for (const p of state.projects.values()) {
1002
- const m = p.messages.get(msg_id);
1003
- if (m) {
1004
- project = p;
1005
- msg = m;
1006
- break;
1007
- }
1008
- }
1009
- if (!project || !msg) {
1010
- return new Response(
1011
- JSON.stringify({ ok: false, error: "message_not_found" }),
1012
- { status: 404, headers: { "content-type": "application/json" } },
1013
- );
1014
- }
1015
- // Already terminal? Resolve immediately.
1016
- if (
1017
- msg.status === "complete" ||
1018
- msg.status === "error" ||
1019
- msg.status === "timeout"
1020
- ) {
1021
- return json({
1022
- msg_id: msg.msg_id,
1023
- status: msg.status,
1024
- response: msg.response ?? null,
1025
- error: msg.error ?? null,
1026
- });
1027
- }
1028
-
1029
- const requested = Number(url.searchParams.get("timeout_ms") ?? "");
1030
- let timeout_ms =
1031
- Number.isFinite(requested) && requested > 0
1032
- ? requested
1033
- : DEFAULT_AWAIT_TIMEOUT_MS;
1034
- // Clamp to TTL.
1035
- if (timeout_ms > MESSAGE_TTL_MS) timeout_ms = MESSAGE_TTL_MS;
1036
-
1037
- const proj = project; // for closure
1038
- const id = msg_id;
1039
-
1040
- return new Response(
1041
- new ReadableStream<Uint8Array>({
1042
- start(controller) {
1043
- const enc = new TextEncoder();
1044
- let done = false;
1045
-
1046
- const set = proj.awaiters.get(id) ?? new Set<Awaiter>();
1047
- proj.awaiters.set(id, set);
1048
-
1049
- const finalize = (payload: any, status = 200) => {
1050
- if (done) return;
1051
- done = true;
1052
- try {
1053
- controller.enqueue(enc.encode(JSON.stringify(payload)));
1054
- controller.close();
1055
- } catch {
1056
- // already closed
1057
- }
1058
- void status;
1059
- };
1060
-
1061
- const awaiter: Awaiter = {
1062
- resolve: (m: ComsMessage) => {
1063
- finalize({
1064
- msg_id: m.msg_id,
1065
- status: m.status,
1066
- response: m.response ?? null,
1067
- error: m.error ?? null,
1068
- });
1069
- },
1070
- timer: null,
1071
- };
1072
-
1073
- awaiter.timer = setTimeout(() => {
1074
- const cur = proj.awaiters.get(id);
1075
- if (cur) {
1076
- cur.delete(awaiter);
1077
- if (cur.size === 0) proj.awaiters.delete(id);
1078
- }
1079
- finalize({
1080
- msg_id: id,
1081
- status: "timeout",
1082
- response: null,
1083
- error: "timeout",
1084
- });
1085
- }, timeout_ms);
1086
- try {
1087
- (awaiter.timer as any).unref?.();
1088
- } catch {
1089
- // noop
1090
- }
1091
-
1092
- set.add(awaiter);
1093
-
1094
- // Connection abort: clean the awaiter.
1095
- try {
1096
- req.signal.addEventListener("abort", () => {
1097
- if (done) return;
1098
- const cur = proj.awaiters.get(id);
1099
- if (cur) {
1100
- cur.delete(awaiter);
1101
- if (cur.size === 0) proj.awaiters.delete(id);
1102
- }
1103
- if (awaiter.timer) clearTimeout(awaiter.timer);
1104
- done = true;
1105
- try {
1106
- controller.close();
1107
- } catch {
1108
- // noop
1109
- }
1110
- });
1111
- } catch {
1112
- // noop
1113
- }
1114
- },
1115
- }),
1116
- {
1117
- status: 200,
1118
- headers: { "content-type": "application/json" },
1119
- },
1120
- );
1121
- }
1122
-
1123
- async function handleSubmitResponse(
1124
- req: Request,
1125
- msg_id: string,
1126
- ): Promise<Response> {
1127
- let body: ResponseSubmitRequest;
1128
- try {
1129
- body = (await req.json()) as ResponseSubmitRequest;
1130
- } catch {
1131
- return errorJson("invalid_json", 400);
1132
- }
1133
- if (
1134
- !body ||
1135
- typeof body !== "object" ||
1136
- typeof body.responder_session !== "string"
1137
- ) {
1138
- return errorJson("invalid_request", 400);
1139
- }
1140
- let project: ProjectState | undefined;
1141
- let msg: ComsMessage | undefined;
1142
- for (const p of state.projects.values()) {
1143
- const m = p.messages.get(msg_id);
1144
- if (m) {
1145
- project = p;
1146
- msg = m;
1147
- break;
1148
- }
1149
- }
1150
- if (!project || !msg) return errorJson("message_not_found", 404);
1151
- if (body.responder_session !== msg.target_session) {
1152
- return errorJson("not_target", 403);
1153
- }
1154
- if (
1155
- msg.status === "complete" ||
1156
- msg.status === "error" ||
1157
- msg.status === "timeout"
1158
- ) {
1159
- return errorJson("already_terminal", 409, { status: msg.status });
1160
- }
1161
-
1162
- const isError = body.error !== null && body.error !== undefined;
1163
- msg.status = isError ? "error" : "complete";
1164
- msg.response = body.response ?? null;
1165
- msg.error = isError ? String(body.error) : null;
1166
- msg.completed_at = nowIso();
1167
-
1168
- // Look up responder name for the SSE response payload.
1169
- const responder = project.agents.get(body.responder_session);
1170
- const responderName = responder?.name ?? "unknown";
1171
-
1172
- // Notify sender (if its stream is open).
1173
- sendToStream(project, msg.sender_session, "response", {
1174
- msg_id: msg.msg_id,
1175
- project: msg.project,
1176
- responder: { session_id: body.responder_session, name: responderName },
1177
- response: msg.response,
1178
- error: msg.error,
1179
- status: msg.status,
1180
- queued_msg_ids: Array.isArray(body.queued_msg_ids) ? body.queued_msg_ids.filter((id: unknown) => typeof id === "string") : [],
1181
- });
1182
- // Also push a final message_status for completeness.
1183
- sendToStream(project, msg.sender_session, "message_status", {
1184
- msg_id: msg.msg_id,
1185
- status: msg.status,
1186
- });
1187
-
1188
- releaseAwaiters(project, msg_id);
1189
-
1190
- const senderName = project.agents.get(msg.sender_session)?.name ?? "(gone)";
1191
- const responseSize =
1192
- typeof msg.response === "string"
1193
- ? msg.response.length
1194
- : msg.response
1195
- ? JSON.stringify(msg.response).length
1196
- : 0;
1197
- logResponse(responderName, senderName, msg.msg_id, isError, msg.error, responseSize);
1198
-
1199
- return json({ ok: true });
1200
- }
1201
-
1202
- function handleDeleteAgent(_req: Request, url: URL, sessionId: string): Response {
1203
- const projectName = url.searchParams.get("project") ?? "default";
1204
- const p = state.projects.get(projectName);
1205
- if (!p) return errorJson("agent_not_found", 404);
1206
- const entry = p.agents.get(sessionId);
1207
- if (!entry) return errorJson("agent_not_found", 404);
1208
-
1209
- // Close stream first; the abort handler may also fire.
1210
- const stream = p.streams.get(sessionId);
1211
- if (stream) {
1212
- try {
1213
- stream.close();
1214
- } catch {
1215
- // noop
1216
- }
1217
- p.streams.delete(sessionId);
1218
- }
1219
-
1220
- p.agents.delete(sessionId);
1221
- nameIndexRemove(p, entry.name, sessionId);
1222
-
1223
- logUnregister(entry.name, "shutdown");
1224
-
1225
- broadcast(
1226
- p,
1227
- "agent_left",
1228
- {
1229
- project: projectName,
1230
- session_id: sessionId,
1231
- name: entry.name,
1232
- reason: "shutdown",
1233
- },
1234
- sessionId,
1235
- );
1236
-
1237
- return json({ ok: true });
1238
- }
1239
-
1240
- // ─────────────────────────────────────────────────────────────────────────────
1241
- // Router
1242
- // ─────────────────────────────────────────────────────────────────────────────
1243
-
1244
- async function router(req: Request): Promise<Response> {
1245
- let url: URL;
1246
- try {
1247
- url = new URL(req.url);
1248
- } catch {
1249
- return errorJson("invalid_url", 400);
1250
- }
1251
- const method = req.method.toUpperCase();
1252
- const pathname = url.pathname;
1253
-
1254
- // 1. /health (no auth)
1255
- if (pathname === "/health" && method === "GET") {
1256
- return handleHealth(req);
1257
- }
1258
-
1259
- // All /v1/* require bearer auth.
1260
- if (pathname.startsWith("/v1/")) {
1261
- if (!authed(req)) return unauthorized();
1262
- } else {
1263
- // Unknown non-/v1 route.
1264
- return errorJson("not_found", 404);
1265
- }
1266
-
1267
- // 2. POST /v1/agents/register
1268
- if (pathname === "/v1/agents/register" && method === "POST") {
1269
- return handleRegister(req);
1270
- }
1271
-
1272
- // 3. GET /v1/events
1273
- if (pathname === "/v1/events" && method === "GET") {
1274
- return handleEvents(req, url);
1275
- }
1276
-
1277
- // 5. GET /v1/agents
1278
- if (pathname === "/v1/agents" && method === "GET") {
1279
- return handleListAgents(req, url);
1280
- }
1281
-
1282
- // 6. POST /v1/messages
1283
- if (pathname === "/v1/messages" && method === "POST") {
1284
- return handleSendMessage(req);
1285
- }
1286
-
1287
- // /v1/agents/:session_id/heartbeat (POST) and DELETE /v1/agents/:session_id
1288
- const agentMatch = pathname.match(
1289
- /^\/v1\/agents\/([^/]+)(?:\/(heartbeat))?$/,
1290
- );
1291
- if (agentMatch) {
1292
- const sessionId = decodeURIComponent(agentMatch[1]);
1293
- const tail = agentMatch[2];
1294
- if (tail === "heartbeat" && method === "POST") {
1295
- return handleHeartbeat(req, sessionId);
1296
- }
1297
- if (!tail && method === "DELETE") {
1298
- return handleDeleteAgent(req, url, sessionId);
1299
- }
1300
- return errorJson("method_not_allowed", 405);
1301
- }
1302
-
1303
- // /v1/messages/:id, /v1/messages/:id/await, /v1/messages/:id/response
1304
- const msgMatch = pathname.match(
1305
- /^\/v1\/messages\/([^/]+)(?:\/(await|response))?$/,
1306
- );
1307
- if (msgMatch) {
1308
- const msg_id = decodeURIComponent(msgMatch[1]);
1309
- const tail = msgMatch[2];
1310
- if (!tail && method === "GET") {
1311
- return handleGetMessage(req, msg_id);
1312
- }
1313
- if (tail === "await" && method === "GET") {
1314
- return handleAwaitMessage(req, url, msg_id);
1315
- }
1316
- if (tail === "response" && method === "POST") {
1317
- return handleSubmitResponse(req, msg_id);
1318
- }
1319
- return errorJson("method_not_allowed", 405);
1320
- }
1321
-
1322
- return errorJson("not_found", 404);
1323
- }
1324
-
1325
- // ─────────────────────────────────────────────────────────────────────────────
1326
- // Cleanup loops (Phase 3)
1327
- // ─────────────────────────────────────────────────────────────────────────────
1328
-
1329
- let staleScanTimer: ReturnType<typeof setInterval> | null = null;
1330
- let ttlScanTimer: ReturnType<typeof setInterval> | null = null;
1331
- let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
1332
- let shuttingDown = false;
1333
- let lastStaleScanAt = Date.now();
1334
-
1335
- function staleScanTick(): void {
1336
- const now = Date.now();
1337
- const elapsed = now - lastStaleScanAt;
1338
- lastStaleScanAt = now;
1339
-
1340
- // Detect time jump (suspend/resume): if elapsed >> expected interval,
1341
- // skip this tick to let agents heartbeat back in.
1342
- if (elapsed > Math.max(STALE_SCAN_INTERVAL_MS * 3, 30_000)) {
1343
- const ts = nowIso();
1344
- for (const [, p] of state.projects) {
1345
- for (const [, entry] of p.agents) {
1346
- entry.last_seen_at = ts;
1347
- // Don't change status here — let the next heartbeat trigger the
1348
- // stale→online transition, which handleHeartbeat() will broadcast
1349
- // to SSE consumers.
1350
- }
1351
- }
1352
- logLine("⏸", C_YELLOW, "time-jump", `detected ${Math.round(elapsed / 1000)}s gap — resetting agent timestamps`);
1353
- return;
1354
- }
1355
- for (const [projectName, p] of state.projects) {
1356
- for (const [sid, entry] of p.agents) {
1357
- const last = Date.parse(entry.last_seen_at);
1358
- if (Number.isNaN(last)) continue;
1359
- const dt = now - last;
1360
- if (dt > OFFLINE_AFTER_MS) {
1361
- // Remove agent, close stream, emit agent_left.
1362
- p.agents.delete(sid);
1363
- nameIndexRemove(p, entry.name, sid);
1364
- const stream = p.streams.get(sid);
1365
- if (stream) {
1366
- try {
1367
- stream.close();
1368
- } catch {
1369
- // noop
1370
- }
1371
- p.streams.delete(sid);
1372
- }
1373
- logOffline(entry.name);
1374
- broadcast(
1375
- p,
1376
- "agent_left",
1377
- {
1378
- project: projectName,
1379
- session_id: sid,
1380
- name: entry.name,
1381
- reason: "stale",
1382
- },
1383
- sid,
1384
- );
1385
- } else if (dt > STALE_AFTER_MS && entry.status !== "stale") {
1386
- entry.status = "stale";
1387
- logStale(entry.name, Math.round(dt / 1000));
1388
- broadcast(
1389
- p,
1390
- "agent_stale",
1391
- {
1392
- project: projectName,
1393
- session_id: sid,
1394
- name: entry.name,
1395
- last_seen_at: entry.last_seen_at,
1396
- },
1397
- sid,
1398
- );
1399
- }
1400
- }
1401
- }
1402
- }
1403
-
1404
- function ttlScanTick(): void {
1405
- const now = Date.now();
1406
- for (const p of state.projects.values()) {
1407
- for (const [id, m] of [...p.messages]) {
1408
- const expires = Date.parse(m.expires_at);
1409
- const completedAt = m.completed_at ? Date.parse(m.completed_at) : 0;
1410
- if (
1411
- m.status === "queued" ||
1412
- m.status === "delivered"
1413
- ) {
1414
- if (Number.isFinite(expires) && now > expires) {
1415
- m.status = "error";
1416
- m.error = "expired";
1417
- m.completed_at = nowIso();
1418
- releaseAwaiters(p, id);
1419
- logExpired(id);
1420
- p.messages.delete(id);
1421
- }
1422
- } else if (m.status === "complete" || m.status === "error") {
1423
- if (
1424
- Number.isFinite(completedAt) &&
1425
- now - completedAt > MESSAGE_TTL_MS
1426
- ) {
1427
- p.messages.delete(id);
1428
- }
1429
- } else if (m.status === "timeout") {
1430
- if (Number.isFinite(expires) && now > expires) {
1431
- p.messages.delete(id);
1432
- }
1433
- }
1434
- }
1435
- }
1436
- }
1437
-
1438
- function keepaliveTick(): void {
1439
- const ts = nowIso();
1440
- const frame = `: ping ${ts}\n\n`;
1441
- for (const p of state.projects.values()) {
1442
- for (const [, w] of p.streams) {
1443
- try {
1444
- w.enqueue(frame);
1445
- } catch {
1446
- // dead; abort handler will reap
1447
- }
1448
- }
1449
- }
1450
- }
1451
-
1452
- function startLoops(): void {
1453
- lastStaleScanAt = Date.now();
1454
- staleScanTimer = setInterval(staleScanTick, STALE_SCAN_INTERVAL_MS);
1455
- ttlScanTimer = setInterval(ttlScanTick, TTL_SCAN_INTERVAL_MS);
1456
- keepaliveTimer = setInterval(keepaliveTick, SSE_KEEPALIVE_MS);
1457
- for (const t of [staleScanTimer, ttlScanTimer, keepaliveTimer]) {
1458
- try {
1459
- (t as any).unref?.();
1460
- } catch {
1461
- // noop
1462
- }
1463
- }
1464
- }
1465
-
1466
- function stopLoops(): void {
1467
- if (staleScanTimer) clearInterval(staleScanTimer);
1468
- if (ttlScanTimer) clearInterval(ttlScanTimer);
1469
- if (keepaliveTimer) clearInterval(keepaliveTimer);
1470
- staleScanTimer = null;
1471
- ttlScanTimer = null;
1472
- keepaliveTimer = null;
1473
- }
1474
-
1475
- // ─────────────────────────────────────────────────────────────────────────────
1476
- // main() — only runs when launched directly
1477
- // ─────────────────────────────────────────────────────────────────────────────
1478
-
1479
- export function main(): void {
1480
- // Token policy.
1481
- if (!TOKEN) {
1482
- if (!isLoopback(HOST)) {
1483
- console.error(
1484
- `coms-net: refusing to bind ${HOST} without an explicit PI_COMS_NET_AUTH_TOKEN.`,
1485
- );
1486
- process.exit(1);
1487
- }
1488
- TOKEN = crypto.randomBytes(32).toString("hex");
1489
- TOKEN_FILE_OWNED_BY_US = true;
1490
- } else {
1491
- TOKEN_FILE_OWNED_BY_US = false;
1492
- }
1493
-
1494
- const dir = projectDir(PROJECT);
1495
- ensureDirSync(dir);
1496
-
1497
- // Boot Bun.serve.
1498
- const server = (globalThis as any).Bun.serve({
1499
- hostname: HOST,
1500
- port: PORT,
1501
- fetch: router,
1502
- // Bun's default idle timeout is 10s — bump it so SSE doesn't get cut.
1503
- idleTimeout: 0,
1504
- });
1505
- const claimedPort: number = Number(server.port);
1506
- const localHost = HOST === "0.0.0.0" || HOST === "::" ? "127.0.0.1" : HOST;
1507
- const localUrl = `http://${localHost}:${claimedPort}`;
1508
- const publicUrl = PUBLIC_URL ?? localUrl;
1509
-
1510
- // Write server.json (NEVER include the token).
1511
- const serverJsonPath = path.join(dir, "server.json");
1512
- const serverJson = {
1513
- version: 1,
1514
- project: PROJECT,
1515
- pid: process.pid,
1516
- host: HOST,
1517
- port: claimedPort,
1518
- local_url: localUrl,
1519
- public_url: publicUrl,
1520
- started_at: state.started_at,
1521
- server_id: state.server_id,
1522
- };
1523
- atomicWriteSync(serverJsonPath, JSON.stringify(serverJson, null, 2));
1524
-
1525
- // Write server.secret.json only if we own the token.
1526
- let secretPath: string | null = null;
1527
- if (TOKEN_FILE_OWNED_BY_US) {
1528
- secretPath = path.join(dir, "server.secret.json");
1529
- atomicWriteSync(
1530
- secretPath,
1531
- JSON.stringify({ token: TOKEN }, null, 2),
1532
- 0o600,
1533
- );
1534
- try {
1535
- fs.chmodSync(secretPath, 0o600);
1536
- } catch {
1537
- // best-effort
1538
- }
1539
- }
1540
-
1541
- // Boot banner — NEVER print the token. Path only.
1542
- const bootDim = LOG_TTY ? C_DIM : "";
1543
- const bootCyan = LOG_TTY ? C_CYAN : "";
1544
- const bootReset = LOG_TTY ? C_RESET : "";
1545
- console.log(`${bootCyan}coms-net${bootReset}: listening on ${bootCyan}${localUrl}${bootReset}`);
1546
- console.log(`${bootDim} project=${PROJECT} pid=${process.pid}${bootReset}`);
1547
- console.log(`${bootDim} server.json=${serverJsonPath}${bootReset}`);
1548
- if (secretPath) {
1549
- console.log(`${bootDim} server.secret.json=${secretPath} (chmod 0600)${bootReset}`);
1550
- } else {
1551
- console.log(`${bootDim} using token from PI_COMS_NET_AUTH_TOKEN${bootReset}`);
1552
- }
1553
- if (!LOG_QUIET) {
1554
- console.log(`${bootDim} ─── events below (Ctrl-C to quit, set PI_COMS_NET_LOG_HEARTBEAT=1 for heartbeat noise) ───${bootReset}`);
1555
- }
1556
-
1557
- // Start cleanup loops.
1558
- startLoops();
1559
-
1560
- // Synchronous unlink helper — must be safe to call multiple times and from any
1561
- // termination path (signal handler, exit event, uncaught exception).
1562
- let filesUnlinked = false;
1563
- const unlinkStateFiles = () => {
1564
- if (filesUnlinked) return;
1565
- filesUnlinked = true;
1566
- try {
1567
- fs.unlinkSync(serverJsonPath);
1568
- } catch {
1569
- // noop
1570
- }
1571
- if (TOKEN_FILE_OWNED_BY_US && secretPath) {
1572
- try {
1573
- fs.unlinkSync(secretPath);
1574
- } catch {
1575
- // noop
1576
- }
1577
- }
1578
- };
1579
-
1580
- // Signal handlers.
1581
- const shutdown = (sig: string) => {
1582
- if (shuttingDown) return;
1583
- shuttingDown = true;
1584
- // FIRST: unlink state files synchronously. This must happen before any other
1585
- // work so that even if the process is hard-killed (SIGKILL from a parent
1586
- // process manager racing the SIGINT handler) or the broadcast/stream-close
1587
- // loop somehow stalls, the registry doesn't leak across runs.
1588
- unlinkStateFiles();
1589
- try {
1590
- console.log(`coms-net: ${sig} received, shutting down`);
1591
- } catch {
1592
- // noop
1593
- }
1594
- // Notify all streams.
1595
- for (const [projectName, p] of state.projects) {
1596
- for (const [sid, entry] of p.agents) {
1597
- broadcast(
1598
- p,
1599
- "agent_left",
1600
- {
1601
- project: projectName,
1602
- session_id: sid,
1603
- name: entry.name,
1604
- reason: "shutdown",
1605
- },
1606
- sid,
1607
- );
1608
- }
1609
- for (const [, w] of p.streams) {
1610
- try {
1611
- w.close();
1612
- } catch {
1613
- // noop
1614
- }
1615
- }
1616
- p.streams.clear();
1617
- }
1618
- // Stop loops.
1619
- stopLoops();
1620
- // Stop server.
1621
- try {
1622
- server.stop?.(true);
1623
- } catch {
1624
- // noop
1625
- }
1626
- // Allow IO to flush.
1627
- setTimeout(() => process.exit(0), 50).unref?.();
1628
- };
1629
-
1630
- process.on("SIGINT", () => shutdown("SIGINT"));
1631
- process.on("SIGTERM", () => shutdown("SIGTERM"));
1632
- // Belt-and-suspenders: any other path to process termination (uncaught
1633
- // exception, explicit process.exit, normal exit) gets a final synchronous
1634
- // chance to unlink. Note: this does NOT fire on SIGKILL.
1635
- process.on("exit", () => {
1636
- unlinkStateFiles();
1637
- });
1638
- }
1639
-
1640
- if (import.meta.main) {
1641
- main();
1642
- }