pi-orchestrator-config 4.1.5

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 (175) hide show
  1. package/README.md +808 -0
  2. package/agents/api-documenter.md +32 -0
  3. package/agents/bash-expert.md +53 -0
  4. package/agents/code-reviewer-docs.md +145 -0
  5. package/agents/code-reviewer-guidelines.md +97 -0
  6. package/agents/code-reviewer-quality.md +142 -0
  7. package/agents/code-reviewer-security.md +107 -0
  8. package/agents/code-reviewer-spec.md +164 -0
  9. package/agents/debugger.md +63 -0
  10. package/agents/docker-expert.md +40 -0
  11. package/agents/docs-fetcher.md +48 -0
  12. package/agents/git-expert.md +63 -0
  13. package/agents/github-expert.md +83 -0
  14. package/agents/go-expert.md +62 -0
  15. package/agents/issue-reviewer-feasibility.md +60 -0
  16. package/agents/issue-reviewer-scope.md +63 -0
  17. package/agents/issue-reviewer-spec.md +77 -0
  18. package/agents/java-expert.md +31 -0
  19. package/agents/jenkins-expert.md +38 -0
  20. package/agents/kubernetes-expert.md +40 -0
  21. package/agents/planner.md +42 -0
  22. package/agents/python-expert.md +61 -0
  23. package/agents/reviewer.md +26 -0
  24. package/agents/scout.md +40 -0
  25. package/agents/security-auditor.md +254 -0
  26. package/agents/technical-documentation-writer.md +37 -0
  27. package/agents/test-automator.md +55 -0
  28. package/agents/test-runner.md +47 -0
  29. package/agents/ts-expert.md +23 -0
  30. package/agents/worker.md +30 -0
  31. package/extensions/acpx-provider/index.ts +104 -0
  32. package/extensions/acpx-provider/load-runtime.ts +99 -0
  33. package/extensions/acpx-provider/runtime-models.ts +40 -0
  34. package/extensions/cli-provider/agents/claude.ts +128 -0
  35. package/extensions/cli-provider/agents/cursor.ts +84 -0
  36. package/extensions/cli-provider/agents/gemini.ts +100 -0
  37. package/extensions/cli-provider/discover.ts +77 -0
  38. package/extensions/cli-provider/index.ts +57 -0
  39. package/extensions/cli-provider/parsers.ts +304 -0
  40. package/extensions/cli-provider/providers.ts +58 -0
  41. package/extensions/cli-provider/runner.ts +225 -0
  42. package/extensions/cli-provider/runtime-models.ts +32 -0
  43. package/extensions/cli-provider/session-reaper.ts +122 -0
  44. package/extensions/cli-provider/sessions.ts +533 -0
  45. package/extensions/cli-provider/shared/discover-cache.ts +235 -0
  46. package/extensions/cli-provider/types.ts +48 -0
  47. package/extensions/coms/coms-net-server.ts +1642 -0
  48. package/extensions/coms/coms-net-wrapper.ts +521 -0
  49. package/extensions/coms/coms-net.ts +1589 -0
  50. package/extensions/coms/coms-p2p.ts +1879 -0
  51. package/extensions/coms/coms-shared.ts +510 -0
  52. package/extensions/coms/coms-wrapper.ts +138 -0
  53. package/extensions/coms/index.ts +20 -0
  54. package/extensions/coms/themeMap.ts +147 -0
  55. package/extensions/image-gen/image-gen.ts +332 -0
  56. package/extensions/image-gen/index.ts +14 -0
  57. package/extensions/orchestrator/agents.ts +134 -0
  58. package/extensions/orchestrator/ask-user.ts +293 -0
  59. package/extensions/orchestrator/async-agents.ts +1037 -0
  60. package/extensions/orchestrator/async-capability.ts +129 -0
  61. package/extensions/orchestrator/async-runner.ts +324 -0
  62. package/extensions/orchestrator/async-status-parse.ts +31 -0
  63. package/extensions/orchestrator/async-status-ui.ts +201 -0
  64. package/extensions/orchestrator/async-wait.ts +36 -0
  65. package/extensions/orchestrator/btw.ts +210 -0
  66. package/extensions/orchestrator/cron-status-format.ts +126 -0
  67. package/extensions/orchestrator/cron-status-ui.ts +132 -0
  68. package/extensions/orchestrator/cron.ts +730 -0
  69. package/extensions/orchestrator/dreaming.ts +355 -0
  70. package/extensions/orchestrator/enforcement-helpers.ts +530 -0
  71. package/extensions/orchestrator/enforcement-rules.ts +329 -0
  72. package/extensions/orchestrator/enforcement.ts +828 -0
  73. package/extensions/orchestrator/extended-autocomplete.ts +593 -0
  74. package/extensions/orchestrator/git-helpers.ts +420 -0
  75. package/extensions/orchestrator/github-autocomplete.ts +250 -0
  76. package/extensions/orchestrator/icons.ts +17 -0
  77. package/extensions/orchestrator/index.ts +100 -0
  78. package/extensions/orchestrator/memory-embeddings.ts +281 -0
  79. package/extensions/orchestrator/memory-promotion.ts +449 -0
  80. package/extensions/orchestrator/memory-provenance.ts +80 -0
  81. package/extensions/orchestrator/memory-query-class.ts +90 -0
  82. package/extensions/orchestrator/memory-scoring.ts +434 -0
  83. package/extensions/orchestrator/memory-tools.ts +883 -0
  84. package/extensions/orchestrator/memory-tree.ts +212 -0
  85. package/extensions/orchestrator/nvim.ts +169 -0
  86. package/extensions/orchestrator/overlay-dashboard-utils.ts +25 -0
  87. package/extensions/orchestrator/overlay-dashboard.ts +512 -0
  88. package/extensions/orchestrator/pi-config-review-state.ts +303 -0
  89. package/extensions/orchestrator/preference-extractor.ts +106 -0
  90. package/extensions/orchestrator/project-settings.ts +468 -0
  91. package/extensions/orchestrator/promotion-queue.ts +311 -0
  92. package/extensions/orchestrator/resolve-agent-model.ts +32 -0
  93. package/extensions/orchestrator/review-ui.ts +204 -0
  94. package/extensions/orchestrator/rule-placeholders.ts +12 -0
  95. package/extensions/orchestrator/rules.ts +481 -0
  96. package/extensions/orchestrator/session-search.ts +243 -0
  97. package/extensions/orchestrator/session-validation.ts +374 -0
  98. package/extensions/orchestrator/situation-report.ts +370 -0
  99. package/extensions/orchestrator/status-bar.ts +31 -0
  100. package/extensions/orchestrator/status-line.ts +192 -0
  101. package/extensions/orchestrator/status.ts +110 -0
  102. package/extensions/orchestrator/subagent-tool.ts +1748 -0
  103. package/extensions/orchestrator/task-lifecycle.ts +88 -0
  104. package/extensions/orchestrator/utils.ts +227 -0
  105. package/extensions/pidash/index.ts +15 -0
  106. package/extensions/pidash/pidash.ts +941 -0
  107. package/extensions/pidiff/index.ts +13 -0
  108. package/extensions/pidiff/pidiff.ts +369 -0
  109. package/extensions/providers/acpx-driver.ts +479 -0
  110. package/extensions/providers/built-in-drivers.ts +52 -0
  111. package/extensions/providers/claude-driver.ts +319 -0
  112. package/extensions/providers/cursor-acpx-driver.ts +398 -0
  113. package/extensions/providers/cursor-cli-driver.ts +484 -0
  114. package/extensions/providers/gemini-driver.ts +302 -0
  115. package/extensions/providers/index.ts +513 -0
  116. package/extensions/shared/build-system-prompt.ts +57 -0
  117. package/extensions/shared/create-runtime-provider.ts +190 -0
  118. package/extensions/shared/daemon-manager.ts +248 -0
  119. package/extensions/shared/file-logger.ts +115 -0
  120. package/extensions/shared/managed-refresh.ts +211 -0
  121. package/extensions/shared/provider-driver.ts +295 -0
  122. package/extensions/shared/provider-errors.ts +305 -0
  123. package/extensions/shared/provider-registry.ts +388 -0
  124. package/extensions/shared/stream-builder.ts +275 -0
  125. package/extensions/shared/types.ts +20 -0
  126. package/extensions/shared/ui/README.md +10 -0
  127. package/extensions/shared/ui/badge.tsx +52 -0
  128. package/extensions/shared/ui/button.tsx +58 -0
  129. package/extensions/shared/ui/collapsible.tsx +21 -0
  130. package/extensions/shared/ui/dropdown-menu.tsx +266 -0
  131. package/extensions/shared/ui/input.tsx +20 -0
  132. package/extensions/shared/ui/popover.tsx +114 -0
  133. package/extensions/shared/ui/resizable.tsx +48 -0
  134. package/extensions/shared/ui/scroll-area.tsx +53 -0
  135. package/extensions/shared/ui/select.tsx +201 -0
  136. package/extensions/shared/ui/separator.tsx +25 -0
  137. package/extensions/shared/ui/tooltip.tsx +64 -0
  138. package/extensions/shared/ws-client.ts +74 -0
  139. package/package.json +59 -0
  140. package/prompts/coderabbit-rate-limit.md +95 -0
  141. package/prompts/create-coms-feature-manager.md +186 -0
  142. package/prompts/create-skill.md +128 -0
  143. package/prompts/domain-model.md +109 -0
  144. package/prompts/external-ai.md +665 -0
  145. package/prompts/implement-and-review.md +25 -0
  146. package/prompts/implement.md +26 -0
  147. package/prompts/issue-review.md +256 -0
  148. package/prompts/pr-review.md +753 -0
  149. package/prompts/query-db.md +154 -0
  150. package/prompts/refine-review.md +208 -0
  151. package/prompts/release.md +235 -0
  152. package/prompts/remember.md +33 -0
  153. package/prompts/review-handler-status.md +27 -0
  154. package/prompts/review-handler.md +677 -0
  155. package/prompts/review-local.md +159 -0
  156. package/prompts/scout-and-plan.md +21 -0
  157. package/rules/00-orchestrator-core.md +42 -0
  158. package/rules/05-issue-first-workflow.md +119 -0
  159. package/rules/10-agent-routing.md +58 -0
  160. package/rules/15-mcp-launchpad.md +46 -0
  161. package/rules/20-code-review-loop.md +234 -0
  162. package/rules/25-documentation-updates.md +15 -0
  163. package/rules/30-prompt-templates.md +19 -0
  164. package/rules/35-memory.md +240 -0
  165. package/rules/40-critical-rules.md +124 -0
  166. package/rules/45-file-preview.md +29 -0
  167. package/rules/50-agent-bug-reporting.md +90 -0
  168. package/rules/55-coms-protocol.md +63 -0
  169. package/rules/60-task-tracking.md +66 -0
  170. package/skills/conflict-resolver/SKILL.md +213 -0
  171. package/skills/skill-creator/SKILL.md +85 -0
  172. package/skills/systematic-debugging/SKILL.md +353 -0
  173. package/skills/test-driven-development/SKILL.md +335 -0
  174. package/skills/update-agents-md/SKILL.md +282 -0
  175. package/templates/coms-feature-manager-prompt.md +313 -0
