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