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