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.
- package/README.md +808 -0
- package/agents/api-documenter.md +32 -0
- package/agents/bash-expert.md +53 -0
- package/agents/code-reviewer-docs.md +145 -0
- package/agents/code-reviewer-guidelines.md +97 -0
- package/agents/code-reviewer-quality.md +142 -0
- package/agents/code-reviewer-security.md +107 -0
- package/agents/code-reviewer-spec.md +164 -0
- package/agents/debugger.md +63 -0
- package/agents/docker-expert.md +40 -0
- package/agents/docs-fetcher.md +48 -0
- package/agents/git-expert.md +63 -0
- package/agents/github-expert.md +83 -0
- package/agents/go-expert.md +62 -0
- package/agents/issue-reviewer-feasibility.md +60 -0
- package/agents/issue-reviewer-scope.md +63 -0
- package/agents/issue-reviewer-spec.md +77 -0
- package/agents/java-expert.md +31 -0
- package/agents/jenkins-expert.md +38 -0
- package/agents/kubernetes-expert.md +40 -0
- package/agents/planner.md +42 -0
- package/agents/python-expert.md +61 -0
- package/agents/reviewer.md +26 -0
- package/agents/scout.md +40 -0
- package/agents/security-auditor.md +254 -0
- package/agents/technical-documentation-writer.md +37 -0
- package/agents/test-automator.md +55 -0
- package/agents/test-runner.md +47 -0
- package/agents/ts-expert.md +23 -0
- package/agents/worker.md +30 -0
- package/extensions/acpx-provider/index.ts +104 -0
- package/extensions/acpx-provider/load-runtime.ts +99 -0
- package/extensions/acpx-provider/runtime-models.ts +40 -0
- package/extensions/cli-provider/agents/claude.ts +128 -0
- package/extensions/cli-provider/agents/cursor.ts +84 -0
- package/extensions/cli-provider/agents/gemini.ts +100 -0
- package/extensions/cli-provider/discover.ts +77 -0
- package/extensions/cli-provider/index.ts +57 -0
- package/extensions/cli-provider/parsers.ts +304 -0
- package/extensions/cli-provider/providers.ts +58 -0
- package/extensions/cli-provider/runner.ts +225 -0
- package/extensions/cli-provider/runtime-models.ts +32 -0
- package/extensions/cli-provider/session-reaper.ts +122 -0
- package/extensions/cli-provider/sessions.ts +533 -0
- package/extensions/cli-provider/shared/discover-cache.ts +235 -0
- package/extensions/cli-provider/types.ts +48 -0
- package/extensions/coms/coms-net-server.ts +1642 -0
- package/extensions/coms/coms-net-wrapper.ts +521 -0
- package/extensions/coms/coms-net.ts +1589 -0
- package/extensions/coms/coms-p2p.ts +1879 -0
- package/extensions/coms/coms-shared.ts +510 -0
- package/extensions/coms/coms-wrapper.ts +138 -0
- package/extensions/coms/index.ts +20 -0
- package/extensions/coms/themeMap.ts +147 -0
- package/extensions/image-gen/image-gen.ts +332 -0
- package/extensions/image-gen/index.ts +14 -0
- package/extensions/orchestrator/agents.ts +134 -0
- package/extensions/orchestrator/ask-user.ts +293 -0
- package/extensions/orchestrator/async-agents.ts +1037 -0
- package/extensions/orchestrator/async-capability.ts +129 -0
- package/extensions/orchestrator/async-runner.ts +324 -0
- package/extensions/orchestrator/async-status-parse.ts +31 -0
- package/extensions/orchestrator/async-status-ui.ts +201 -0
- package/extensions/orchestrator/async-wait.ts +36 -0
- package/extensions/orchestrator/btw.ts +210 -0
- package/extensions/orchestrator/cron-status-format.ts +126 -0
- package/extensions/orchestrator/cron-status-ui.ts +132 -0
- package/extensions/orchestrator/cron.ts +730 -0
- package/extensions/orchestrator/dreaming.ts +355 -0
- package/extensions/orchestrator/enforcement-helpers.ts +530 -0
- package/extensions/orchestrator/enforcement-rules.ts +329 -0
- package/extensions/orchestrator/enforcement.ts +828 -0
- package/extensions/orchestrator/extended-autocomplete.ts +593 -0
- package/extensions/orchestrator/git-helpers.ts +420 -0
- package/extensions/orchestrator/github-autocomplete.ts +250 -0
- package/extensions/orchestrator/icons.ts +17 -0
- package/extensions/orchestrator/index.ts +100 -0
- package/extensions/orchestrator/memory-embeddings.ts +281 -0
- package/extensions/orchestrator/memory-promotion.ts +449 -0
- package/extensions/orchestrator/memory-provenance.ts +80 -0
- package/extensions/orchestrator/memory-query-class.ts +90 -0
- package/extensions/orchestrator/memory-scoring.ts +434 -0
- package/extensions/orchestrator/memory-tools.ts +883 -0
- package/extensions/orchestrator/memory-tree.ts +212 -0
- package/extensions/orchestrator/nvim.ts +169 -0
- package/extensions/orchestrator/overlay-dashboard-utils.ts +25 -0
- package/extensions/orchestrator/overlay-dashboard.ts +512 -0
- package/extensions/orchestrator/pi-config-review-state.ts +303 -0
- package/extensions/orchestrator/preference-extractor.ts +106 -0
- package/extensions/orchestrator/project-settings.ts +468 -0
- package/extensions/orchestrator/promotion-queue.ts +311 -0
- package/extensions/orchestrator/resolve-agent-model.ts +32 -0
- package/extensions/orchestrator/review-ui.ts +204 -0
- package/extensions/orchestrator/rule-placeholders.ts +12 -0
- package/extensions/orchestrator/rules.ts +481 -0
- package/extensions/orchestrator/session-search.ts +243 -0
- package/extensions/orchestrator/session-validation.ts +374 -0
- package/extensions/orchestrator/situation-report.ts +370 -0
- package/extensions/orchestrator/status-bar.ts +31 -0
- package/extensions/orchestrator/status-line.ts +192 -0
- package/extensions/orchestrator/status.ts +110 -0
- package/extensions/orchestrator/subagent-tool.ts +1748 -0
- package/extensions/orchestrator/task-lifecycle.ts +88 -0
- package/extensions/orchestrator/utils.ts +227 -0
- package/extensions/pidash/index.ts +15 -0
- package/extensions/pidash/pidash.ts +941 -0
- package/extensions/pidiff/index.ts +13 -0
- package/extensions/pidiff/pidiff.ts +369 -0
- package/extensions/providers/acpx-driver.ts +479 -0
- package/extensions/providers/built-in-drivers.ts +52 -0
- package/extensions/providers/claude-driver.ts +319 -0
- package/extensions/providers/cursor-acpx-driver.ts +398 -0
- package/extensions/providers/cursor-cli-driver.ts +484 -0
- package/extensions/providers/gemini-driver.ts +302 -0
- package/extensions/providers/index.ts +513 -0
- package/extensions/shared/build-system-prompt.ts +57 -0
- package/extensions/shared/create-runtime-provider.ts +190 -0
- package/extensions/shared/daemon-manager.ts +248 -0
- package/extensions/shared/file-logger.ts +115 -0
- package/extensions/shared/managed-refresh.ts +211 -0
- package/extensions/shared/provider-driver.ts +295 -0
- package/extensions/shared/provider-errors.ts +305 -0
- package/extensions/shared/provider-registry.ts +388 -0
- package/extensions/shared/stream-builder.ts +275 -0
- package/extensions/shared/types.ts +20 -0
- package/extensions/shared/ui/README.md +10 -0
- package/extensions/shared/ui/badge.tsx +52 -0
- package/extensions/shared/ui/button.tsx +58 -0
- package/extensions/shared/ui/collapsible.tsx +21 -0
- package/extensions/shared/ui/dropdown-menu.tsx +266 -0
- package/extensions/shared/ui/input.tsx +20 -0
- package/extensions/shared/ui/popover.tsx +114 -0
- package/extensions/shared/ui/resizable.tsx +48 -0
- package/extensions/shared/ui/scroll-area.tsx +53 -0
- package/extensions/shared/ui/select.tsx +201 -0
- package/extensions/shared/ui/separator.tsx +25 -0
- package/extensions/shared/ui/tooltip.tsx +64 -0
- package/extensions/shared/ws-client.ts +74 -0
- package/package.json +59 -0
- package/prompts/coderabbit-rate-limit.md +95 -0
- package/prompts/create-coms-feature-manager.md +186 -0
- package/prompts/create-skill.md +128 -0
- package/prompts/domain-model.md +109 -0
- package/prompts/external-ai.md +665 -0
- package/prompts/implement-and-review.md +25 -0
- package/prompts/implement.md +26 -0
- package/prompts/issue-review.md +256 -0
- package/prompts/pr-review.md +753 -0
- package/prompts/query-db.md +154 -0
- package/prompts/refine-review.md +208 -0
- package/prompts/release.md +235 -0
- package/prompts/remember.md +33 -0
- package/prompts/review-handler-status.md +27 -0
- package/prompts/review-handler.md +677 -0
- package/prompts/review-local.md +159 -0
- package/prompts/scout-and-plan.md +21 -0
- package/rules/00-orchestrator-core.md +42 -0
- package/rules/05-issue-first-workflow.md +119 -0
- package/rules/10-agent-routing.md +58 -0
- package/rules/15-mcp-launchpad.md +46 -0
- package/rules/20-code-review-loop.md +234 -0
- package/rules/25-documentation-updates.md +15 -0
- package/rules/30-prompt-templates.md +19 -0
- package/rules/35-memory.md +240 -0
- package/rules/40-critical-rules.md +124 -0
- package/rules/45-file-preview.md +29 -0
- package/rules/50-agent-bug-reporting.md +90 -0
- package/rules/55-coms-protocol.md +63 -0
- package/rules/60-task-tracking.md +66 -0
- package/skills/conflict-resolver/SKILL.md +213 -0
- package/skills/skill-creator/SKILL.md +85 -0
- package/skills/systematic-debugging/SKILL.md +353 -0
- package/skills/test-driven-development/SKILL.md +335 -0
- package/skills/update-agents-md/SKILL.md +282 -0
- package/templates/coms-feature-manager-prompt.md +313 -0
|
@@ -0,0 +1,1879 @@
|
|
|
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 — Peer-to-peer messaging between Pi agents on the same machine
|
|
5
|
+
*
|
|
6
|
+
* Each agent listens on a single endpoint (unix socket on POSIX, named pipe on
|
|
7
|
+
* Windows) and discovers peers through per-project registry files under
|
|
8
|
+
* ~/.pi/coms/projects/<project>/agents/<name>.json.
|
|
9
|
+
*
|
|
10
|
+
* Phase A (foundation): identity resolution, registry I/O, transport bind/send,
|
|
11
|
+
* connection handlers. Phase B: tools (coms_list/send/get), agent_end
|
|
12
|
+
* response capture. Phase C: live pool widget, ping + keepalive cycles, /coms
|
|
13
|
+
* slash command, clean shutdown lifecycle.
|
|
14
|
+
*
|
|
15
|
+
* Usage: loaded via extensions/coms/index.ts
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
20
|
+
import { Type } from "typebox";
|
|
21
|
+
import { applyExtensionDefaults } from "./themeMap.js";
|
|
22
|
+
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";
|
|
23
|
+
import * as net from "node:net";
|
|
24
|
+
import * as fs from "node:fs";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import * as os from "node:os";
|
|
27
|
+
import * as crypto from "node:crypto";
|
|
28
|
+
import { Worker } from "node:worker_threads";
|
|
29
|
+
|
|
30
|
+
// ━━ Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
31
|
+
|
|
32
|
+
const COMS_DIR = process.env.PI_COMS_DIR || path.join(os.homedir(), ".pi", "coms");
|
|
33
|
+
const MAX_HOPS = Number(process.env.PI_COMS_MAX_HOPS) || 5;
|
|
34
|
+
const TIMEOUT_MS = Number(process.env.PI_COMS_TIMEOUT_MS) || 1_800_000;
|
|
35
|
+
const PING_INTERVAL_MS = Number(process.env.PI_COMS_PING_INTERVAL_MS) || 10_000;
|
|
36
|
+
const KEEPALIVE_INTERVAL_MS = 30_000;
|
|
37
|
+
const LINE_CAP_BYTES = 64 * 1024;
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
// ━━ Types ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
41
|
+
|
|
42
|
+
type EnvelopeType = "prompt" | "response" | "ping";
|
|
43
|
+
|
|
44
|
+
interface Envelope {
|
|
45
|
+
type: EnvelopeType;
|
|
46
|
+
msg_id: string;
|
|
47
|
+
sender_session: string;
|
|
48
|
+
sender_endpoint: string;
|
|
49
|
+
hops: number;
|
|
50
|
+
timestamp: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface PromptEnvelope extends Envelope {
|
|
54
|
+
type: "prompt";
|
|
55
|
+
prompt: string;
|
|
56
|
+
sender_name: string;
|
|
57
|
+
sender_cwd: string;
|
|
58
|
+
conversation_id?: string | null;
|
|
59
|
+
response_schema?: object | null;
|
|
60
|
+
tasks?: Array<{ subject: string; description: string }> | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ResponseEnvelope extends Envelope {
|
|
64
|
+
type: "response";
|
|
65
|
+
response: any;
|
|
66
|
+
error?: string | null;
|
|
67
|
+
queued_msg_ids?: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface PingEnvelope extends Envelope {
|
|
71
|
+
type: "ping";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface AgentCard {
|
|
75
|
+
name: string;
|
|
76
|
+
purpose: string;
|
|
77
|
+
model: string;
|
|
78
|
+
color: string;
|
|
79
|
+
context_used_pct: number;
|
|
80
|
+
queue_depth: number;
|
|
81
|
+
tasks_summary?: { total: number; completed: number; in_progress: number } | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Pong {
|
|
85
|
+
type: "pong";
|
|
86
|
+
msg_id: string;
|
|
87
|
+
agent_card: AgentCard;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface RegistryEntry {
|
|
91
|
+
session_id: string;
|
|
92
|
+
name: string;
|
|
93
|
+
purpose: string;
|
|
94
|
+
model: string;
|
|
95
|
+
color: string;
|
|
96
|
+
/** Informational only — not used for liveness checks (socket existence + heartbeat used instead). */
|
|
97
|
+
pid: number;
|
|
98
|
+
endpoint: string;
|
|
99
|
+
cwd: string;
|
|
100
|
+
started_at: string;
|
|
101
|
+
explicit: boolean;
|
|
102
|
+
version: number;
|
|
103
|
+
// Live status snapshot — refreshed every KEEPALIVE_INTERVAL_MS by the heartbeat.
|
|
104
|
+
// Optional so older entries (pre-heartbeat-refresh) still parse cleanly.
|
|
105
|
+
context_used_pct?: number;
|
|
106
|
+
queue_depth?: number;
|
|
107
|
+
heartbeat_at?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface PendingReply {
|
|
111
|
+
resolve: (value: any) => void;
|
|
112
|
+
reject: (err: Error) => void;
|
|
113
|
+
timer: NodeJS.Timeout | null;
|
|
114
|
+
promise: Promise<{ response?: any; error?: string | null; queued_msg_ids?: string[] }>;
|
|
115
|
+
result?: { response?: any; error?: string | null; queued_msg_ids?: string[] };
|
|
116
|
+
target_name?: string;
|
|
117
|
+
created_at: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface InboundContext {
|
|
121
|
+
msg_id: string;
|
|
122
|
+
hops: number;
|
|
123
|
+
sender_endpoint: string;
|
|
124
|
+
sender_session: string;
|
|
125
|
+
sender_name: string;
|
|
126
|
+
sender_cwd: string;
|
|
127
|
+
prompt: string;
|
|
128
|
+
tasks?: Array<{ subject: string; description: string }> | null;
|
|
129
|
+
response_schema?: object | null;
|
|
130
|
+
fulfilled: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function makeEndpoint(sessionId: string): string {
|
|
134
|
+
if (process.platform === "win32") {
|
|
135
|
+
return `\\\\.\\pipe\\pi-coms-${sessionId}`;
|
|
136
|
+
}
|
|
137
|
+
return path.join(COMS_DIR, "sockets", `${sessionId}.sock`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Inline ping worker — responds to pings on a separate socket, immune to main-thread event-loop blocks */
|
|
141
|
+
function createPingWorker(pingEndpoint: string): Worker {
|
|
142
|
+
const workerCode = `
|
|
143
|
+
const net = require("net");
|
|
144
|
+
const fs = require("fs");
|
|
145
|
+
const { parentPort, workerData } = require("worker_threads");
|
|
146
|
+
|
|
147
|
+
let cachedPong = null;
|
|
148
|
+
parentPort.on("message", (msg) => {
|
|
149
|
+
if (msg.type === "update_card") cachedPong = msg.pong;
|
|
150
|
+
if (msg.type === "shutdown") {
|
|
151
|
+
if (srv) try { srv.close(); } catch {}
|
|
152
|
+
try { fs.unlinkSync(workerData.endpoint); } catch {}
|
|
153
|
+
process.exit(0);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const srv = net.createServer((socket) => {
|
|
158
|
+
let buf = "";
|
|
159
|
+
socket.on("data", (chunk) => {
|
|
160
|
+
buf += chunk.toString("utf-8");
|
|
161
|
+
if (buf.length > 65536) { try { socket.destroy(); } catch {} return; }
|
|
162
|
+
const nl = buf.indexOf("\\n");
|
|
163
|
+
if (nl < 0) return;
|
|
164
|
+
const line = buf.slice(0, nl);
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(line);
|
|
167
|
+
if (parsed.type === "ping" && cachedPong) {
|
|
168
|
+
const resp = { ...cachedPong, msg_id: parsed.msg_id };
|
|
169
|
+
socket.write(JSON.stringify(resp) + "\\n");
|
|
170
|
+
} else {
|
|
171
|
+
socket.write(JSON.stringify({ type: "nack", msg_id: parsed.msg_id || "", error: "not ready" }) + "\\n");
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
socket.write(JSON.stringify({ type: "nack", msg_id: "", error: "parse error" }) + "\\n");
|
|
175
|
+
}
|
|
176
|
+
try { socket.end(); } catch {}
|
|
177
|
+
});
|
|
178
|
+
socket.once("error", () => { try { socket.destroy(); } catch {} });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Clean up stale socket file
|
|
182
|
+
if (fs.existsSync(workerData.endpoint)) {
|
|
183
|
+
try { fs.unlinkSync(workerData.endpoint); } catch {}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
srv.listen(workerData.endpoint, () => {
|
|
187
|
+
parentPort.postMessage({ type: "ready" });
|
|
188
|
+
});
|
|
189
|
+
srv.on("error", (err) => {
|
|
190
|
+
parentPort.postMessage({ type: "error", message: err.message });
|
|
191
|
+
});
|
|
192
|
+
`;
|
|
193
|
+
const worker = new Worker(workerCode, {
|
|
194
|
+
eval: true,
|
|
195
|
+
workerData: { endpoint: pingEndpoint },
|
|
196
|
+
});
|
|
197
|
+
worker.unref();
|
|
198
|
+
return worker;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ━━ CLI flag shape (read via pi.registerFlag/pi.getFlag) ━━━━━━━━━━━━━━━━━━━
|
|
202
|
+
|
|
203
|
+
interface CliFlags {
|
|
204
|
+
name?: string;
|
|
205
|
+
purpose?: string;
|
|
206
|
+
project?: string;
|
|
207
|
+
color?: string;
|
|
208
|
+
explicit?: boolean;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function readCliFlags(pi: ExtensionAPI): CliFlags {
|
|
212
|
+
// Identity flags are declared via pi.registerFlag at extension load time so
|
|
213
|
+
// pi's CLI parser accepts them; here we just read them back.
|
|
214
|
+
const name = pi.getFlag("cname") as string | undefined;
|
|
215
|
+
const purpose = pi.getFlag("purpose") as string | undefined;
|
|
216
|
+
const project = pi.getFlag("project") as string | undefined;
|
|
217
|
+
const color = pi.getFlag("color") as string | undefined;
|
|
218
|
+
const explicit = pi.getFlag("explicit") as boolean | undefined;
|
|
219
|
+
return {
|
|
220
|
+
name: name && name.length > 0 ? name : undefined,
|
|
221
|
+
purpose: purpose && purpose.length > 0 ? purpose : undefined,
|
|
222
|
+
project: project && project.length > 0 ? project : undefined,
|
|
223
|
+
color: color && color.length > 0 ? color : undefined,
|
|
224
|
+
explicit: explicit === true,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ━━ Registry I/O ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
229
|
+
|
|
230
|
+
function projectAgentsDir(project: string): string {
|
|
231
|
+
return path.join(COMS_DIR, "projects", project, "agents");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function registryFilePath(project: string, sessionId: string): string {
|
|
235
|
+
// Guard against path traversal from malformed session_id
|
|
236
|
+
if (sessionId.includes("/") || sessionId.includes("\\") || sessionId.includes("..")) {
|
|
237
|
+
throw new Error(`Invalid session_id: ${sessionId}`);
|
|
238
|
+
}
|
|
239
|
+
return path.join(projectAgentsDir(project), `${sessionId}.json`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function writeRegistryAtomic(entry: RegistryEntry, project: string): string {
|
|
243
|
+
const dir = projectAgentsDir(project);
|
|
244
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
245
|
+
const final = registryFilePath(project, entry.session_id);
|
|
246
|
+
const tmp = `${final}.tmp`;
|
|
247
|
+
fs.writeFileSync(tmp, JSON.stringify(entry, null, 2));
|
|
248
|
+
fs.renameSync(tmp, final);
|
|
249
|
+
return final;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function readAllRegistryEntries(project: string): RegistryEntry[] {
|
|
253
|
+
const dir = projectAgentsDir(project);
|
|
254
|
+
if (!fs.existsSync(dir)) return [];
|
|
255
|
+
const out: RegistryEntry[] = [];
|
|
256
|
+
let files: string[];
|
|
257
|
+
try {
|
|
258
|
+
files = fs.readdirSync(dir);
|
|
259
|
+
} catch {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
for (const f of files) {
|
|
263
|
+
if (!f.endsWith(".json")) continue;
|
|
264
|
+
try {
|
|
265
|
+
const raw = fs.readFileSync(path.join(dir, f), "utf-8");
|
|
266
|
+
const parsed = JSON.parse(raw) as RegistryEntry;
|
|
267
|
+
if (parsed && typeof parsed.session_id === "string") {
|
|
268
|
+
out.push(parsed);
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
// skip malformed
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function readAllRegistryEntriesAcrossProjects(): RegistryEntry[] {
|
|
278
|
+
const root = path.join(COMS_DIR, "projects");
|
|
279
|
+
let projects: string[];
|
|
280
|
+
try {
|
|
281
|
+
projects = fs.readdirSync(root);
|
|
282
|
+
} catch {
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
const out: RegistryEntry[] = [];
|
|
286
|
+
for (const p of projects) {
|
|
287
|
+
try {
|
|
288
|
+
if (!fs.statSync(path.join(root, p)).isDirectory()) continue;
|
|
289
|
+
} catch {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
out.push(...readAllRegistryEntries(p));
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function removeRegistryEntry(project: string, sessionId: string): void {
|
|
298
|
+
try {
|
|
299
|
+
fs.unlinkSync(registryFilePath(project, sessionId));
|
|
300
|
+
} catch {
|
|
301
|
+
// best-effort
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function pruneDeadEntries(project: string): Promise<RegistryEntry[]> {
|
|
306
|
+
const entries = readAllRegistryEntries(project);
|
|
307
|
+
const socketsDir = path.join(COMS_DIR, "sockets");
|
|
308
|
+
|
|
309
|
+
// Phase 1: Sync pre-filter (fast — no I/O)
|
|
310
|
+
const candidates: RegistryEntry[] = [];
|
|
311
|
+
for (const entry of entries) {
|
|
312
|
+
if (!entry.endpoint || typeof entry.endpoint !== "string") {
|
|
313
|
+
removeRegistryEntry(project, entry.session_id);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
// On Windows, endpoints are named pipes — fs.existsSync doesn't apply
|
|
317
|
+
if (process.platform !== "win32" && !fs.existsSync(entry.endpoint)) {
|
|
318
|
+
removeRegistryEntry(project, entry.session_id);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
// Check heartbeat/started_at structural validity (malformed = remove, missing = remove)
|
|
322
|
+
// Timestamp staleness is NOT checked here — stale entries go to Phase 2 for socket probing.
|
|
323
|
+
// This prevents false pruning after laptop suspend/resume when all timestamps are stale
|
|
324
|
+
// but peers are still alive.
|
|
325
|
+
const lastSeen = entry.heartbeat_at ?? entry.started_at;
|
|
326
|
+
if (lastSeen) {
|
|
327
|
+
const lastSeenMs = new Date(lastSeen).getTime();
|
|
328
|
+
if (isNaN(lastSeenMs)) {
|
|
329
|
+
removeRegistryEntry(project, entry.session_id);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
removeRegistryEntry(project, entry.session_id);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
candidates.push(entry);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Phase 2: Parallel socket probes (capped at 10 concurrent to avoid EMFILE)
|
|
340
|
+
if (candidates.length === 0) return [];
|
|
341
|
+
const PROBE_CONCURRENCY = 10;
|
|
342
|
+
const results: ("in_use" | "stale")[] = [];
|
|
343
|
+
for (let start = 0; start < candidates.length; start += PROBE_CONCURRENCY) {
|
|
344
|
+
const batch = candidates.slice(start, start + PROBE_CONCURRENCY);
|
|
345
|
+
const batchResults = await Promise.allSettled(batch.map(entry => probeStaleSocket(entry.endpoint)));
|
|
346
|
+
results.push(...batchResults.map(r => r.status === "fulfilled" ? r.value : "in_use" as const));
|
|
347
|
+
}
|
|
348
|
+
const live: RegistryEntry[] = [];
|
|
349
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
350
|
+
if (results[i] === "stale") {
|
|
351
|
+
const entry = candidates[i];
|
|
352
|
+
removeRegistryEntry(project, entry.session_id);
|
|
353
|
+
if (entry.endpoint.startsWith(socketsDir + path.sep) || entry.endpoint.startsWith(socketsDir + "/")) {
|
|
354
|
+
try { fs.unlinkSync(entry.endpoint); } catch { /* best-effort */ }
|
|
355
|
+
try { fs.unlinkSync(`${entry.endpoint}.ping`); } catch { /* best-effort */ }
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
live.push(candidates[i]);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return live;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function pruneDeadEntriesAllProjects(): Promise<RegistryEntry[]> {
|
|
365
|
+
const root = path.join(COMS_DIR, "projects");
|
|
366
|
+
let projects: string[];
|
|
367
|
+
try {
|
|
368
|
+
projects = fs.readdirSync(root);
|
|
369
|
+
} catch {
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
const out: RegistryEntry[] = [];
|
|
373
|
+
for (const p of projects) {
|
|
374
|
+
try {
|
|
375
|
+
if (!fs.statSync(path.join(root, p)).isDirectory()) continue;
|
|
376
|
+
} catch {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
out.push(...await pruneDeadEntries(p));
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ━━ Transport ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
385
|
+
|
|
386
|
+
function probeStaleSocket(endpoint: string): Promise<"in_use" | "stale"> {
|
|
387
|
+
return new Promise((resolve) => {
|
|
388
|
+
const sock = net.createConnection({ path: endpoint });
|
|
389
|
+
let settled = false;
|
|
390
|
+
const finish = (verdict: "in_use" | "stale") => {
|
|
391
|
+
if (settled) return;
|
|
392
|
+
settled = true;
|
|
393
|
+
try { sock.destroy(); } catch { /* ignore */ }
|
|
394
|
+
resolve(verdict);
|
|
395
|
+
};
|
|
396
|
+
const timer = setTimeout(() => finish("stale"), 250);
|
|
397
|
+
sock.once("connect", () => {
|
|
398
|
+
clearTimeout(timer);
|
|
399
|
+
finish("in_use");
|
|
400
|
+
});
|
|
401
|
+
sock.once("error", (err: any) => {
|
|
402
|
+
clearTimeout(timer);
|
|
403
|
+
if (err && (err.code === "ECONNREFUSED" || err.code === "ENOENT")) {
|
|
404
|
+
finish("stale");
|
|
405
|
+
} else {
|
|
406
|
+
// Transient errors (EMFILE, EACCES, etc.) — treat as live to avoid false pruning
|
|
407
|
+
finish("in_use");
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Strict liveness probe — only returns "alive" on actual successful TCP connect.
|
|
415
|
+
* Unlike probeStaleSocket which treats transient errors as "in_use" (safe for
|
|
416
|
+
* pruning decisions), this function treats ANY error as "dead" (safe for
|
|
417
|
+
* duplicate-name rejection where false positives block startup).
|
|
418
|
+
*/
|
|
419
|
+
function probeAlive(endpoint: string): Promise<boolean> {
|
|
420
|
+
return new Promise((resolve) => {
|
|
421
|
+
const sock = net.createConnection({ path: endpoint });
|
|
422
|
+
let settled = false;
|
|
423
|
+
const finish = (alive: boolean) => {
|
|
424
|
+
if (settled) return;
|
|
425
|
+
settled = true;
|
|
426
|
+
try { sock.destroy(); } catch { /* ignore */ }
|
|
427
|
+
resolve(alive);
|
|
428
|
+
};
|
|
429
|
+
const timer = setTimeout(() => finish(false), 500);
|
|
430
|
+
sock.once("connect", () => {
|
|
431
|
+
clearTimeout(timer);
|
|
432
|
+
finish(true);
|
|
433
|
+
});
|
|
434
|
+
sock.once("error", () => {
|
|
435
|
+
clearTimeout(timer);
|
|
436
|
+
finish(false);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function bindEndpoint(
|
|
442
|
+
endpoint: string,
|
|
443
|
+
connHandler: (socket: net.Socket) => void,
|
|
444
|
+
): Promise<net.Server> {
|
|
445
|
+
if (process.platform !== "win32" && fs.existsSync(endpoint)) {
|
|
446
|
+
const verdict = await probeStaleSocket(endpoint);
|
|
447
|
+
if (verdict === "in_use") {
|
|
448
|
+
throw new Error(`coms: endpoint already in use (${endpoint})`);
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
fs.unlinkSync(endpoint);
|
|
452
|
+
} catch {
|
|
453
|
+
// best-effort
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return await new Promise<net.Server>((resolve, reject) => {
|
|
457
|
+
const server = net.createServer(connHandler);
|
|
458
|
+
server.once("error", reject);
|
|
459
|
+
server.listen(endpoint, () => {
|
|
460
|
+
server.removeListener("error", reject);
|
|
461
|
+
resolve(server);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function readOneLine(socket: net.Socket): Promise<string> {
|
|
467
|
+
return new Promise((resolve, reject) => {
|
|
468
|
+
let buf = "";
|
|
469
|
+
let settled = false;
|
|
470
|
+
const onData = (chunk: Buffer) => {
|
|
471
|
+
buf += chunk.toString("utf-8");
|
|
472
|
+
if (buf.length > LINE_CAP_BYTES) {
|
|
473
|
+
if (settled) return;
|
|
474
|
+
settled = true;
|
|
475
|
+
socket.removeListener("data", onData);
|
|
476
|
+
reject(new Error("line too large"));
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const nl = buf.indexOf("\n");
|
|
480
|
+
if (nl >= 0) {
|
|
481
|
+
if (settled) return;
|
|
482
|
+
settled = true;
|
|
483
|
+
socket.removeListener("data", onData);
|
|
484
|
+
resolve(buf.slice(0, nl));
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
socket.on("data", onData);
|
|
488
|
+
socket.once("error", (err) => {
|
|
489
|
+
if (settled) return;
|
|
490
|
+
settled = true;
|
|
491
|
+
reject(err);
|
|
492
|
+
});
|
|
493
|
+
socket.once("close", () => {
|
|
494
|
+
if (settled) return;
|
|
495
|
+
settled = true;
|
|
496
|
+
reject(new Error("connection closed before line received"));
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function sendEnvelope(endpoint: string, envelope: Envelope | Pong | { type: string; msg_id?: string; [k: string]: any }): Promise<any> {
|
|
502
|
+
return new Promise((resolve, reject) => {
|
|
503
|
+
const sock = net.createConnection({ path: endpoint });
|
|
504
|
+
let settled = false;
|
|
505
|
+
const fail = (err: Error) => {
|
|
506
|
+
if (settled) return;
|
|
507
|
+
settled = true;
|
|
508
|
+
try { sock.destroy(); } catch { /* ignore */ }
|
|
509
|
+
reject(err);
|
|
510
|
+
};
|
|
511
|
+
sock.once("error", fail);
|
|
512
|
+
sock.once("connect", async () => {
|
|
513
|
+
try {
|
|
514
|
+
sock.write(JSON.stringify(envelope) + "\n");
|
|
515
|
+
const line = await readOneLine(sock);
|
|
516
|
+
const parsed = JSON.parse(line);
|
|
517
|
+
try { sock.end(); } catch { /* ignore */ }
|
|
518
|
+
if (settled) return;
|
|
519
|
+
settled = true;
|
|
520
|
+
if (parsed && parsed.type === "nack") {
|
|
521
|
+
reject(new Error(parsed.error || "nack"));
|
|
522
|
+
} else {
|
|
523
|
+
resolve(parsed);
|
|
524
|
+
}
|
|
525
|
+
} catch (err) {
|
|
526
|
+
fail(err instanceof Error ? err : new Error(String(err)));
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ━━ Default export ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
533
|
+
|
|
534
|
+
export default function (pi: ExtensionAPI) {
|
|
535
|
+
// ━━ Register identity CLI flags so pi's parser accepts them. ━━━━━━━━━
|
|
536
|
+
// Without these, pi 0.73+ rejects the invocation with "Unknown options:
|
|
537
|
+
// --name, --project, ..." before this extension's hooks ever fire.
|
|
538
|
+
// Agent name flag for coms peer identity.
|
|
539
|
+
pi.registerFlag("cname", {
|
|
540
|
+
description: "Override coms agent name (otherwise from frontmatter or auto-generated). Distinct from pi's own --name, which the harness owns and resumes.",
|
|
541
|
+
type: "string",
|
|
542
|
+
default: undefined,
|
|
543
|
+
});
|
|
544
|
+
pi.registerFlag("purpose", {
|
|
545
|
+
description: "Override agent purpose (otherwise from frontmatter description)",
|
|
546
|
+
type: "string",
|
|
547
|
+
default: undefined,
|
|
548
|
+
});
|
|
549
|
+
pi.registerFlag("project", {
|
|
550
|
+
description: "Project namespace for peer discovery",
|
|
551
|
+
type: "string",
|
|
552
|
+
default: "default",
|
|
553
|
+
});
|
|
554
|
+
pi.registerFlag("color", {
|
|
555
|
+
description: "Hex color #RRGGBB (otherwise from frontmatter or palette fallback)",
|
|
556
|
+
type: "string",
|
|
557
|
+
default: undefined,
|
|
558
|
+
});
|
|
559
|
+
pi.registerFlag("explicit", {
|
|
560
|
+
description: "Hide this agent from auto-discovery; only addressable by exact name",
|
|
561
|
+
type: "boolean",
|
|
562
|
+
default: false,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// State containers — shared across all hooks for this extension instance.
|
|
566
|
+
let identity: {
|
|
567
|
+
session_id: string;
|
|
568
|
+
name: string;
|
|
569
|
+
purpose: string;
|
|
570
|
+
color: string;
|
|
571
|
+
project: string;
|
|
572
|
+
explicit: boolean;
|
|
573
|
+
cwd: string;
|
|
574
|
+
model: string;
|
|
575
|
+
endpoint: string;
|
|
576
|
+
registryFile: string;
|
|
577
|
+
} | null = null;
|
|
578
|
+
const peerCards: Map<string, AgentCard & { staleCount: number }> = new Map();
|
|
579
|
+
const pendingReplies: Map<string, PendingReply> = new Map();
|
|
580
|
+
const inboundQueue: Map<string, InboundContext> = new Map();
|
|
581
|
+
let server: net.Server | null = null;
|
|
582
|
+
let pingTimer: NodeJS.Timeout | null = null;
|
|
583
|
+
let pingWorker: Worker | null = null;
|
|
584
|
+
let pingWorkerReady = false;
|
|
585
|
+
let cardUpdateTimer: NodeJS.Timeout | null = null;
|
|
586
|
+
let keepaliveTimer: NodeJS.Timeout | null = null;
|
|
587
|
+
let includeExplicit = false;
|
|
588
|
+
let displayProject: string | null = null;
|
|
589
|
+
let currentCtx: ExtensionContext | null = null;
|
|
590
|
+
let currentInbound: InboundContext | null = null;
|
|
591
|
+
let processingInbound = false;
|
|
592
|
+
|
|
593
|
+
function getPendingInboundCount(): number {
|
|
594
|
+
let count = 0;
|
|
595
|
+
for (const i of inboundQueue.values()) if (!i.fulfilled && i !== currentInbound) count++;
|
|
596
|
+
return count;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
let lastPoolSnapshot = "";
|
|
600
|
+
function maybeRefreshWidget(): void {
|
|
601
|
+
if (!currentCtx?.hasUI) return;
|
|
602
|
+
const pc = getPendingInboundCount();
|
|
603
|
+
const key = `pending=${pc}|` + [...peerCards.entries()].map(([k, v]) => `${k}:${v.staleCount}`).sort().join(",");
|
|
604
|
+
if (key === lastPoolSnapshot) return;
|
|
605
|
+
lastPoolSnapshot = key;
|
|
606
|
+
try { installPoolWidget(currentCtx); } catch {}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Phase A stub handlers — each just acks valid envelopes. Phase B replaces these.
|
|
610
|
+
function ackOk(socket: net.Socket, msg_id: string): void {
|
|
611
|
+
try {
|
|
612
|
+
socket.write(JSON.stringify({ type: "ack", msg_id }) + "\n");
|
|
613
|
+
} catch {
|
|
614
|
+
// ignore
|
|
615
|
+
}
|
|
616
|
+
try { socket.end(); } catch { /* ignore */ }
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function nack(socket: net.Socket, msg_id: string, error: string): void {
|
|
620
|
+
try {
|
|
621
|
+
socket.write(JSON.stringify({ type: "nack", msg_id, error }) + "\n");
|
|
622
|
+
} catch {
|
|
623
|
+
// ignore
|
|
624
|
+
}
|
|
625
|
+
try { socket.end(); } catch { /* ignore */ }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function handlePrompt(socket: net.Socket, env: PromptEnvelope): void {
|
|
629
|
+
// 1. Hop limit check
|
|
630
|
+
if (typeof env.hops !== "number" || env.hops >= MAX_HOPS) {
|
|
631
|
+
nack(socket, env.msg_id, "hops exceeded");
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// 2. Insert into inbound queue (store message content for FIFO re-injection)
|
|
636
|
+
const inbound: InboundContext = {
|
|
637
|
+
msg_id: env.msg_id,
|
|
638
|
+
hops: env.hops,
|
|
639
|
+
sender_endpoint: env.sender_endpoint,
|
|
640
|
+
sender_session: env.sender_session,
|
|
641
|
+
sender_name: env.sender_name,
|
|
642
|
+
sender_cwd: env.sender_cwd,
|
|
643
|
+
prompt: env.prompt,
|
|
644
|
+
tasks: Array.isArray(env.tasks) ? env.tasks : null,
|
|
645
|
+
response_schema: env.response_schema ?? null,
|
|
646
|
+
fulfilled: false,
|
|
647
|
+
};
|
|
648
|
+
inboundQueue.set(env.msg_id, inbound);
|
|
649
|
+
maybeRefreshWidget();
|
|
650
|
+
|
|
651
|
+
// 3. If already processing another inbound, just queue — agent_end will drain FIFO.
|
|
652
|
+
if (processingInbound) {
|
|
653
|
+
ackOk(socket, env.msg_id);
|
|
654
|
+
|
|
655
|
+
try {
|
|
656
|
+
pi.appendEntry("coms-log", {
|
|
657
|
+
event: "inbound_queued",
|
|
658
|
+
msg_id: env.msg_id,
|
|
659
|
+
sender: env.sender_session,
|
|
660
|
+
hops: env.hops,
|
|
661
|
+
queue_depth: getPendingInboundCount(),
|
|
662
|
+
});
|
|
663
|
+
} catch { /* best-effort */ }
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// 4. Not busy — inject immediately.
|
|
668
|
+
currentInbound = inbound;
|
|
669
|
+
processingInbound = true;
|
|
670
|
+
try {
|
|
671
|
+
pi.sendMessage(
|
|
672
|
+
{
|
|
673
|
+
customType: "coms-inbound",
|
|
674
|
+
content: buildInboundContent(`[from ${env.sender_name} @ ${env.sender_cwd}]`, env.prompt, env.tasks),
|
|
675
|
+
display: true,
|
|
676
|
+
details: {
|
|
677
|
+
msg_id: env.msg_id,
|
|
678
|
+
sender_session: env.sender_session,
|
|
679
|
+
response_schema: env.response_schema ?? null,
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
683
|
+
);
|
|
684
|
+
} catch (err) {
|
|
685
|
+
inboundQueue.delete(env.msg_id);
|
|
686
|
+
maybeRefreshWidget();
|
|
687
|
+
currentInbound = null;
|
|
688
|
+
processingInbound = false;
|
|
689
|
+
nack(socket, env.msg_id, "internal error");
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// 5. Ack + audit log
|
|
694
|
+
ackOk(socket, env.msg_id);
|
|
695
|
+
|
|
696
|
+
try {
|
|
697
|
+
pi.appendEntry("coms-log", {
|
|
698
|
+
event: "inbound_prompt",
|
|
699
|
+
msg_id: env.msg_id,
|
|
700
|
+
sender: env.sender_session,
|
|
701
|
+
hops: env.hops,
|
|
702
|
+
});
|
|
703
|
+
} catch {
|
|
704
|
+
// best-effort
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function handleResponse(socket: net.Socket, env: ResponseEnvelope): void {
|
|
709
|
+
const pending = pendingReplies.get(env.msg_id);
|
|
710
|
+
if (pending) {
|
|
711
|
+
if (pending.timer) {
|
|
712
|
+
try { clearTimeout(pending.timer); } catch { /* ignore */ }
|
|
713
|
+
pending.timer = null;
|
|
714
|
+
}
|
|
715
|
+
const queuedMsgIds: string[] = Array.isArray(env.queued_msg_ids) ? env.queued_msg_ids.filter((id: unknown) => typeof id === "string") : [];
|
|
716
|
+
pending.result = { response: env.response, error: env.error ?? null, queued_msg_ids: queuedMsgIds };
|
|
717
|
+
try {
|
|
718
|
+
pending.resolve(pending.result);
|
|
719
|
+
} catch (e: any) {
|
|
720
|
+
try { pi.appendEntry("coms-log", { event: "resolve_failed", msg_id: env.msg_id, reason: e?.message ?? String(e) }); } catch { /* best-effort */ }
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Auto-deliver response as followUp so the LLM sees it without polling
|
|
724
|
+
const targetName = pending.target_name ?? "peer";
|
|
725
|
+
const responseText = formatComsResponseText(targetName, env.response, env.error ?? null, queuedMsgIds);
|
|
726
|
+
try {
|
|
727
|
+
pi.sendMessage(
|
|
728
|
+
{
|
|
729
|
+
customType: "coms-response",
|
|
730
|
+
content: responseText,
|
|
731
|
+
display: true,
|
|
732
|
+
details: {
|
|
733
|
+
msg_id: env.msg_id,
|
|
734
|
+
target_name: targetName,
|
|
735
|
+
error: env.error ?? null,
|
|
736
|
+
queued_msg_ids: queuedMsgIds,
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
740
|
+
);
|
|
741
|
+
} catch { /* best-effort */ }
|
|
742
|
+
|
|
743
|
+
// Clean up immediately since response was delivered
|
|
744
|
+
setTimeout(() => { pendingReplies.delete(env.msg_id); }, 60_000).unref();
|
|
745
|
+
} else {
|
|
746
|
+
try {
|
|
747
|
+
pi.appendEntry("coms-log", { event: "orphan_response", msg_id: env.msg_id });
|
|
748
|
+
} catch {
|
|
749
|
+
// best-effort
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
ackOk(socket, env.msg_id);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function buildAgentCard(): AgentCard {
|
|
756
|
+
const ctx = currentCtx;
|
|
757
|
+
const ident = identity;
|
|
758
|
+
const pct = ctx ? Math.round(ctx.getContextUsage()?.percent ?? 0) : 0;
|
|
759
|
+
return {
|
|
760
|
+
name: ident?.name ?? "unknown",
|
|
761
|
+
purpose: ident?.purpose ?? "",
|
|
762
|
+
model: ctx?.model?.id ?? ident?.model ?? "unknown",
|
|
763
|
+
color: ident?.color ?? "#36F9F6",
|
|
764
|
+
context_used_pct: pct,
|
|
765
|
+
queue_depth: getPendingInboundCount(),
|
|
766
|
+
tasks_summary: readTaskSummary(currentCtx?.cwd ?? process.cwd(), currentCtx?.sessionManager?.getSessionId?.()),
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** Push current agent card to ping worker so it can respond independently */
|
|
771
|
+
function updatePingWorkerCard(): void {
|
|
772
|
+
if (!pingWorker || !pingWorkerReady) return;
|
|
773
|
+
try {
|
|
774
|
+
const card = buildAgentCard();
|
|
775
|
+
const pong: Pong = { type: "pong", msg_id: "", agent_card: card };
|
|
776
|
+
pingWorker.postMessage({ type: "update_card", pong });
|
|
777
|
+
} catch { /* ignore */ }
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function handlePing(socket: net.Socket, env: PingEnvelope): void {
|
|
781
|
+
const card = buildAgentCard();
|
|
782
|
+
const pong: Pong = { type: "pong", msg_id: env.msg_id, agent_card: card };
|
|
783
|
+
try {
|
|
784
|
+
socket.write(JSON.stringify(pong) + "\n");
|
|
785
|
+
} catch {
|
|
786
|
+
// ignore
|
|
787
|
+
}
|
|
788
|
+
try { socket.end(); } catch { /* ignore */ }
|
|
789
|
+
// Also update worker with latest card
|
|
790
|
+
updatePingWorkerCard();
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function isValidEnvelope(obj: any): obj is Envelope {
|
|
794
|
+
return (
|
|
795
|
+
obj &&
|
|
796
|
+
typeof obj === "object" &&
|
|
797
|
+
typeof obj.type === "string" &&
|
|
798
|
+
typeof obj.msg_id === "string" &&
|
|
799
|
+
typeof obj.sender_session === "string" &&
|
|
800
|
+
typeof obj.sender_endpoint === "string"
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function connHandler(socket: net.Socket): void {
|
|
805
|
+
let buf = "";
|
|
806
|
+
let handled = false;
|
|
807
|
+
const onData = (chunk: Buffer) => {
|
|
808
|
+
if (handled) return;
|
|
809
|
+
buf += chunk.toString("utf-8");
|
|
810
|
+
if (buf.length > LINE_CAP_BYTES) {
|
|
811
|
+
handled = true;
|
|
812
|
+
socket.removeListener("data", onData);
|
|
813
|
+
nack(socket, "", "malformed envelope");
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
const nl = buf.indexOf("\n");
|
|
817
|
+
if (nl < 0) return;
|
|
818
|
+
handled = true;
|
|
819
|
+
socket.removeListener("data", onData);
|
|
820
|
+
const line = buf.slice(0, nl);
|
|
821
|
+
let parsed: any;
|
|
822
|
+
try {
|
|
823
|
+
parsed = JSON.parse(line);
|
|
824
|
+
} catch {
|
|
825
|
+
nack(socket, "", "malformed envelope");
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (!isValidEnvelope(parsed)) {
|
|
829
|
+
const mid = parsed && typeof parsed.msg_id === "string" ? parsed.msg_id : "";
|
|
830
|
+
nack(socket, mid, "malformed envelope");
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
try {
|
|
834
|
+
if (parsed.type === "prompt") {
|
|
835
|
+
handlePrompt(socket, parsed as PromptEnvelope);
|
|
836
|
+
} else if (parsed.type === "response") {
|
|
837
|
+
handleResponse(socket, parsed as ResponseEnvelope);
|
|
838
|
+
} else if (parsed.type === "ping") {
|
|
839
|
+
handlePing(socket, parsed as PingEnvelope);
|
|
840
|
+
} else {
|
|
841
|
+
nack(socket, parsed.msg_id, "unknown type");
|
|
842
|
+
}
|
|
843
|
+
} catch {
|
|
844
|
+
nack(socket, parsed.msg_id, "internal error");
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
socket.on("data", onData);
|
|
848
|
+
socket.once("error", () => {
|
|
849
|
+
// connection failures during handshake — drop quietly
|
|
850
|
+
try { socket.destroy(); } catch { /* ignore */ }
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// ━━ session_start ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
855
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
856
|
+
applyExtensionDefaults(import.meta.url, ctx);
|
|
857
|
+
currentCtx = ctx;
|
|
858
|
+
shuttingDown = false;
|
|
859
|
+
peerCards.clear();
|
|
860
|
+
|
|
861
|
+
// 1. Resolve identity from CLI flags > frontmatter > defaults.
|
|
862
|
+
const flags = readCliFlags(pi);
|
|
863
|
+
const fm = readFrontmatterFromArgv(process.argv);
|
|
864
|
+
const project = flags.project || "default";
|
|
865
|
+
const explicit = flags.explicit === true;
|
|
866
|
+
const session_id = ulid();
|
|
867
|
+
|
|
868
|
+
const defaultName = `agent-${session_id.slice(-6)}`;
|
|
869
|
+
const name = flags.name || fm.name || defaultName;
|
|
870
|
+
const purpose = flags.purpose || fm.description || "";
|
|
871
|
+
|
|
872
|
+
// Color: validate at every level; fall through invalid hex to next.
|
|
873
|
+
// Order: --color CLI flag > frontmatter color > deterministic fallback.
|
|
874
|
+
let color = fallbackColor(session_id);
|
|
875
|
+
if (fm.color && isValidHex(fm.color)) {
|
|
876
|
+
color = fm.color;
|
|
877
|
+
}
|
|
878
|
+
if (flags.color && isValidHex(flags.color)) {
|
|
879
|
+
color = flags.color;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const endpoint = makeEndpoint(session_id);
|
|
883
|
+
const cwd = ctx.cwd || process.cwd();
|
|
884
|
+
const model = ctx.model?.id ?? "unknown";
|
|
885
|
+
|
|
886
|
+
// 2. Ensure storage dirs exist.
|
|
887
|
+
try {
|
|
888
|
+
fs.mkdirSync(path.join(COMS_DIR, "projects", project, "agents"), { recursive: true });
|
|
889
|
+
if (process.platform !== "win32") {
|
|
890
|
+
fs.mkdirSync(path.join(COMS_DIR, "sockets"), { recursive: true });
|
|
891
|
+
try { fs.chmodSync(COMS_DIR, 0o700); } catch { /* best-effort */ }
|
|
892
|
+
}
|
|
893
|
+
} catch (err) {
|
|
894
|
+
ctx.ui?.notify?.(`📡 coms: failed to create dirs — ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// 2b. Reject duplicate names — each peer must have a unique name in the project.
|
|
899
|
+
const existingEntries = readAllRegistryEntries(project);
|
|
900
|
+
for (const existing of existingEntries) {
|
|
901
|
+
if (existing.name === name && existing.session_id !== session_id) {
|
|
902
|
+
// Verify the existing peer is actually alive via .ping endpoint.
|
|
903
|
+
if (typeof existing.endpoint === "string" && existing.endpoint) {
|
|
904
|
+
const pingEp = `${existing.endpoint}.ping`;
|
|
905
|
+
const hasPing = process.platform === "win32" || fs.existsSync(pingEp);
|
|
906
|
+
const hasMain = process.platform === "win32" || fs.existsSync(existing.endpoint);
|
|
907
|
+
|
|
908
|
+
// Use strict probe — only "alive" on actual TCP connect.
|
|
909
|
+
// Transient errors (EMFILE, EACCES) resolve to false, not blocking startup.
|
|
910
|
+
let alive = false;
|
|
911
|
+
if (hasPing) {
|
|
912
|
+
alive = await probeAlive(pingEp);
|
|
913
|
+
}
|
|
914
|
+
if (!alive && hasMain) {
|
|
915
|
+
alive = await probeAlive(existing.endpoint);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (alive) {
|
|
919
|
+
throw new Error(
|
|
920
|
+
`name "${name}" is already taken by a live peer. ` +
|
|
921
|
+
`Use --cname to pick a different name, or stop the other peer first.`
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (!hasPing && !hasMain) {
|
|
926
|
+
// No socket files at all — definitely dead
|
|
927
|
+
removeRegistryEntry(project, existing.session_id);
|
|
928
|
+
} else {
|
|
929
|
+
// Probe(s) returned stale — remove registry, peer is dead
|
|
930
|
+
removeRegistryEntry(project, existing.session_id);
|
|
931
|
+
}
|
|
932
|
+
} else {
|
|
933
|
+
// No valid endpoint — remove stale entry
|
|
934
|
+
removeRegistryEntry(project, existing.session_id);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// 3. Bind the endpoint.
|
|
940
|
+
try {
|
|
941
|
+
server = await bindEndpoint(endpoint, connHandler);
|
|
942
|
+
} catch (err) {
|
|
943
|
+
ctx.ui?.notify?.(`📡 coms: bind failed — ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// 3b. Start ping worker on separate socket for liveness checks
|
|
948
|
+
const pingEndpoint = `${endpoint}.ping`;
|
|
949
|
+
try {
|
|
950
|
+
pingWorker = createPingWorker(pingEndpoint);
|
|
951
|
+
pingWorker.on("message", (msg: any) => {
|
|
952
|
+
if (msg.type === "ready") {
|
|
953
|
+
pingWorkerReady = true;
|
|
954
|
+
updatePingWorkerCard(); // Push initial card immediately
|
|
955
|
+
}
|
|
956
|
+
if (msg.type === "error") console.debug("[coms] ping worker error:", msg.message);
|
|
957
|
+
});
|
|
958
|
+
pingWorker.on("error", () => { pingWorkerReady = false; });
|
|
959
|
+
pingWorker.on("exit", () => { pingWorkerReady = false; pingWorker = null; });
|
|
960
|
+
} catch (err) {
|
|
961
|
+
console.debug("[coms] ping worker failed to start:", err instanceof Error ? err.message : String(err));
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// 4. Build + write registry entry atomically.
|
|
965
|
+
const entry: RegistryEntry = {
|
|
966
|
+
session_id,
|
|
967
|
+
name,
|
|
968
|
+
purpose,
|
|
969
|
+
model,
|
|
970
|
+
color,
|
|
971
|
+
pid: process.pid,
|
|
972
|
+
endpoint,
|
|
973
|
+
cwd,
|
|
974
|
+
started_at: nowIso(),
|
|
975
|
+
explicit,
|
|
976
|
+
version: 1,
|
|
977
|
+
};
|
|
978
|
+
let registryFile: string;
|
|
979
|
+
try {
|
|
980
|
+
registryFile = writeRegistryAtomic(entry, project);
|
|
981
|
+
} catch (err) {
|
|
982
|
+
ctx.ui?.notify?.(`📡 coms: registry write failed — ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
983
|
+
try { server?.close(); } catch { /* ignore */ }
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
identity = {
|
|
988
|
+
session_id,
|
|
989
|
+
name,
|
|
990
|
+
purpose,
|
|
991
|
+
color,
|
|
992
|
+
project,
|
|
993
|
+
explicit,
|
|
994
|
+
cwd,
|
|
995
|
+
model,
|
|
996
|
+
endpoint,
|
|
997
|
+
registryFile,
|
|
998
|
+
};
|
|
999
|
+
includeExplicit = false;
|
|
1000
|
+
displayProject = project;
|
|
1001
|
+
|
|
1002
|
+
// 5. Audit log: boot.
|
|
1003
|
+
try {
|
|
1004
|
+
pi.appendEntry("coms-log", { event: "boot", session_id, name, project });
|
|
1005
|
+
} catch {
|
|
1006
|
+
// best-effort
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// 6. Surface presence in the UI + install the live pool widget.
|
|
1010
|
+
try {
|
|
1011
|
+
ctx.ui.setStatus("coms", `📡 ${name}@${project}`);
|
|
1012
|
+
installPoolWidget(ctx);
|
|
1013
|
+
ctx.ui.notify(
|
|
1014
|
+
`📡 coms ready · ${name}@${project} · ${displayProject ?? project} pool`,
|
|
1015
|
+
"info",
|
|
1016
|
+
);
|
|
1017
|
+
} catch {
|
|
1018
|
+
// hasUI may be false in some contexts — non-fatal.
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// 7. Start ping + keepalive cycles (skip in one-shot modes).
|
|
1022
|
+
if (ctx.mode !== "print" && ctx.mode !== "json") {
|
|
1023
|
+
pingTimer = setInterval(() => { refreshPool().catch(() => {}); }, PING_INTERVAL_MS);
|
|
1024
|
+
try { (pingTimer as any).unref?.(); } catch { /* ignore */ }
|
|
1025
|
+
// Update ping worker card periodically
|
|
1026
|
+
cardUpdateTimer = setInterval(updatePingWorkerCard, 5000);
|
|
1027
|
+
try { (cardUpdateTimer as any).unref?.(); } catch { /* ignore */ }
|
|
1028
|
+
keepaliveTimer = setInterval(async () => {
|
|
1029
|
+
if (!identity) return;
|
|
1030
|
+
if (shuttingDown) return;
|
|
1031
|
+
try {
|
|
1032
|
+
// Socket self-heal: if our socket file was deleted (e.g., by pruneStaleRegistry
|
|
1033
|
+
// timeout), the net.Server is orphaned — listening on an inode with no filesystem
|
|
1034
|
+
// entry. Re-bind the server and ping worker on the same endpoint path.
|
|
1035
|
+
if (!shuttingDown && process.platform !== "win32" && !fs.existsSync(identity.endpoint)) {
|
|
1036
|
+
try {
|
|
1037
|
+
pi.appendEntry("coms-log", { event: "self_heal_socket", session_id: identity.session_id, reason: "socket file missing" });
|
|
1038
|
+
// Close old server
|
|
1039
|
+
if (server) {
|
|
1040
|
+
try { server.close(); } catch { /* ignore */ }
|
|
1041
|
+
server = null;
|
|
1042
|
+
}
|
|
1043
|
+
// Re-bind new server on same endpoint
|
|
1044
|
+
server = await bindEndpoint(identity.endpoint, connHandler);
|
|
1045
|
+
// Restart ping worker — terminate old one and remove its
|
|
1046
|
+
// event handlers to prevent late exit/error from clobbering
|
|
1047
|
+
// the new worker's state.
|
|
1048
|
+
if (pingWorker) {
|
|
1049
|
+
const oldWorker = pingWorker;
|
|
1050
|
+
oldWorker.removeAllListeners();
|
|
1051
|
+
try { oldWorker.postMessage({ type: "shutdown" }); } catch {}
|
|
1052
|
+
try { oldWorker.terminate(); } catch {}
|
|
1053
|
+
pingWorker = null;
|
|
1054
|
+
pingWorkerReady = false;
|
|
1055
|
+
}
|
|
1056
|
+
const pingEndpoint = `${identity.endpoint}.ping`;
|
|
1057
|
+
try {
|
|
1058
|
+
pingWorker = createPingWorker(pingEndpoint);
|
|
1059
|
+
pingWorker.on("message", (msg: any) => {
|
|
1060
|
+
if (msg.type === "ready") {
|
|
1061
|
+
pingWorkerReady = true;
|
|
1062
|
+
updatePingWorkerCard();
|
|
1063
|
+
}
|
|
1064
|
+
if (msg.type === "error") console.debug("[coms] ping worker error:", msg.message);
|
|
1065
|
+
});
|
|
1066
|
+
pingWorker.on("error", () => { pingWorkerReady = false; });
|
|
1067
|
+
pingWorker.on("exit", () => { pingWorkerReady = false; pingWorker = null; });
|
|
1068
|
+
} catch { /* best-effort */ }
|
|
1069
|
+
} catch (err) {
|
|
1070
|
+
pi.appendEntry("coms-log", { event: "self_heal_socket_failed", session_id: identity.session_id, reason: err instanceof Error ? err.message : String(err) });
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const ctx = currentCtx;
|
|
1075
|
+
// Detect missing-registry BEFORE writing so the self_heal audit only
|
|
1076
|
+
// fires when something actually went wrong (file unlinked under us).
|
|
1077
|
+
const missingBeforeWrite = !fs.existsSync(identity.registryFile);
|
|
1078
|
+
const live: RegistryEntry = {
|
|
1079
|
+
session_id: identity.session_id,
|
|
1080
|
+
name: identity.name,
|
|
1081
|
+
purpose: identity.purpose,
|
|
1082
|
+
model: ctx?.model?.id ?? identity.model,
|
|
1083
|
+
color: identity.color,
|
|
1084
|
+
pid: process.pid,
|
|
1085
|
+
endpoint: identity.endpoint,
|
|
1086
|
+
cwd: identity.cwd,
|
|
1087
|
+
started_at: nowIso(),
|
|
1088
|
+
explicit: identity.explicit,
|
|
1089
|
+
version: 1,
|
|
1090
|
+
context_used_pct: Math.round(ctx?.getContextUsage()?.percent ?? 0),
|
|
1091
|
+
queue_depth: getPendingInboundCount(),
|
|
1092
|
+
heartbeat_at: nowIso(),
|
|
1093
|
+
};
|
|
1094
|
+
// Unconditional atomic write: handles BOTH the live-status refresh
|
|
1095
|
+
// (file present → overwrite with fresh values) AND self-heal (file
|
|
1096
|
+
// missing → re-create entry). The atomic write also bumps mtime.
|
|
1097
|
+
writeRegistryAtomic(live, identity.project);
|
|
1098
|
+
if (missingBeforeWrite) {
|
|
1099
|
+
pi.appendEntry("coms-log", { event: "self_heal", session_id: identity.session_id, reason: "registry file missing" });
|
|
1100
|
+
// Edge case: if the file was unlinked again between our write and
|
|
1101
|
+
// this check, re-write once to be safe.
|
|
1102
|
+
if (!fs.existsSync(identity.registryFile)) {
|
|
1103
|
+
writeRegistryAtomic(live, identity.project);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
} catch { /* best-effort */ }
|
|
1107
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
1108
|
+
try { (keepaliveTimer as any).unref?.(); } catch { /* ignore */ }
|
|
1109
|
+
|
|
1110
|
+
// Kick one ping cycle immediately so the widget populates fast.
|
|
1111
|
+
refreshPool().catch(() => {});
|
|
1112
|
+
} // end mode guard for timers
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
// ━━ Helpers used by tools ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1116
|
+
|
|
1117
|
+
async function pingPeer(endpoint: string): Promise<AgentCard | null> {
|
|
1118
|
+
if (!identity) return null;
|
|
1119
|
+
const env: PingEnvelope = {
|
|
1120
|
+
type: "ping",
|
|
1121
|
+
msg_id: ulid(),
|
|
1122
|
+
sender_session: identity.session_id,
|
|
1123
|
+
sender_endpoint: identity.endpoint,
|
|
1124
|
+
hops: 0,
|
|
1125
|
+
timestamp: nowIso(),
|
|
1126
|
+
};
|
|
1127
|
+
try {
|
|
1128
|
+
const resp = await sendEnvelope(endpoint, env);
|
|
1129
|
+
if (resp && resp.type === "pong" && resp.agent_card) {
|
|
1130
|
+
return resp.agent_card as AgentCard;
|
|
1131
|
+
}
|
|
1132
|
+
} catch {
|
|
1133
|
+
// ignore — peer unreachable
|
|
1134
|
+
}
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// ━━ Pool widget rendering ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1139
|
+
function renderPool(width: number, theme: Theme): string[] {
|
|
1140
|
+
const projectFilter = displayProject ?? identity?.project ?? "default";
|
|
1141
|
+
const registryEntries = projectFilter === "*"
|
|
1142
|
+
? readAllRegistryEntriesAcrossProjects()
|
|
1143
|
+
: readAllRegistryEntries(projectFilter);
|
|
1144
|
+
|
|
1145
|
+
interface Row {
|
|
1146
|
+
name: string;
|
|
1147
|
+
model: string;
|
|
1148
|
+
color: string;
|
|
1149
|
+
purpose: string;
|
|
1150
|
+
pct: number | null;
|
|
1151
|
+
pending: boolean;
|
|
1152
|
+
stale: boolean;
|
|
1153
|
+
tasks?: { total: number; completed: number; in_progress: number } | null;
|
|
1154
|
+
queue_depth: number;
|
|
1155
|
+
}
|
|
1156
|
+
const rows: Row[] = [];
|
|
1157
|
+
const seenSessions = new Set<string>();
|
|
1158
|
+
|
|
1159
|
+
for (const [sid, card] of peerCards.entries()) {
|
|
1160
|
+
if (identity && sid === identity.session_id) continue;
|
|
1161
|
+
seenSessions.add(sid);
|
|
1162
|
+
rows.push({
|
|
1163
|
+
name: card.name,
|
|
1164
|
+
model: card.model,
|
|
1165
|
+
color: card.color,
|
|
1166
|
+
purpose: card.purpose,
|
|
1167
|
+
pct: card.context_used_pct,
|
|
1168
|
+
pending: false,
|
|
1169
|
+
stale: (card.staleCount ?? 0) >= 3,
|
|
1170
|
+
tasks: card.tasks_summary,
|
|
1171
|
+
queue_depth: card.queue_depth ?? 0,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Registry-only entries that aren't yet in peerCards → pending
|
|
1176
|
+
const seenNames = new Set(rows.map((r) => r.name));
|
|
1177
|
+
for (const entry of registryEntries) {
|
|
1178
|
+
if (identity && entry.session_id === identity.session_id) continue;
|
|
1179
|
+
if (!includeExplicit && entry.explicit) continue;
|
|
1180
|
+
if (seenSessions.has(entry.session_id)) continue;
|
|
1181
|
+
if (seenNames.has(entry.name)) continue;
|
|
1182
|
+
rows.push({
|
|
1183
|
+
name: entry.name,
|
|
1184
|
+
model: entry.model,
|
|
1185
|
+
color: entry.color,
|
|
1186
|
+
purpose: entry.purpose,
|
|
1187
|
+
pct: null,
|
|
1188
|
+
pending: true,
|
|
1189
|
+
stale: false,
|
|
1190
|
+
queue_depth: 0,
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Border helpers — sandwich the body with single-line box-drawing rules
|
|
1195
|
+
// so the widget reads as its own block above the minimal footer. The
|
|
1196
|
+
// top border carries a branded ` coms ` tag so the widget reads as its
|
|
1197
|
+
// own block; bottom border stays a plain rule for minimalism.
|
|
1198
|
+
const safeWidth = Math.max(0, width);
|
|
1199
|
+
let topBorder: string;
|
|
1200
|
+
let bottomBorder: string;
|
|
1201
|
+
if (safeWidth < 12) {
|
|
1202
|
+
topBorder = theme.fg("dim", "━".repeat(safeWidth));
|
|
1203
|
+
bottomBorder = theme.fg("dim", "━".repeat(safeWidth));
|
|
1204
|
+
} else {
|
|
1205
|
+
const left = theme.fg("dim", "┏━") + theme.fg("border", " coms ");
|
|
1206
|
+
const leftFill = theme.fg("dim", "━");
|
|
1207
|
+
const pendingCount = getPendingInboundCount();
|
|
1208
|
+
const pendingSuffix = ` (${pendingCount} pending)`;
|
|
1209
|
+
const nameLen = identity ? identity.name.length + pendingSuffix.length : 0;
|
|
1210
|
+
const rightTagVisLen = identity ? nameLen + 3 : 0;
|
|
1211
|
+
const remaining = safeWidth - 9 /* "┏━ coms ━" */ - rightTagVisLen - 1 /* "┓" */;
|
|
1212
|
+
if (identity && remaining >= 1) {
|
|
1213
|
+
const pendingPart = pendingCount > 0 ? theme.fg("warning", pendingSuffix) : theme.fg("dim", pendingSuffix);
|
|
1214
|
+
const rightTag =
|
|
1215
|
+
theme.fg("dim", " ") +
|
|
1216
|
+
hexFg(identity.color, identity.name) +
|
|
1217
|
+
pendingPart +
|
|
1218
|
+
theme.fg("dim", " ━");
|
|
1219
|
+
const middle = theme.fg("dim", "━".repeat(remaining));
|
|
1220
|
+
const right = theme.fg("dim", "┓");
|
|
1221
|
+
topBorder = left + leftFill + middle + rightTag + right;
|
|
1222
|
+
} else {
|
|
1223
|
+
const fallbackRemaining = Math.max(0, safeWidth - 2 /* "┏━" */ - 6 /* " coms " */ - 1 /* "┓" */);
|
|
1224
|
+
const right = theme.fg("dim", "━".repeat(fallbackRemaining) + "┓");
|
|
1225
|
+
topBorder = left + right;
|
|
1226
|
+
}
|
|
1227
|
+
bottomBorder = theme.fg("dim", "┗" + "━".repeat(safeWidth - 2) + "┛");
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (rows.length === 0) {
|
|
1231
|
+
const emptyMsg = theme.fg("muted", "no peers connected");
|
|
1232
|
+
return [
|
|
1233
|
+
topBorder,
|
|
1234
|
+
truncateToWidth(theme.fg("dim", " ") + emptyMsg, width),
|
|
1235
|
+
bottomBorder,
|
|
1236
|
+
];
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
1240
|
+
|
|
1241
|
+
const out: string[] = [topBorder];
|
|
1242
|
+
|
|
1243
|
+
for (const r of rows) {
|
|
1244
|
+
const pctNum = r.pct ?? 0;
|
|
1245
|
+
const filled = Math.max(0, Math.min(15, Math.round((pctNum / 100) * 15)));
|
|
1246
|
+
const empty = 15 - filled;
|
|
1247
|
+
const pctLabel = r.pct == null ? "--%" : `${r.pct}%`;
|
|
1248
|
+
|
|
1249
|
+
if (r.stale) {
|
|
1250
|
+
const dimRow = `✗ ${r.name.padEnd(12)} ${abbreviateModel(r.model).padEnd(14)} [${"-".repeat(15)}] ${pctLabel.padStart(4)} — ${r.purpose || ""}`;
|
|
1251
|
+
out.push(truncateToWidth(" " + theme.fg("dim", dimRow), width));
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const swatch = r.pending ? theme.fg("dim", "●") : hexFg(r.color, "●");
|
|
1256
|
+
const namePart = theme.fg("accent", r.name.padEnd(12));
|
|
1257
|
+
const modelPart = theme.fg("dim", abbreviateModel(r.model).padEnd(14));
|
|
1258
|
+
const barFill = r.pending
|
|
1259
|
+
? theme.fg("dim", "-".repeat(15))
|
|
1260
|
+
: hexFg(r.color, "#".repeat(filled)) + theme.fg("dim", "-".repeat(empty));
|
|
1261
|
+
const bar = theme.fg("warning", "[") + barFill + theme.fg("warning", "]");
|
|
1262
|
+
const pctPart = " " + theme.fg("accent", pctLabel.padStart(4));
|
|
1263
|
+
const sep = theme.fg("dim", " — ");
|
|
1264
|
+
const purposePart = theme.fg("muted", r.purpose || "");
|
|
1265
|
+
|
|
1266
|
+
const tasksPart = renderTasksPart(r.tasks, theme);
|
|
1267
|
+
const queuePart = renderQueuePart(r.queue_depth, theme);
|
|
1268
|
+
const line = " " + swatch + " " + namePart + " " + modelPart + " " + bar + pctPart + tasksPart + queuePart + sep + purposePart;
|
|
1269
|
+
out.push(truncateToWidth(line, width));
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
out.push(bottomBorder);
|
|
1273
|
+
return out;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function installPoolWidget(ctx: ExtensionContext): void {
|
|
1277
|
+
if (!ctx.hasUI) return;
|
|
1278
|
+
try {
|
|
1279
|
+
ctx.ui.setWidget("coms-pool", (_tui, theme) => ({
|
|
1280
|
+
invalidate() {},
|
|
1281
|
+
render(width: number): string[] {
|
|
1282
|
+
return renderPool(width, theme);
|
|
1283
|
+
},
|
|
1284
|
+
}), { placement: "belowEditor" });
|
|
1285
|
+
} catch {
|
|
1286
|
+
// non-fatal
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// ━━ Ping cycle ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1291
|
+
async function refreshPool(): Promise<void> {
|
|
1292
|
+
if (!identity) return;
|
|
1293
|
+
const projectFilter = displayProject ?? identity.project;
|
|
1294
|
+
const live = projectFilter === "*"
|
|
1295
|
+
? await pruneDeadEntriesAllProjects()
|
|
1296
|
+
: await pruneDeadEntries(projectFilter);
|
|
1297
|
+
|
|
1298
|
+
const peers = live.filter((e) =>
|
|
1299
|
+
e.session_id !== identity!.session_id && (includeExplicit || !e.explicit),
|
|
1300
|
+
);
|
|
1301
|
+
|
|
1302
|
+
const results = await Promise.allSettled(peers.map(async (peer) => {
|
|
1303
|
+
const pingEnv: PingEnvelope = {
|
|
1304
|
+
type: "ping",
|
|
1305
|
+
msg_id: ulid(),
|
|
1306
|
+
sender_session: identity!.session_id,
|
|
1307
|
+
sender_endpoint: identity!.endpoint,
|
|
1308
|
+
hops: 0,
|
|
1309
|
+
timestamp: nowIso(),
|
|
1310
|
+
};
|
|
1311
|
+
// Try dedicated ping socket first (immune to event-loop blocks), fall back to main
|
|
1312
|
+
const pingEndpoint = `${peer.endpoint}.ping`;
|
|
1313
|
+
let reply: any;
|
|
1314
|
+
try {
|
|
1315
|
+
reply = await sendEnvelope(pingEndpoint, pingEnv);
|
|
1316
|
+
} catch {
|
|
1317
|
+
reply = await sendEnvelope(peer.endpoint, pingEnv);
|
|
1318
|
+
}
|
|
1319
|
+
return { peer, pong: reply as Pong };
|
|
1320
|
+
}));
|
|
1321
|
+
|
|
1322
|
+
const seenSessions = new Set<string>();
|
|
1323
|
+
let changed = false;
|
|
1324
|
+
|
|
1325
|
+
for (const r of results) {
|
|
1326
|
+
if (r.status === "fulfilled" && r.value.pong && r.value.pong.agent_card) {
|
|
1327
|
+
const { peer, pong } = r.value;
|
|
1328
|
+
seenSessions.add(peer.session_id);
|
|
1329
|
+
const prev = peerCards.get(peer.session_id);
|
|
1330
|
+
const next = { ...pong.agent_card, staleCount: 0 };
|
|
1331
|
+
if (!prev || JSON.stringify({ ...prev, staleCount: 0 }) !== JSON.stringify(next)) {
|
|
1332
|
+
peerCards.set(peer.session_id, next);
|
|
1333
|
+
changed = true;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
for (const [sid, card] of peerCards.entries()) {
|
|
1339
|
+
if (identity && sid === identity.session_id) continue;
|
|
1340
|
+
if (!seenSessions.has(sid)) {
|
|
1341
|
+
card.staleCount = (card.staleCount ?? 0) + 1;
|
|
1342
|
+
if (card.staleCount > 6) {
|
|
1343
|
+
peerCards.delete(sid);
|
|
1344
|
+
}
|
|
1345
|
+
changed = true;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Dedup peerCards by name — when a peer reloads, it gets a new session_id.
|
|
1350
|
+
// Both old (stale) and new (alive) entries exist briefly. Keep the one with
|
|
1351
|
+
// lower staleCount (fresher). This prevents brief duplicate display in the widget.
|
|
1352
|
+
const nameToSid = new Map<string, string>();
|
|
1353
|
+
for (const [sid, card] of peerCards.entries()) {
|
|
1354
|
+
const existing = nameToSid.get(card.name);
|
|
1355
|
+
if (existing) {
|
|
1356
|
+
const prevCard = peerCards.get(existing)!;
|
|
1357
|
+
// Only dedup when one is clearly stale (reload artifact).
|
|
1358
|
+
// If both are alive (staleCount 0), keep both — they're distinct peers.
|
|
1359
|
+
if (card.staleCount === 0 && prevCard.staleCount === 0) {
|
|
1360
|
+
// Both alive — keep both, skip dedup
|
|
1361
|
+
} else if (card.staleCount < prevCard.staleCount) {
|
|
1362
|
+
peerCards.delete(existing);
|
|
1363
|
+
nameToSid.set(card.name, sid);
|
|
1364
|
+
changed = true;
|
|
1365
|
+
} else {
|
|
1366
|
+
peerCards.delete(sid);
|
|
1367
|
+
changed = true;
|
|
1368
|
+
}
|
|
1369
|
+
} else {
|
|
1370
|
+
nameToSid.set(card.name, sid);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
if (changed && currentCtx?.hasUI) {
|
|
1375
|
+
installPoolWidget(currentCtx);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function listProjects(): string[] {
|
|
1380
|
+
const root = path.join(COMS_DIR, "projects");
|
|
1381
|
+
try {
|
|
1382
|
+
return fs.readdirSync(root).filter((d) => {
|
|
1383
|
+
try { return fs.statSync(path.join(root, d)).isDirectory(); } catch { return false; }
|
|
1384
|
+
});
|
|
1385
|
+
} catch {
|
|
1386
|
+
return [];
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
async function resolveTarget(target: string): Promise<RegistryEntry | null> {
|
|
1391
|
+
// Prefer exact session_id match first (unambiguous).
|
|
1392
|
+
if (identity) {
|
|
1393
|
+
const localEntries = await pruneDeadEntries(identity.project);
|
|
1394
|
+
// Try session_id match first (always unambiguous)
|
|
1395
|
+
const bySession = localEntries.find((e) => e.session_id === target);
|
|
1396
|
+
if (bySession) return bySession;
|
|
1397
|
+
// Name match — warn if ambiguous
|
|
1398
|
+
const byName = localEntries.filter((e) => e.name === target);
|
|
1399
|
+
if (byName.length === 1) return byName[0];
|
|
1400
|
+
if (byName.length > 1) {
|
|
1401
|
+
// Ambiguous — sort deterministically: freshest heartbeat first, session_id tiebreaker
|
|
1402
|
+
byName.sort((a, b) => {
|
|
1403
|
+
const haRaw = a.heartbeat_at ? new Date(a.heartbeat_at).getTime() : 0;
|
|
1404
|
+
const hbRaw = b.heartbeat_at ? new Date(b.heartbeat_at).getTime() : 0;
|
|
1405
|
+
const ha = isNaN(haRaw) ? 0 : haRaw;
|
|
1406
|
+
const hb = isNaN(hbRaw) ? 0 : hbRaw;
|
|
1407
|
+
if (hb !== ha) return hb - ha; // freshest first
|
|
1408
|
+
return a.session_id.localeCompare(b.session_id); // stable tiebreak
|
|
1409
|
+
});
|
|
1410
|
+
try {
|
|
1411
|
+
pi.appendEntry("coms-log", {
|
|
1412
|
+
event: "ambiguous_target",
|
|
1413
|
+
target,
|
|
1414
|
+
matches: byName.length,
|
|
1415
|
+
selected_session: byName[0].session_id,
|
|
1416
|
+
selected_heartbeat: byName[0].heartbeat_at ?? null,
|
|
1417
|
+
});
|
|
1418
|
+
} catch { /* best-effort */ }
|
|
1419
|
+
return byName[0];
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
// Fall back to scanning all projects by session_id.
|
|
1423
|
+
for (const proj of listProjects()) {
|
|
1424
|
+
const entries = await pruneDeadEntries(proj);
|
|
1425
|
+
const bySession = entries.find((e) => e.session_id === target);
|
|
1426
|
+
if (bySession) return bySession;
|
|
1427
|
+
}
|
|
1428
|
+
// Fall back to name match across projects.
|
|
1429
|
+
for (const proj of listProjects()) {
|
|
1430
|
+
const entries = await pruneDeadEntries(proj);
|
|
1431
|
+
const byName = entries.find((e) => e.name === target);
|
|
1432
|
+
if (byName) return byName;
|
|
1433
|
+
}
|
|
1434
|
+
return null;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// ━━ Tools ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1438
|
+
|
|
1439
|
+
pi.registerTool({
|
|
1440
|
+
name: "coms_list",
|
|
1441
|
+
label: "Coms List",
|
|
1442
|
+
description:
|
|
1443
|
+
"List peer agents discoverable via coms. Returns names, models, and live context-window usage. " +
|
|
1444
|
+
"Use project=\"*\" to scan all projects. include_explicit=true reveals agents marked --explicit.",
|
|
1445
|
+
parameters: Type.Object({
|
|
1446
|
+
project: Type.Optional(Type.String({ description: "Project name, or \"*\" for all projects. Defaults to caller's project." })),
|
|
1447
|
+
include_explicit: Type.Optional(Type.Boolean({ description: "Include agents launched with --explicit. Default false." })),
|
|
1448
|
+
}),
|
|
1449
|
+
async execute(_callId, params) {
|
|
1450
|
+
const includeExp = params.include_explicit === true;
|
|
1451
|
+
const projectFilter = params.project ?? identity?.project ?? "default";
|
|
1452
|
+
const projects = projectFilter === "*" ? listProjects() : [projectFilter];
|
|
1453
|
+
|
|
1454
|
+
const collected: { entry: RegistryEntry; project: string }[] = [];
|
|
1455
|
+
for (const proj of projects) {
|
|
1456
|
+
for (const entry of await pruneDeadEntries(proj)) {
|
|
1457
|
+
if (entry.explicit && !includeExp) continue;
|
|
1458
|
+
if (identity && entry.session_id === identity.session_id) continue;
|
|
1459
|
+
collected.push({ entry, project: proj });
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Ping each peer in parallel for live context usage.
|
|
1464
|
+
const pongs = await Promise.allSettled(collected.map((c) => pingPeer(c.entry.endpoint)));
|
|
1465
|
+
|
|
1466
|
+
const agents = collected.map((c, i) => {
|
|
1467
|
+
const r = pongs[i];
|
|
1468
|
+
const pong = r.status === "fulfilled" ? r.value : null;
|
|
1469
|
+
return {
|
|
1470
|
+
name: c.entry.name,
|
|
1471
|
+
session_id: c.entry.session_id,
|
|
1472
|
+
purpose: c.entry.purpose,
|
|
1473
|
+
model: c.entry.model,
|
|
1474
|
+
cwd: c.entry.cwd,
|
|
1475
|
+
project: c.project,
|
|
1476
|
+
alive: pong != null,
|
|
1477
|
+
context_used_pct: pong ? pong.context_used_pct : null,
|
|
1478
|
+
queue_depth: pong ? pong.queue_depth : 0,
|
|
1479
|
+
color: c.entry.color,
|
|
1480
|
+
};
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
const lines = agents.length === 0
|
|
1484
|
+
? "No peer agents found."
|
|
1485
|
+
: agents.map((a) => {
|
|
1486
|
+
const ctxStr = a.context_used_pct != null ? ` ${a.context_used_pct}%` : " ?%";
|
|
1487
|
+
const live = a.alive ? "●" : "✗";
|
|
1488
|
+
const queueStr = formatQueueStr(a.queue_depth);
|
|
1489
|
+
return `${live} ${a.name} (${a.model})${ctxStr}${queueStr}${a.purpose ? ` — ${a.purpose}` : ""}`;
|
|
1490
|
+
}).join("\n");
|
|
1491
|
+
|
|
1492
|
+
return {
|
|
1493
|
+
content: [{ type: "text" as const, text: `${agents.length} peer(s):\n${lines}` }],
|
|
1494
|
+
details: { agents, project: projectFilter },
|
|
1495
|
+
};
|
|
1496
|
+
},
|
|
1497
|
+
renderCall(args, theme) {
|
|
1498
|
+
const proj = (args as any).project;
|
|
1499
|
+
const filter = proj ? ` ${proj}` : "";
|
|
1500
|
+
return new Text(
|
|
1501
|
+
theme.fg("toolTitle", theme.bold("coms_list")) + theme.fg("dim", filter),
|
|
1502
|
+
0, 0,
|
|
1503
|
+
);
|
|
1504
|
+
},
|
|
1505
|
+
renderResult(result, options, theme) {
|
|
1506
|
+
const details = result.details as any;
|
|
1507
|
+
const agents: any[] = details?.agents ?? [];
|
|
1508
|
+
const header = theme.fg("accent", `📡 ${agents.length} peer(s)`);
|
|
1509
|
+
if (!options.expanded || agents.length === 0) {
|
|
1510
|
+
return new Text(header, 0, 0);
|
|
1511
|
+
}
|
|
1512
|
+
const rows = agents.map((a) => {
|
|
1513
|
+
const dot = a.alive ? theme.fg("success", "●") : theme.fg("error", "✗");
|
|
1514
|
+
const pct = a.context_used_pct != null ? `${a.context_used_pct}%` : "?%";
|
|
1515
|
+
return `${dot} ${theme.fg("accent", a.name)} ${theme.fg("dim", a.model)} ${theme.fg("warning", pct)}`;
|
|
1516
|
+
}).join("\n");
|
|
1517
|
+
return new Text(header + "\n" + rows, 0, 0);
|
|
1518
|
+
},
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
pi.registerTool({
|
|
1522
|
+
name: "coms_send",
|
|
1523
|
+
label: "Coms Send",
|
|
1524
|
+
description:
|
|
1525
|
+
"Send a prompt to a peer agent. Returns synchronously with a msg_id once the receiver acks. " +
|
|
1526
|
+
"The response auto-delivers as a followUp message when the peer replies — no polling needed. Use coms_get for non-blocking status checks if needed. " +
|
|
1527
|
+
"Throws if the receiver is unreachable or rejects the envelope.\n\n" +
|
|
1528
|
+
"When delegating multiple work items, use the `tasks` parameter to include structured task definitions. " +
|
|
1529
|
+
"The peer receives them as an instruction to create tasks via TaskCreate and track progress in their task widget.",
|
|
1530
|
+
parameters: Type.Object({
|
|
1531
|
+
target: Type.String({ description: "Peer name (preferred, scoped to your project) or session_id (global)." }),
|
|
1532
|
+
prompt: Type.String({ description: "The prompt to send." }),
|
|
1533
|
+
conversation_id: Type.Optional(Type.String()),
|
|
1534
|
+
response_schema: Type.Optional(Type.Any({ description: "Optional JSON Schema describing the expected response shape." })),
|
|
1535
|
+
tasks: Type.Optional(Type.Array(Type.Object({
|
|
1536
|
+
subject: Type.String({ description: "Brief task title" }),
|
|
1537
|
+
description: Type.String({ description: "Detailed task description" }),
|
|
1538
|
+
}), { description: "Optional structured tasks to create in the peer's task list." })),
|
|
1539
|
+
}),
|
|
1540
|
+
async execute(_callId, params) {
|
|
1541
|
+
if (!identity) {
|
|
1542
|
+
throw new Error("coms not initialised");
|
|
1543
|
+
}
|
|
1544
|
+
const target = await resolveTarget(params.target);
|
|
1545
|
+
if (!target) {
|
|
1546
|
+
throw new Error(`coms: no live agent matching "${params.target}"`);
|
|
1547
|
+
}
|
|
1548
|
+
const hops = currentInbound ? currentInbound.hops + 1 : 0;
|
|
1549
|
+
if (hops >= MAX_HOPS) {
|
|
1550
|
+
throw new Error(`coms: hop limit reached (${hops} >= ${MAX_HOPS})`);
|
|
1551
|
+
}
|
|
1552
|
+
const msg_id = ulid();
|
|
1553
|
+
const env: PromptEnvelope = {
|
|
1554
|
+
type: "prompt",
|
|
1555
|
+
msg_id,
|
|
1556
|
+
sender_session: identity.session_id,
|
|
1557
|
+
sender_endpoint: identity.endpoint,
|
|
1558
|
+
sender_name: identity.name,
|
|
1559
|
+
sender_cwd: identity.cwd,
|
|
1560
|
+
hops,
|
|
1561
|
+
timestamp: nowIso(),
|
|
1562
|
+
prompt: params.prompt,
|
|
1563
|
+
conversation_id: params.conversation_id ?? null,
|
|
1564
|
+
response_schema: (params.response_schema as object | undefined) ?? null,
|
|
1565
|
+
tasks: params.tasks ?? null,
|
|
1566
|
+
};
|
|
1567
|
+
|
|
1568
|
+
// Send the envelope synchronously and wait for the receiver's ack.
|
|
1569
|
+
await sendEnvelope(target.endpoint, env);
|
|
1570
|
+
|
|
1571
|
+
// Register a pending entry whose promise the receiver-side handleResponse
|
|
1572
|
+
// (or the timeout below) will settle.
|
|
1573
|
+
let resolveFn!: (v: { response?: any; error?: string | null }) => void;
|
|
1574
|
+
let rejectFn!: (e: Error) => void;
|
|
1575
|
+
const promise = new Promise<{ response?: any; error?: string | null }>((res, rej) => {
|
|
1576
|
+
resolveFn = res;
|
|
1577
|
+
rejectFn = rej;
|
|
1578
|
+
});
|
|
1579
|
+
const entry: PendingReply = {
|
|
1580
|
+
resolve: resolveFn,
|
|
1581
|
+
reject: rejectFn,
|
|
1582
|
+
timer: null,
|
|
1583
|
+
promise,
|
|
1584
|
+
target_name: target.name,
|
|
1585
|
+
created_at: nowIso(),
|
|
1586
|
+
};
|
|
1587
|
+
entry.timer = setTimeout(() => {
|
|
1588
|
+
if (entry.result) return;
|
|
1589
|
+
entry.result = { error: "timeout" };
|
|
1590
|
+
try { entry.resolve(entry.result); } catch { /* ignore */ }
|
|
1591
|
+
// Clean up timed-out entry
|
|
1592
|
+
setTimeout(() => { pendingReplies.delete(msg_id); }, 60_000).unref();
|
|
1593
|
+
}, TIMEOUT_MS);
|
|
1594
|
+
// Don't keep the event loop alive solely for this timer.
|
|
1595
|
+
try { (entry.timer as any).unref?.(); } catch { /* ignore */ }
|
|
1596
|
+
pendingReplies.set(msg_id, entry);
|
|
1597
|
+
|
|
1598
|
+
try {
|
|
1599
|
+
pi.appendEntry("coms-log", {
|
|
1600
|
+
event: "outbound_prompt",
|
|
1601
|
+
msg_id,
|
|
1602
|
+
target: target.name,
|
|
1603
|
+
hops,
|
|
1604
|
+
});
|
|
1605
|
+
} catch {
|
|
1606
|
+
// best-effort
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
return {
|
|
1610
|
+
content: [{ type: "text" as const, text: `coms_send → ${target.name}\nmsg_id ${msg_id}\nhops ${hops}` }],
|
|
1611
|
+
details: { msg_id, target: target.name, target_session: target.session_id, hops },
|
|
1612
|
+
};
|
|
1613
|
+
},
|
|
1614
|
+
renderCall(args, theme) {
|
|
1615
|
+
const tgt = (args as any).target ?? "?";
|
|
1616
|
+
const prompt = (args as any).prompt ?? "";
|
|
1617
|
+
const preview = prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt;
|
|
1618
|
+
return new Text(
|
|
1619
|
+
theme.fg("toolTitle", theme.bold("coms_send ")) +
|
|
1620
|
+
theme.fg("accent", tgt) +
|
|
1621
|
+
theme.fg("dim", " — ") +
|
|
1622
|
+
theme.fg("muted", preview),
|
|
1623
|
+
0, 0,
|
|
1624
|
+
);
|
|
1625
|
+
},
|
|
1626
|
+
renderResult(result, _options, theme) {
|
|
1627
|
+
const d = result.details as any;
|
|
1628
|
+
if (!d) {
|
|
1629
|
+
const t = result.content[0];
|
|
1630
|
+
return new Text(t?.type === "text" ? t.text : "", 0, 0);
|
|
1631
|
+
}
|
|
1632
|
+
return new Text(
|
|
1633
|
+
theme.fg("success", "→ ") +
|
|
1634
|
+
theme.fg("accent", d.target) +
|
|
1635
|
+
theme.fg("dim", ` msg_id `) +
|
|
1636
|
+
theme.fg("warning", d.msg_id),
|
|
1637
|
+
0, 0,
|
|
1638
|
+
);
|
|
1639
|
+
},
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
pi.registerTool({
|
|
1643
|
+
name: "coms_get",
|
|
1644
|
+
label: "Coms Get",
|
|
1645
|
+
description:
|
|
1646
|
+
"Non-blocking poll of a pending coms_send reply. Returns status pending|complete|error and (when complete) the response.",
|
|
1647
|
+
parameters: Type.Object({
|
|
1648
|
+
msg_id: Type.String({ description: "msg_id returned by coms_send." }),
|
|
1649
|
+
}),
|
|
1650
|
+
async execute(_callId, params) {
|
|
1651
|
+
const entry = pendingReplies.get(params.msg_id);
|
|
1652
|
+
if (!entry) {
|
|
1653
|
+
return {
|
|
1654
|
+
content: [{ type: "text" as const, text: `coms_get: unknown msg_id ${params.msg_id}` }],
|
|
1655
|
+
details: { status: "error", error: "unknown msg_id" },
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
if (entry.result) {
|
|
1659
|
+
const r = entry.result;
|
|
1660
|
+
const text = r.error
|
|
1661
|
+
? `coms_get: error — ${r.error}`
|
|
1662
|
+
: `coms_get: complete\n${typeof r.response === "string" ? r.response : JSON.stringify(r.response, null, 2)}`;
|
|
1663
|
+
return {
|
|
1664
|
+
content: [{ type: "text" as const, text }],
|
|
1665
|
+
details: { status: "complete", response: r.response, error: r.error ?? null, queued_msg_ids: r.queued_msg_ids ?? [] },
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
return {
|
|
1669
|
+
content: [{ type: "text" as const, text: `coms_get: pending` }],
|
|
1670
|
+
details: { status: "pending" },
|
|
1671
|
+
};
|
|
1672
|
+
},
|
|
1673
|
+
renderCall(args, theme) {
|
|
1674
|
+
const id = (args as any).msg_id ?? "?";
|
|
1675
|
+
return new Text(
|
|
1676
|
+
theme.fg("toolTitle", theme.bold("coms_get ")) + theme.fg("warning", id),
|
|
1677
|
+
0, 0,
|
|
1678
|
+
);
|
|
1679
|
+
},
|
|
1680
|
+
renderResult(result, _options, theme) {
|
|
1681
|
+
const d = result.details as any;
|
|
1682
|
+
const status = d?.status ?? "?";
|
|
1683
|
+
const color = status === "complete" ? "success" : status === "pending" ? "warning" : "error";
|
|
1684
|
+
return new Text(theme.fg(color, status), 0, 0);
|
|
1685
|
+
},
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
// ━━ agent_end: capture turn output and dispatch response back ━━━━━━━━
|
|
1689
|
+
|
|
1690
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
1691
|
+
if (!currentInbound || !identity) {
|
|
1692
|
+
// Drain any orphaned inbounds — can't send responses without identity,
|
|
1693
|
+
// but must clear the queue to prevent permanent buildup
|
|
1694
|
+
if (!identity) {
|
|
1695
|
+
for (const [id, ib] of inboundQueue) {
|
|
1696
|
+
ib.fulfilled = true;
|
|
1697
|
+
inboundQueue.delete(id);
|
|
1698
|
+
}
|
|
1699
|
+
currentInbound = null;
|
|
1700
|
+
}
|
|
1701
|
+
processingInbound = false;
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
const inbound = currentInbound;
|
|
1705
|
+
|
|
1706
|
+
// Walk the session branch for the most recent assistant message text.
|
|
1707
|
+
let lastAssistantText = "";
|
|
1708
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
1709
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
1710
|
+
const m = entry.message as any;
|
|
1711
|
+
if (typeof m.content === "string") {
|
|
1712
|
+
lastAssistantText = m.content;
|
|
1713
|
+
} else if (Array.isArray(m.content)) {
|
|
1714
|
+
lastAssistantText = m.content
|
|
1715
|
+
.filter((b: any) => b && b.type === "text")
|
|
1716
|
+
.map((b: any) => b.text)
|
|
1717
|
+
.join("\n");
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
let payload: any = lastAssistantText;
|
|
1723
|
+
let error: string | null = null;
|
|
1724
|
+
if (inbound.response_schema && typeof inbound.response_schema === "object") {
|
|
1725
|
+
try {
|
|
1726
|
+
payload = JSON.parse(lastAssistantText);
|
|
1727
|
+
} catch {
|
|
1728
|
+
error = "response not valid JSON";
|
|
1729
|
+
payload = null;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// Use inline count instead of getPendingInboundCount() — `inbound` (local param)
|
|
1734
|
+
// differs from `currentInbound` (module-scoped, already null at this point).
|
|
1735
|
+
const queuedIds: string[] = [];
|
|
1736
|
+
for (const i of inboundQueue.values()) if (!i.fulfilled && i !== inbound) queuedIds.push(i.msg_id);
|
|
1737
|
+
const respEnv: ResponseEnvelope = {
|
|
1738
|
+
type: "response",
|
|
1739
|
+
msg_id: inbound.msg_id,
|
|
1740
|
+
sender_session: identity.session_id,
|
|
1741
|
+
sender_endpoint: identity.endpoint,
|
|
1742
|
+
hops: 0,
|
|
1743
|
+
timestamp: nowIso(),
|
|
1744
|
+
response: payload,
|
|
1745
|
+
error,
|
|
1746
|
+
queued_msg_ids: queuedIds,
|
|
1747
|
+
};
|
|
1748
|
+
|
|
1749
|
+
try {
|
|
1750
|
+
await sendEnvelope(inbound.sender_endpoint, respEnv);
|
|
1751
|
+
try {
|
|
1752
|
+
pi.appendEntry("coms-log", {
|
|
1753
|
+
event: "outbound_response",
|
|
1754
|
+
msg_id: inbound.msg_id,
|
|
1755
|
+
error,
|
|
1756
|
+
});
|
|
1757
|
+
} catch { /* best-effort */ }
|
|
1758
|
+
} catch (e: any) {
|
|
1759
|
+
try {
|
|
1760
|
+
pi.appendEntry("coms-log", {
|
|
1761
|
+
event: "outbound_response_failed",
|
|
1762
|
+
msg_id: inbound.msg_id,
|
|
1763
|
+
reason: e?.message ?? String(e),
|
|
1764
|
+
});
|
|
1765
|
+
} catch { /* best-effort */ }
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
inbound.fulfilled = true;
|
|
1769
|
+
inboundQueue.delete(inbound.msg_id);
|
|
1770
|
+
maybeRefreshWidget();
|
|
1771
|
+
currentInbound = null;
|
|
1772
|
+
|
|
1773
|
+
// FIFO drain: pick the next queued (oldest unfulfilled) inbound
|
|
1774
|
+
const next = [...inboundQueue.values()].find((i) => !i.fulfilled);
|
|
1775
|
+
if (next) {
|
|
1776
|
+
currentInbound = next;
|
|
1777
|
+
try {
|
|
1778
|
+
pi.sendMessage(
|
|
1779
|
+
{
|
|
1780
|
+
customType: "coms-inbound",
|
|
1781
|
+
content: buildInboundContent(`[from ${next.sender_name} @ ${next.sender_cwd}]`, next.prompt, next.tasks),
|
|
1782
|
+
display: true,
|
|
1783
|
+
details: {
|
|
1784
|
+
msg_id: next.msg_id,
|
|
1785
|
+
sender_session: next.sender_session,
|
|
1786
|
+
response_schema: next.response_schema ?? null,
|
|
1787
|
+
},
|
|
1788
|
+
},
|
|
1789
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
1790
|
+
);
|
|
1791
|
+
} catch (err: any) {
|
|
1792
|
+
// Failed to inject — drain ALL remaining queued messages with error responses
|
|
1793
|
+
try { pi.appendEntry("coms-log", { event: "fifo_drain_failed", msg_id: next.msg_id, reason: err?.message ?? String(err) }); } catch { /* best-effort */ }
|
|
1794
|
+
const remaining = [next, ...[...inboundQueue.values()].filter(i => !i.fulfilled && i.msg_id !== next.msg_id)];
|
|
1795
|
+
for (let idx = 0; idx < remaining.length; idx++) {
|
|
1796
|
+
const orphan = remaining[idx];
|
|
1797
|
+
try {
|
|
1798
|
+
await sendEnvelope(orphan.sender_endpoint, {
|
|
1799
|
+
type: "response",
|
|
1800
|
+
msg_id: orphan.msg_id,
|
|
1801
|
+
sender_session: identity!.session_id,
|
|
1802
|
+
sender_endpoint: identity!.endpoint,
|
|
1803
|
+
hops: 0,
|
|
1804
|
+
timestamp: nowIso(),
|
|
1805
|
+
response: null,
|
|
1806
|
+
error: "injection_failed",
|
|
1807
|
+
queued_msg_ids: remaining.slice(idx + 1).map(r => r.msg_id),
|
|
1808
|
+
});
|
|
1809
|
+
} catch (e: any) {
|
|
1810
|
+
try { pi.appendEntry("coms-log", { event: "orphan_cleanup_failed", msg_id: orphan.msg_id, reason: e?.message ?? String(e) }); } catch { /* best-effort */ }
|
|
1811
|
+
}
|
|
1812
|
+
orphan.fulfilled = true;
|
|
1813
|
+
inboundQueue.delete(orphan.msg_id);
|
|
1814
|
+
}
|
|
1815
|
+
currentInbound = null;
|
|
1816
|
+
processingInbound = false;
|
|
1817
|
+
}
|
|
1818
|
+
} else {
|
|
1819
|
+
processingInbound = false;
|
|
1820
|
+
}
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
// ━━ /coms slash command ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1824
|
+
pi.registerCommand("coms", {
|
|
1825
|
+
description: "Force-refresh the coms pool widget (or filter with --all / --project <name>)",
|
|
1826
|
+
handler: async (args, ctx) => {
|
|
1827
|
+
const trimmed = (args ?? "").trim();
|
|
1828
|
+
if (trimmed.includes("--all")) {
|
|
1829
|
+
includeExplicit = !includeExplicit;
|
|
1830
|
+
try { ctx.ui.notify(`coms: include_explicit = ${includeExplicit}`, "info"); } catch { /* ignore */ }
|
|
1831
|
+
}
|
|
1832
|
+
const projectMatch = trimmed.match(/--project\s+(\S+)/);
|
|
1833
|
+
if (projectMatch) {
|
|
1834
|
+
displayProject = projectMatch[1];
|
|
1835
|
+
try { ctx.ui.notify(`coms: displaying project ${displayProject}`, "info"); } catch { /* ignore */ }
|
|
1836
|
+
}
|
|
1837
|
+
await refreshPool();
|
|
1838
|
+
},
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
// ━━ Clean shutdown ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1842
|
+
let shuttingDown = false;
|
|
1843
|
+
async function cleanShutdown(): Promise<void> {
|
|
1844
|
+
if (shuttingDown) return;
|
|
1845
|
+
shuttingDown = true;
|
|
1846
|
+
if (pingTimer) { try { clearInterval(pingTimer); } catch { /* ignore */ } pingTimer = null; }
|
|
1847
|
+
if (keepaliveTimer) { try { clearInterval(keepaliveTimer); } catch { /* ignore */ } keepaliveTimer = null; }
|
|
1848
|
+
if (server) {
|
|
1849
|
+
try { server.close(); } catch { /* ignore */ }
|
|
1850
|
+
server = null;
|
|
1851
|
+
}
|
|
1852
|
+
if (cardUpdateTimer) {
|
|
1853
|
+
clearInterval(cardUpdateTimer);
|
|
1854
|
+
cardUpdateTimer = null;
|
|
1855
|
+
}
|
|
1856
|
+
if (pingWorker) {
|
|
1857
|
+
try { pingWorker.postMessage({ type: "shutdown" }); } catch {}
|
|
1858
|
+
pingWorker = null;
|
|
1859
|
+
pingWorkerReady = false;
|
|
1860
|
+
}
|
|
1861
|
+
if (identity) {
|
|
1862
|
+
if (process.platform !== "win32") {
|
|
1863
|
+
try { fs.unlinkSync(identity.endpoint); } catch { /* ignore */ }
|
|
1864
|
+
}
|
|
1865
|
+
try { removeRegistryEntry(identity.project, identity.session_id); } catch { /* ignore */ }
|
|
1866
|
+
try {
|
|
1867
|
+
pi.appendEntry("coms-log", { event: "shutdown", session_id: identity.session_id });
|
|
1868
|
+
} catch { /* best-effort */ }
|
|
1869
|
+
}
|
|
1870
|
+
if (currentCtx?.hasUI) {
|
|
1871
|
+
try { currentCtx.ui.setWidget("coms-pool", undefined); } catch { /* ignore */ }
|
|
1872
|
+
try { currentCtx.ui.setStatus("coms", undefined); } catch { /* ignore */ }
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
pi.on("session_shutdown", async () => { await cleanShutdown(); });
|
|
1877
|
+
process.on("SIGINT", () => { void cleanShutdown(); });
|
|
1878
|
+
process.on("SIGTERM", () => { void cleanShutdown(); });
|
|
1879
|
+
}
|