@@ -0,0 +1,1589 @@
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
+ /**
4
+ * coms-net — HTTP/SSE Pi Agent Communication Network (client)
5
+ *
6
+ * Drop-in successor to `coms-p2p.ts` whose substrate is a dedicated
7
+ * Bun HTTP/SSE hub instead of per-agent Unix sockets / named pipes. The
8
+ * user-facing tool surface is renamed for total separation from v1:
9
+ *
10
+ * tools coms_net_list / coms_net_send / coms_net_get
11
+ * slash command /coms-net
12
+ * widget key "coms-net-pool" (placement: belowEditor only)
13
+ * audit channel "coms-net-log"
14
+ * customType "coms-net-inbound"
15
+ * status key "coms-net"
16
+ * registry root ~/.pi/coms-net/
17
+ *
18
+ * Both `coms.ts` and `coms-net.ts` may be loaded together without identifier
19
+ * collision. v1 stays untouched.
20
+ *
21
+ * Usage:
22
+ * bun extensions/coms/coms-net-server.ts # start hub
23
+ * loaded via extensions/coms/index.ts # auto-discover local server.json
24
+ * loaded via extensions/coms/index.ts --server-url http://host:port \
25
+ * --auth-token <tok> --name planner --project default
26
+ *
27
+ * Note: the agent name flag is `--name` for coms peer identity.
28
+ * `--name` and resumes it across sessions, so coms-net uses a distinct flag.
29
+ */
30
+
31
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
32
+ import { Text } from "@earendil-works/pi-tui";
33
+ import { truncateToWidth } from "@earendil-works/pi-tui";
34
+ import { Type } from "typebox";
35
+ import { applyExtensionDefaults } from "./themeMap.js";
36
+ import { ulid, hexFg, isValidHex, fallbackColor, comsParseYamlFrontmatter as parseFrontmatter, nowIso, abbreviateModel, findSystemPromptPath, readFrontmatterFromArgv, readTaskSummary, buildInboundContent, renderTasksPart, renderQueuePart, formatQueueStr, formatComsResponseText, FALLBACK_PALETTE, type TasksSummary } from "./coms-shared.js";
37
+ import * as fs from "node:fs";
38
+ import * as path from "node:path";
39
+ import * as os from "node:os";
40
+ import * as crypto from "node:crypto";
41
+
42
+ // ━━ Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
43
+
44
+ const COMS_NET_DIR = path.join(os.homedir(), ".pi", "coms-net");
45
+ const MAX_HOPS = Number(process.env.PI_COMS_NET_MAX_HOPS) || 5;
46
+ const HEARTBEAT_MS = Number(process.env.PI_COMS_NET_HEARTBEAT_MS) || 10_000;
47
+ const RECONNECT_BASE_MS = 500;
48
+ const RECONNECT_MAX_MS = 10_000;
49
+ const MESSAGE_TIMEOUT_MS = Number(process.env.PI_COMS_NET_MESSAGE_TTL_MS) || 1_800_000;
50
+ const HTTP_TIMEOUT_MS = 10_000;
51
+ const SHUTDOWN_DELETE_TIMEOUT_MS = 2_000;
52
+
53
+ const SERVER_URL_ENV = process.env.PI_COMS_NET_SERVER_URL;
54
+ const AUTH_TOKEN_ENV = process.env.PI_COMS_NET_AUTH_TOKEN;
55
+ const PROJECT_ENV = process.env.PI_COMS_NET_PROJECT;
56
+
57
+
58
+ // ━━ Shared types (canonical block — mirrored on server) ━━━━━━━━━━━━━━━━━━━
59
+
60
+ type AgentStatus = "online" | "stale" | "offline";
61
+ type MessageStatus = "queued" | "delivered" | "complete" | "error" | "timeout";
62
+
63
+ interface AgentCard {
64
+ session_id: string;
65
+ name: string;
66
+ purpose: string;
67
+ model: string;
68
+ provider?: string;
69
+ color: string;
70
+ cwd: string;
71
+ project: string;
72
+ explicit: boolean;
73
+ started_at: string;
74
+ context_used_pct: number;
75
+ queue_depth: number;
76
+ status: AgentStatus;
77
+ tasks_summary?: { total: number; completed: number; in_progress: number } | null;
78
+ }
79
+
80
+ interface RegisterRequest {
81
+ project: string;
82
+ session_id: string;
83
+ name: string;
84
+ purpose: string;
85
+ model: string;
86
+ provider?: string;
87
+ color: string;
88
+ cwd: string;
89
+ explicit: boolean;
90
+ }
91
+
92
+ interface RegisterResponse {
93
+ ok: true;
94
+ agent: AgentCard;
95
+ heartbeat_interval_ms: number;
96
+ sse_url: string;
97
+ }
98
+
99
+ interface HeartbeatRequest {
100
+ project: string;
101
+ context_used_pct: number;
102
+ queue_depth: number;
103
+ model?: string;
104
+ status?: AgentStatus;
105
+ tasks_summary?: { total: number; completed: number; in_progress: number } | null;
106
+ }
107
+
108
+ interface SendRequest {
109
+ project: string;
110
+ sender_session: string;
111
+ target: string;
112
+ target_session: string | null;
113
+ prompt: string;
114
+ conversation_id: string | null;
115
+ response_schema: object | null;
116
+ hops: number;
117
+ tasks?: Array<{ subject: string; description: string }> | null;
118
+ }
119
+
120
+ interface SendResponse {
121
+ ok: true;
122
+ msg_id: string;
123
+ status: MessageStatus;
124
+ target_session: string;
125
+ }
126
+
127
+ interface ResponseSubmitRequest {
128
+ project: string;
129
+ responder_session: string;
130
+ response: any;
131
+ error: string | null;
132
+ queued_msg_ids?: string[];
133
+ }
134
+
135
+ interface InboundContext {
136
+ msg_id: string;
137
+ hops: number;
138
+ sender_session: string;
139
+ sender_name: string;
140
+ sender_cwd: string;
141
+ prompt: string;
142
+ tasks?: Array<{ subject: string; description: string }> | null;
143
+ response_schema?: object | null;
144
+ fulfilled: boolean;
145
+ }
146
+
147
+ interface PendingReply {
148
+ resolve: (value: { response?: any; error?: string | null; queued_msg_ids?: string[] }) => void;
149
+ reject: (err: Error) => void;
150
+ promise: Promise<{ response?: any; error?: string | null; queued_msg_ids?: string[] }>;
151
+ result?: { response?: any; error?: string | null; queued_msg_ids?: string[] };
152
+ target_name?: string;
153
+ target_session?: string;
154
+ created_at: string;
155
+ }
156
+
157
+ interface ServerJson {
158
+ version: number;
159
+ project: string;
160
+ pid?: number;
161
+ host?: string;
162
+ port?: number;
163
+ local_url: string;
164
+ public_url?: string;
165
+ started_at?: string;
166
+ }
167
+
168
+ interface ServerSecretJson {
169
+ token: string;
170
+ }
171
+
172
+ class HttpError extends Error {
173
+ status: number;
174
+ body: any;
175
+ constructor(status: number, body: any, message?: string) {
176
+ super(message ?? `HTTP ${status}`);
177
+ this.status = status;
178
+ this.body = body;
179
+ }
180
+ }
181
+
182
+ // ━━ Registry / server-discovery I/O ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
183
+
184
+ function projectDir(project: string): string {
185
+ return path.join(COMS_NET_DIR, "projects", project);
186
+ }
187
+
188
+ function readServerJson(project: string): ServerJson | null {
189
+ const p = path.join(projectDir(project), "server.json");
190
+ try {
191
+ if (!fs.existsSync(p)) return null;
192
+ const raw = fs.readFileSync(p, "utf-8");
193
+ const parsed = JSON.parse(raw) as ServerJson;
194
+ if (!parsed || typeof parsed.local_url !== "string") return null;
195
+ return parsed;
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+
201
+ function readServerSecret(project: string): ServerSecretJson | null {
202
+ const p = path.join(projectDir(project), "server.secret.json");
203
+ try {
204
+ if (!fs.existsSync(p)) return null;
205
+ // Only trust the token if the file is mode 0600.
206
+ const st = fs.statSync(p);
207
+ const mode = st.mode & 0o777;
208
+ if (mode !== 0o600) return null;
209
+ const raw = fs.readFileSync(p, "utf-8");
210
+ const parsed = JSON.parse(raw) as ServerSecretJson;
211
+ if (!parsed || typeof parsed.token !== "string" || parsed.token.length === 0) return null;
212
+ return parsed;
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ function resolveServerUrl(project: string, cliFlag: string | undefined): string | null {
219
+ if (cliFlag && cliFlag.length > 0) return cliFlag.replace(/\/+$/, "");
220
+ if (SERVER_URL_ENV && SERVER_URL_ENV.length > 0) return SERVER_URL_ENV.replace(/\/+$/, "");
221
+ const sj = readServerJson(project);
222
+ if (sj && sj.local_url) return sj.local_url.replace(/\/+$/, "");
223
+ return null;
224
+ }
225
+
226
+ function resolveAuthToken(project: string, cliFlag: string | undefined): string | null {
227
+ if (cliFlag && cliFlag.length > 0) return cliFlag;
228
+ if (AUTH_TOKEN_ENV && AUTH_TOKEN_ENV.length > 0) return AUTH_TOKEN_ENV;
229
+ const sec = readServerSecret(project);
230
+ if (sec) return sec.token;
231
+ return null;
232
+ }
233
+
234
+ // ━━ CLI flag shape ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
235
+
236
+ interface CliFlags {
237
+ name?: string;
238
+ purpose?: string;
239
+ project?: string;
240
+ color?: string;
241
+ explicit?: boolean;
242
+ serverUrl?: string;
243
+ authToken?: string;
244
+ }
245
+
246
+ function readCliFlags(pi: ExtensionAPI): CliFlags {
247
+ const name = pi.getFlag("cname") as string | undefined;
248
+ const purpose = pi.getFlag("purpose") as string | undefined;
249
+ const project = pi.getFlag("project") as string | undefined;
250
+ const color = pi.getFlag("color") as string | undefined;
251
+ const explicit = pi.getFlag("explicit") as boolean | undefined;
252
+ const serverUrl = pi.getFlag("server-url") as string | undefined;
253
+ const authToken = pi.getFlag("auth-token") as string | undefined;
254
+ return {
255
+ name: name && name.length > 0 ? name : undefined,
256
+ purpose: purpose && purpose.length > 0 ? purpose : undefined,
257
+ project: project && project.length > 0 ? project : undefined,
258
+ color: color && color.length > 0 ? color : undefined,
259
+ explicit: explicit === true,
260
+ serverUrl: serverUrl && serverUrl.length > 0 ? serverUrl : undefined,
261
+ authToken: authToken && authToken.length > 0 ? authToken : undefined,
262
+ };
263
+ }
264
+
265
+ // ━━ Default export ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
266
+
267
+ export default function (pi: ExtensionAPI) {
268
+ // ━━ Identity flags ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
269
+ // Agent name flag for coms peer identity.
270
+ pi.registerFlag("cname", {
271
+ description: "Override coms-net agent name (otherwise from frontmatter or auto-generated). Distinct from pi's own --name, which the harness owns and resumes.",
272
+ type: "string",
273
+ default: undefined,
274
+ });
275
+ pi.registerFlag("purpose", {
276
+ description: "Override agent purpose (otherwise from frontmatter description)",
277
+ type: "string",
278
+ default: undefined,
279
+ });
280
+ pi.registerFlag("project", {
281
+ description: "Project namespace for the coms-net hub",
282
+ type: "string",
283
+ default: "default",
284
+ });
285
+ pi.registerFlag("color", {
286
+ description: "Hex color #RRGGBB (otherwise from frontmatter or palette fallback)",
287
+ type: "string",
288
+ default: undefined,
289
+ });
290
+ pi.registerFlag("explicit", {
291
+ description: "Hide this agent from auto-discovery; only addressable by exact name",
292
+ type: "boolean",
293
+ default: false,
294
+ });
295
+ pi.registerFlag("server-url", {
296
+ description: "coms-net server base URL (overrides env and local server.json)",
297
+ type: "string",
298
+ default: undefined,
299
+ });
300
+ pi.registerFlag("auth-token", {
301
+ description: "Bearer token for the coms-net hub (overrides env and server.secret.json). NEVER logged.",
302
+ type: "string",
303
+ default: undefined,
304
+ });
305
+
306
+ // ━━ Module-scope state ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
307
+ let identity: {
308
+ session_id: string;
309
+ name: string;
310
+ purpose: string;
311
+ color: string;
312
+ project: string;
313
+ explicit: boolean;
314
+ cwd: string;
315
+ model: string;
316
+ started_at: string;
317
+ } | null = null;
318
+ let serverUrl: string | null = null;
319
+ let authToken: string | null = null;
320
+ let sseUrlPath: string | null = null;
321
+ const peerCards: Map<string, AgentCard> = new Map();
322
+ const pendingReplies: Map<string, PendingReply> = new Map();
323
+ const inboundQueue: Map<string, InboundContext> = new Map();
324
+ let sseAbort: AbortController | null = null;
325
+ let heartbeatTimer: NodeJS.Timeout | null = null;
326
+ let reconnectTimer: NodeJS.Timeout | null = null;
327
+ let reconnectAttempts = 0;
328
+ let notifiedReconnectCap = false;
329
+ let currentCtx: ExtensionContext | null = null;
330
+ let currentInbound: InboundContext | null = null;
331
+ let processingInbound = false;
332
+ let includeExplicit = false;
333
+ let displayProject: string | null = null;
334
+ let lastWidgetSnapshot = "";
335
+ let shuttingDown = false;
336
+
337
+ // ━━ HTTP helper ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
338
+
339
+ async function httpFetch(method: string, urlPath: string, body?: any, opts?: { timeoutMs?: number; signal?: AbortSignal }): Promise<any> {
340
+ if (!serverUrl) throw new Error("coms-net: no server URL");
341
+ if (!authToken) throw new Error("coms-net: no auth token");
342
+ const url = serverUrl + urlPath;
343
+ const headers: Record<string, string> = {
344
+ "Authorization": `Bearer ${authToken}`,
345
+ "Accept": "application/json",
346
+ };
347
+ const init: any = { method, headers };
348
+ if (body !== undefined) {
349
+ headers["Content-Type"] = "application/json";
350
+ init.body = JSON.stringify(body);
351
+ }
352
+ // Timeout via AbortController unless caller passed their own signal.
353
+ let timer: any = null;
354
+ const ac = new AbortController();
355
+ const timeoutMs = opts?.timeoutMs ?? HTTP_TIMEOUT_MS;
356
+ if (opts?.signal) {
357
+ init.signal = opts.signal;
358
+ } else {
359
+ init.signal = ac.signal;
360
+ timer = setTimeout(() => { try { ac.abort(); } catch { /* ignore */ } }, timeoutMs);
361
+ try { (timer as any).unref?.(); } catch { /* ignore */ }
362
+ }
363
+ let resp: Response;
364
+ try {
365
+ resp = await fetch(url, init);
366
+ } catch (err: any) {
367
+ if (timer) { try { clearTimeout(timer); } catch { /* ignore */ } }
368
+ throw new Error(`coms-net: fetch failed: ${err?.message ?? String(err)}`);
369
+ }
370
+ if (timer) { try { clearTimeout(timer); } catch { /* ignore */ } }
371
+ const text = await resp.text();
372
+ let parsed: any = null;
373
+ if (text.length > 0) {
374
+ try { parsed = JSON.parse(text); } catch { parsed = text; }
375
+ }
376
+ if (!resp.ok) {
377
+ throw new HttpError(resp.status, parsed, `HTTP ${resp.status} ${method} ${urlPath}`);
378
+ }
379
+ return parsed;
380
+ }
381
+
382
+ // ━━ Audit log helper (never throws) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
383
+
384
+ function audit(event: string, extra: Record<string, any> = {}): void {
385
+ try {
386
+ pi.appendEntry("coms-net-log", { event, ts: nowIso(), ...extra });
387
+ } catch {
388
+ // best-effort
389
+ }
390
+ }
391
+
392
+ // ━━ Strip auth token from any user-visible error string ━━━━━━━━━━━━━━━
393
+
394
+ function safeError(err: any): string {
395
+ const msg = err instanceof Error ? err.message : String(err);
396
+ if (!authToken) return msg;
397
+ // Defense in depth: never leak the bearer.
398
+ return msg.split(authToken).join("<redacted>");
399
+ }
400
+
401
+ // ━━ SSE parser (hand-rolled, no dep) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
402
+
403
+ function makeSseParser(onEvent: (event: string, data: any, id?: string) => void) {
404
+ const decoder = new TextDecoder("utf-8");
405
+ let buf = "";
406
+ return {
407
+ feed(chunk: Uint8Array): void {
408
+ buf += decoder.decode(chunk, { stream: true });
409
+ let idx;
410
+ while ((idx = buf.indexOf("\n\n")) >= 0) {
411
+ const frame = buf.slice(0, idx);
412
+ buf = buf.slice(idx + 2);
413
+ let event = "message";
414
+ const dataLines: string[] = [];
415
+ let id: string | undefined;
416
+ for (const line of frame.split("\n")) {
417
+ if (line.length === 0) continue;
418
+ if (line.startsWith(":")) continue; // SSE comment
419
+ if (line.startsWith("event:")) {
420
+ event = line.slice(6).trimStart();
421
+ } else if (line.startsWith("data:")) {
422
+ let v = line.slice(5);
423
+ if (v.startsWith(" ")) v = v.slice(1);
424
+ dataLines.push(v);
425
+ } else if (line.startsWith("id:")) {
426
+ id = line.slice(3).trimStart();
427
+ }
428
+ }
429
+ if (dataLines.length > 0) {
430
+ const joined = dataLines.join("\n");
431
+ let data: any = joined;
432
+ try { data = JSON.parse(joined); } catch { /* keep as string */ }
433
+ try { onEvent(event, data, id); } catch { /* ignore handler errors */ }
434
+ }
435
+ }
436
+ },
437
+ };
438
+ }
439
+
440
+ // ━━ Pool snapshot diff (used to gate widget renders) ━━━━━━━━━━━━━━━━━━━
441
+
442
+ function getPendingInboundCount(): number {
443
+ let count = 0;
444
+ for (const i of inboundQueue.values()) if (!i.fulfilled && i !== currentInbound) count++;
445
+ return count;
446
+ }
447
+
448
+ function poolSnapshotKey(): string {
449
+ const pendingCount = getPendingInboundCount();
450
+ const arr = [...peerCards.values()]
451
+ .map(c => `${c.session_id}|${c.name}|${c.color}|${c.model}|${c.context_used_pct}|${c.queue_depth}|${c.status}|${c.purpose}|${c.explicit ? 1 : 0}`)
452
+ .sort();
453
+ return `pending=${pendingCount}\n` + arr.join("\n");
454
+ }
455
+
456
+ function maybeRequestRender(): void {
457
+ const next = poolSnapshotKey();
458
+ if (next === lastWidgetSnapshot) return;
459
+ lastWidgetSnapshot = next;
460
+ // The widget render closure pulls from `peerCards` directly; we just need
461
+ // to re-install / re-render. Pi's TUI invalidates on setWidget no-op; we
462
+ // rely on the next frame.
463
+ if (currentCtx?.hasUI) {
464
+ try {
465
+ installPoolWidget(currentCtx);
466
+ } catch {
467
+ // non-fatal
468
+ }
469
+ }
470
+ }
471
+
472
+ // ━━ SSE event dispatch ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
473
+
474
+ function applyAgentPatch(prev: AgentCard, patch: Partial<AgentCard>): AgentCard {
475
+ return { ...prev, ...patch };
476
+ }
477
+
478
+ function handleSseEvent(event: string, data: any, _id?: string): void {
479
+ if (!data || typeof data !== "object") return;
480
+ switch (event) {
481
+ case "hello": {
482
+ audit("sse_hello", { server_id: data.server_id, server_time: data.server_time });
483
+ return;
484
+ }
485
+ case "pool_snapshot": {
486
+ peerCards.clear();
487
+ const agents: AgentCard[] = Array.isArray(data.agents) ? data.agents : [];
488
+ for (const a of agents) {
489
+ if (!a || typeof a.session_id !== "string") continue;
490
+ if (identity && a.session_id === identity.session_id) continue;
491
+ peerCards.set(a.session_id, a);
492
+ }
493
+ maybeRequestRender();
494
+ return;
495
+ }
496
+ case "agent_joined": {
497
+ const a: AgentCard | undefined = data.agent;
498
+ if (!a || typeof a.session_id !== "string") return;
499
+ if (identity && a.session_id === identity.session_id) return;
500
+ peerCards.set(a.session_id, a);
501
+ maybeRequestRender();
502
+ return;
503
+ }
504
+ case "agent_updated": {
505
+ const a: Partial<AgentCard> | undefined = data.agent;
506
+ if (!a || typeof a.session_id !== "string") return;
507
+ if (identity && a.session_id === identity.session_id) return;
508
+ const prev = peerCards.get(a.session_id);
509
+ if (prev) {
510
+ peerCards.set(a.session_id, applyAgentPatch(prev, a));
511
+ } else if (a.name && a.color && a.model) {
512
+ // Defensive: treat as a join.
513
+ peerCards.set(a.session_id, a as AgentCard);
514
+ }
515
+ maybeRequestRender();
516
+ return;
517
+ }
518
+ case "agent_stale": {
519
+ const sid: string | undefined = data.session_id;
520
+ if (!sid) return;
521
+ const prev = peerCards.get(sid);
522
+ if (prev) {
523
+ peerCards.set(sid, { ...prev, status: "stale" });
524
+ maybeRequestRender();
525
+ }
526
+ return;
527
+ }
528
+ case "agent_left": {
529
+ const sid: string | undefined = data.session_id;
530
+ if (!sid) return;
531
+ if (peerCards.delete(sid)) {
532
+ maybeRequestRender();
533
+ }
534
+ return;
535
+ }
536
+ case "prompt": {
537
+ handleInboundPrompt(data);
538
+ return;
539
+ }
540
+ case "response": {
541
+ handleInboundResponse(data);
542
+ return;
543
+ }
544
+ case "message_status": {
545
+ // Informational. No-op beyond audit at debug level.
546
+ return;
547
+ }
548
+ case "server_ping": {
549
+ return;
550
+ }
551
+ case "error": {
552
+ audit("sse_error", { code: data.code, message: data.message });
553
+ return;
554
+ }
555
+ default:
556
+ return;
557
+ }
558
+ }
559
+
560
+ function handleInboundPrompt(data: any): void {
561
+ const msg_id: string | undefined = data?.msg_id;
562
+ if (!msg_id || typeof msg_id !== "string") return;
563
+ const sender = data.sender ?? {};
564
+ const senderName = typeof sender.name === "string" ? sender.name : "unknown";
565
+ const senderCwd = typeof sender.cwd === "string" ? sender.cwd : "?";
566
+ const senderSession = typeof sender.session_id === "string" ? sender.session_id : "?";
567
+ const promptText = typeof data.prompt === "string" ? data.prompt : "";
568
+ const hops = typeof data.hops === "number" ? data.hops : 0;
569
+ const responseSchema = (data.response_schema && typeof data.response_schema === "object") ? data.response_schema : null;
570
+ const tasks: Array<{ subject: string; description: string }> | null = Array.isArray(data.tasks) ? data.tasks : null;
571
+
572
+ const inbound: InboundContext = {
573
+ msg_id,
574
+ hops,
575
+ sender_session: senderSession,
576
+ sender_name: senderName,
577
+ sender_cwd: senderCwd,
578
+ prompt: promptText,
579
+ tasks: tasks,
580
+ response_schema: responseSchema,
581
+ fulfilled: false,
582
+ };
583
+ inboundQueue.set(msg_id, inbound);
584
+ maybeRequestRender();
585
+
586
+ // If already processing another inbound, just queue — agent_end will drain FIFO.
587
+ if (processingInbound) {
588
+ try {
589
+ pi.appendEntry("coms-net-log", {
590
+ event: "prompt_queued",
591
+ ts: nowIso(),
592
+ msg_id,
593
+ sender: senderSession,
594
+ hops,
595
+ queue_depth: getPendingInboundCount(),
596
+ });
597
+ } catch { /* best-effort */ }
598
+ return;
599
+ }
600
+
601
+ // Not busy — inject immediately.
602
+ currentInbound = inbound;
603
+ processingInbound = true;
604
+ try {
605
+ pi.sendMessage(
606
+ {
607
+ customType: "coms-net-inbound",
608
+ content: buildInboundContent(
609
+ `[inbound coms-net message from ${senderName} @ ${senderCwd}]\n` +
610
+ `[reply by writing a normal assistant message — your turn output is auto-returned to ${senderName}. ` +
611
+ `DO NOT call coms_net_send/coms_net_get to reply; that creates a ping-pong loop.]`,
612
+ promptText, tasks),
613
+ display: true,
614
+ details: {
615
+ msg_id,
616
+ sender_session: senderSession,
617
+ response_schema: responseSchema,
618
+ hops,
619
+ },
620
+ },
621
+ { deliverAs: "followUp", triggerTurn: true },
622
+ );
623
+ try {
624
+ pi.appendEntry("coms-net-log", {
625
+ event: "prompt_in",
626
+ ts: nowIso(),
627
+ msg_id,
628
+ sender: senderSession,
629
+ hops,
630
+ });
631
+ } catch { /* best-effort */ }
632
+ } catch (err) {
633
+ inboundQueue.delete(msg_id);
634
+ maybeRequestRender();
635
+ currentInbound = null;
636
+ processingInbound = false;
637
+ audit("prompt_in_failed", { msg_id, reason: safeError(err) });
638
+ }
639
+ }
640
+
641
+ function handleInboundResponse(data: any): void {
642
+ const msg_id: string | undefined = data?.msg_id;
643
+ if (!msg_id) return;
644
+ const responseVal = data.response;
645
+ const errVal: string | null = typeof data.error === "string" ? data.error : null;
646
+ const pending = pendingReplies.get(msg_id);
647
+ if (pending) {
648
+ const queuedMsgIds: string[] = Array.isArray(data.queued_msg_ids) ? data.queued_msg_ids.filter((id: unknown) => typeof id === "string") : [];
649
+ pending.result = { response: responseVal, error: errVal, queued_msg_ids: queuedMsgIds };
650
+ try {
651
+ pending.resolve(pending.result);
652
+ } catch (e: any) {
653
+ audit("resolve_failed", { msg_id, reason: e?.message ?? String(e) });
654
+ }
655
+
656
+ // Auto-deliver response as followUp so the LLM sees it without polling
657
+ const targetName = pending.target_name ?? "peer";
658
+ const responseText = formatComsResponseText(targetName, responseVal, errVal, queuedMsgIds);
659
+ try {
660
+ pi.sendMessage(
661
+ {
662
+ customType: "coms-response",
663
+ content: responseText,
664
+ display: true,
665
+ details: {
666
+ msg_id,
667
+ target_name: targetName,
668
+ error: errVal,
669
+ queued_msg_ids: queuedMsgIds,
670
+ },
671
+ },
672
+ { deliverAs: "followUp", triggerTurn: true },
673
+ );
674
+ } catch { /* best-effort */ }
675
+
676
+ // Clean up after short delay — coms_net_get may still poll briefly
677
+ setTimeout(() => { pendingReplies.delete(msg_id); }, 60_000).unref();
678
+ try {
679
+ pi.appendEntry("coms-net-log", {
680
+ event: "response_in",
681
+ ts: nowIso(),
682
+ msg_id,
683
+ error: errVal,
684
+ });
685
+ } catch { /* best-effort */ }
686
+ } else {
687
+ audit("orphan_response", { msg_id });
688
+ }
689
+ }
690
+
691
+ // ━━ SSE open + read loop ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
692
+
693
+ async function openSse(): Promise<void> {
694
+ if (!serverUrl || !authToken || !sseUrlPath || !identity) return;
695
+ if (sseAbort) {
696
+ try { sseAbort.abort(); } catch { /* ignore */ }
697
+ }
698
+ const ac = new AbortController();
699
+ sseAbort = ac;
700
+ const url = serverUrl + sseUrlPath;
701
+ const headers: Record<string, string> = {
702
+ "Authorization": `Bearer ${authToken}`,
703
+ "Accept": "text/event-stream",
704
+ };
705
+ let resp: Response;
706
+ try {
707
+ resp = await fetch(url, { method: "GET", headers, signal: ac.signal });
708
+ } catch (err: any) {
709
+ audit("sse_connect_failed", { reason: safeError(err) });
710
+ scheduleReconnect();
711
+ return;
712
+ }
713
+ if (!resp.ok || !resp.body) {
714
+ audit("sse_connect_http_error", { status: resp.status });
715
+ scheduleReconnect();
716
+ return;
717
+ }
718
+ // Connection established. Reset the backoff state.
719
+ reconnectAttempts = 0;
720
+ notifiedReconnectCap = false;
721
+ try {
722
+ pi.appendEntry("coms-net-log", { event: "sse_open", ts: nowIso(), url: sseUrlPath });
723
+ } catch { /* best-effort */ }
724
+
725
+ const parser = makeSseParser((event, data, id) => handleSseEvent(event, data, id));
726
+ const reader = resp.body.getReader();
727
+ try {
728
+ while (true) {
729
+ const { done, value } = await reader.read();
730
+ if (done) break;
731
+ if (value) parser.feed(value);
732
+ }
733
+ audit("sse_disconnect", { reason: "stream_end" });
734
+ } catch (err: any) {
735
+ if (ac.signal.aborted) {
736
+ audit("sse_disconnect", { reason: "aborted" });
737
+ return;
738
+ }
739
+ audit("sse_disconnect", { reason: safeError(err) });
740
+ } finally {
741
+ try { reader.releaseLock(); } catch { /* ignore */ }
742
+ }
743
+ if (!shuttingDown) {
744
+ scheduleReconnect();
745
+ }
746
+ }
747
+
748
+ function scheduleReconnect(): void {
749
+ if (shuttingDown) return;
750
+ if (reconnectTimer) return;
751
+ const backoff = Math.min(RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts), RECONNECT_MAX_MS);
752
+ reconnectAttempts++;
753
+ audit("sse_reconnect_scheduled", { attempt: reconnectAttempts, backoff_ms: backoff });
754
+ if (backoff >= RECONNECT_MAX_MS && !notifiedReconnectCap) {
755
+ notifiedReconnectCap = true;
756
+ if (currentCtx?.hasUI) {
757
+ try { currentCtx.ui.notify("📡 coms-net: reconnect backoff at ceiling", "warning"); } catch { /* ignore */ }
758
+ }
759
+ }
760
+ reconnectTimer = setTimeout(async () => {
761
+ reconnectTimer = null;
762
+ if (shuttingDown) return;
763
+ try {
764
+ await reRegisterAndOpen();
765
+ } catch (err) {
766
+ audit("sse_reconnect_failed", { reason: safeError(err) });
767
+ scheduleReconnect();
768
+ }
769
+ }, backoff);
770
+ try { (reconnectTimer as any).unref?.(); } catch { /* ignore */ }
771
+ }
772
+
773
+ async function reRegisterAndOpen(): Promise<void> {
774
+ if (!identity) return;
775
+ // Re-register (server upserts), then re-open SSE.
776
+ const reg = await registerAgent();
777
+ sseUrlPath = reg.sse_url;
778
+ audit("sse_reconnect", { attempt: reconnectAttempts });
779
+ // Fire and forget; openSse manages its own lifecycle.
780
+ void openSse();
781
+ }
782
+
783
+ // ━━ Registration ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
784
+
785
+ async function registerAgent(): Promise<RegisterResponse> {
786
+ if (!identity) throw new Error("coms-net: not initialised");
787
+ const ctx = currentCtx;
788
+ const req: RegisterRequest = {
789
+ project: identity.project,
790
+ session_id: identity.session_id,
791
+ name: identity.name,
792
+ purpose: identity.purpose,
793
+ model: ctx?.model?.id ?? identity.model,
794
+ color: identity.color,
795
+ cwd: identity.cwd,
796
+ explicit: identity.explicit,
797
+ };
798
+ const resp = await httpFetch("POST", "/v1/agents/register", req) as RegisterResponse;
799
+ if (!resp || !resp.agent) {
800
+ throw new Error("coms-net: malformed register response");
801
+ }
802
+ // Server may auto-suffix the name on collision.
803
+ if (resp.agent.name !== identity.name) {
804
+ try {
805
+ pi.appendEntry("coms-net-log", {
806
+ event: "name_collision",
807
+ ts: nowIso(),
808
+ desired: identity.name,
809
+ assigned: resp.agent.name,
810
+ project: identity.project,
811
+ });
812
+ } catch { /* best-effort */ }
813
+ identity.name = resp.agent.name;
814
+ }
815
+ try {
816
+ pi.appendEntry("coms-net-log", {
817
+ event: "register",
818
+ ts: nowIso(),
819
+ session_id: identity.session_id,
820
+ name: identity.name,
821
+ project: identity.project,
822
+ });
823
+ } catch { /* best-effort */ }
824
+ return resp;
825
+ }
826
+
827
+ // ━━ session_start ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
828
+
829
+ pi.on("session_start", async (_event, ctx) => {
830
+ applyExtensionDefaults(import.meta.url, ctx);
831
+ currentCtx = ctx;
832
+ shuttingDown = false;
833
+
834
+ // 1. Resolve identity from CLI > frontmatter > defaults.
835
+ const flags = readCliFlags(pi);
836
+ const fm = readFrontmatterFromArgv(process.argv);
837
+ const project = flags.project || PROJECT_ENV || "default";
838
+ const explicit = flags.explicit === true;
839
+ const session_id = ulid();
840
+
841
+ const defaultName = `agent-${session_id.slice(-6)}`;
842
+ const desiredName = flags.name || fm.name || defaultName;
843
+ const purpose = flags.purpose || fm.description || "";
844
+
845
+ // Color — fallback chain: --color > frontmatter > deterministic.
846
+ let color = fallbackColor(session_id);
847
+ if (fm.color && isValidHex(fm.color)) color = fm.color;
848
+ if (flags.color && isValidHex(flags.color)) color = flags.color;
849
+
850
+ const cwd = ctx.cwd || process.cwd();
851
+ const model = ctx.model?.id ?? "unknown";
852
+ const started_at = nowIso();
853
+
854
+ identity = {
855
+ session_id,
856
+ name: desiredName,
857
+ purpose,
858
+ color,
859
+ project,
860
+ explicit,
861
+ cwd,
862
+ model,
863
+ started_at,
864
+ };
865
+ displayProject = project;
866
+ includeExplicit = false;
867
+
868
+ // 2. Resolve server URL.
869
+ serverUrl = resolveServerUrl(project, flags.serverUrl);
870
+ if (!serverUrl) {
871
+ ctx.ui?.notify?.(
872
+ `📡 coms-net: no server URL for project "${project}". Start one with: bun extensions/coms/coms-net-server.ts`,
873
+ "error",
874
+ );
875
+ audit("boot_failed", { reason: "no_server_url", project });
876
+ return;
877
+ }
878
+
879
+ // 3. Resolve auth token.
880
+ authToken = resolveAuthToken(project, flags.authToken);
881
+ if (!authToken) {
882
+ ctx.ui?.notify?.(
883
+ `📡 coms-net: no auth token for project "${project}". Set PI_COMS_NET_AUTH_TOKEN or pass --auth-token. ` +
884
+ `If running a local server, ensure ~/.pi/coms-net/projects/${project}/server.secret.json exists with mode 0600.`,
885
+ "error",
886
+ );
887
+ audit("boot_failed", { reason: "no_auth_token", project });
888
+ return;
889
+ }
890
+
891
+ // 4. Health check — verify reachability without consuming auth surface.
892
+ try {
893
+ await httpFetch("GET", "/health");
894
+ } catch (err) {
895
+ ctx.ui?.notify?.(
896
+ `📡 coms-net: server unreachable at ${serverUrl} — ${safeError(err)}. ` +
897
+ `Start one with: bun extensions/coms/coms-net-server.ts`,
898
+ "error",
899
+ );
900
+ audit("boot_failed", { reason: "health_failed", error: safeError(err) });
901
+ return;
902
+ }
903
+
904
+ // 5. Register agent.
905
+ let reg: RegisterResponse;
906
+ try {
907
+ reg = await registerAgent();
908
+ } catch (err) {
909
+ ctx.ui?.notify?.(
910
+ `📡 coms-net: register failed — ${safeError(err)}`,
911
+ "error",
912
+ );
913
+ audit("boot_failed", { reason: "register_failed", error: safeError(err) });
914
+ return;
915
+ }
916
+ sseUrlPath = reg.sse_url;
917
+
918
+ // 6. Boot audit.
919
+ try {
920
+ pi.appendEntry("coms-net-log", {
921
+ event: "boot",
922
+ ts: nowIso(),
923
+ session_id: identity.session_id,
924
+ name: identity.name,
925
+ project: identity.project,
926
+ server_url: serverUrl,
927
+ });
928
+ } catch { /* best-effort */ }
929
+
930
+ // 7. Install widget + status. Success is the default — only failures notify
931
+ // (status line + widget already convey the connected state).
932
+ try {
933
+ ctx.ui.setStatus("coms-net", `📡 ${identity.name}@${identity.project}`);
934
+ installPoolWidget(ctx);
935
+ } catch {
936
+ // hasUI may be false in some contexts.
937
+ }
938
+
939
+ // 8. Open SSE — fire and forget.
940
+ void openSse();
941
+
942
+ // 9. Heartbeat loop.
943
+ heartbeatTimer = setInterval(() => {
944
+ if (!identity || shuttingDown) return;
945
+ const ctxNow = currentCtx;
946
+ const pct = Math.round(ctxNow?.getContextUsage()?.percent ?? 0);
947
+ const hbReq: HeartbeatRequest = {
948
+ project: identity.project,
949
+ context_used_pct: pct,
950
+ queue_depth: getPendingInboundCount(),
951
+ tasks_summary: readTaskSummary(identity?.cwd ?? process.cwd(), currentCtx?.sessionManager?.getSessionId?.()),
952
+ model: ctxNow?.model?.id ?? identity.model,
953
+ status: "online",
954
+ };
955
+ httpFetch("POST", `/v1/agents/${encodeURIComponent(identity.session_id)}/heartbeat`, hbReq, { timeoutMs: 5_000 })
956
+ .catch((err) => {
957
+ audit("heartbeat_failed", { reason: safeError(err) });
958
+ });
959
+ }, HEARTBEAT_MS);
960
+ try { (heartbeatTimer as any).unref?.(); } catch { /* ignore */ }
961
+ });
962
+
963
+ // ━━ Pool widget rendering ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
964
+
965
+ function renderPool(width: number, theme: Theme): string[] {
966
+ interface Row {
967
+ name: string;
968
+ model: string;
969
+ color: string;
970
+ purpose: string;
971
+ pct: number | null;
972
+ pending: boolean;
973
+ stale: boolean;
974
+ tasks?: { total: number; completed: number; in_progress: number } | null;
975
+ queue_depth: number;
976
+ }
977
+
978
+ const rows: Row[] = [];
979
+ for (const [sid, card] of peerCards.entries()) {
980
+ if (identity && sid === identity.session_id) continue;
981
+ if (!includeExplicit && card.explicit) continue;
982
+ rows.push({
983
+ name: card.name,
984
+ model: card.model,
985
+ color: card.color,
986
+ purpose: card.purpose,
987
+ pct: typeof card.context_used_pct === "number" ? card.context_used_pct : null,
988
+ pending: card.status === "stale",
989
+ stale: card.status === "offline",
990
+ tasks: card.tasks_summary,
991
+ queue_depth: card.queue_depth ?? 0,
992
+ });
993
+ }
994
+
995
+ const safeWidth = Math.max(0, width);
996
+ let topBorder: string;
997
+ let bottomBorder: string;
998
+ if (safeWidth < 16) {
999
+ topBorder = theme.fg("dim", "━".repeat(safeWidth));
1000
+ bottomBorder = theme.fg("dim", "━".repeat(safeWidth));
1001
+ } else {
1002
+ const left = theme.fg("dim", "┏━") + theme.fg("border", " coms-net ");
1003
+ const leftFill = theme.fg("dim", "━");
1004
+ const pendingCount = getPendingInboundCount();
1005
+ const pendingSuffix = ` (${pendingCount} pending)`;
1006
+ const nameLen = identity ? identity.name.length + pendingSuffix.length : 0;
1007
+ const rightTagVisLen = identity ? nameLen + 3 : 0;
1008
+ // "┏━ coms-net ━" prefix has 13 visible cells.
1009
+ const remaining = safeWidth - 13 - rightTagVisLen - 1; // -1 for "┓"
1010
+ if (identity && remaining >= 1) {
1011
+ const pendingPart = pendingCount > 0 ? theme.fg("warning", pendingSuffix) : theme.fg("dim", pendingSuffix);
1012
+ const rightTag =
1013
+ theme.fg("dim", " ") +
1014
+ hexFg(identity.color, identity.name) +
1015
+ pendingPart +
1016
+ theme.fg("dim", " ━");
1017
+ const middle = theme.fg("dim", "━".repeat(remaining));
1018
+ const right = theme.fg("dim", "┓");
1019
+ topBorder = left + leftFill + middle + rightTag + right;
1020
+ } else {
1021
+ const fallbackRemaining = Math.max(0, safeWidth - 2 /* "┏━" */ - 10 /* " coms-net " */ - 1 /* "┓" */);
1022
+ const right = theme.fg("dim", "━".repeat(fallbackRemaining) + "┓");
1023
+ topBorder = left + right;
1024
+ }
1025
+ bottomBorder = theme.fg("dim", "┗" + "━".repeat(safeWidth - 2) + "┛");
1026
+ }
1027
+
1028
+ if (rows.length === 0) {
1029
+ const emptyMsg = theme.fg("muted", "no peers connected");
1030
+ return [
1031
+ topBorder,
1032
+ truncateToWidth(theme.fg("dim", " ") + emptyMsg, width),
1033
+ bottomBorder,
1034
+ ];
1035
+ }
1036
+
1037
+ rows.sort((a, b) => a.name.localeCompare(b.name));
1038
+
1039
+ const out: string[] = [topBorder];
1040
+
1041
+ for (const r of rows) {
1042
+ const pctNum = r.pct ?? 0;
1043
+ const filled = Math.max(0, Math.min(15, Math.round((pctNum / 100) * 15)));
1044
+ const empty = 15 - filled;
1045
+ const pctLabel = r.pct == null ? "--%" : `${r.pct}%`;
1046
+
1047
+ if (r.stale) {
1048
+ const dimRow = `✗ ${r.name.padEnd(12)} ${abbreviateModel(r.model).padEnd(14)} [${"-".repeat(15)}] ${pctLabel.padStart(4)} — ${r.purpose || ""}`;
1049
+ out.push(truncateToWidth(" " + theme.fg("dim", dimRow), width));
1050
+ continue;
1051
+ }
1052
+
1053
+ const swatch = r.pending ? theme.fg("dim", "●") : hexFg(r.color, "●");
1054
+ const namePart = theme.fg("accent", r.name.padEnd(12));
1055
+ const modelPart = theme.fg("dim", abbreviateModel(r.model).padEnd(14));
1056
+ const barFill = r.pending
1057
+ ? theme.fg("dim", "-".repeat(15))
1058
+ : hexFg(r.color, "#".repeat(filled)) + theme.fg("dim", "-".repeat(empty));
1059
+ const bar = theme.fg("warning", "[") + barFill + theme.fg("warning", "]");
1060
+ const pctPart = " " + theme.fg("accent", pctLabel.padStart(4));
1061
+ const sep = theme.fg("dim", " — ");
1062
+ const purposePart = theme.fg("muted", r.purpose || "");
1063
+
1064
+ const tasksPart = renderTasksPart(r.tasks, theme);
1065
+ const queuePart = renderQueuePart(r.queue_depth, theme);
1066
+ const line = " " + swatch + " " + namePart + " " + modelPart + " " + bar + pctPart + tasksPart + queuePart + sep + purposePart;
1067
+ out.push(truncateToWidth(line, width));
1068
+ }
1069
+
1070
+ out.push(bottomBorder);
1071
+ return out;
1072
+ }
1073
+
1074
+ function installPoolWidget(ctx: ExtensionContext): void {
1075
+ if (!ctx.hasUI) return;
1076
+ try {
1077
+ ctx.ui.setWidget("coms-net-pool", (_tui, theme) => ({
1078
+ invalidate() {},
1079
+ render(width: number): string[] {
1080
+ return renderPool(width, theme);
1081
+ },
1082
+ }), { placement: "belowEditor" });
1083
+ } catch {
1084
+ // non-fatal
1085
+ }
1086
+ }
1087
+
1088
+ // ━━ Tools ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1089
+
1090
+ pi.registerTool({
1091
+ name: "coms_net_list",
1092
+ label: "Coms Net List",
1093
+ description:
1094
+ "List peer agents on the coms-net hub for the current project. Returns names, models, and live context-window usage. " +
1095
+ "Set include_explicit=true to reveal agents launched with --explicit.",
1096
+ parameters: Type.Object({
1097
+ project: Type.Optional(Type.String({ description: "Project name (defaults to caller's project)." })),
1098
+ include_explicit: Type.Optional(Type.Boolean({ description: "Include agents launched with --explicit. Default false." })),
1099
+ }),
1100
+ async execute(_callId, params) {
1101
+ if (!identity) {
1102
+ throw new Error("coms-net not initialised");
1103
+ }
1104
+ const projectFilter = (params as any).project ?? identity.project;
1105
+ const includeExp = (params as any).include_explicit === true;
1106
+ const qs = `?project=${encodeURIComponent(projectFilter)}&include_explicit=${includeExp ? "true" : "false"}`;
1107
+ const resp = await httpFetch("GET", `/v1/agents${qs}`);
1108
+ const agents: AgentCard[] = Array.isArray(resp?.agents) ? resp.agents : [];
1109
+ const peers = agents.filter(a => a.session_id !== identity!.session_id);
1110
+
1111
+ const lines = peers.length === 0
1112
+ ? "No peer agents found."
1113
+ : peers.map((a) => {
1114
+ const live = a.status === "online" ? "●" : a.status === "stale" ? "~" : "✗";
1115
+ const ctxStr = typeof a.context_used_pct === "number" ? ` ${a.context_used_pct}%` : " ?%";
1116
+ const queueStr = formatQueueStr(a.queue_depth);
1117
+ return `${live} ${a.name} (${abbreviateModel(a.model)})${ctxStr}${queueStr}${a.purpose ? ` — ${a.purpose}` : ""}`;
1118
+ }).join("\n");
1119
+
1120
+ return {
1121
+ content: [{ type: "text" as const, text: `${peers.length} peer(s):\n${lines}` }],
1122
+ details: { agents: peers, project: projectFilter },
1123
+ };
1124
+ },
1125
+ renderCall(args, theme) {
1126
+ const proj = (args as any).project;
1127
+ const filter = proj ? ` ${proj}` : "";
1128
+ return new Text(
1129
+ theme.fg("toolTitle", theme.bold("coms_net_list")) + theme.fg("dim", filter),
1130
+ 0, 0,
1131
+ );
1132
+ },
1133
+ renderResult(result, options, theme) {
1134
+ const details = result.details as any;
1135
+ const agents: any[] = details?.agents ?? [];
1136
+ const header = theme.fg("accent", `📡 ${agents.length} peer(s)`);
1137
+ if (!options.expanded || agents.length === 0) {
1138
+ return new Text(header, 0, 0);
1139
+ }
1140
+ const rows = agents.map((a) => {
1141
+ const dot = a.status === "online" ? theme.fg("success", "●")
1142
+ : a.status === "stale" ? theme.fg("warning", "~")
1143
+ : theme.fg("error", "✗");
1144
+ const pct = typeof a.context_used_pct === "number" ? `${a.context_used_pct}%` : "?%";
1145
+ return `${dot} ${theme.fg("accent", a.name)} ${theme.fg("dim", abbreviateModel(a.model))} ${theme.fg("warning", pct)}`;
1146
+ }).join("\n");
1147
+ return new Text(header + "\n" + rows, 0, 0);
1148
+ },
1149
+ });
1150
+
1151
+ pi.registerTool({
1152
+ name: "coms_net_send",
1153
+ label: "Coms Net Send",
1154
+ description:
1155
+ "INITIATE a new outbound message to a peer agent on the coms-net hub. " +
1156
+ "Returns synchronously with a msg_id once the server queues the prompt. " +
1157
+ "The response auto-delivers as a followUp message when the peer replies \u2014 no polling needed. Use coms_net_get for non-blocking status checks if needed.\n\n" +
1158
+ "⚠️ DO NOT call this tool to REPLY to an inbound message. " +
1159
+ "When you receive a `[from <peer>] …` follow-up, just write your answer as your normal assistant message — " +
1160
+ "the coms-net extension automatically captures the final assistant text at the end of your turn and " +
1161
+ "submits it back to the original caller. Calling coms_net_send in response creates an infinite ping-pong loop.\n\n" +
1162
+ "Only valid uses: (a) you, the user, or your task explicitly ask to start a new conversation with a peer; " +
1163
+ "(b) you are forwarding/delegating to a *different* peer than the one whose prompt you are currently answering; " +
1164
+ "in that case `hops` is auto-incremented and the hop limit will eventually stop runaway chains.\n\n" +
1165
+ "When delegating multiple work items, use the `tasks` parameter to include structured task definitions. " +
1166
+ "The peer receives them as an instruction to create tasks via TaskCreate and track progress in their task widget.",
1167
+ parameters: Type.Object({
1168
+ target: Type.String({ description: "Peer name (preferred, scoped to your project) or session_id." }),
1169
+ prompt: Type.String({ description: "The prompt to send." }),
1170
+ conversation_id: Type.Optional(Type.String()),
1171
+ response_schema: Type.Optional(Type.Any({ description: "Optional JSON Schema describing the expected response shape." })),
1172
+ tasks: Type.Optional(Type.Array(Type.Object({
1173
+ subject: Type.String({ description: "Brief task title" }),
1174
+ description: Type.String({ description: "Detailed task description" }),
1175
+ }), { description: "Optional structured tasks to create in the peer's task list." })),
1176
+ }),
1177
+ async execute(_callId, params) {
1178
+ if (!identity) throw new Error("coms-net not initialised");
1179
+
1180
+ const hops = currentInbound ? currentInbound.hops + 1 : 0;
1181
+ if (hops >= MAX_HOPS) {
1182
+ throw new Error(`coms-net: hop limit reached (${hops} >= ${MAX_HOPS})`);
1183
+ }
1184
+
1185
+ const req: SendRequest = {
1186
+ project: identity.project,
1187
+ sender_session: identity.session_id,
1188
+ target: params.target,
1189
+ target_session: null,
1190
+ prompt: params.prompt,
1191
+ conversation_id: (params as any).conversation_id ?? null,
1192
+ response_schema: ((params as any).response_schema as object | undefined) ?? null,
1193
+ hops,
1194
+ tasks: params.tasks ?? null,
1195
+ };
1196
+
1197
+ let resp: SendResponse;
1198
+ try {
1199
+ resp = await httpFetch("POST", "/v1/messages", req) as SendResponse;
1200
+ } catch (err) {
1201
+ if (err instanceof HttpError) {
1202
+ const detail = (err.body && err.body.error) || err.message;
1203
+ throw new Error(`coms-net: send failed (${err.status}): ${detail}`);
1204
+ }
1205
+ throw new Error(`coms-net: send failed: ${safeError(err)}`);
1206
+ }
1207
+ const { msg_id, target_session } = resp;
1208
+
1209
+ // Park a pending entry that the SSE `response` event will resolve.
1210
+ let resolveFn!: (v: { response?: any; error?: string | null }) => void;
1211
+ let rejectFn!: (e: Error) => void;
1212
+ const promise = new Promise<{ response?: any; error?: string | null }>((res, rej) => {
1213
+ resolveFn = res;
1214
+ rejectFn = rej;
1215
+ });
1216
+ pendingReplies.set(msg_id, {
1217
+ resolve: resolveFn,
1218
+ reject: rejectFn,
1219
+ promise,
1220
+ target_name: params.target,
1221
+ target_session,
1222
+ created_at: nowIso(),
1223
+ });
1224
+ // Hard TTL cleanup — prevents leaks if response never arrives
1225
+ setTimeout(() => {
1226
+ const p = pendingReplies.get(msg_id);
1227
+ if (p && !p.result) {
1228
+ p.result = { error: "timeout" };
1229
+ try { p.resolve(p.result); } catch { /* ignore */ }
1230
+ }
1231
+ pendingReplies.delete(msg_id);
1232
+ }, MESSAGE_TIMEOUT_MS).unref();
1233
+
1234
+ try {
1235
+ pi.appendEntry("coms-net-log", {
1236
+ event: "prompt_out",
1237
+ ts: nowIso(),
1238
+ msg_id,
1239
+ target: params.target,
1240
+ target_session,
1241
+ hops,
1242
+ });
1243
+ } catch { /* best-effort */ }
1244
+
1245
+ return {
1246
+ content: [{
1247
+ type: "text" as const,
1248
+ text: `coms_net_send → ${params.target}\nmsg_id ${msg_id}\nhops ${hops}`,
1249
+ }],
1250
+ details: { msg_id, target: params.target, target_session, hops },
1251
+ };
1252
+ },
1253
+ renderCall(args, theme) {
1254
+ const tgt = (args as any).target ?? "?";
1255
+ const prompt = (args as any).prompt ?? "";
1256
+ const preview = prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt;
1257
+ return new Text(
1258
+ theme.fg("toolTitle", theme.bold("coms_net_send ")) +
1259
+ theme.fg("accent", tgt) +
1260
+ theme.fg("dim", " — ") +
1261
+ theme.fg("muted", preview),
1262
+ 0, 0,
1263
+ );
1264
+ },
1265
+ renderResult(result, _options, theme) {
1266
+ const d = result.details as any;
1267
+ if (!d) {
1268
+ const t = result.content[0];
1269
+ return new Text(t?.type === "text" ? t.text : "", 0, 0);
1270
+ }
1271
+ return new Text(
1272
+ theme.fg("success", "→ ") +
1273
+ theme.fg("accent", d.target) +
1274
+ theme.fg("dim", ` msg_id `) +
1275
+ theme.fg("warning", d.msg_id),
1276
+ 0, 0,
1277
+ );
1278
+ },
1279
+ });
1280
+
1281
+ pi.registerTool({
1282
+ name: "coms_net_get",
1283
+ label: "Coms Net Get",
1284
+ description:
1285
+ "Non-blocking poll of a reply to YOUR OWN coms_net_send. Returns status pending|complete|error and (when complete) the response. " +
1286
+ "Only use msg_ids you got back from coms_net_send, never msg_ids from an inbound `[from <peer>] …` prompt — " +
1287
+ "those belong to the peer, and replying to them happens automatically via your normal assistant message at end of turn.",
1288
+ parameters: Type.Object({
1289
+ msg_id: Type.String({ description: "msg_id returned by coms_net_send." }),
1290
+ }),
1291
+ async execute(_callId, params) {
1292
+ const msg_id = (params as any).msg_id as string;
1293
+ // Local SSE-resolved fast path.
1294
+ const pending = pendingReplies.get(msg_id);
1295
+ if (pending && pending.result) {
1296
+ const r = pending.result;
1297
+ const text = r.error
1298
+ ? `coms_net_get: error — ${r.error}`
1299
+ : `coms_net_get: complete\n${typeof r.response === "string" ? r.response : JSON.stringify(r.response, null, 2)}`;
1300
+ return {
1301
+ content: [{ type: "text" as const, text }],
1302
+ details: { status: "complete", response: r.response, error: r.error ?? null, queued_msg_ids: r.queued_msg_ids ?? [] },
1303
+ };
1304
+ }
1305
+ // Fall back to server.
1306
+ let resp: any;
1307
+ try {
1308
+ resp = await httpFetch("GET", `/v1/messages/${encodeURIComponent(msg_id)}`);
1309
+ } catch (err) {
1310
+ if (err instanceof HttpError && err.status === 404) {
1311
+ return {
1312
+ content: [{ type: "text" as const, text: `coms_net_get: unknown msg_id ${msg_id}` }],
1313
+ details: { status: "error", error: "unknown msg_id" },
1314
+ };
1315
+ }
1316
+ return {
1317
+ content: [{ type: "text" as const, text: `coms_net_get: error — ${safeError(err)}` }],
1318
+ details: { status: "error", error: safeError(err) },
1319
+ };
1320
+ }
1321
+ const status = resp?.status ?? "pending";
1322
+ if (status === "complete" || status === "error" || status === "timeout") {
1323
+ const text = resp.error
1324
+ ? `coms_net_get: ${status} — ${resp.error}`
1325
+ : `coms_net_get: ${status}\n${typeof resp.response === "string" ? resp.response : JSON.stringify(resp.response, null, 2)}`;
1326
+ return {
1327
+ content: [{ type: "text" as const, text }],
1328
+ details: { status, response: resp.response, error: resp.error ?? null },
1329
+ };
1330
+ }
1331
+ return {
1332
+ content: [{ type: "text" as const, text: `coms_net_get: ${status}` }],
1333
+ details: { status },
1334
+ };
1335
+ },
1336
+ renderCall(args, theme) {
1337
+ const id = (args as any).msg_id ?? "?";
1338
+ return new Text(
1339
+ theme.fg("toolTitle", theme.bold("coms_net_get ")) + theme.fg("warning", id),
1340
+ 0, 0,
1341
+ );
1342
+ },
1343
+ renderResult(result, _options, theme) {
1344
+ const d = result.details as any;
1345
+ const status = d?.status ?? "?";
1346
+ const color = status === "complete" ? "success"
1347
+ : status === "pending" || status === "queued" || status === "delivered" ? "warning"
1348
+ : "error";
1349
+ return new Text(theme.fg(color, status), 0, 0);
1350
+ },
1351
+ });
1352
+
1353
+ // ━━ agent_end: capture turn output and submit response ━━━━━━━━━━━━━━━━
1354
+
1355
+ pi.on("agent_end", async (_event, ctx) => {
1356
+ if (!currentInbound || !identity) {
1357
+ if (!identity) {
1358
+ for (const [id, ib] of inboundQueue) {
1359
+ ib.fulfilled = true;
1360
+ inboundQueue.delete(id);
1361
+ }
1362
+ currentInbound = null;
1363
+ }
1364
+ processingInbound = false;
1365
+ return;
1366
+ }
1367
+ const inbound = currentInbound;
1368
+
1369
+ // Walk the session branch for the most recent assistant text.
1370
+ let lastAssistantText = "";
1371
+ for (const entry of ctx.sessionManager.getBranch()) {
1372
+ if (entry.type === "message" && entry.message.role === "assistant") {
1373
+ const m = entry.message as any;
1374
+ if (typeof m.content === "string") {
1375
+ lastAssistantText = m.content;
1376
+ } else if (Array.isArray(m.content)) {
1377
+ lastAssistantText = m.content
1378
+ .filter((b: any) => b && b.type === "text")
1379
+ .map((b: any) => b.text)
1380
+ .join("\n");
1381
+ }
1382
+ }
1383
+ }
1384
+
1385
+ let payload: any = lastAssistantText;
1386
+ let error: string | null = null;
1387
+ if (inbound.response_schema && typeof inbound.response_schema === "object") {
1388
+ try {
1389
+ payload = JSON.parse(lastAssistantText);
1390
+ } catch {
1391
+ error = "response not valid JSON";
1392
+ payload = null;
1393
+ }
1394
+ }
1395
+
1396
+ // Use inline count instead of getPendingInboundCount() — `inbound` (local param)
1397
+ // differs from `currentInbound` (module-scoped, already null at this point).
1398
+ const queuedIds: string[] = [];
1399
+ for (const i of inboundQueue.values()) if (!i.fulfilled && i !== inbound) queuedIds.push(i.msg_id);
1400
+ const req: ResponseSubmitRequest = {
1401
+ project: identity.project,
1402
+ responder_session: identity.session_id,
1403
+ response: payload,
1404
+ error,
1405
+ queued_msg_ids: queuedIds,
1406
+ };
1407
+
1408
+ try {
1409
+ await httpFetch("POST", `/v1/messages/${encodeURIComponent(inbound.msg_id)}/response`, req);
1410
+ try {
1411
+ pi.appendEntry("coms-net-log", {
1412
+ event: "response_out",
1413
+ ts: nowIso(),
1414
+ msg_id: inbound.msg_id,
1415
+ error,
1416
+ });
1417
+ } catch { /* best-effort */ }
1418
+ } catch (e: any) {
1419
+ audit("response_out_failed", { msg_id: inbound.msg_id, reason: safeError(e) });
1420
+ }
1421
+
1422
+ inbound.fulfilled = true;
1423
+ inboundQueue.delete(inbound.msg_id);
1424
+ currentInbound = null;
1425
+
1426
+ // FIFO drain: pick the next queued (oldest unfulfilled) inbound
1427
+ const next = [...inboundQueue.values()].find((i) => !i.fulfilled);
1428
+ if (next) {
1429
+ currentInbound = next;
1430
+ try {
1431
+ pi.sendMessage(
1432
+ {
1433
+ customType: "coms-net-inbound",
1434
+ content: buildInboundContent(
1435
+ `[inbound coms-net message from ${next.sender_name} @ ${next.sender_cwd}]\n` +
1436
+ `[reply by writing a normal assistant message — your turn output is auto-returned to ${next.sender_name}. ` +
1437
+ `DO NOT call coms_net_send/coms_net_get to reply; that creates a ping-pong loop.]`,
1438
+ next.prompt, next.tasks),
1439
+ display: true,
1440
+ details: {
1441
+ msg_id: next.msg_id,
1442
+ sender_session: next.sender_session,
1443
+ response_schema: next.response_schema ?? null,
1444
+ hops: next.hops,
1445
+ },
1446
+ },
1447
+ { deliverAs: "followUp", triggerTurn: true },
1448
+ );
1449
+ } catch (err: any) {
1450
+ // Failed to inject — drain ALL remaining queued messages with error responses
1451
+ audit("fifo_drain_failed", { msg_id: next.msg_id, reason: safeError(err) });
1452
+ const remaining = [next, ...[...inboundQueue.values()].filter(i => !i.fulfilled && i.msg_id !== next.msg_id)];
1453
+ for (let idx = 0; idx < remaining.length; idx++) {
1454
+ const orphan = remaining[idx];
1455
+ try {
1456
+ await httpFetch("POST", `/v1/messages/${encodeURIComponent(orphan.msg_id)}/response`, {
1457
+ project: identity!.project,
1458
+ responder_session: identity!.session_id,
1459
+ response: null,
1460
+ error: "injection_failed",
1461
+ queued_msg_ids: remaining.slice(idx + 1).map(r => r.msg_id),
1462
+ });
1463
+ } catch (e: any) {
1464
+ audit("orphan_cleanup_failed", { msg_id: orphan.msg_id, reason: e?.message ?? String(e) });
1465
+ }
1466
+ orphan.fulfilled = true;
1467
+ inboundQueue.delete(orphan.msg_id);
1468
+ }
1469
+ currentInbound = null;
1470
+ processingInbound = false;
1471
+ }
1472
+ } else {
1473
+ processingInbound = false;
1474
+ }
1475
+ });
1476
+
1477
+ // ━━ /coms-net slash command ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1478
+
1479
+ pi.registerCommand("coms-net", {
1480
+ description: "Refresh the coms-net pool widget; or --all / --project <name> / --server / --reconnect",
1481
+ handler: async (args, ctx) => {
1482
+ const trimmed = (args ?? "").trim();
1483
+ if (trimmed.includes("--all")) {
1484
+ includeExplicit = !includeExplicit;
1485
+ try { ctx.ui.notify(`coms-net: include_explicit = ${includeExplicit}`, "info"); } catch { /* ignore */ }
1486
+ }
1487
+ if (trimmed.includes("--reconnect")) {
1488
+ try { ctx.ui.notify("coms-net: reconnecting SSE...", "info"); } catch { /* ignore */ }
1489
+ if (sseAbort) {
1490
+ try { sseAbort.abort(); } catch { /* ignore */ }
1491
+ sseAbort = null;
1492
+ }
1493
+ reconnectAttempts = 0;
1494
+ notifiedReconnectCap = false;
1495
+ try { await reRegisterAndOpen(); } catch (err) { audit("manual_reconnect_failed", { reason: safeError(err) }); }
1496
+ }
1497
+ if (trimmed.includes("--server")) {
1498
+ try {
1499
+ const health = await httpFetch("GET", "/health");
1500
+ ctx.ui.notify(
1501
+ `coms-net server: ${serverUrl} · version ${health?.version ?? "?"} · server_id ${health?.server_id ?? "?"}`,
1502
+ "info",
1503
+ );
1504
+ } catch (err) {
1505
+ ctx.ui.notify(`coms-net: server health failed — ${safeError(err)}`, "error");
1506
+ }
1507
+ }
1508
+ const projectMatch = trimmed.match(/--project\s+(\S+)/);
1509
+ if (projectMatch) {
1510
+ displayProject = projectMatch[1];
1511
+ try { ctx.ui.notify(`coms-net: displaying project ${displayProject}`, "info"); } catch { /* ignore */ }
1512
+ }
1513
+
1514
+ // Bare invocation or after --project: force-refresh.
1515
+ try {
1516
+ const projectFilter = displayProject ?? identity?.project ?? "default";
1517
+ const qs = `?project=${encodeURIComponent(projectFilter)}&include_explicit=${includeExplicit ? "true" : "false"}`;
1518
+ const resp = await httpFetch("GET", `/v1/agents${qs}`);
1519
+ const agents: AgentCard[] = Array.isArray(resp?.agents) ? resp.agents : [];
1520
+ peerCards.clear();
1521
+ for (const a of agents) {
1522
+ if (identity && a.session_id === identity.session_id) continue;
1523
+ peerCards.set(a.session_id, a);
1524
+ }
1525
+ maybeRequestRender();
1526
+ } catch (err) {
1527
+ audit("refresh_failed", { reason: safeError(err) });
1528
+ }
1529
+ },
1530
+ });
1531
+
1532
+ // ━━ Clean shutdown (idempotent) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1533
+
1534
+ async function cleanShutdown(): Promise<void> {
1535
+ if (shuttingDown) return;
1536
+ shuttingDown = true;
1537
+
1538
+ if (heartbeatTimer) {
1539
+ try { clearInterval(heartbeatTimer); } catch { /* ignore */ }
1540
+ heartbeatTimer = null;
1541
+ }
1542
+ if (reconnectTimer) {
1543
+ try { clearTimeout(reconnectTimer); } catch { /* ignore */ }
1544
+ reconnectTimer = null;
1545
+ }
1546
+ if (sseAbort) {
1547
+ try { sseAbort.abort(); } catch { /* ignore */ }
1548
+ sseAbort = null;
1549
+ }
1550
+
1551
+ // Best-effort DELETE with short timeout.
1552
+ if (identity && serverUrl && authToken) {
1553
+ const ac = new AbortController();
1554
+ const t = setTimeout(() => { try { ac.abort(); } catch { /* ignore */ } }, SHUTDOWN_DELETE_TIMEOUT_MS);
1555
+ try { (t as any).unref?.(); } catch { /* ignore */ }
1556
+ try {
1557
+ await httpFetch(
1558
+ "DELETE",
1559
+ `/v1/agents/${encodeURIComponent(identity.session_id)}?project=${encodeURIComponent(identity.project)}`,
1560
+ undefined,
1561
+ { signal: ac.signal },
1562
+ );
1563
+ } catch {
1564
+ // best-effort — server may already be gone.
1565
+ } finally {
1566
+ try { clearTimeout(t); } catch { /* ignore */ }
1567
+ }
1568
+ }
1569
+
1570
+ if (identity) {
1571
+ try {
1572
+ pi.appendEntry("coms-net-log", {
1573
+ event: "shutdown",
1574
+ ts: nowIso(),
1575
+ session_id: identity.session_id,
1576
+ });
1577
+ } catch { /* best-effort */ }
1578
+ }
1579
+
1580
+ if (currentCtx?.hasUI) {
1581
+ try { currentCtx.ui.setWidget("coms-net-pool", undefined); } catch { /* ignore */ }
1582
+ try { currentCtx.ui.setStatus("coms-net", ""); } catch { /* ignore */ }
1583
+ }
1584
+ }
1585
+
1586
+ pi.on("session_shutdown", async () => { await cleanShutdown(); });
1587
+ process.on("SIGINT", () => { void cleanShutdown(); });
1588
+ process.on("SIGTERM", () => { void cleanShutdown(); });
1589
+ }