@chankov/agent-skills 0.3.0 → 0.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 (159) hide show
  1. package/.versions/0.3.2/.claude/commands/build.md +18 -0
  2. package/.versions/0.3.2/.claude/commands/code-simplify.md +22 -0
  3. package/.versions/0.3.2/.claude/commands/design-agent.md +14 -0
  4. package/.versions/0.3.2/.claude/commands/doctor-agent-skills.md +13 -0
  5. package/.versions/0.3.2/.claude/commands/plan.md +16 -0
  6. package/.versions/0.3.2/.claude/commands/prime.md +22 -0
  7. package/.versions/0.3.2/.claude/commands/review.md +16 -0
  8. package/.versions/0.3.2/.claude/commands/setup-agent-skills.md +19 -0
  9. package/.versions/0.3.2/.claude/commands/ship.md +17 -0
  10. package/.versions/0.3.2/.claude/commands/spec.md +15 -0
  11. package/.versions/0.3.2/.claude/commands/test.md +19 -0
  12. package/.versions/0.3.2/.opencode/commands/as-build.md +17 -0
  13. package/.versions/0.3.2/.opencode/commands/as-code-simplify.md +16 -0
  14. package/.versions/0.3.2/.opencode/commands/as-design-agent.md +15 -0
  15. package/.versions/0.3.2/.opencode/commands/as-doctor-agent-skills.md +11 -0
  16. package/.versions/0.3.2/.opencode/commands/as-plan.md +16 -0
  17. package/.versions/0.3.2/.opencode/commands/as-prime.md +22 -0
  18. package/.versions/0.3.2/.opencode/commands/as-review.md +15 -0
  19. package/.versions/0.3.2/.opencode/commands/as-setup-agent-skills.md +11 -0
  20. package/.versions/0.3.2/.opencode/commands/as-ship.md +16 -0
  21. package/.versions/0.3.2/.opencode/commands/as-spec.md +16 -0
  22. package/.versions/0.3.2/.opencode/commands/as-test.md +21 -0
  23. package/.versions/0.3.2/.pi/agents/agent-chain.yaml +49 -0
  24. package/.versions/0.3.2/.pi/agents/bowser.md +19 -0
  25. package/.versions/0.3.2/.pi/agents/pi-pi/agent-expert.md +98 -0
  26. package/.versions/0.3.2/.pi/agents/pi-pi/cli-expert.md +41 -0
  27. package/.versions/0.3.2/.pi/agents/pi-pi/config-expert.md +63 -0
  28. package/.versions/0.3.2/.pi/agents/pi-pi/ext-expert.md +43 -0
  29. package/.versions/0.3.2/.pi/agents/pi-pi/keybinding-expert.md +134 -0
  30. package/.versions/0.3.2/.pi/agents/pi-pi/pi-orchestrator.md +57 -0
  31. package/.versions/0.3.2/.pi/agents/pi-pi/prompt-expert.md +70 -0
  32. package/.versions/0.3.2/.pi/agents/pi-pi/skill-expert.md +42 -0
  33. package/.versions/0.3.2/.pi/agents/pi-pi/theme-expert.md +40 -0
  34. package/.versions/0.3.2/.pi/agents/pi-pi/tui-expert.md +85 -0
  35. package/.versions/0.3.2/.pi/agents/teams.yaml +31 -0
  36. package/.versions/0.3.2/.pi/damage-control-rules.yaml +278 -0
  37. package/.versions/0.3.2/.pi/extensions/agent-skills-update-check/README.md +58 -0
  38. package/.versions/0.3.2/.pi/extensions/agent-skills-update-check/index.ts +161 -0
  39. package/.versions/0.3.2/.pi/extensions/agent-skills-update-check/package.json +6 -0
  40. package/.versions/0.3.2/.pi/extensions/chrome-devtools-mcp/README.md +39 -0
  41. package/.versions/0.3.2/.pi/extensions/chrome-devtools-mcp/index.ts +61 -0
  42. package/.versions/0.3.2/.pi/extensions/chrome-devtools-mcp/package.json +6 -0
  43. package/.versions/0.3.2/.pi/extensions/compact-and-continue/README.md +42 -0
  44. package/.versions/0.3.2/.pi/extensions/compact-and-continue/index.ts +120 -0
  45. package/.versions/0.3.2/.pi/extensions/compact-and-continue/package.json +6 -0
  46. package/.versions/0.3.2/.pi/extensions/mcp-bridge/README.md +46 -0
  47. package/.versions/0.3.2/.pi/extensions/mcp-bridge/index.ts +206 -0
  48. package/.versions/0.3.2/.pi/extensions/mcp-bridge/package.json +6 -0
  49. package/.versions/0.3.2/.pi/extensions/package-lock.json +1143 -0
  50. package/.versions/0.3.2/.pi/extensions/package.json +9 -0
  51. package/.versions/0.3.2/.pi/harnesses/agent-chain/README.md +37 -0
  52. package/.versions/0.3.2/.pi/harnesses/agent-chain/index.ts +795 -0
  53. package/.versions/0.3.2/.pi/harnesses/agent-chain/package.json +6 -0
  54. package/.versions/0.3.2/.pi/harnesses/agent-team/README.md +38 -0
  55. package/.versions/0.3.2/.pi/harnesses/agent-team/index.ts +732 -0
  56. package/.versions/0.3.2/.pi/harnesses/agent-team/package.json +6 -0
  57. package/.versions/0.3.2/.pi/harnesses/coms/README.md +36 -0
  58. package/.versions/0.3.2/.pi/harnesses/coms/index.ts +1595 -0
  59. package/.versions/0.3.2/.pi/harnesses/coms/package.json +6 -0
  60. package/.versions/0.3.2/.pi/harnesses/coms-net/README.md +46 -0
  61. package/.versions/0.3.2/.pi/harnesses/coms-net/index.ts +1637 -0
  62. package/.versions/0.3.2/.pi/harnesses/coms-net/package.json +6 -0
  63. package/.versions/0.3.2/.pi/harnesses/damage-control/README.md +38 -0
  64. package/.versions/0.3.2/.pi/harnesses/damage-control/index.ts +207 -0
  65. package/.versions/0.3.2/.pi/harnesses/damage-control/package.json +6 -0
  66. package/.versions/0.3.2/.pi/harnesses/damage-control-continue/README.md +37 -0
  67. package/.versions/0.3.2/.pi/harnesses/damage-control-continue/index.ts +234 -0
  68. package/.versions/0.3.2/.pi/harnesses/damage-control-continue/package.json +6 -0
  69. package/.versions/0.3.2/.pi/harnesses/minimal/README.md +27 -0
  70. package/.versions/0.3.2/.pi/harnesses/minimal/index.ts +32 -0
  71. package/.versions/0.3.2/.pi/harnesses/minimal/package.json +6 -0
  72. package/.versions/0.3.2/.pi/harnesses/package-lock.json +35 -0
  73. package/.versions/0.3.2/.pi/harnesses/package.json +9 -0
  74. package/.versions/0.3.2/.pi/harnesses/pi-pi/README.md +39 -0
  75. package/.versions/0.3.2/.pi/harnesses/pi-pi/index.ts +631 -0
  76. package/.versions/0.3.2/.pi/harnesses/pi-pi/package.json +6 -0
  77. package/.versions/0.3.2/.pi/harnesses/purpose-gate/README.md +27 -0
  78. package/.versions/0.3.2/.pi/harnesses/purpose-gate/index.ts +82 -0
  79. package/.versions/0.3.2/.pi/harnesses/purpose-gate/package.json +6 -0
  80. package/.versions/0.3.2/.pi/harnesses/session-replay/README.md +28 -0
  81. package/.versions/0.3.2/.pi/harnesses/session-replay/index.ts +214 -0
  82. package/.versions/0.3.2/.pi/harnesses/session-replay/package.json +6 -0
  83. package/.versions/0.3.2/.pi/harnesses/subagent-widget/README.md +36 -0
  84. package/.versions/0.3.2/.pi/harnesses/subagent-widget/index.ts +479 -0
  85. package/.versions/0.3.2/.pi/harnesses/subagent-widget/package.json +6 -0
  86. package/.versions/0.3.2/.pi/harnesses/system-select/README.md +39 -0
  87. package/.versions/0.3.2/.pi/harnesses/system-select/index.ts +165 -0
  88. package/.versions/0.3.2/.pi/harnesses/system-select/package.json +6 -0
  89. package/.versions/0.3.2/.pi/harnesses/tilldone/README.md +35 -0
  90. package/.versions/0.3.2/.pi/harnesses/tilldone/index.ts +724 -0
  91. package/.versions/0.3.2/.pi/harnesses/tilldone/package.json +6 -0
  92. package/.versions/0.3.2/.pi/harnesses/tool-counter/README.md +31 -0
  93. package/.versions/0.3.2/.pi/harnesses/tool-counter/index.ts +100 -0
  94. package/.versions/0.3.2/.pi/harnesses/tool-counter/package.json +6 -0
  95. package/.versions/0.3.2/.pi/harnesses/tool-counter-widget/README.md +27 -0
  96. package/.versions/0.3.2/.pi/harnesses/tool-counter-widget/index.ts +66 -0
  97. package/.versions/0.3.2/.pi/harnesses/tool-counter-widget/package.json +6 -0
  98. package/.versions/0.3.2/.pi/prompts/build.md +24 -0
  99. package/.versions/0.3.2/.pi/prompts/code-simplify.md +22 -0
  100. package/.versions/0.3.2/.pi/prompts/doctor-agent-skills.md +13 -0
  101. package/.versions/0.3.2/.pi/prompts/plan.md +16 -0
  102. package/.versions/0.3.2/.pi/prompts/review.md +16 -0
  103. package/.versions/0.3.2/.pi/prompts/setup-agent-skills.md +19 -0
  104. package/.versions/0.3.2/.pi/prompts/ship.md +17 -0
  105. package/.versions/0.3.2/.pi/prompts/spec.md +15 -0
  106. package/.versions/0.3.2/.pi/prompts/test.md +19 -0
  107. package/.versions/0.3.2/.pi/skills/bowser/SKILL.md +114 -0
  108. package/.versions/0.3.2/.version +1 -0
  109. package/.versions/0.3.2/agents/builder.md +6 -0
  110. package/.versions/0.3.2/agents/code-reviewer.md +93 -0
  111. package/.versions/0.3.2/agents/documenter.md +6 -0
  112. package/.versions/0.3.2/agents/plan-reviewer.md +22 -0
  113. package/.versions/0.3.2/agents/planner.md +6 -0
  114. package/.versions/0.3.2/agents/scout.md +6 -0
  115. package/.versions/0.3.2/agents/security-auditor.md +97 -0
  116. package/.versions/0.3.2/agents/test-engineer.md +89 -0
  117. package/.versions/0.3.2/hooks/SIMPLIFY-IGNORE.md +90 -0
  118. package/.versions/0.3.2/hooks/hooks.json +14 -0
  119. package/.versions/0.3.2/hooks/session-start.sh +74 -0
  120. package/.versions/0.3.2/hooks/simplify-ignore-test.sh +247 -0
  121. package/.versions/0.3.2/hooks/simplify-ignore.sh +302 -0
  122. package/.versions/0.3.2/references/accessibility-checklist.md +159 -0
  123. package/.versions/0.3.2/references/performance-checklist.md +121 -0
  124. package/.versions/0.3.2/references/prompting-patterns.md +380 -0
  125. package/.versions/0.3.2/references/security-checklist.md +134 -0
  126. package/.versions/0.3.2/references/testing-patterns.md +236 -0
  127. package/.versions/0.3.2/skills/api-and-interface-design/SKILL.md +294 -0
  128. package/.versions/0.3.2/skills/browser-testing-with-devtools/SKILL.md +335 -0
  129. package/.versions/0.3.2/skills/ci-cd-and-automation/SKILL.md +390 -0
  130. package/.versions/0.3.2/skills/code-review-and-quality/SKILL.md +347 -0
  131. package/.versions/0.3.2/skills/code-simplification/SKILL.md +331 -0
  132. package/.versions/0.3.2/skills/context-engineering/SKILL.md +291 -0
  133. package/.versions/0.3.2/skills/debugging-and-error-recovery/SKILL.md +300 -0
  134. package/.versions/0.3.2/skills/deprecation-and-migration/SKILL.md +206 -0
  135. package/.versions/0.3.2/skills/designing-agents/SKILL.md +394 -0
  136. package/.versions/0.3.2/skills/designing-agents/pi-harness-authoring.md +213 -0
  137. package/.versions/0.3.2/skills/documentation-and-adrs/SKILL.md +278 -0
  138. package/.versions/0.3.2/skills/frontend-ui-engineering/SKILL.md +322 -0
  139. package/.versions/0.3.2/skills/git-workflow-and-versioning/SKILL.md +316 -0
  140. package/.versions/0.3.2/skills/guided-workspace-setup/SKILL.md +345 -0
  141. package/.versions/0.3.2/skills/idea-refine/SKILL.md +178 -0
  142. package/.versions/0.3.2/skills/idea-refine/examples.md +238 -0
  143. package/.versions/0.3.2/skills/idea-refine/frameworks.md +99 -0
  144. package/.versions/0.3.2/skills/idea-refine/refinement-criteria.md +113 -0
  145. package/.versions/0.3.2/skills/idea-refine/scripts/idea-refine.sh +15 -0
  146. package/.versions/0.3.2/skills/incremental-implementation/SKILL.md +279 -0
  147. package/.versions/0.3.2/skills/performance-optimization/SKILL.md +350 -0
  148. package/.versions/0.3.2/skills/planning-and-task-breakdown/SKILL.md +237 -0
  149. package/.versions/0.3.2/skills/security-and-hardening/SKILL.md +349 -0
  150. package/.versions/0.3.2/skills/shipping-and-launch/SKILL.md +309 -0
  151. package/.versions/0.3.2/skills/source-driven-development/SKILL.md +194 -0
  152. package/.versions/0.3.2/skills/spec-driven-development/SKILL.md +237 -0
  153. package/.versions/0.3.2/skills/test-driven-development/SKILL.md +379 -0
  154. package/.versions/0.3.2/skills/using-agent-skills/SKILL.md +176 -0
  155. package/CHANGELOG.md +36 -0
  156. package/bin/lib/bootstrap.js +56 -1
  157. package/docs/npm-install.md +30 -0
  158. package/package.json +1 -1
  159. package/skills/guided-workspace-setup/SKILL.md +16 -2
@@ -0,0 +1,1595 @@
1
+ /**
2
+ * coms — Peer-to-peer messaging between Pi agents on the same machine
3
+ *
4
+ * Each agent listens on a single endpoint (unix socket on POSIX, named pipe on
5
+ * Windows) and discovers peers through per-project registry files under
6
+ * ~/.pi/coms/projects/<project>/agents/<name>.json.
7
+ *
8
+ * Phase A (foundation): identity resolution, registry I/O, transport bind/send,
9
+ * connection handlers. Phase B: tools (coms_list/send/get/await), agent_end
10
+ * response capture. Phase C: live pool widget, ping + keepalive cycles, /coms
11
+ * slash command, clean shutdown lifecycle.
12
+ *
13
+ * Usage: pi -e extensions/coms.ts
14
+ */
15
+
16
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
17
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
18
+ import { Text, Container, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
19
+ import type { AutocompleteItem } from "@mariozechner/pi-tui";
20
+ import { Type } from "@sinclair/typebox";
21
+ import * as net from "node:net";
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ import * as os from "node:os";
25
+ import * as crypto from "node:crypto";
26
+
27
+ // ━━ Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
28
+
29
+ const COMS_DIR = process.env.PI_COMS_DIR || path.join(os.homedir(), ".pi", "coms");
30
+ const MAX_HOPS = Number(process.env.PI_COMS_MAX_HOPS) || 5;
31
+ const TIMEOUT_MS = Number(process.env.PI_COMS_TIMEOUT_MS) || 1_800_000;
32
+ const PING_INTERVAL_MS = Number(process.env.PI_COMS_PING_INTERVAL_MS) || 10_000;
33
+ const KEEPALIVE_INTERVAL_MS = 30_000;
34
+ const LINE_CAP_BYTES = 64 * 1024;
35
+
36
+ const FALLBACK_PALETTE = [
37
+ "#72F1B8", "#36F9F6", "#FF7EDB", "#FEDE5D",
38
+ "#C792EA", "#FF8B39", "#4D9DE0", "#FFAA8B",
39
+ ];
40
+
41
+ // ━━ Types ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42
+
43
+ type EnvelopeType = "prompt" | "response" | "ping";
44
+
45
+ interface Envelope {
46
+ type: EnvelopeType;
47
+ msg_id: string;
48
+ sender_session: string;
49
+ sender_endpoint: string;
50
+ hops: number;
51
+ timestamp: string;
52
+ }
53
+
54
+ interface PromptEnvelope extends Envelope {
55
+ type: "prompt";
56
+ prompt: string;
57
+ sender_name: string;
58
+ sender_cwd: string;
59
+ conversation_id?: string | null;
60
+ response_schema?: object | null;
61
+ }
62
+
63
+ interface ResponseEnvelope extends Envelope {
64
+ type: "response";
65
+ response: any;
66
+ error?: string | null;
67
+ }
68
+
69
+ interface PingEnvelope extends Envelope {
70
+ type: "ping";
71
+ }
72
+
73
+ interface AgentCard {
74
+ name: string;
75
+ purpose: string;
76
+ model: string;
77
+ color: string;
78
+ context_used_pct: number;
79
+ queue_depth: number;
80
+ }
81
+
82
+ interface Pong {
83
+ type: "pong";
84
+ msg_id: string;
85
+ agent_card: AgentCard;
86
+ }
87
+
88
+ interface RegistryEntry {
89
+ session_id: string;
90
+ name: string;
91
+ purpose: string;
92
+ model: string;
93
+ color: string;
94
+ pid: number;
95
+ endpoint: string;
96
+ cwd: string;
97
+ started_at: string;
98
+ explicit: boolean;
99
+ version: number;
100
+ // Live status snapshot — refreshed every KEEPALIVE_INTERVAL_MS by the heartbeat.
101
+ // Optional so older entries (pre-heartbeat-refresh) still parse cleanly.
102
+ context_used_pct?: number;
103
+ queue_depth?: number;
104
+ heartbeat_at?: string;
105
+ }
106
+
107
+ interface PendingReply {
108
+ resolve: (value: any) => void;
109
+ reject: (err: Error) => void;
110
+ timer: NodeJS.Timeout | null;
111
+ promise: Promise<{ response?: any; error?: string | null }>;
112
+ result?: { response?: any; error?: string | null };
113
+ target_name?: string;
114
+ created_at: string;
115
+ }
116
+
117
+ interface InboundContext {
118
+ msg_id: string;
119
+ hops: number;
120
+ sender_endpoint: string;
121
+ sender_session: string;
122
+ response_schema?: object | null;
123
+ fulfilled: boolean;
124
+ }
125
+
126
+ // ━━ Helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
127
+
128
+ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
129
+
130
+ function ulid(): string {
131
+ const time = Date.now();
132
+ const rand = crypto.randomBytes(10);
133
+ let timeStr = "";
134
+ let t = time;
135
+ for (let i = 9; i >= 0; i--) {
136
+ timeStr = CROCKFORD[t % 32] + timeStr;
137
+ t = Math.floor(t / 32);
138
+ }
139
+ let randStr = "";
140
+ let bits = 0;
141
+ let value = 0;
142
+ for (const byte of rand) {
143
+ value = (value << 8) | byte;
144
+ bits += 8;
145
+ while (bits >= 5) {
146
+ bits -= 5;
147
+ randStr += CROCKFORD[(value >> bits) & 31];
148
+ }
149
+ }
150
+ return (timeStr + randStr).slice(0, 26);
151
+ }
152
+
153
+ function hexFg(hex: string, s: string): string {
154
+ const r = parseInt(hex.slice(1, 3), 16);
155
+ const g = parseInt(hex.slice(3, 5), 16);
156
+ const b = parseInt(hex.slice(5, 7), 16);
157
+ return `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
158
+ }
159
+
160
+ function isValidHex(hex: string): boolean {
161
+ return /^#[0-9a-fA-F]{6}$/.test(hex);
162
+ }
163
+
164
+ function fallbackColor(sessionId: string): string {
165
+ const h = crypto.createHash("sha256").update(sessionId).digest("hex").slice(0, 8);
166
+ return FALLBACK_PALETTE[Number(BigInt("0x" + h)) % FALLBACK_PALETTE.length];
167
+ }
168
+
169
+ function parseFrontmatter(raw: string): { name?: string; description?: string; color?: string; body: string } {
170
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
171
+ if (!match) return { body: raw };
172
+ const frontmatter: Record<string, string> = {};
173
+ for (const line of match[1].split("\n")) {
174
+ const idx = line.indexOf(":");
175
+ if (idx > 0) {
176
+ const key = line.slice(0, idx).trim();
177
+ let val = line.slice(idx + 1).trim();
178
+ // strip surrounding quotes for values like color: "#36F9F6"
179
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
180
+ val = val.slice(1, -1);
181
+ }
182
+ frontmatter[key] = val;
183
+ }
184
+ }
185
+ return {
186
+ name: frontmatter.name,
187
+ description: frontmatter.description,
188
+ color: frontmatter.color,
189
+ body: match[2],
190
+ };
191
+ }
192
+
193
+ function makeEndpoint(sessionId: string): string {
194
+ if (process.platform === "win32") {
195
+ return `\\\\.\\pipe\\pi-coms-${sessionId}`;
196
+ }
197
+ return path.join(COMS_DIR, "sockets", `${sessionId}.sock`);
198
+ }
199
+
200
+ function nowIso(): string {
201
+ return new Date().toISOString();
202
+ }
203
+
204
+ function abbreviateModel(model: string): string {
205
+ let m = model || "";
206
+ if (m.startsWith("claude-")) m = m.slice("claude-".length);
207
+ if (m.length > 14) m = m.slice(0, 14);
208
+ return m;
209
+ }
210
+
211
+ // ━━ CLI flag shape (read via pi.registerFlag/pi.getFlag) ━━━━━━━━━━━━━━━━━━━
212
+
213
+ interface CliFlags {
214
+ name?: string;
215
+ purpose?: string;
216
+ project?: string;
217
+ color?: string;
218
+ explicit?: boolean;
219
+ }
220
+
221
+ function readCliFlags(pi: ExtensionAPI): CliFlags {
222
+ // Identity flags are declared via pi.registerFlag at extension load time so
223
+ // pi's CLI parser accepts them; here we just read them back.
224
+ const name = pi.getFlag("name") as string | undefined;
225
+ const purpose = pi.getFlag("purpose") as string | undefined;
226
+ const project = pi.getFlag("project") as string | undefined;
227
+ const color = pi.getFlag("color") as string | undefined;
228
+ const explicit = pi.getFlag("explicit") as boolean | undefined;
229
+ return {
230
+ name: name && name.length > 0 ? name : undefined,
231
+ purpose: purpose && purpose.length > 0 ? purpose : undefined,
232
+ project: project && project.length > 0 ? project : undefined,
233
+ color: color && color.length > 0 ? color : undefined,
234
+ explicit: explicit === true,
235
+ };
236
+ }
237
+
238
+ // ━━ Registry I/O ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
239
+
240
+ function projectAgentsDir(project: string): string {
241
+ return path.join(COMS_DIR, "projects", project, "agents");
242
+ }
243
+
244
+ function registryFilePath(project: string, name: string): string {
245
+ return path.join(projectAgentsDir(project), `${name}.json`);
246
+ }
247
+
248
+ function writeRegistryAtomic(entry: RegistryEntry, project: string): string {
249
+ const dir = projectAgentsDir(project);
250
+ fs.mkdirSync(dir, { recursive: true });
251
+ const final = registryFilePath(project, entry.name);
252
+ const tmp = `${final}.tmp`;
253
+ fs.writeFileSync(tmp, JSON.stringify(entry, null, 2));
254
+ fs.renameSync(tmp, final);
255
+ return final;
256
+ }
257
+
258
+ function readAllRegistryEntries(project: string): RegistryEntry[] {
259
+ const dir = projectAgentsDir(project);
260
+ if (!fs.existsSync(dir)) return [];
261
+ const out: RegistryEntry[] = [];
262
+ let files: string[];
263
+ try {
264
+ files = fs.readdirSync(dir);
265
+ } catch {
266
+ return [];
267
+ }
268
+ for (const f of files) {
269
+ if (!f.endsWith(".json")) continue;
270
+ try {
271
+ const raw = fs.readFileSync(path.join(dir, f), "utf-8");
272
+ const parsed = JSON.parse(raw) as RegistryEntry;
273
+ if (parsed && typeof parsed.session_id === "string") {
274
+ out.push(parsed);
275
+ }
276
+ } catch {
277
+ // skip malformed
278
+ }
279
+ }
280
+ return out;
281
+ }
282
+
283
+ function readAllRegistryEntriesAcrossProjects(): RegistryEntry[] {
284
+ const root = path.join(COMS_DIR, "projects");
285
+ let projects: string[];
286
+ try {
287
+ projects = fs.readdirSync(root);
288
+ } catch {
289
+ return [];
290
+ }
291
+ const out: RegistryEntry[] = [];
292
+ for (const p of projects) {
293
+ try {
294
+ if (!fs.statSync(path.join(root, p)).isDirectory()) continue;
295
+ } catch {
296
+ continue;
297
+ }
298
+ out.push(...readAllRegistryEntries(p));
299
+ }
300
+ return out;
301
+ }
302
+
303
+ function removeRegistryEntry(project: string, name: string): void {
304
+ try {
305
+ fs.unlinkSync(registryFilePath(project, name));
306
+ } catch {
307
+ // best-effort
308
+ }
309
+ }
310
+
311
+ function pruneDeadEntries(project: string): RegistryEntry[] {
312
+ const entries = readAllRegistryEntries(project);
313
+ const live: RegistryEntry[] = [];
314
+ for (const entry of entries) {
315
+ try {
316
+ process.kill(entry.pid, 0);
317
+ live.push(entry);
318
+ } catch (e: any) {
319
+ if (e && e.code === "ESRCH") {
320
+ removeRegistryEntry(project, entry.name);
321
+ } else {
322
+ // EPERM means the process exists but we can't signal it — treat as live.
323
+ live.push(entry);
324
+ }
325
+ }
326
+ }
327
+ return live;
328
+ }
329
+
330
+ function resolveUniqueName(project: string, desiredName: string): string {
331
+ // Returns a name that doesn't collide with any LIVE registered agent.
332
+ // pruneDeadEntries auto-removes ESRCH entries; we only care about live ones.
333
+ const liveEntries = pruneDeadEntries(project);
334
+ const liveNames = new Set(liveEntries.map(e => e.name));
335
+ if (!liveNames.has(desiredName)) return desiredName;
336
+ let n = 2;
337
+ while (liveNames.has(`${desiredName}${n}`)) n++;
338
+ return `${desiredName}${n}`;
339
+ }
340
+
341
+ function pruneDeadEntriesAllProjects(): RegistryEntry[] {
342
+ const root = path.join(COMS_DIR, "projects");
343
+ let projects: string[];
344
+ try {
345
+ projects = fs.readdirSync(root);
346
+ } catch {
347
+ return [];
348
+ }
349
+ const out: RegistryEntry[] = [];
350
+ for (const p of projects) {
351
+ try {
352
+ if (!fs.statSync(path.join(root, p)).isDirectory()) continue;
353
+ } catch {
354
+ continue;
355
+ }
356
+ out.push(...pruneDeadEntries(p));
357
+ }
358
+ return out;
359
+ }
360
+
361
+ function keepaliveTouch(file: string): void {
362
+ try {
363
+ const now = new Date();
364
+ fs.utimesSync(file, now, now);
365
+ } catch {
366
+ // best-effort
367
+ }
368
+ }
369
+
370
+ // ━━ Transport ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
371
+
372
+ function probeStaleSocket(endpoint: string): Promise<"in_use" | "stale"> {
373
+ return new Promise((resolve) => {
374
+ const sock = net.createConnection({ path: endpoint });
375
+ let settled = false;
376
+ const finish = (verdict: "in_use" | "stale") => {
377
+ if (settled) return;
378
+ settled = true;
379
+ try { sock.destroy(); } catch { /* ignore */ }
380
+ resolve(verdict);
381
+ };
382
+ const timer = setTimeout(() => finish("stale"), 250);
383
+ sock.once("connect", () => {
384
+ clearTimeout(timer);
385
+ finish("in_use");
386
+ });
387
+ sock.once("error", (err: any) => {
388
+ clearTimeout(timer);
389
+ if (err && err.code === "ECONNREFUSED") {
390
+ finish("stale");
391
+ } else {
392
+ // ENOENT or other — treat as stale (file may be gone or unusable)
393
+ finish("stale");
394
+ }
395
+ });
396
+ });
397
+ }
398
+
399
+ async function bindEndpoint(
400
+ endpoint: string,
401
+ connHandler: (socket: net.Socket) => void,
402
+ ): Promise<net.Server> {
403
+ if (process.platform !== "win32" && fs.existsSync(endpoint)) {
404
+ const verdict = await probeStaleSocket(endpoint);
405
+ if (verdict === "in_use") {
406
+ throw new Error(`coms: endpoint already in use (${endpoint})`);
407
+ }
408
+ try {
409
+ fs.unlinkSync(endpoint);
410
+ } catch {
411
+ // best-effort
412
+ }
413
+ }
414
+ return await new Promise<net.Server>((resolve, reject) => {
415
+ const server = net.createServer(connHandler);
416
+ server.once("error", reject);
417
+ server.listen(endpoint, () => {
418
+ server.removeListener("error", reject);
419
+ resolve(server);
420
+ });
421
+ });
422
+ }
423
+
424
+ function readOneLine(socket: net.Socket): Promise<string> {
425
+ return new Promise((resolve, reject) => {
426
+ let buf = "";
427
+ let settled = false;
428
+ const onData = (chunk: Buffer) => {
429
+ buf += chunk.toString("utf-8");
430
+ if (buf.length > LINE_CAP_BYTES) {
431
+ if (settled) return;
432
+ settled = true;
433
+ socket.removeListener("data", onData);
434
+ reject(new Error("line too large"));
435
+ return;
436
+ }
437
+ const nl = buf.indexOf("\n");
438
+ if (nl >= 0) {
439
+ if (settled) return;
440
+ settled = true;
441
+ socket.removeListener("data", onData);
442
+ resolve(buf.slice(0, nl));
443
+ }
444
+ };
445
+ socket.on("data", onData);
446
+ socket.once("error", (err) => {
447
+ if (settled) return;
448
+ settled = true;
449
+ reject(err);
450
+ });
451
+ socket.once("close", () => {
452
+ if (settled) return;
453
+ settled = true;
454
+ reject(new Error("connection closed before line received"));
455
+ });
456
+ });
457
+ }
458
+
459
+ function sendEnvelope(endpoint: string, envelope: Envelope | Pong | { type: string; msg_id?: string; [k: string]: any }): Promise<any> {
460
+ return new Promise((resolve, reject) => {
461
+ const sock = net.createConnection({ path: endpoint });
462
+ let settled = false;
463
+ const fail = (err: Error) => {
464
+ if (settled) return;
465
+ settled = true;
466
+ try { sock.destroy(); } catch { /* ignore */ }
467
+ reject(err);
468
+ };
469
+ sock.once("error", fail);
470
+ sock.once("connect", async () => {
471
+ try {
472
+ sock.write(JSON.stringify(envelope) + "\n");
473
+ const line = await readOneLine(sock);
474
+ const parsed = JSON.parse(line);
475
+ try { sock.end(); } catch { /* ignore */ }
476
+ if (settled) return;
477
+ settled = true;
478
+ if (parsed && parsed.type === "nack") {
479
+ reject(new Error(parsed.error || "nack"));
480
+ } else {
481
+ resolve(parsed);
482
+ }
483
+ } catch (err) {
484
+ fail(err instanceof Error ? err : new Error(String(err)));
485
+ }
486
+ });
487
+ });
488
+ }
489
+
490
+ // ━━ System-prompt frontmatter scan ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
491
+
492
+ function findSystemPromptPath(argv: string[]): string | null {
493
+ // Prefer --system-prompt (overwrite). Fall back to --append-system-prompt.
494
+ // These flags are pi-builtin (not extension-registered) so we still scan
495
+ // argv directly. First match wins per preference order.
496
+ const scan = (flag: string): string | null => {
497
+ for (let i = 0; i < argv.length; i++) {
498
+ if (argv[i] === flag && i + 1 < argv.length) {
499
+ const candidate = argv[i + 1];
500
+ if (candidate.endsWith(".md")) {
501
+ try {
502
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
503
+ return candidate;
504
+ }
505
+ } catch {
506
+ // fall through
507
+ }
508
+ }
509
+ }
510
+ }
511
+ return null;
512
+ };
513
+ return scan("--system-prompt") ?? scan("--append-system-prompt");
514
+ }
515
+
516
+ function readFrontmatterFromArgv(argv: string[]): { name?: string; description?: string; color?: string } {
517
+ const p = findSystemPromptPath(argv);
518
+ if (!p) return {};
519
+ try {
520
+ const raw = fs.readFileSync(p, "utf-8");
521
+ const { name, description, color } = parseFrontmatter(raw);
522
+ return { name, description, color };
523
+ } catch {
524
+ return {};
525
+ }
526
+ }
527
+
528
+ // ━━ Default export ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
529
+
530
+ export default function (pi: ExtensionAPI) {
531
+ // ━━ Register identity CLI flags so pi's parser accepts them. ━━━━━━━━━
532
+ // Without these, pi 0.73+ rejects the invocation with "Unknown options:
533
+ // --name, --project, ..." before this extension's hooks ever fire.
534
+ pi.registerFlag("name", {
535
+ description: "Override agent name (otherwise from frontmatter or auto-generated)",
536
+ type: "string",
537
+ default: undefined,
538
+ });
539
+ pi.registerFlag("purpose", {
540
+ description: "Override agent purpose (otherwise from frontmatter description)",
541
+ type: "string",
542
+ default: undefined,
543
+ });
544
+ pi.registerFlag("project", {
545
+ description: "Project namespace for peer discovery",
546
+ type: "string",
547
+ default: "default",
548
+ });
549
+ pi.registerFlag("color", {
550
+ description: "Hex color #RRGGBB (otherwise from frontmatter or palette fallback)",
551
+ type: "string",
552
+ default: undefined,
553
+ });
554
+ pi.registerFlag("explicit", {
555
+ description: "Hide this agent from auto-discovery; only addressable by exact name",
556
+ type: "boolean",
557
+ default: false,
558
+ });
559
+
560
+ // State containers — shared across all hooks for this extension instance.
561
+ let identity: {
562
+ session_id: string;
563
+ name: string;
564
+ purpose: string;
565
+ color: string;
566
+ project: string;
567
+ explicit: boolean;
568
+ cwd: string;
569
+ model: string;
570
+ endpoint: string;
571
+ registryFile: string;
572
+ } | null = null;
573
+ const peerCards: Map<string, AgentCard & { staleCount: number }> = new Map();
574
+ const pendingReplies: Map<string, PendingReply> = new Map();
575
+ const inboundQueue: Map<string, InboundContext> = new Map();
576
+ let server: net.Server | null = null;
577
+ let pingTimer: NodeJS.Timeout | null = null;
578
+ let keepaliveTimer: NodeJS.Timeout | null = null;
579
+ let includeExplicit = false;
580
+ let displayProject: string | null = null;
581
+ let currentCtx: ExtensionContext | null = null;
582
+ let currentInbound: InboundContext | null = null;
583
+
584
+ // Phase A stub handlers — each just acks valid envelopes. Phase B replaces these.
585
+ function ackOk(socket: net.Socket, msg_id: string): void {
586
+ try {
587
+ socket.write(JSON.stringify({ type: "ack", msg_id }) + "\n");
588
+ } catch {
589
+ // ignore
590
+ }
591
+ try { socket.end(); } catch { /* ignore */ }
592
+ }
593
+
594
+ function nack(socket: net.Socket, msg_id: string, error: string): void {
595
+ try {
596
+ socket.write(JSON.stringify({ type: "nack", msg_id, error }) + "\n");
597
+ } catch {
598
+ // ignore
599
+ }
600
+ try { socket.end(); } catch { /* ignore */ }
601
+ }
602
+
603
+ function handlePrompt(socket: net.Socket, env: PromptEnvelope): void {
604
+ // 1. Hop limit check
605
+ if (typeof env.hops !== "number" || env.hops >= MAX_HOPS) {
606
+ nack(socket, env.msg_id, "hops exceeded");
607
+ return;
608
+ }
609
+
610
+ // 2. Insert into inbound queue
611
+ const inbound: InboundContext = {
612
+ msg_id: env.msg_id,
613
+ hops: env.hops,
614
+ sender_endpoint: env.sender_endpoint,
615
+ sender_session: env.sender_session,
616
+ response_schema: env.response_schema ?? null,
617
+ fulfilled: false,
618
+ };
619
+ inboundQueue.set(env.msg_id, inbound);
620
+
621
+ // 3. Track the current inbound so that any coms_send issued during the
622
+ // resulting LLM turn inherits the right hop count.
623
+ currentInbound = inbound;
624
+
625
+ // 4. Inject as a follow-up message into the receiver's next turn.
626
+ try {
627
+ pi.sendMessage(
628
+ {
629
+ customType: "coms-inbound",
630
+ content: `[from ${env.sender_name} @ ${env.sender_cwd}]\n\n${env.prompt}`,
631
+ display: true,
632
+ details: {
633
+ msg_id: env.msg_id,
634
+ sender_session: env.sender_session,
635
+ response_schema: env.response_schema ?? null,
636
+ },
637
+ },
638
+ { deliverAs: "followUp", triggerTurn: true },
639
+ );
640
+ } catch (err) {
641
+ // If sendMessage fails, drop the inbound and nack.
642
+ inboundQueue.delete(env.msg_id);
643
+ currentInbound = null;
644
+ nack(socket, env.msg_id, "internal error");
645
+ return;
646
+ }
647
+
648
+ // 5. Ack + audit log
649
+ ackOk(socket, env.msg_id);
650
+ try {
651
+ pi.appendEntry("coms-log", {
652
+ event: "inbound_prompt",
653
+ msg_id: env.msg_id,
654
+ sender: env.sender_session,
655
+ hops: env.hops,
656
+ });
657
+ } catch {
658
+ // best-effort
659
+ }
660
+ }
661
+
662
+ function handleResponse(socket: net.Socket, env: ResponseEnvelope): void {
663
+ const pending = pendingReplies.get(env.msg_id);
664
+ if (pending) {
665
+ if (pending.timer) {
666
+ try { clearTimeout(pending.timer); } catch { /* ignore */ }
667
+ pending.timer = null;
668
+ }
669
+ pending.result = { response: env.response, error: env.error ?? null };
670
+ try {
671
+ pending.resolve(pending.result);
672
+ } catch {
673
+ // ignore
674
+ }
675
+ // Note: do NOT delete the entry here — coms_get poll may still want it.
676
+ } else {
677
+ try {
678
+ pi.appendEntry("coms-log", { event: "orphan_response", msg_id: env.msg_id });
679
+ } catch {
680
+ // best-effort
681
+ }
682
+ }
683
+ ackOk(socket, env.msg_id);
684
+ }
685
+
686
+ function handlePing(socket: net.Socket, env: PingEnvelope): void {
687
+ const ctx = currentCtx;
688
+ const ident = identity;
689
+ const pct = ctx ? Math.round(ctx.getContextUsage()?.percent ?? 0) : 0;
690
+ const card: AgentCard = {
691
+ name: ident?.name ?? "unknown",
692
+ purpose: ident?.purpose ?? "",
693
+ model: ctx?.model?.id ?? ident?.model ?? "unknown",
694
+ color: ident?.color ?? "#36F9F6",
695
+ context_used_pct: pct,
696
+ queue_depth: inboundQueue.size,
697
+ };
698
+ const pong: Pong = { type: "pong", msg_id: env.msg_id, agent_card: card };
699
+ try {
700
+ socket.write(JSON.stringify(pong) + "\n");
701
+ } catch {
702
+ // ignore
703
+ }
704
+ try { socket.end(); } catch { /* ignore */ }
705
+ }
706
+
707
+ function isValidEnvelope(obj: any): obj is Envelope {
708
+ return (
709
+ obj &&
710
+ typeof obj === "object" &&
711
+ typeof obj.type === "string" &&
712
+ typeof obj.msg_id === "string" &&
713
+ typeof obj.sender_session === "string" &&
714
+ typeof obj.sender_endpoint === "string"
715
+ );
716
+ }
717
+
718
+ function connHandler(socket: net.Socket): void {
719
+ let buf = "";
720
+ let handled = false;
721
+ const onData = (chunk: Buffer) => {
722
+ if (handled) return;
723
+ buf += chunk.toString("utf-8");
724
+ if (buf.length > LINE_CAP_BYTES) {
725
+ handled = true;
726
+ socket.removeListener("data", onData);
727
+ nack(socket, "", "malformed envelope");
728
+ return;
729
+ }
730
+ const nl = buf.indexOf("\n");
731
+ if (nl < 0) return;
732
+ handled = true;
733
+ socket.removeListener("data", onData);
734
+ const line = buf.slice(0, nl);
735
+ let parsed: any;
736
+ try {
737
+ parsed = JSON.parse(line);
738
+ } catch {
739
+ nack(socket, "", "malformed envelope");
740
+ return;
741
+ }
742
+ if (!isValidEnvelope(parsed)) {
743
+ const mid = parsed && typeof parsed.msg_id === "string" ? parsed.msg_id : "";
744
+ nack(socket, mid, "malformed envelope");
745
+ return;
746
+ }
747
+ try {
748
+ if (parsed.type === "prompt") {
749
+ handlePrompt(socket, parsed as PromptEnvelope);
750
+ } else if (parsed.type === "response") {
751
+ handleResponse(socket, parsed as ResponseEnvelope);
752
+ } else if (parsed.type === "ping") {
753
+ handlePing(socket, parsed as PingEnvelope);
754
+ } else {
755
+ nack(socket, parsed.msg_id, "unknown type");
756
+ }
757
+ } catch {
758
+ nack(socket, parsed.msg_id, "internal error");
759
+ }
760
+ };
761
+ socket.on("data", onData);
762
+ socket.once("error", () => {
763
+ // connection failures during handshake — drop quietly
764
+ try { socket.destroy(); } catch { /* ignore */ }
765
+ });
766
+ }
767
+
768
+ // ━━ session_start ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
769
+ pi.on("session_start", async (_event, ctx) => {
770
+ currentCtx = ctx;
771
+
772
+ // 1. Resolve identity from CLI flags > frontmatter > defaults.
773
+ const flags = readCliFlags(pi);
774
+ const fm = readFrontmatterFromArgv(process.argv);
775
+ const project = flags.project || "default";
776
+ const explicit = flags.explicit === true;
777
+ const session_id = ulid();
778
+
779
+ const defaultName = `agent-${session_id.slice(-6)}`;
780
+ const desiredName = flags.name || fm.name || defaultName;
781
+ const name = resolveUniqueName(project, desiredName);
782
+ if (name !== desiredName) {
783
+ try {
784
+ pi.appendEntry("coms-log", { event: "name_collision", desired: desiredName, assigned: name, project });
785
+ } catch {
786
+ // best-effort
787
+ }
788
+ }
789
+ const purpose = flags.purpose || fm.description || "";
790
+
791
+ // Color: validate at every level; fall through invalid hex to next.
792
+ // Order: --color CLI flag > frontmatter color > deterministic fallback.
793
+ let color = fallbackColor(session_id);
794
+ if (fm.color && isValidHex(fm.color)) {
795
+ color = fm.color;
796
+ }
797
+ if (flags.color && isValidHex(flags.color)) {
798
+ color = flags.color;
799
+ }
800
+
801
+ const endpoint = makeEndpoint(session_id);
802
+ const cwd = ctx.cwd || process.cwd();
803
+ const model = ctx.model?.id ?? "unknown";
804
+
805
+ // 2. Ensure storage dirs exist.
806
+ try {
807
+ fs.mkdirSync(path.join(COMS_DIR, "projects", project, "agents"), { recursive: true });
808
+ if (process.platform !== "win32") {
809
+ fs.mkdirSync(path.join(COMS_DIR, "sockets"), { recursive: true });
810
+ try { fs.chmodSync(COMS_DIR, 0o700); } catch { /* best-effort */ }
811
+ }
812
+ } catch (err) {
813
+ ctx.ui?.notify?.(`📡 coms: failed to create dirs — ${err instanceof Error ? err.message : String(err)}`, "error");
814
+ return;
815
+ }
816
+
817
+ // 3. Bind the endpoint.
818
+ try {
819
+ server = await bindEndpoint(endpoint, connHandler);
820
+ } catch (err) {
821
+ ctx.ui?.notify?.(`📡 coms: bind failed — ${err instanceof Error ? err.message : String(err)}`, "error");
822
+ return;
823
+ }
824
+
825
+ // 4. Build + write registry entry atomically.
826
+ const entry: RegistryEntry = {
827
+ session_id,
828
+ name,
829
+ purpose,
830
+ model,
831
+ color,
832
+ pid: process.pid,
833
+ endpoint,
834
+ cwd,
835
+ started_at: nowIso(),
836
+ explicit,
837
+ version: 1,
838
+ };
839
+ let registryFile: string;
840
+ try {
841
+ registryFile = writeRegistryAtomic(entry, project);
842
+ } catch (err) {
843
+ ctx.ui?.notify?.(`📡 coms: registry write failed — ${err instanceof Error ? err.message : String(err)}`, "error");
844
+ try { server?.close(); } catch { /* ignore */ }
845
+ return;
846
+ }
847
+
848
+ identity = {
849
+ session_id,
850
+ name,
851
+ purpose,
852
+ color,
853
+ project,
854
+ explicit,
855
+ cwd,
856
+ model,
857
+ endpoint,
858
+ registryFile,
859
+ };
860
+ includeExplicit = false;
861
+ displayProject = project;
862
+
863
+ // 5. Audit log: boot.
864
+ try {
865
+ pi.appendEntry("coms-log", { event: "boot", session_id, name, project });
866
+ } catch {
867
+ // best-effort
868
+ }
869
+
870
+ // 6. Surface presence in the UI + install the live pool widget.
871
+ try {
872
+ ctx.ui.setStatus("coms", `📡 ${name}@${project}`);
873
+ installPoolWidget(ctx);
874
+ ctx.ui.notify(
875
+ `📡 coms ready · ${name}@${project} · ${displayProject ?? project} pool`,
876
+ "info",
877
+ );
878
+ } catch {
879
+ // hasUI may be false in some contexts — non-fatal.
880
+ }
881
+
882
+ // 7. Start ping + keepalive cycles.
883
+ pingTimer = setInterval(() => { refreshPool().catch(() => {}); }, PING_INTERVAL_MS);
884
+ try { (pingTimer as any).unref?.(); } catch { /* ignore */ }
885
+ keepaliveTimer = setInterval(() => {
886
+ if (!identity) return;
887
+ try {
888
+ const ctx = currentCtx;
889
+ // Detect missing-registry BEFORE writing so the self_heal audit only
890
+ // fires when something actually went wrong (file unlinked under us).
891
+ const missingBeforeWrite = !fs.existsSync(identity.registryFile);
892
+ const live: RegistryEntry = {
893
+ session_id: identity.session_id,
894
+ name: identity.name,
895
+ purpose: identity.purpose,
896
+ model: ctx?.model?.id ?? identity.model,
897
+ color: identity.color,
898
+ pid: process.pid,
899
+ endpoint: identity.endpoint,
900
+ cwd: identity.cwd,
901
+ started_at: nowIso(),
902
+ explicit: identity.explicit,
903
+ version: 1,
904
+ context_used_pct: Math.round(ctx?.getContextUsage()?.percent ?? 0),
905
+ queue_depth: inboundQueue.size,
906
+ heartbeat_at: nowIso(),
907
+ };
908
+ // Unconditional atomic write: handles BOTH the live-status refresh
909
+ // (file present → overwrite with fresh values) AND self-heal (file
910
+ // missing → re-create entry). The atomic write also bumps mtime, so
911
+ // keepaliveTouch is now redundant.
912
+ writeRegistryAtomic(live, identity.project);
913
+ if (missingBeforeWrite) {
914
+ pi.appendEntry("coms-log", { event: "self_heal", session_id: identity.session_id, reason: "registry file missing" });
915
+ // Edge case: if the file was unlinked again between our write and
916
+ // this check, re-write once to be safe.
917
+ if (!fs.existsSync(identity.registryFile)) {
918
+ writeRegistryAtomic(live, identity.project);
919
+ }
920
+ }
921
+ } catch { /* best-effort */ }
922
+ }, KEEPALIVE_INTERVAL_MS);
923
+ try { (keepaliveTimer as any).unref?.(); } catch { /* ignore */ }
924
+
925
+ // Kick one ping cycle immediately so the widget populates fast.
926
+ refreshPool().catch(() => {});
927
+ });
928
+
929
+ // ━━ Helpers used by tools ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
930
+
931
+ async function pingPeer(endpoint: string): Promise<AgentCard | null> {
932
+ if (!identity) return null;
933
+ const env: PingEnvelope = {
934
+ type: "ping",
935
+ msg_id: ulid(),
936
+ sender_session: identity.session_id,
937
+ sender_endpoint: identity.endpoint,
938
+ hops: 0,
939
+ timestamp: nowIso(),
940
+ };
941
+ try {
942
+ const resp = await sendEnvelope(endpoint, env);
943
+ if (resp && resp.type === "pong" && resp.agent_card) {
944
+ return resp.agent_card as AgentCard;
945
+ }
946
+ } catch {
947
+ // ignore — peer unreachable
948
+ }
949
+ return null;
950
+ }
951
+
952
+ // ━━ Pool widget rendering ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
953
+ function renderPool(width: number, theme: Theme): string[] {
954
+ const projectFilter = displayProject ?? identity?.project ?? "default";
955
+ const registryEntries = projectFilter === "*"
956
+ ? readAllRegistryEntriesAcrossProjects()
957
+ : readAllRegistryEntries(projectFilter);
958
+
959
+ interface Row {
960
+ name: string;
961
+ model: string;
962
+ color: string;
963
+ purpose: string;
964
+ pct: number | null;
965
+ pending: boolean;
966
+ stale: boolean;
967
+ }
968
+ const rows: Row[] = [];
969
+ const seenSessions = new Set<string>();
970
+
971
+ for (const [sid, card] of peerCards.entries()) {
972
+ if (identity && sid === identity.session_id) continue;
973
+ seenSessions.add(sid);
974
+ rows.push({
975
+ name: card.name,
976
+ model: card.model,
977
+ color: card.color,
978
+ purpose: card.purpose,
979
+ pct: card.context_used_pct,
980
+ pending: false,
981
+ stale: (card.staleCount ?? 0) >= 3,
982
+ });
983
+ }
984
+
985
+ // Registry-only entries that aren't yet in peerCards → pending
986
+ const seenNames = new Set(rows.map((r) => r.name));
987
+ for (const entry of registryEntries) {
988
+ if (identity && entry.session_id === identity.session_id) continue;
989
+ if (!includeExplicit && entry.explicit) continue;
990
+ if (seenSessions.has(entry.session_id)) continue;
991
+ if (seenNames.has(entry.name)) continue;
992
+ rows.push({
993
+ name: entry.name,
994
+ model: entry.model,
995
+ color: entry.color,
996
+ purpose: entry.purpose,
997
+ pct: null,
998
+ pending: true,
999
+ stale: false,
1000
+ });
1001
+ }
1002
+
1003
+ // Border helpers — sandwich the body with single-line box-drawing rules
1004
+ // so the widget reads as its own block above the minimal footer. The
1005
+ // top border carries a branded ` coms ` tag so the widget reads as its
1006
+ // own block; bottom border stays a plain rule for minimalism.
1007
+ const safeWidth = Math.max(0, width);
1008
+ let topBorder: string;
1009
+ let bottomBorder: string;
1010
+ if (safeWidth < 12) {
1011
+ topBorder = theme.fg("dim", "━".repeat(safeWidth));
1012
+ bottomBorder = theme.fg("dim", "━".repeat(safeWidth));
1013
+ } else {
1014
+ const left = theme.fg("dim", "┏━") + theme.fg("border", " coms ");
1015
+ const leftFill = theme.fg("dim", "━");
1016
+ const nameLen = identity ? identity.name.length : 0;
1017
+ const rightTagVisLen = identity ? nameLen + 4 : 0;
1018
+ const remaining = safeWidth - 9 /* "┏━ coms ━" */ - rightTagVisLen - 1 /* "┓" */;
1019
+ if (identity && remaining >= 1) {
1020
+ const rightTag =
1021
+ theme.fg("dim", " ") +
1022
+ hexFg(identity.color, identity.name) +
1023
+ theme.fg("dim", " ━");
1024
+ const middle = theme.fg("dim", "━".repeat(remaining));
1025
+ const right = theme.fg("dim", "┓");
1026
+ topBorder = left + leftFill + middle + rightTag + right;
1027
+ } else {
1028
+ const fallbackRemaining = Math.max(0, safeWidth - 2 /* "┏━" */ - 6 /* " coms " */ - 1 /* "┓" */);
1029
+ const right = theme.fg("dim", "━".repeat(fallbackRemaining) + "┓");
1030
+ topBorder = left + right;
1031
+ }
1032
+ bottomBorder = theme.fg("dim", "┗" + "━".repeat(safeWidth - 2) + "┛");
1033
+ }
1034
+
1035
+ if (rows.length === 0) {
1036
+ const emptyMsg = theme.fg("muted", "no peers connected");
1037
+ return [
1038
+ topBorder,
1039
+ truncateToWidth(theme.fg("dim", " ") + emptyMsg, width),
1040
+ bottomBorder,
1041
+ ];
1042
+ }
1043
+
1044
+ rows.sort((a, b) => a.name.localeCompare(b.name));
1045
+
1046
+ const out: string[] = [topBorder];
1047
+
1048
+ for (const r of rows) {
1049
+ const pctNum = r.pct ?? 0;
1050
+ const filled = Math.max(0, Math.min(15, Math.round((pctNum / 100) * 15)));
1051
+ const empty = 15 - filled;
1052
+ const pctLabel = r.pct == null ? "--%" : `${r.pct}%`;
1053
+
1054
+ if (r.stale) {
1055
+ const dimRow = `✗ ${r.name.padEnd(12)} ${abbreviateModel(r.model).padEnd(14)} [${"-".repeat(15)}] ${pctLabel.padStart(4)} — ${r.purpose || ""}`;
1056
+ out.push(truncateToWidth(" " + theme.fg("dim", dimRow), width));
1057
+ continue;
1058
+ }
1059
+
1060
+ const swatch = r.pending ? theme.fg("dim", "●") : hexFg(r.color, "●");
1061
+ const namePart = theme.fg("accent", r.name.padEnd(12));
1062
+ const modelPart = theme.fg("dim", abbreviateModel(r.model).padEnd(14));
1063
+ const barFill = r.pending
1064
+ ? theme.fg("dim", "-".repeat(15))
1065
+ : hexFg(r.color, "#".repeat(filled)) + theme.fg("dim", "-".repeat(empty));
1066
+ const bar = theme.fg("warning", "[") + barFill + theme.fg("warning", "]");
1067
+ const pctPart = " " + theme.fg("accent", pctLabel.padStart(4));
1068
+ const sep = theme.fg("dim", " — ");
1069
+ const purposePart = theme.fg("muted", r.purpose || "");
1070
+
1071
+ const line = " " + swatch + " " + namePart + " " + modelPart + " " + bar + pctPart + sep + purposePart;
1072
+ out.push(truncateToWidth(line, width));
1073
+ }
1074
+
1075
+ out.push(bottomBorder);
1076
+ return out;
1077
+ }
1078
+
1079
+ function installPoolWidget(ctx: ExtensionContext): void {
1080
+ if (!ctx.hasUI) return;
1081
+ try {
1082
+ ctx.ui.setWidget("coms-pool", (_tui, theme) => ({
1083
+ invalidate() {},
1084
+ render(width: number): string[] {
1085
+ return renderPool(width, theme);
1086
+ },
1087
+ }), { placement: "belowEditor" });
1088
+ } catch {
1089
+ // non-fatal
1090
+ }
1091
+ }
1092
+
1093
+ // ━━ Ping cycle ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1094
+ async function refreshPool(): Promise<void> {
1095
+ if (!identity) return;
1096
+ const projectFilter = displayProject ?? identity.project;
1097
+ const live = projectFilter === "*"
1098
+ ? pruneDeadEntriesAllProjects()
1099
+ : pruneDeadEntries(projectFilter);
1100
+
1101
+ const peers = live.filter((e) =>
1102
+ e.session_id !== identity!.session_id && (includeExplicit || !e.explicit),
1103
+ );
1104
+
1105
+ const results = await Promise.allSettled(peers.map(async (peer) => {
1106
+ const pingEnv: PingEnvelope = {
1107
+ type: "ping",
1108
+ msg_id: ulid(),
1109
+ sender_session: identity!.session_id,
1110
+ sender_endpoint: identity!.endpoint,
1111
+ hops: 0,
1112
+ timestamp: nowIso(),
1113
+ };
1114
+ const reply = await sendEnvelope(peer.endpoint, pingEnv);
1115
+ return { peer, pong: reply as Pong };
1116
+ }));
1117
+
1118
+ const seenSessions = new Set<string>();
1119
+ let changed = false;
1120
+
1121
+ for (const r of results) {
1122
+ if (r.status === "fulfilled" && r.value.pong && r.value.pong.agent_card) {
1123
+ const { peer, pong } = r.value;
1124
+ seenSessions.add(peer.session_id);
1125
+ const prev = peerCards.get(peer.session_id);
1126
+ const next = { ...pong.agent_card, staleCount: 0 };
1127
+ if (!prev || JSON.stringify({ ...prev, staleCount: 0 }) !== JSON.stringify(next)) {
1128
+ peerCards.set(peer.session_id, next);
1129
+ changed = true;
1130
+ }
1131
+ }
1132
+ }
1133
+
1134
+ for (const [sid, card] of peerCards.entries()) {
1135
+ if (identity && sid === identity.session_id) continue;
1136
+ if (!seenSessions.has(sid)) {
1137
+ card.staleCount = (card.staleCount ?? 0) + 1;
1138
+ if (card.staleCount > 6) {
1139
+ peerCards.delete(sid);
1140
+ }
1141
+ changed = true;
1142
+ }
1143
+ }
1144
+
1145
+ if (changed && currentCtx?.hasUI) {
1146
+ installPoolWidget(currentCtx);
1147
+ }
1148
+ }
1149
+
1150
+ function listProjects(): string[] {
1151
+ const root = path.join(COMS_DIR, "projects");
1152
+ try {
1153
+ return fs.readdirSync(root).filter((d) => {
1154
+ try { return fs.statSync(path.join(root, d)).isDirectory(); } catch { return false; }
1155
+ });
1156
+ } catch {
1157
+ return [];
1158
+ }
1159
+ }
1160
+
1161
+ function resolveTarget(target: string): RegistryEntry | null {
1162
+ // Prefer name match within current project.
1163
+ if (identity) {
1164
+ const localEntries = pruneDeadEntries(identity.project);
1165
+ const byName = localEntries.find((e) => e.name === target);
1166
+ if (byName) return byName;
1167
+ }
1168
+ // Fall back to scanning all projects by session_id (or name as last resort).
1169
+ for (const proj of listProjects()) {
1170
+ const entries = pruneDeadEntries(proj);
1171
+ const bySession = entries.find((e) => e.session_id === target);
1172
+ if (bySession) return bySession;
1173
+ }
1174
+ for (const proj of listProjects()) {
1175
+ const entries = pruneDeadEntries(proj);
1176
+ const byName = entries.find((e) => e.name === target);
1177
+ if (byName) return byName;
1178
+ }
1179
+ return null;
1180
+ }
1181
+
1182
+ // ━━ Tools ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1183
+
1184
+ pi.registerTool({
1185
+ name: "coms_list",
1186
+ label: "Coms List",
1187
+ description:
1188
+ "List peer agents discoverable via coms. Returns names, models, and live context-window usage. " +
1189
+ "Use project=\"*\" to scan all projects. include_explicit=true reveals agents marked --explicit.",
1190
+ parameters: Type.Object({
1191
+ project: Type.Optional(Type.String({ description: "Project name, or \"*\" for all projects. Defaults to caller's project." })),
1192
+ include_explicit: Type.Optional(Type.Boolean({ description: "Include agents launched with --explicit. Default false." })),
1193
+ }),
1194
+ async execute(_callId, params) {
1195
+ const includeExp = params.include_explicit === true;
1196
+ const projectFilter = params.project ?? identity?.project ?? "default";
1197
+ const projects = projectFilter === "*" ? listProjects() : [projectFilter];
1198
+
1199
+ const collected: { entry: RegistryEntry; project: string }[] = [];
1200
+ for (const proj of projects) {
1201
+ for (const entry of pruneDeadEntries(proj)) {
1202
+ if (entry.explicit && !includeExp) continue;
1203
+ if (identity && entry.session_id === identity.session_id) continue;
1204
+ collected.push({ entry, project: proj });
1205
+ }
1206
+ }
1207
+
1208
+ // Ping each peer in parallel for live context usage.
1209
+ const pongs = await Promise.allSettled(collected.map((c) => pingPeer(c.entry.endpoint)));
1210
+
1211
+ const agents = collected.map((c, i) => {
1212
+ const r = pongs[i];
1213
+ const pong = r.status === "fulfilled" ? r.value : null;
1214
+ return {
1215
+ name: c.entry.name,
1216
+ session_id: c.entry.session_id,
1217
+ purpose: c.entry.purpose,
1218
+ model: c.entry.model,
1219
+ cwd: c.entry.cwd,
1220
+ project: c.project,
1221
+ alive: pong != null,
1222
+ context_used_pct: pong ? pong.context_used_pct : null,
1223
+ color: c.entry.color,
1224
+ };
1225
+ });
1226
+
1227
+ const lines = agents.length === 0
1228
+ ? "No peer agents found."
1229
+ : agents.map((a) => {
1230
+ const ctxStr = a.context_used_pct != null ? ` ${a.context_used_pct}%` : " ?%";
1231
+ const live = a.alive ? "●" : "✗";
1232
+ return `${live} ${a.name} (${a.model})${ctxStr}${a.purpose ? ` — ${a.purpose}` : ""}`;
1233
+ }).join("\n");
1234
+
1235
+ return {
1236
+ content: [{ type: "text" as const, text: `${agents.length} peer(s):\n${lines}` }],
1237
+ details: { agents, project: projectFilter },
1238
+ };
1239
+ },
1240
+ renderCall(args, theme) {
1241
+ const proj = (args as any).project;
1242
+ const filter = proj ? ` ${proj}` : "";
1243
+ return new Text(
1244
+ theme.fg("toolTitle", theme.bold("coms_list")) + theme.fg("dim", filter),
1245
+ 0, 0,
1246
+ );
1247
+ },
1248
+ renderResult(result, options, theme) {
1249
+ const details = result.details as any;
1250
+ const agents: any[] = details?.agents ?? [];
1251
+ const header = theme.fg("accent", `📡 ${agents.length} peer(s)`);
1252
+ if (!options.expanded || agents.length === 0) {
1253
+ return new Text(header, 0, 0);
1254
+ }
1255
+ const rows = agents.map((a) => {
1256
+ const dot = a.alive ? theme.fg("success", "●") : theme.fg("error", "✗");
1257
+ const pct = a.context_used_pct != null ? `${a.context_used_pct}%` : "?%";
1258
+ return `${dot} ${theme.fg("accent", a.name)} ${theme.fg("dim", a.model)} ${theme.fg("warning", pct)}`;
1259
+ }).join("\n");
1260
+ return new Text(header + "\n" + rows, 0, 0);
1261
+ },
1262
+ });
1263
+
1264
+ pi.registerTool({
1265
+ name: "coms_send",
1266
+ label: "Coms Send",
1267
+ description:
1268
+ "Send a prompt to a peer agent. Returns synchronously with a msg_id once the receiver acks. " +
1269
+ "Use coms_get (non-blocking) or coms_await (blocking) with the msg_id to retrieve the response. " +
1270
+ "Throws if the receiver is unreachable or rejects the envelope.",
1271
+ parameters: Type.Object({
1272
+ target: Type.String({ description: "Peer name (preferred, scoped to your project) or session_id (global)." }),
1273
+ prompt: Type.String({ description: "The prompt to send." }),
1274
+ conversation_id: Type.Optional(Type.String()),
1275
+ response_schema: Type.Optional(Type.Any({ description: "Optional JSON Schema describing the expected response shape." })),
1276
+ }),
1277
+ async execute(_callId, params) {
1278
+ if (!identity) {
1279
+ throw new Error("coms not initialised");
1280
+ }
1281
+ const target = resolveTarget(params.target);
1282
+ if (!target) {
1283
+ throw new Error(`coms: no live agent matching "${params.target}"`);
1284
+ }
1285
+ const hops = currentInbound ? currentInbound.hops + 1 : 0;
1286
+ if (hops >= MAX_HOPS) {
1287
+ throw new Error(`coms: hop limit reached (${hops} >= ${MAX_HOPS})`);
1288
+ }
1289
+ const msg_id = ulid();
1290
+ const env: PromptEnvelope = {
1291
+ type: "prompt",
1292
+ msg_id,
1293
+ sender_session: identity.session_id,
1294
+ sender_endpoint: identity.endpoint,
1295
+ sender_name: identity.name,
1296
+ sender_cwd: identity.cwd,
1297
+ hops,
1298
+ timestamp: nowIso(),
1299
+ prompt: params.prompt,
1300
+ conversation_id: params.conversation_id ?? null,
1301
+ response_schema: (params.response_schema as object | undefined) ?? null,
1302
+ };
1303
+
1304
+ // Send the envelope synchronously and wait for the receiver's ack.
1305
+ await sendEnvelope(target.endpoint, env);
1306
+
1307
+ // Register a pending entry whose promise the receiver-side handleResponse
1308
+ // (or the timeout below) will settle.
1309
+ let resolveFn!: (v: { response?: any; error?: string | null }) => void;
1310
+ let rejectFn!: (e: Error) => void;
1311
+ const promise = new Promise<{ response?: any; error?: string | null }>((res, rej) => {
1312
+ resolveFn = res;
1313
+ rejectFn = rej;
1314
+ });
1315
+ const entry: PendingReply = {
1316
+ resolve: resolveFn,
1317
+ reject: rejectFn,
1318
+ timer: null,
1319
+ promise,
1320
+ target_name: target.name,
1321
+ created_at: nowIso(),
1322
+ };
1323
+ entry.timer = setTimeout(() => {
1324
+ if (entry.result) return;
1325
+ entry.result = { error: "timeout" };
1326
+ try { entry.resolve(entry.result); } catch { /* ignore */ }
1327
+ }, TIMEOUT_MS);
1328
+ // Don't keep the event loop alive solely for this timer.
1329
+ try { (entry.timer as any).unref?.(); } catch { /* ignore */ }
1330
+ pendingReplies.set(msg_id, entry);
1331
+
1332
+ try {
1333
+ pi.appendEntry("coms-log", {
1334
+ event: "outbound_prompt",
1335
+ msg_id,
1336
+ target: target.name,
1337
+ hops,
1338
+ });
1339
+ } catch {
1340
+ // best-effort
1341
+ }
1342
+
1343
+ return {
1344
+ content: [{ type: "text" as const, text: `coms_send → ${target.name}\nmsg_id ${msg_id}\nhops ${hops}` }],
1345
+ details: { msg_id, target: target.name, target_session: target.session_id, hops },
1346
+ };
1347
+ },
1348
+ renderCall(args, theme) {
1349
+ const tgt = (args as any).target ?? "?";
1350
+ const prompt = (args as any).prompt ?? "";
1351
+ const preview = prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt;
1352
+ return new Text(
1353
+ theme.fg("toolTitle", theme.bold("coms_send ")) +
1354
+ theme.fg("accent", tgt) +
1355
+ theme.fg("dim", " — ") +
1356
+ theme.fg("muted", preview),
1357
+ 0, 0,
1358
+ );
1359
+ },
1360
+ renderResult(result, _options, theme) {
1361
+ const d = result.details as any;
1362
+ if (!d) {
1363
+ const t = result.content[0];
1364
+ return new Text(t?.type === "text" ? t.text : "", 0, 0);
1365
+ }
1366
+ return new Text(
1367
+ theme.fg("success", "→ ") +
1368
+ theme.fg("accent", d.target) +
1369
+ theme.fg("dim", ` msg_id `) +
1370
+ theme.fg("warning", d.msg_id),
1371
+ 0, 0,
1372
+ );
1373
+ },
1374
+ });
1375
+
1376
+ pi.registerTool({
1377
+ name: "coms_get",
1378
+ label: "Coms Get",
1379
+ description:
1380
+ "Non-blocking poll of a pending coms_send reply. Returns status pending|complete|error and (when complete) the response.",
1381
+ parameters: Type.Object({
1382
+ msg_id: Type.String({ description: "msg_id returned by coms_send." }),
1383
+ }),
1384
+ async execute(_callId, params) {
1385
+ const entry = pendingReplies.get(params.msg_id);
1386
+ if (!entry) {
1387
+ return {
1388
+ content: [{ type: "text" as const, text: `coms_get: unknown msg_id ${params.msg_id}` }],
1389
+ details: { status: "error", error: "unknown msg_id" },
1390
+ };
1391
+ }
1392
+ if (entry.result) {
1393
+ const r = entry.result;
1394
+ const text = r.error
1395
+ ? `coms_get: error — ${r.error}`
1396
+ : `coms_get: complete\n${typeof r.response === "string" ? r.response : JSON.stringify(r.response, null, 2)}`;
1397
+ return {
1398
+ content: [{ type: "text" as const, text }],
1399
+ details: { status: "complete", response: r.response, error: r.error ?? null },
1400
+ };
1401
+ }
1402
+ return {
1403
+ content: [{ type: "text" as const, text: `coms_get: pending` }],
1404
+ details: { status: "pending" },
1405
+ };
1406
+ },
1407
+ renderCall(args, theme) {
1408
+ const id = (args as any).msg_id ?? "?";
1409
+ return new Text(
1410
+ theme.fg("toolTitle", theme.bold("coms_get ")) + theme.fg("warning", id),
1411
+ 0, 0,
1412
+ );
1413
+ },
1414
+ renderResult(result, _options, theme) {
1415
+ const d = result.details as any;
1416
+ const status = d?.status ?? "?";
1417
+ const color = status === "complete" ? "success" : status === "pending" ? "warning" : "error";
1418
+ return new Text(theme.fg(color, status), 0, 0);
1419
+ },
1420
+ });
1421
+
1422
+ pi.registerTool({
1423
+ name: "coms_await",
1424
+ label: "Coms Await",
1425
+ description:
1426
+ "Block until a pending coms_send reply lands or the timeout fires. Default timeout 30 minutes (PI_COMS_TIMEOUT_MS).",
1427
+ parameters: Type.Object({
1428
+ msg_id: Type.String({ description: "msg_id returned by coms_send." }),
1429
+ timeout_ms: Type.Optional(Type.Number({ description: "Override the default timeout (ms)." })),
1430
+ }),
1431
+ async execute(_callId, params) {
1432
+ const entry = pendingReplies.get(params.msg_id);
1433
+ if (!entry) {
1434
+ return {
1435
+ content: [{ type: "text" as const, text: `coms_await: unknown msg_id ${params.msg_id}` }],
1436
+ details: { error: "unknown msg_id" },
1437
+ };
1438
+ }
1439
+ const timeoutMs = typeof params.timeout_ms === "number" && params.timeout_ms > 0
1440
+ ? params.timeout_ms
1441
+ : TIMEOUT_MS;
1442
+
1443
+ const timed = new Promise<{ error: string }>((resolve) => {
1444
+ const t = setTimeout(() => resolve({ error: "timeout" }), timeoutMs);
1445
+ try { (t as any).unref?.(); } catch { /* ignore */ }
1446
+ });
1447
+
1448
+ const winner = await Promise.race([entry.promise, timed]);
1449
+ if ((winner as any).error) {
1450
+ return {
1451
+ content: [{ type: "text" as const, text: `coms_await: error — ${(winner as any).error}` }],
1452
+ details: { error: (winner as any).error },
1453
+ };
1454
+ }
1455
+ const resp = (winner as any).response;
1456
+ return {
1457
+ content: [{ type: "text" as const, text: typeof resp === "string" ? resp : JSON.stringify(resp, null, 2) }],
1458
+ details: { response: resp },
1459
+ };
1460
+ },
1461
+ renderCall(args, theme) {
1462
+ const id = (args as any).msg_id ?? "?";
1463
+ return new Text(
1464
+ theme.fg("toolTitle", theme.bold("coms_await ")) + theme.fg("warning", id),
1465
+ 0, 0,
1466
+ );
1467
+ },
1468
+ renderResult(result, _options, theme) {
1469
+ const d = result.details as any;
1470
+ if (d?.error) return new Text(theme.fg("error", `✗ ${d.error}`), 0, 0);
1471
+ return new Text(theme.fg("success", "✓ response received"), 0, 0);
1472
+ },
1473
+ });
1474
+
1475
+ // ━━ agent_end: capture turn output and dispatch response back ━━━━━━━━
1476
+
1477
+ pi.on("agent_end", async (_event, ctx) => {
1478
+ const inbound = [...inboundQueue.values()].reverse().find((i) => !i.fulfilled);
1479
+ if (!inbound || !identity) return;
1480
+
1481
+ // Walk the session branch for the most recent assistant message text.
1482
+ let lastAssistantText = "";
1483
+ for (const entry of ctx.sessionManager.getBranch()) {
1484
+ if (entry.type === "message" && entry.message.role === "assistant") {
1485
+ const m = entry.message as any;
1486
+ if (typeof m.content === "string") {
1487
+ lastAssistantText = m.content;
1488
+ } else if (Array.isArray(m.content)) {
1489
+ lastAssistantText = m.content
1490
+ .filter((b: any) => b && b.type === "text")
1491
+ .map((b: any) => b.text)
1492
+ .join("\n");
1493
+ }
1494
+ }
1495
+ }
1496
+
1497
+ let payload: any = lastAssistantText;
1498
+ let error: string | null = null;
1499
+ if (inbound.response_schema && typeof inbound.response_schema === "object") {
1500
+ try {
1501
+ payload = JSON.parse(lastAssistantText);
1502
+ } catch {
1503
+ error = "response not valid JSON";
1504
+ payload = null;
1505
+ }
1506
+ }
1507
+
1508
+ const respEnv: ResponseEnvelope = {
1509
+ type: "response",
1510
+ msg_id: inbound.msg_id,
1511
+ sender_session: identity.session_id,
1512
+ sender_endpoint: identity.endpoint,
1513
+ hops: 0,
1514
+ timestamp: nowIso(),
1515
+ response: payload,
1516
+ error,
1517
+ };
1518
+
1519
+ try {
1520
+ await sendEnvelope(inbound.sender_endpoint, respEnv);
1521
+ try {
1522
+ pi.appendEntry("coms-log", {
1523
+ event: "outbound_response",
1524
+ msg_id: inbound.msg_id,
1525
+ error,
1526
+ });
1527
+ } catch {
1528
+ // best-effort
1529
+ }
1530
+ } catch (e: any) {
1531
+ try {
1532
+ pi.appendEntry("coms-log", {
1533
+ event: "outbound_response_failed",
1534
+ msg_id: inbound.msg_id,
1535
+ reason: e?.message ?? String(e),
1536
+ });
1537
+ } catch {
1538
+ // best-effort
1539
+ }
1540
+ }
1541
+
1542
+ inbound.fulfilled = true;
1543
+ inboundQueue.delete(inbound.msg_id);
1544
+ if (currentInbound && currentInbound.msg_id === inbound.msg_id) {
1545
+ currentInbound = null;
1546
+ }
1547
+ });
1548
+
1549
+ // ━━ /coms slash command ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1550
+ pi.registerCommand("coms", {
1551
+ description: "Force-refresh the coms pool widget (or filter with --all / --project <name>)",
1552
+ handler: async (args, ctx) => {
1553
+ const trimmed = (args ?? "").trim();
1554
+ if (trimmed.includes("--all")) {
1555
+ includeExplicit = !includeExplicit;
1556
+ try { ctx.ui.notify(`coms: include_explicit = ${includeExplicit}`, "info"); } catch { /* ignore */ }
1557
+ }
1558
+ const projectMatch = trimmed.match(/--project\s+(\S+)/);
1559
+ if (projectMatch) {
1560
+ displayProject = projectMatch[1];
1561
+ try { ctx.ui.notify(`coms: displaying project ${displayProject}`, "info"); } catch { /* ignore */ }
1562
+ }
1563
+ await refreshPool();
1564
+ },
1565
+ });
1566
+
1567
+ // ━━ Clean shutdown ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1568
+ let shuttingDown = false;
1569
+ async function cleanShutdown(): Promise<void> {
1570
+ if (shuttingDown) return;
1571
+ shuttingDown = true;
1572
+ if (pingTimer) { try { clearInterval(pingTimer); } catch { /* ignore */ } pingTimer = null; }
1573
+ if (keepaliveTimer) { try { clearInterval(keepaliveTimer); } catch { /* ignore */ } keepaliveTimer = null; }
1574
+ if (server) {
1575
+ try { server.close(); } catch { /* ignore */ }
1576
+ server = null;
1577
+ }
1578
+ if (identity) {
1579
+ if (process.platform !== "win32") {
1580
+ try { fs.unlinkSync(identity.endpoint); } catch { /* ignore */ }
1581
+ }
1582
+ try { removeRegistryEntry(identity.project, identity.name); } catch { /* ignore */ }
1583
+ try {
1584
+ pi.appendEntry("coms-log", { event: "shutdown", session_id: identity.session_id });
1585
+ } catch { /* best-effort */ }
1586
+ }
1587
+ if (currentCtx?.hasUI) {
1588
+ try { currentCtx.ui.setWidget("coms-pool", undefined); } catch { /* ignore */ }
1589
+ }
1590
+ }
1591
+
1592
+ pi.on("session_shutdown", async () => { await cleanShutdown(); });
1593
+ process.on("SIGINT", () => { void cleanShutdown(); });
1594
+ process.on("SIGTERM", () => { void cleanShutdown(); });
1595
+ }