@chankov/agent-skills 0.3.0 → 0.3.3
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/.versions/0.3.2/.claude/commands/build.md +18 -0
- package/.versions/0.3.2/.claude/commands/code-simplify.md +22 -0
- package/.versions/0.3.2/.claude/commands/design-agent.md +14 -0
- package/.versions/0.3.2/.claude/commands/doctor-agent-skills.md +13 -0
- package/.versions/0.3.2/.claude/commands/plan.md +16 -0
- package/.versions/0.3.2/.claude/commands/prime.md +22 -0
- package/.versions/0.3.2/.claude/commands/review.md +16 -0
- package/.versions/0.3.2/.claude/commands/setup-agent-skills.md +19 -0
- package/.versions/0.3.2/.claude/commands/ship.md +17 -0
- package/.versions/0.3.2/.claude/commands/spec.md +15 -0
- package/.versions/0.3.2/.claude/commands/test.md +19 -0
- package/.versions/0.3.2/.opencode/commands/as-build.md +17 -0
- package/.versions/0.3.2/.opencode/commands/as-code-simplify.md +16 -0
- package/.versions/0.3.2/.opencode/commands/as-design-agent.md +15 -0
- package/.versions/0.3.2/.opencode/commands/as-doctor-agent-skills.md +11 -0
- package/.versions/0.3.2/.opencode/commands/as-plan.md +16 -0
- package/.versions/0.3.2/.opencode/commands/as-prime.md +22 -0
- package/.versions/0.3.2/.opencode/commands/as-review.md +15 -0
- package/.versions/0.3.2/.opencode/commands/as-setup-agent-skills.md +11 -0
- package/.versions/0.3.2/.opencode/commands/as-ship.md +16 -0
- package/.versions/0.3.2/.opencode/commands/as-spec.md +16 -0
- package/.versions/0.3.2/.opencode/commands/as-test.md +21 -0
- package/.versions/0.3.2/.pi/agents/agent-chain.yaml +49 -0
- package/.versions/0.3.2/.pi/agents/bowser.md +19 -0
- package/.versions/0.3.2/.pi/agents/pi-pi/agent-expert.md +98 -0
- package/.versions/0.3.2/.pi/agents/pi-pi/cli-expert.md +41 -0
- package/.versions/0.3.2/.pi/agents/pi-pi/config-expert.md +63 -0
- package/.versions/0.3.2/.pi/agents/pi-pi/ext-expert.md +43 -0
- package/.versions/0.3.2/.pi/agents/pi-pi/keybinding-expert.md +134 -0
- package/.versions/0.3.2/.pi/agents/pi-pi/pi-orchestrator.md +57 -0
- package/.versions/0.3.2/.pi/agents/pi-pi/prompt-expert.md +70 -0
- package/.versions/0.3.2/.pi/agents/pi-pi/skill-expert.md +42 -0
- package/.versions/0.3.2/.pi/agents/pi-pi/theme-expert.md +40 -0
- package/.versions/0.3.2/.pi/agents/pi-pi/tui-expert.md +85 -0
- package/.versions/0.3.2/.pi/agents/teams.yaml +31 -0
- package/.versions/0.3.2/.pi/damage-control-rules.yaml +278 -0
- package/.versions/0.3.2/.pi/extensions/agent-skills-update-check/README.md +58 -0
- package/.versions/0.3.2/.pi/extensions/agent-skills-update-check/index.ts +161 -0
- package/.versions/0.3.2/.pi/extensions/agent-skills-update-check/package.json +6 -0
- package/.versions/0.3.2/.pi/extensions/chrome-devtools-mcp/README.md +39 -0
- package/.versions/0.3.2/.pi/extensions/chrome-devtools-mcp/index.ts +61 -0
- package/.versions/0.3.2/.pi/extensions/chrome-devtools-mcp/package.json +6 -0
- package/.versions/0.3.2/.pi/extensions/compact-and-continue/README.md +42 -0
- package/.versions/0.3.2/.pi/extensions/compact-and-continue/index.ts +120 -0
- package/.versions/0.3.2/.pi/extensions/compact-and-continue/package.json +6 -0
- package/.versions/0.3.2/.pi/extensions/mcp-bridge/README.md +46 -0
- package/.versions/0.3.2/.pi/extensions/mcp-bridge/index.ts +206 -0
- package/.versions/0.3.2/.pi/extensions/mcp-bridge/package.json +6 -0
- package/.versions/0.3.2/.pi/extensions/package-lock.json +1143 -0
- package/.versions/0.3.2/.pi/extensions/package.json +9 -0
- package/.versions/0.3.2/.pi/harnesses/agent-chain/README.md +37 -0
- package/.versions/0.3.2/.pi/harnesses/agent-chain/index.ts +795 -0
- package/.versions/0.3.2/.pi/harnesses/agent-chain/package.json +6 -0
- package/.versions/0.3.2/.pi/harnesses/agent-team/README.md +38 -0
- package/.versions/0.3.2/.pi/harnesses/agent-team/index.ts +732 -0
- package/.versions/0.3.2/.pi/harnesses/agent-team/package.json +6 -0
- package/.versions/0.3.2/.pi/harnesses/coms/README.md +36 -0
- package/.versions/0.3.2/.pi/harnesses/coms/index.ts +1595 -0
- package/.versions/0.3.2/.pi/harnesses/coms/package.json +6 -0
- package/.versions/0.3.2/.pi/harnesses/coms-net/README.md +46 -0
- package/.versions/0.3.2/.pi/harnesses/coms-net/index.ts +1637 -0
- package/.versions/0.3.2/.pi/harnesses/coms-net/package.json +6 -0
- package/.versions/0.3.2/.pi/harnesses/damage-control/README.md +38 -0
- package/.versions/0.3.2/.pi/harnesses/damage-control/index.ts +207 -0
- package/.versions/0.3.2/.pi/harnesses/damage-control/package.json +6 -0
- package/.versions/0.3.2/.pi/harnesses/damage-control-continue/README.md +37 -0
- package/.versions/0.3.2/.pi/harnesses/damage-control-continue/index.ts +234 -0
- package/.versions/0.3.2/.pi/harnesses/damage-control-continue/package.json +6 -0
- package/.versions/0.3.2/.pi/harnesses/minimal/README.md +27 -0
- package/.versions/0.3.2/.pi/harnesses/minimal/index.ts +32 -0
- package/.versions/0.3.2/.pi/harnesses/minimal/package.json +6 -0
- package/.versions/0.3.2/.pi/harnesses/package-lock.json +35 -0
- package/.versions/0.3.2/.pi/harnesses/package.json +9 -0
- package/.versions/0.3.2/.pi/harnesses/pi-pi/README.md +39 -0
- package/.versions/0.3.2/.pi/harnesses/pi-pi/index.ts +631 -0
- package/.versions/0.3.2/.pi/harnesses/pi-pi/package.json +6 -0
- package/.versions/0.3.2/.pi/harnesses/purpose-gate/README.md +27 -0
- package/.versions/0.3.2/.pi/harnesses/purpose-gate/index.ts +82 -0
- package/.versions/0.3.2/.pi/harnesses/purpose-gate/package.json +6 -0
- package/.versions/0.3.2/.pi/harnesses/session-replay/README.md +28 -0
- package/.versions/0.3.2/.pi/harnesses/session-replay/index.ts +214 -0
- package/.versions/0.3.2/.pi/harnesses/session-replay/package.json +6 -0
- package/.versions/0.3.2/.pi/harnesses/subagent-widget/README.md +36 -0
- package/.versions/0.3.2/.pi/harnesses/subagent-widget/index.ts +479 -0
- package/.versions/0.3.2/.pi/harnesses/subagent-widget/package.json +6 -0
- package/.versions/0.3.2/.pi/harnesses/system-select/README.md +39 -0
- package/.versions/0.3.2/.pi/harnesses/system-select/index.ts +165 -0
- package/.versions/0.3.2/.pi/harnesses/system-select/package.json +6 -0
- package/.versions/0.3.2/.pi/harnesses/tilldone/README.md +35 -0
- package/.versions/0.3.2/.pi/harnesses/tilldone/index.ts +724 -0
- package/.versions/0.3.2/.pi/harnesses/tilldone/package.json +6 -0
- package/.versions/0.3.2/.pi/harnesses/tool-counter/README.md +31 -0
- package/.versions/0.3.2/.pi/harnesses/tool-counter/index.ts +100 -0
- package/.versions/0.3.2/.pi/harnesses/tool-counter/package.json +6 -0
- package/.versions/0.3.2/.pi/harnesses/tool-counter-widget/README.md +27 -0
- package/.versions/0.3.2/.pi/harnesses/tool-counter-widget/index.ts +66 -0
- package/.versions/0.3.2/.pi/harnesses/tool-counter-widget/package.json +6 -0
- package/.versions/0.3.2/.pi/prompts/build.md +24 -0
- package/.versions/0.3.2/.pi/prompts/code-simplify.md +22 -0
- package/.versions/0.3.2/.pi/prompts/doctor-agent-skills.md +13 -0
- package/.versions/0.3.2/.pi/prompts/plan.md +16 -0
- package/.versions/0.3.2/.pi/prompts/review.md +16 -0
- package/.versions/0.3.2/.pi/prompts/setup-agent-skills.md +19 -0
- package/.versions/0.3.2/.pi/prompts/ship.md +17 -0
- package/.versions/0.3.2/.pi/prompts/spec.md +15 -0
- package/.versions/0.3.2/.pi/prompts/test.md +19 -0
- package/.versions/0.3.2/.pi/skills/bowser/SKILL.md +114 -0
- package/.versions/0.3.2/.version +1 -0
- package/.versions/0.3.2/agents/builder.md +6 -0
- package/.versions/0.3.2/agents/code-reviewer.md +93 -0
- package/.versions/0.3.2/agents/documenter.md +6 -0
- package/.versions/0.3.2/agents/plan-reviewer.md +22 -0
- package/.versions/0.3.2/agents/planner.md +6 -0
- package/.versions/0.3.2/agents/scout.md +6 -0
- package/.versions/0.3.2/agents/security-auditor.md +97 -0
- package/.versions/0.3.2/agents/test-engineer.md +89 -0
- package/.versions/0.3.2/hooks/SIMPLIFY-IGNORE.md +90 -0
- package/.versions/0.3.2/hooks/hooks.json +14 -0
- package/.versions/0.3.2/hooks/session-start.sh +74 -0
- package/.versions/0.3.2/hooks/simplify-ignore-test.sh +247 -0
- package/.versions/0.3.2/hooks/simplify-ignore.sh +302 -0
- package/.versions/0.3.2/references/accessibility-checklist.md +159 -0
- package/.versions/0.3.2/references/performance-checklist.md +121 -0
- package/.versions/0.3.2/references/prompting-patterns.md +380 -0
- package/.versions/0.3.2/references/security-checklist.md +134 -0
- package/.versions/0.3.2/references/testing-patterns.md +236 -0
- package/.versions/0.3.2/skills/api-and-interface-design/SKILL.md +294 -0
- package/.versions/0.3.2/skills/browser-testing-with-devtools/SKILL.md +335 -0
- package/.versions/0.3.2/skills/ci-cd-and-automation/SKILL.md +390 -0
- package/.versions/0.3.2/skills/code-review-and-quality/SKILL.md +347 -0
- package/.versions/0.3.2/skills/code-simplification/SKILL.md +331 -0
- package/.versions/0.3.2/skills/context-engineering/SKILL.md +291 -0
- package/.versions/0.3.2/skills/debugging-and-error-recovery/SKILL.md +300 -0
- package/.versions/0.3.2/skills/deprecation-and-migration/SKILL.md +206 -0
- package/.versions/0.3.2/skills/designing-agents/SKILL.md +394 -0
- package/.versions/0.3.2/skills/designing-agents/pi-harness-authoring.md +213 -0
- package/.versions/0.3.2/skills/documentation-and-adrs/SKILL.md +278 -0
- package/.versions/0.3.2/skills/frontend-ui-engineering/SKILL.md +322 -0
- package/.versions/0.3.2/skills/git-workflow-and-versioning/SKILL.md +316 -0
- package/.versions/0.3.2/skills/guided-workspace-setup/SKILL.md +345 -0
- package/.versions/0.3.2/skills/idea-refine/SKILL.md +178 -0
- package/.versions/0.3.2/skills/idea-refine/examples.md +238 -0
- package/.versions/0.3.2/skills/idea-refine/frameworks.md +99 -0
- package/.versions/0.3.2/skills/idea-refine/refinement-criteria.md +113 -0
- package/.versions/0.3.2/skills/idea-refine/scripts/idea-refine.sh +15 -0
- package/.versions/0.3.2/skills/incremental-implementation/SKILL.md +279 -0
- package/.versions/0.3.2/skills/performance-optimization/SKILL.md +350 -0
- package/.versions/0.3.2/skills/planning-and-task-breakdown/SKILL.md +237 -0
- package/.versions/0.3.2/skills/security-and-hardening/SKILL.md +349 -0
- package/.versions/0.3.2/skills/shipping-and-launch/SKILL.md +309 -0
- package/.versions/0.3.2/skills/source-driven-development/SKILL.md +194 -0
- package/.versions/0.3.2/skills/spec-driven-development/SKILL.md +237 -0
- package/.versions/0.3.2/skills/test-driven-development/SKILL.md +379 -0
- package/.versions/0.3.2/skills/using-agent-skills/SKILL.md +176 -0
- package/.versions/0.3.3/.claude/commands/build.md +18 -0
- package/.versions/0.3.3/.claude/commands/code-simplify.md +22 -0
- package/.versions/0.3.3/.claude/commands/design-agent.md +14 -0
- package/.versions/0.3.3/.claude/commands/doctor-agent-skills.md +13 -0
- package/.versions/0.3.3/.claude/commands/plan.md +16 -0
- package/.versions/0.3.3/.claude/commands/prime.md +22 -0
- package/.versions/0.3.3/.claude/commands/review.md +16 -0
- package/.versions/0.3.3/.claude/commands/setup-agent-skills.md +19 -0
- package/.versions/0.3.3/.claude/commands/ship.md +17 -0
- package/.versions/0.3.3/.claude/commands/spec.md +15 -0
- package/.versions/0.3.3/.claude/commands/test.md +19 -0
- package/.versions/0.3.3/.opencode/commands/as-build.md +17 -0
- package/.versions/0.3.3/.opencode/commands/as-code-simplify.md +16 -0
- package/.versions/0.3.3/.opencode/commands/as-design-agent.md +15 -0
- package/.versions/0.3.3/.opencode/commands/as-doctor-agent-skills.md +11 -0
- package/.versions/0.3.3/.opencode/commands/as-plan.md +16 -0
- package/.versions/0.3.3/.opencode/commands/as-prime.md +22 -0
- package/.versions/0.3.3/.opencode/commands/as-review.md +15 -0
- package/.versions/0.3.3/.opencode/commands/as-setup-agent-skills.md +11 -0
- package/.versions/0.3.3/.opencode/commands/as-ship.md +16 -0
- package/.versions/0.3.3/.opencode/commands/as-spec.md +16 -0
- package/.versions/0.3.3/.opencode/commands/as-test.md +21 -0
- package/.versions/0.3.3/.pi/agents/agent-chain.yaml +49 -0
- package/.versions/0.3.3/.pi/agents/bowser.md +19 -0
- package/.versions/0.3.3/.pi/agents/pi-pi/agent-expert.md +98 -0
- package/.versions/0.3.3/.pi/agents/pi-pi/cli-expert.md +41 -0
- package/.versions/0.3.3/.pi/agents/pi-pi/config-expert.md +63 -0
- package/.versions/0.3.3/.pi/agents/pi-pi/ext-expert.md +43 -0
- package/.versions/0.3.3/.pi/agents/pi-pi/keybinding-expert.md +134 -0
- package/.versions/0.3.3/.pi/agents/pi-pi/pi-orchestrator.md +57 -0
- package/.versions/0.3.3/.pi/agents/pi-pi/prompt-expert.md +70 -0
- package/.versions/0.3.3/.pi/agents/pi-pi/skill-expert.md +42 -0
- package/.versions/0.3.3/.pi/agents/pi-pi/theme-expert.md +40 -0
- package/.versions/0.3.3/.pi/agents/pi-pi/tui-expert.md +85 -0
- package/.versions/0.3.3/.pi/agents/teams.yaml +31 -0
- package/.versions/0.3.3/.pi/damage-control-rules.yaml +278 -0
- package/.versions/0.3.3/.pi/extensions/agent-skills-update-check/README.md +58 -0
- package/.versions/0.3.3/.pi/extensions/agent-skills-update-check/index.ts +161 -0
- package/.versions/0.3.3/.pi/extensions/agent-skills-update-check/package.json +6 -0
- package/.versions/0.3.3/.pi/extensions/chrome-devtools-mcp/README.md +39 -0
- package/.versions/0.3.3/.pi/extensions/chrome-devtools-mcp/index.ts +61 -0
- package/.versions/0.3.3/.pi/extensions/chrome-devtools-mcp/package.json +6 -0
- package/.versions/0.3.3/.pi/extensions/compact-and-continue/README.md +42 -0
- package/.versions/0.3.3/.pi/extensions/compact-and-continue/index.ts +120 -0
- package/.versions/0.3.3/.pi/extensions/compact-and-continue/package.json +6 -0
- package/.versions/0.3.3/.pi/extensions/mcp-bridge/README.md +46 -0
- package/.versions/0.3.3/.pi/extensions/mcp-bridge/index.ts +206 -0
- package/.versions/0.3.3/.pi/extensions/mcp-bridge/package.json +6 -0
- package/.versions/0.3.3/.pi/extensions/package-lock.json +1143 -0
- package/.versions/0.3.3/.pi/extensions/package.json +9 -0
- package/.versions/0.3.3/.pi/harnesses/agent-chain/README.md +37 -0
- package/.versions/0.3.3/.pi/harnesses/agent-chain/index.ts +795 -0
- package/.versions/0.3.3/.pi/harnesses/agent-chain/package.json +6 -0
- package/.versions/0.3.3/.pi/harnesses/agent-team/README.md +38 -0
- package/.versions/0.3.3/.pi/harnesses/agent-team/index.ts +732 -0
- package/.versions/0.3.3/.pi/harnesses/agent-team/package.json +6 -0
- package/.versions/0.3.3/.pi/harnesses/coms/README.md +36 -0
- package/.versions/0.3.3/.pi/harnesses/coms/index.ts +1595 -0
- package/.versions/0.3.3/.pi/harnesses/coms/package.json +6 -0
- package/.versions/0.3.3/.pi/harnesses/coms-net/README.md +46 -0
- package/.versions/0.3.3/.pi/harnesses/coms-net/index.ts +1637 -0
- package/.versions/0.3.3/.pi/harnesses/coms-net/package.json +6 -0
- package/.versions/0.3.3/.pi/harnesses/damage-control/README.md +38 -0
- package/.versions/0.3.3/.pi/harnesses/damage-control/index.ts +207 -0
- package/.versions/0.3.3/.pi/harnesses/damage-control/package.json +6 -0
- package/.versions/0.3.3/.pi/harnesses/damage-control-continue/README.md +37 -0
- package/.versions/0.3.3/.pi/harnesses/damage-control-continue/index.ts +234 -0
- package/.versions/0.3.3/.pi/harnesses/damage-control-continue/package.json +6 -0
- package/.versions/0.3.3/.pi/harnesses/minimal/README.md +27 -0
- package/.versions/0.3.3/.pi/harnesses/minimal/index.ts +32 -0
- package/.versions/0.3.3/.pi/harnesses/minimal/package.json +6 -0
- package/.versions/0.3.3/.pi/harnesses/package-lock.json +35 -0
- package/.versions/0.3.3/.pi/harnesses/package.json +9 -0
- package/.versions/0.3.3/.pi/harnesses/pi-pi/README.md +39 -0
- package/.versions/0.3.3/.pi/harnesses/pi-pi/index.ts +631 -0
- package/.versions/0.3.3/.pi/harnesses/pi-pi/package.json +6 -0
- package/.versions/0.3.3/.pi/harnesses/purpose-gate/README.md +27 -0
- package/.versions/0.3.3/.pi/harnesses/purpose-gate/index.ts +82 -0
- package/.versions/0.3.3/.pi/harnesses/purpose-gate/package.json +6 -0
- package/.versions/0.3.3/.pi/harnesses/session-replay/README.md +28 -0
- package/.versions/0.3.3/.pi/harnesses/session-replay/index.ts +214 -0
- package/.versions/0.3.3/.pi/harnesses/session-replay/package.json +6 -0
- package/.versions/0.3.3/.pi/harnesses/subagent-widget/README.md +36 -0
- package/.versions/0.3.3/.pi/harnesses/subagent-widget/index.ts +479 -0
- package/.versions/0.3.3/.pi/harnesses/subagent-widget/package.json +6 -0
- package/.versions/0.3.3/.pi/harnesses/system-select/README.md +39 -0
- package/.versions/0.3.3/.pi/harnesses/system-select/index.ts +165 -0
- package/.versions/0.3.3/.pi/harnesses/system-select/package.json +6 -0
- package/.versions/0.3.3/.pi/harnesses/tilldone/README.md +35 -0
- package/.versions/0.3.3/.pi/harnesses/tilldone/index.ts +724 -0
- package/.versions/0.3.3/.pi/harnesses/tilldone/package.json +6 -0
- package/.versions/0.3.3/.pi/harnesses/tool-counter/README.md +31 -0
- package/.versions/0.3.3/.pi/harnesses/tool-counter/index.ts +100 -0
- package/.versions/0.3.3/.pi/harnesses/tool-counter/package.json +6 -0
- package/.versions/0.3.3/.pi/harnesses/tool-counter-widget/README.md +27 -0
- package/.versions/0.3.3/.pi/harnesses/tool-counter-widget/index.ts +66 -0
- package/.versions/0.3.3/.pi/harnesses/tool-counter-widget/package.json +6 -0
- package/.versions/0.3.3/.pi/prompts/build.md +24 -0
- package/.versions/0.3.3/.pi/prompts/code-simplify.md +22 -0
- package/.versions/0.3.3/.pi/prompts/doctor-agent-skills.md +13 -0
- package/.versions/0.3.3/.pi/prompts/plan.md +16 -0
- package/.versions/0.3.3/.pi/prompts/review.md +16 -0
- package/.versions/0.3.3/.pi/prompts/setup-agent-skills.md +19 -0
- package/.versions/0.3.3/.pi/prompts/ship.md +17 -0
- package/.versions/0.3.3/.pi/prompts/spec.md +15 -0
- package/.versions/0.3.3/.pi/prompts/test.md +19 -0
- package/.versions/0.3.3/.pi/skills/bowser/SKILL.md +114 -0
- package/.versions/0.3.3/.version +1 -0
- package/.versions/0.3.3/agents/builder.md +6 -0
- package/.versions/0.3.3/agents/code-reviewer.md +93 -0
- package/.versions/0.3.3/agents/documenter.md +6 -0
- package/.versions/0.3.3/agents/plan-reviewer.md +22 -0
- package/.versions/0.3.3/agents/planner.md +6 -0
- package/.versions/0.3.3/agents/scout.md +6 -0
- package/.versions/0.3.3/agents/security-auditor.md +97 -0
- package/.versions/0.3.3/agents/test-engineer.md +89 -0
- package/.versions/0.3.3/hooks/SIMPLIFY-IGNORE.md +90 -0
- package/.versions/0.3.3/hooks/hooks.json +14 -0
- package/.versions/0.3.3/hooks/session-start.sh +74 -0
- package/.versions/0.3.3/hooks/simplify-ignore-test.sh +247 -0
- package/.versions/0.3.3/hooks/simplify-ignore.sh +302 -0
- package/.versions/0.3.3/references/accessibility-checklist.md +159 -0
- package/.versions/0.3.3/references/performance-checklist.md +121 -0
- package/.versions/0.3.3/references/prompting-patterns.md +380 -0
- package/.versions/0.3.3/references/security-checklist.md +134 -0
- package/.versions/0.3.3/references/testing-patterns.md +236 -0
- package/.versions/0.3.3/scripts/coms-net-server.ts +1741 -0
- package/.versions/0.3.3/skills/api-and-interface-design/SKILL.md +294 -0
- package/.versions/0.3.3/skills/browser-testing-with-devtools/SKILL.md +335 -0
- package/.versions/0.3.3/skills/ci-cd-and-automation/SKILL.md +390 -0
- package/.versions/0.3.3/skills/code-review-and-quality/SKILL.md +347 -0
- package/.versions/0.3.3/skills/code-simplification/SKILL.md +331 -0
- package/.versions/0.3.3/skills/context-engineering/SKILL.md +291 -0
- package/.versions/0.3.3/skills/debugging-and-error-recovery/SKILL.md +300 -0
- package/.versions/0.3.3/skills/deprecation-and-migration/SKILL.md +206 -0
- package/.versions/0.3.3/skills/designing-agents/SKILL.md +394 -0
- package/.versions/0.3.3/skills/designing-agents/pi-harness-authoring.md +213 -0
- package/.versions/0.3.3/skills/documentation-and-adrs/SKILL.md +278 -0
- package/.versions/0.3.3/skills/frontend-ui-engineering/SKILL.md +322 -0
- package/.versions/0.3.3/skills/git-workflow-and-versioning/SKILL.md +316 -0
- package/.versions/0.3.3/skills/guided-workspace-setup/SKILL.md +345 -0
- package/.versions/0.3.3/skills/idea-refine/SKILL.md +178 -0
- package/.versions/0.3.3/skills/idea-refine/examples.md +238 -0
- package/.versions/0.3.3/skills/idea-refine/frameworks.md +99 -0
- package/.versions/0.3.3/skills/idea-refine/refinement-criteria.md +113 -0
- package/.versions/0.3.3/skills/idea-refine/scripts/idea-refine.sh +15 -0
- package/.versions/0.3.3/skills/incremental-implementation/SKILL.md +279 -0
- package/.versions/0.3.3/skills/performance-optimization/SKILL.md +350 -0
- package/.versions/0.3.3/skills/planning-and-task-breakdown/SKILL.md +237 -0
- package/.versions/0.3.3/skills/security-and-hardening/SKILL.md +349 -0
- package/.versions/0.3.3/skills/shipping-and-launch/SKILL.md +309 -0
- package/.versions/0.3.3/skills/source-driven-development/SKILL.md +194 -0
- package/.versions/0.3.3/skills/spec-driven-development/SKILL.md +237 -0
- package/.versions/0.3.3/skills/test-driven-development/SKILL.md +379 -0
- package/.versions/0.3.3/skills/using-agent-skills/SKILL.md +176 -0
- package/CHANGELOG.md +53 -0
- package/bin/lib/bootstrap.js +56 -1
- package/bin/snapshot-version.js +8 -1
- package/docs/npm-install.md +30 -0
- package/package.json +2 -1
- package/scripts/coms-net-server.ts +1741 -0
- package/skills/guided-workspace-setup/SKILL.md +16 -2
|
@@ -0,0 +1,1741 @@
|
|
|
1
|
+
// scripts/coms-net-server.ts
|
|
2
|
+
//
|
|
3
|
+
// coms-net HTTP/SSE hub server (v1) — runs on Bun or Node (>= 22.6).
|
|
4
|
+
//
|
|
5
|
+
// Implements the protocol defined in specs/coms-net-v1.md.
|
|
6
|
+
//
|
|
7
|
+
// Hard rules:
|
|
8
|
+
// - Entrypoint guard: server boot lives inside main(); only fires when this file
|
|
9
|
+
// is the process entrypoint. Importing the module must NOT start the server.
|
|
10
|
+
// - Token policy:
|
|
11
|
+
// * PI_COMS_NET_AUTH_TOKEN set -> use it; do NOT write server.secret.json.
|
|
12
|
+
// * Loopback bind w/o env token -> generate random, write server.secret.json (0600).
|
|
13
|
+
// * Non-loopback bind w/o env token -> fail startup (exit 1).
|
|
14
|
+
// - Never log the auth token. Print only the *path* to server.secret.json.
|
|
15
|
+
// - crypto.timingSafeEqual is length-guarded.
|
|
16
|
+
// - Atomic writes via .tmp + renameSync.
|
|
17
|
+
// - SIGINT/SIGTERM unlinks server.json (always best-effort) and server.secret.json
|
|
18
|
+
// only if TOKEN_FILE_OWNED_BY_US is true.
|
|
19
|
+
// - Status state machine: queued | delivered | complete | error | timeout.
|
|
20
|
+
// No `in_progress` (dropped from v1).
|
|
21
|
+
|
|
22
|
+
import * as crypto from "node:crypto";
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as http from "node:http";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import * as os from "node:os";
|
|
27
|
+
import * as url from "node:url";
|
|
28
|
+
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
// Env-var reads (module scope; all tunables here)
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const HOST = process.env.PI_COMS_NET_HOST ?? "127.0.0.1";
|
|
34
|
+
const PORT = Number(process.env.PI_COMS_NET_PORT ?? 0);
|
|
35
|
+
const PUBLIC_URL = process.env.PI_COMS_NET_PUBLIC_URL;
|
|
36
|
+
const PROJECT = process.env.PI_COMS_NET_PROJECT ?? "default";
|
|
37
|
+
const ENV_TOKEN = process.env.PI_COMS_NET_AUTH_TOKEN;
|
|
38
|
+
const REG_ROOT = path.join(os.homedir(), ".pi", "coms-net");
|
|
39
|
+
|
|
40
|
+
const MAX_HOPS = Number(process.env.PI_COMS_NET_MAX_HOPS ?? 5);
|
|
41
|
+
const MESSAGE_TTL_MS = Number(process.env.PI_COMS_NET_MESSAGE_TTL_MS ?? 1_800_000);
|
|
42
|
+
const MAX_INBOX = Number(process.env.PI_COMS_NET_MAX_INBOX ?? 100);
|
|
43
|
+
const MAX_PROMPT_CHARS = Number(process.env.PI_COMS_NET_MAX_PROMPT_CHARS ?? 200_000);
|
|
44
|
+
const MAX_RESPONSE_CHARS = Number(process.env.PI_COMS_NET_MAX_RESPONSE_CHARS ?? 200_000);
|
|
45
|
+
const MAX_SCHEMA_CHARS = Number(process.env.PI_COMS_NET_MAX_SCHEMA_CHARS ?? 50_000);
|
|
46
|
+
const HEARTBEAT_MS = Number(process.env.PI_COMS_NET_HEARTBEAT_MS ?? 10_000);
|
|
47
|
+
const STALE_AFTER_MS = Number(process.env.PI_COMS_NET_STALE_AFTER_MS ?? 30_000);
|
|
48
|
+
const OFFLINE_AFTER_MS = Number(process.env.PI_COMS_NET_OFFLINE_AFTER_MS ?? 60_000);
|
|
49
|
+
|
|
50
|
+
const STALE_SCAN_INTERVAL_MS = 5_000;
|
|
51
|
+
const TTL_SCAN_INTERVAL_MS = 10_000;
|
|
52
|
+
const SSE_KEEPALIVE_MS = 15_000;
|
|
53
|
+
const DEFAULT_AWAIT_TIMEOUT_MS = 30_000;
|
|
54
|
+
|
|
55
|
+
let TOKEN: string = ENV_TOKEN ?? "";
|
|
56
|
+
let TOKEN_FILE_OWNED_BY_US = false;
|
|
57
|
+
|
|
58
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
// Console event logger
|
|
60
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
// Concise, color-coded per-event lines so the operator can watch what's
|
|
62
|
+
// flowing through the hub. Uses ANSI 24-bit colors when stdout is a TTY,
|
|
63
|
+
// otherwise plain ASCII. Auth tokens NEVER appear here.
|
|
64
|
+
//
|
|
65
|
+
// Format: HH:MM:SS.sss <symbol> <kind:10> <detail>
|
|
66
|
+
//
|
|
67
|
+
// Set PI_COMS_NET_LOG_HEARTBEAT=1 to also see heartbeats (very chatty).
|
|
68
|
+
// Set PI_COMS_NET_LOG_QUIET=1 to suppress everything except startup/shutdown.
|
|
69
|
+
|
|
70
|
+
const LOG_TTY = process.stdout.isTTY === true;
|
|
71
|
+
const LOG_QUIET = process.env.PI_COMS_NET_LOG_QUIET === "1";
|
|
72
|
+
const LOG_HEARTBEAT = process.env.PI_COMS_NET_LOG_HEARTBEAT === "1";
|
|
73
|
+
const LOG_PROMPT_PREVIEW = process.env.PI_COMS_NET_LOG_PROMPT_PREVIEW === "1";
|
|
74
|
+
|
|
75
|
+
const C_DIM = LOG_TTY ? "\x1b[2m" : "";
|
|
76
|
+
const C_RESET = LOG_TTY ? "\x1b[0m" : "";
|
|
77
|
+
const C_GREEN = LOG_TTY ? "\x1b[32m" : "";
|
|
78
|
+
const C_CYAN = LOG_TTY ? "\x1b[36m" : "";
|
|
79
|
+
const C_YELLOW = LOG_TTY ? "\x1b[33m" : "";
|
|
80
|
+
const C_RED = LOG_TTY ? "\x1b[31m" : "";
|
|
81
|
+
const C_PINK = LOG_TTY ? "\x1b[95m" : "";
|
|
82
|
+
const C_BLUE = LOG_TTY ? "\x1b[34m" : "";
|
|
83
|
+
|
|
84
|
+
function logLine(symbol: string, color: string, kind: string, detail: string): void {
|
|
85
|
+
if (LOG_QUIET) return;
|
|
86
|
+
const t = new Date().toISOString().slice(11, 23); // HH:MM:SS.sss
|
|
87
|
+
const padded = kind.padEnd(10);
|
|
88
|
+
console.log(`${C_DIM}${t}${C_RESET} ${color}${symbol}${C_RESET} ${color}${padded}${C_RESET} ${detail}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const tail6 = (id: string) => id.length > 6 ? id.slice(-6) : id;
|
|
92
|
+
const dim = (s: string) => `${C_DIM}${s}${C_RESET}`;
|
|
93
|
+
|
|
94
|
+
function logRegister(name: string, project: string, sid: string, isReregister: boolean): void {
|
|
95
|
+
const verb = isReregister ? "re-register" : "register";
|
|
96
|
+
logLine(isReregister ? "↻" : "✓", C_GREEN, verb, `${name}@${project} ${dim("sid=…" + tail6(sid))}`);
|
|
97
|
+
}
|
|
98
|
+
function logUnregister(name: string, reason: string): void {
|
|
99
|
+
logLine("✗", C_RED, "unregister", `${name} ${dim("reason=" + reason)}`);
|
|
100
|
+
}
|
|
101
|
+
function logSseOpen(name: string, totalStreams: number): void {
|
|
102
|
+
logLine("⇄", C_CYAN, "sse-open", `${name} ${dim(`(${totalStreams} stream${totalStreams === 1 ? "" : "s"})`)}`);
|
|
103
|
+
}
|
|
104
|
+
function logSseClose(name: string, reason: string): void {
|
|
105
|
+
logLine("⇄", C_DIM, "sse-close", `${name} ${dim("reason=" + reason)}`);
|
|
106
|
+
}
|
|
107
|
+
function logMessageSend(sender: string, target: string, msgId: string, prompt: string, hops: number, delivered: boolean): void {
|
|
108
|
+
const status = delivered ? dim("delivered") : dim("queued");
|
|
109
|
+
const size = dim(`${prompt.length}c`);
|
|
110
|
+
if (LOG_PROMPT_PREVIEW) {
|
|
111
|
+
const preview = prompt.length > 50 ? prompt.slice(0, 47) + "…" : prompt;
|
|
112
|
+
const safePreview = preview.replace(/\n/g, " ⏎ ");
|
|
113
|
+
logLine("→", C_PINK, "message", `${sender} → ${target} ${dim(tail6(msgId))} "${safePreview}" ${dim(`hops=${hops}`)} ${size} ${status}`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
logLine("→", C_PINK, "message", `${sender} → ${target} ${dim(tail6(msgId))} ${dim(`hops=${hops}`)} ${size} ${status}`);
|
|
117
|
+
}
|
|
118
|
+
function logResponse(responder: string, sender: string, msgId: string, isError: boolean, error: string | null, size: number): void {
|
|
119
|
+
const status = isError ? `${C_RED}error=${error}${C_RESET}` : dim(`${size}c`);
|
|
120
|
+
logLine("←", isError ? C_RED : C_GREEN, "response", `${responder} → ${sender} ${dim(tail6(msgId))} ${status}`);
|
|
121
|
+
}
|
|
122
|
+
function logStale(name: string, dtSec: number): void {
|
|
123
|
+
logLine("⚠", C_YELLOW, "stale", `${name} ${dim(`(${dtSec}s since last heartbeat)`)}`);
|
|
124
|
+
}
|
|
125
|
+
function logOffline(name: string): void {
|
|
126
|
+
logLine("⌛", C_RED, "offline", `${name} ${dim("removed (no heartbeat)")}`);
|
|
127
|
+
}
|
|
128
|
+
function logExpired(msgId: string): void {
|
|
129
|
+
logLine("⏱", C_YELLOW, "expired", dim(tail6(msgId)));
|
|
130
|
+
}
|
|
131
|
+
function logHeartbeat(name: string, pct: number, depth: number): void {
|
|
132
|
+
if (!LOG_HEARTBEAT) return;
|
|
133
|
+
logLine("♥", C_BLUE, "heartbeat", `${name} ${dim(`ctx=${pct}% queue=${depth}`)}`);
|
|
134
|
+
}
|
|
135
|
+
function logRejected(reason: string, detail: string): void {
|
|
136
|
+
logLine("✗", C_YELLOW, "rejected", `${reason} ${dim(detail)}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
// Shared types (paste into both server and client per spec)
|
|
141
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export type AgentStatus = "online" | "stale" | "offline";
|
|
144
|
+
export type MessageStatus =
|
|
145
|
+
| "queued"
|
|
146
|
+
| "delivered"
|
|
147
|
+
| "complete"
|
|
148
|
+
| "error"
|
|
149
|
+
| "timeout";
|
|
150
|
+
|
|
151
|
+
export type AgentCard = {
|
|
152
|
+
session_id: string;
|
|
153
|
+
name: string;
|
|
154
|
+
purpose: string;
|
|
155
|
+
model: string;
|
|
156
|
+
provider?: string;
|
|
157
|
+
color: string;
|
|
158
|
+
cwd: string;
|
|
159
|
+
project: string;
|
|
160
|
+
explicit: boolean;
|
|
161
|
+
started_at: string;
|
|
162
|
+
context_used_pct: number;
|
|
163
|
+
queue_depth: number;
|
|
164
|
+
status: AgentStatus;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export type RegistryEntry = AgentCard & {
|
|
168
|
+
last_seen_at: string;
|
|
169
|
+
registered_at: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export type ComsMessage = {
|
|
173
|
+
msg_id: string;
|
|
174
|
+
project: string;
|
|
175
|
+
sender_session: string;
|
|
176
|
+
target_session: string;
|
|
177
|
+
prompt: string;
|
|
178
|
+
conversation_id: string | null;
|
|
179
|
+
response_schema: object | null;
|
|
180
|
+
hops: number;
|
|
181
|
+
status: MessageStatus;
|
|
182
|
+
response?: any;
|
|
183
|
+
error?: string | null;
|
|
184
|
+
created_at: string;
|
|
185
|
+
delivered_at?: string;
|
|
186
|
+
completed_at?: string;
|
|
187
|
+
expires_at: string;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export type RegisterRequest = {
|
|
191
|
+
project: string;
|
|
192
|
+
session_id: string;
|
|
193
|
+
name: string;
|
|
194
|
+
purpose: string;
|
|
195
|
+
model: string;
|
|
196
|
+
provider?: string;
|
|
197
|
+
color: string;
|
|
198
|
+
cwd: string;
|
|
199
|
+
explicit: boolean;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export type RegisterResponse = {
|
|
203
|
+
ok: true;
|
|
204
|
+
agent: AgentCard;
|
|
205
|
+
heartbeat_interval_ms: number;
|
|
206
|
+
sse_url: string;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export type HeartbeatRequest = {
|
|
210
|
+
project: string;
|
|
211
|
+
context_used_pct: number;
|
|
212
|
+
queue_depth: number;
|
|
213
|
+
model?: string;
|
|
214
|
+
status?: AgentStatus;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export type SendRequest = {
|
|
218
|
+
project: string;
|
|
219
|
+
sender_session: string;
|
|
220
|
+
target: string;
|
|
221
|
+
target_session: string | null;
|
|
222
|
+
prompt: string;
|
|
223
|
+
conversation_id: string | null;
|
|
224
|
+
response_schema: object | null;
|
|
225
|
+
hops: number;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export type SendResponse = {
|
|
229
|
+
ok: true;
|
|
230
|
+
msg_id: string;
|
|
231
|
+
status: MessageStatus;
|
|
232
|
+
target_session: string;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export type ResponseSubmitRequest = {
|
|
236
|
+
project: string;
|
|
237
|
+
responder_session: string;
|
|
238
|
+
response: any;
|
|
239
|
+
error: string | null;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export type ErrorResponse = { ok: false; error: string; details?: any };
|
|
243
|
+
|
|
244
|
+
// SSE writer & per-project state
|
|
245
|
+
type Awaiter = {
|
|
246
|
+
resolve: (m: ComsMessage) => void;
|
|
247
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
type SseWriter = {
|
|
251
|
+
session_id: string;
|
|
252
|
+
enqueue: (frame: string) => void;
|
|
253
|
+
close: () => void;
|
|
254
|
+
lastId: number;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
type ProjectState = {
|
|
258
|
+
agents: Map<string, RegistryEntry>;
|
|
259
|
+
nameIndex: Map<string, Set<string>>;
|
|
260
|
+
messages: Map<string, ComsMessage>;
|
|
261
|
+
streams: Map<string, SseWriter>;
|
|
262
|
+
awaiters: Map<string, Set<Awaiter>>;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
type ServerState = {
|
|
266
|
+
server_id: string;
|
|
267
|
+
started_at: string;
|
|
268
|
+
projects: Map<string, ProjectState>;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
272
|
+
// Helpers (module scope, all pure / deterministic)
|
|
273
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
276
|
+
|
|
277
|
+
export function ulid(): string {
|
|
278
|
+
const time = Date.now();
|
|
279
|
+
const rand = crypto.randomBytes(10);
|
|
280
|
+
let timeStr = "";
|
|
281
|
+
let t = time;
|
|
282
|
+
for (let i = 9; i >= 0; i--) {
|
|
283
|
+
timeStr = CROCKFORD[t % 32] + timeStr;
|
|
284
|
+
t = Math.floor(t / 32);
|
|
285
|
+
}
|
|
286
|
+
let randStr = "";
|
|
287
|
+
let bits = 0;
|
|
288
|
+
let value = 0;
|
|
289
|
+
for (const byte of rand) {
|
|
290
|
+
value = (value << 8) | byte;
|
|
291
|
+
bits += 8;
|
|
292
|
+
while (bits >= 5) {
|
|
293
|
+
bits -= 5;
|
|
294
|
+
randStr += CROCKFORD[(value >> bits) & 31];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return (timeStr + randStr).slice(0, 26);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function nowIso(): string {
|
|
301
|
+
return new Date().toISOString();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function isLoopback(host: string): boolean {
|
|
305
|
+
return host === "127.0.0.1" || host === "::1" || host === "localhost";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function tokensEqual(a: string, b: string): boolean {
|
|
309
|
+
const ab = Buffer.from(a, "utf-8");
|
|
310
|
+
const bb = Buffer.from(b, "utf-8");
|
|
311
|
+
if (ab.length !== bb.length) return false;
|
|
312
|
+
return crypto.timingSafeEqual(ab, bb);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function authed(req: Request): boolean {
|
|
316
|
+
if (!TOKEN) return false;
|
|
317
|
+
const h = req.headers.get("authorization") ?? "";
|
|
318
|
+
if (!h.startsWith("Bearer ")) return false;
|
|
319
|
+
return tokensEqual(h.slice(7), TOKEN);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function json(body: unknown, status = 200): Response {
|
|
323
|
+
return new Response(JSON.stringify(body), {
|
|
324
|
+
status,
|
|
325
|
+
headers: { "content-type": "application/json" },
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function errorJson(error: string, status = 400, details?: any): Response {
|
|
330
|
+
const body: ErrorResponse = { ok: false, error };
|
|
331
|
+
if (details !== undefined) body.details = details;
|
|
332
|
+
return json(body, status);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function unauthorized(): Response {
|
|
336
|
+
return new Response(JSON.stringify({ ok: false, error: "unauthorized" }), {
|
|
337
|
+
status: 401,
|
|
338
|
+
headers: {
|
|
339
|
+
"content-type": "application/json",
|
|
340
|
+
"www-authenticate": 'Bearer realm="coms-net"',
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function jsonCharLength(value: unknown): number {
|
|
346
|
+
return typeof value === "string" ? value.length : JSON.stringify(value).length;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function projectDir(project: string): string {
|
|
350
|
+
return path.join(REG_ROOT, "projects", project);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function ensureDirSync(dir: string): void {
|
|
354
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function atomicWriteSync(filePath: string, content: string, mode?: number): void {
|
|
358
|
+
const dir = path.dirname(filePath);
|
|
359
|
+
ensureDirSync(dir);
|
|
360
|
+
const tmp = `${filePath}.tmp`;
|
|
361
|
+
fs.writeFileSync(tmp, content);
|
|
362
|
+
if (mode !== undefined) {
|
|
363
|
+
try {
|
|
364
|
+
fs.chmodSync(tmp, mode);
|
|
365
|
+
} catch {
|
|
366
|
+
// best-effort
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
fs.renameSync(tmp, filePath);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function sseFrame(event: string, data: unknown, id?: number): string {
|
|
373
|
+
const lines = [`event: ${event}`];
|
|
374
|
+
if (id !== undefined) lines.push(`id: ${id}`);
|
|
375
|
+
lines.push(`data: ${JSON.stringify(data)}`);
|
|
376
|
+
return lines.join("\n") + "\n\n";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function resolveUniqueName(
|
|
380
|
+
project: ProjectState,
|
|
381
|
+
desiredName: string,
|
|
382
|
+
): string {
|
|
383
|
+
const liveNames = new Set(
|
|
384
|
+
[...project.agents.values()].map((a) => a.name),
|
|
385
|
+
);
|
|
386
|
+
if (!liveNames.has(desiredName)) return desiredName;
|
|
387
|
+
let n = 2;
|
|
388
|
+
while (liveNames.has(`${desiredName}${n}`)) n++;
|
|
389
|
+
return `${desiredName}${n}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
393
|
+
// State (module scope, single instance shared by router & loops)
|
|
394
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
const state: ServerState = {
|
|
397
|
+
server_id: ulid(),
|
|
398
|
+
started_at: nowIso(),
|
|
399
|
+
projects: new Map<string, ProjectState>(),
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
function getOrCreateProject(name: string): ProjectState {
|
|
403
|
+
let p = state.projects.get(name);
|
|
404
|
+
if (!p) {
|
|
405
|
+
p = {
|
|
406
|
+
agents: new Map(),
|
|
407
|
+
nameIndex: new Map(),
|
|
408
|
+
messages: new Map(),
|
|
409
|
+
streams: new Map(),
|
|
410
|
+
awaiters: new Map(),
|
|
411
|
+
};
|
|
412
|
+
state.projects.set(name, p);
|
|
413
|
+
}
|
|
414
|
+
return p;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function nameIndexAdd(p: ProjectState, name: string, sessionId: string): void {
|
|
418
|
+
let bag = p.nameIndex.get(name);
|
|
419
|
+
if (!bag) {
|
|
420
|
+
bag = new Set();
|
|
421
|
+
p.nameIndex.set(name, bag);
|
|
422
|
+
}
|
|
423
|
+
bag.add(sessionId);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function nameIndexRemove(
|
|
427
|
+
p: ProjectState,
|
|
428
|
+
name: string,
|
|
429
|
+
sessionId: string,
|
|
430
|
+
): void {
|
|
431
|
+
const bag = p.nameIndex.get(name);
|
|
432
|
+
if (!bag) return;
|
|
433
|
+
bag.delete(sessionId);
|
|
434
|
+
if (bag.size === 0) p.nameIndex.delete(name);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function entryToCard(e: RegistryEntry): AgentCard {
|
|
438
|
+
const {
|
|
439
|
+
session_id,
|
|
440
|
+
name,
|
|
441
|
+
purpose,
|
|
442
|
+
model,
|
|
443
|
+
provider,
|
|
444
|
+
color,
|
|
445
|
+
cwd,
|
|
446
|
+
project,
|
|
447
|
+
explicit,
|
|
448
|
+
started_at,
|
|
449
|
+
context_used_pct,
|
|
450
|
+
queue_depth,
|
|
451
|
+
status,
|
|
452
|
+
} = e;
|
|
453
|
+
return {
|
|
454
|
+
session_id,
|
|
455
|
+
name,
|
|
456
|
+
purpose,
|
|
457
|
+
model,
|
|
458
|
+
provider,
|
|
459
|
+
color,
|
|
460
|
+
cwd,
|
|
461
|
+
project,
|
|
462
|
+
explicit,
|
|
463
|
+
started_at,
|
|
464
|
+
context_used_pct,
|
|
465
|
+
queue_depth,
|
|
466
|
+
status,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function broadcast(
|
|
471
|
+
p: ProjectState,
|
|
472
|
+
event: string,
|
|
473
|
+
data: unknown,
|
|
474
|
+
excludeSession?: string,
|
|
475
|
+
): void {
|
|
476
|
+
for (const [sid, w] of p.streams) {
|
|
477
|
+
if (excludeSession && sid === excludeSession) continue;
|
|
478
|
+
const id = ++w.lastId;
|
|
479
|
+
try {
|
|
480
|
+
w.enqueue(sseFrame(event, data, id));
|
|
481
|
+
} catch {
|
|
482
|
+
// stream is dead; the abort handler will reap it
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function sendToStream(
|
|
488
|
+
p: ProjectState,
|
|
489
|
+
sessionId: string,
|
|
490
|
+
event: string,
|
|
491
|
+
data: unknown,
|
|
492
|
+
): void {
|
|
493
|
+
const w = p.streams.get(sessionId);
|
|
494
|
+
if (!w) return;
|
|
495
|
+
const id = ++w.lastId;
|
|
496
|
+
try {
|
|
497
|
+
w.enqueue(sseFrame(event, data, id));
|
|
498
|
+
} catch {
|
|
499
|
+
// dead; abort handler will reap
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function releaseAwaiters(p: ProjectState, msg_id: string): void {
|
|
504
|
+
const set = p.awaiters.get(msg_id);
|
|
505
|
+
if (!set) return;
|
|
506
|
+
const message = p.messages.get(msg_id);
|
|
507
|
+
for (const a of set) {
|
|
508
|
+
if (a.timer) clearTimeout(a.timer);
|
|
509
|
+
try {
|
|
510
|
+
if (message) a.resolve(message);
|
|
511
|
+
} catch {
|
|
512
|
+
// noop
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
p.awaiters.delete(msg_id);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function inboxDepthFor(p: ProjectState, targetSession: string): number {
|
|
519
|
+
let n = 0;
|
|
520
|
+
for (const m of p.messages.values()) {
|
|
521
|
+
if (m.target_session !== targetSession) continue;
|
|
522
|
+
if (m.status === "queued" || m.status === "delivered") n++;
|
|
523
|
+
}
|
|
524
|
+
return n;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
528
|
+
// Route handlers
|
|
529
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
async function handleHealth(_req: Request): Promise<Response> {
|
|
532
|
+
return json({
|
|
533
|
+
ok: true,
|
|
534
|
+
version: 1,
|
|
535
|
+
server_id: state.server_id,
|
|
536
|
+
started_at: state.started_at,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function handleRegister(req: Request): Promise<Response> {
|
|
541
|
+
let body: RegisterRequest;
|
|
542
|
+
try {
|
|
543
|
+
body = (await req.json()) as RegisterRequest;
|
|
544
|
+
} catch {
|
|
545
|
+
return errorJson("invalid_json", 400);
|
|
546
|
+
}
|
|
547
|
+
if (
|
|
548
|
+
!body ||
|
|
549
|
+
typeof body !== "object" ||
|
|
550
|
+
typeof body.session_id !== "string" ||
|
|
551
|
+
typeof body.project !== "string" ||
|
|
552
|
+
typeof body.name !== "string"
|
|
553
|
+
) {
|
|
554
|
+
return errorJson("invalid_request", 400);
|
|
555
|
+
}
|
|
556
|
+
const projectName = body.project || "default";
|
|
557
|
+
const p = getOrCreateProject(projectName);
|
|
558
|
+
const desiredName = body.name && body.name.length > 0 ? body.name : "agent";
|
|
559
|
+
let resolvedName = desiredName;
|
|
560
|
+
const existing = p.agents.get(body.session_id);
|
|
561
|
+
const isReregister = !!existing;
|
|
562
|
+
if (existing) {
|
|
563
|
+
// upsert: keep their existing name unless they ask for a different one
|
|
564
|
+
resolvedName =
|
|
565
|
+
body.name && body.name !== existing.name
|
|
566
|
+
? resolveUniqueName(p, desiredName)
|
|
567
|
+
: existing.name;
|
|
568
|
+
} else {
|
|
569
|
+
resolvedName = resolveUniqueName(p, desiredName);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const card: AgentCard = {
|
|
573
|
+
session_id: body.session_id,
|
|
574
|
+
name: resolvedName,
|
|
575
|
+
purpose: body.purpose ?? "",
|
|
576
|
+
model: body.model ?? "unknown",
|
|
577
|
+
provider: body.provider,
|
|
578
|
+
color: body.color ?? "#888888",
|
|
579
|
+
cwd: body.cwd ?? "",
|
|
580
|
+
project: projectName,
|
|
581
|
+
explicit: body.explicit === true,
|
|
582
|
+
started_at: existing?.started_at ?? nowIso(),
|
|
583
|
+
context_used_pct: existing?.context_used_pct ?? 0,
|
|
584
|
+
queue_depth: existing?.queue_depth ?? 0,
|
|
585
|
+
status: "online",
|
|
586
|
+
};
|
|
587
|
+
const entry: RegistryEntry = {
|
|
588
|
+
...card,
|
|
589
|
+
registered_at: existing?.registered_at ?? nowIso(),
|
|
590
|
+
last_seen_at: nowIso(),
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
if (existing && existing.name !== entry.name) {
|
|
594
|
+
nameIndexRemove(p, existing.name, body.session_id);
|
|
595
|
+
}
|
|
596
|
+
p.agents.set(body.session_id, entry);
|
|
597
|
+
nameIndexAdd(p, entry.name, body.session_id);
|
|
598
|
+
|
|
599
|
+
logRegister(entry.name, projectName, body.session_id, isReregister);
|
|
600
|
+
|
|
601
|
+
// Emit agent_joined to OTHER streams (do not echo to a stream that may not
|
|
602
|
+
// exist yet — the registering client opens SSE next).
|
|
603
|
+
broadcast(
|
|
604
|
+
p,
|
|
605
|
+
"agent_joined",
|
|
606
|
+
{ project: projectName, agent: entryToCard(entry) },
|
|
607
|
+
body.session_id,
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
const sse_url = `/v1/events?project=${encodeURIComponent(projectName)}&session_id=${encodeURIComponent(body.session_id)}`;
|
|
611
|
+
const resp: RegisterResponse = {
|
|
612
|
+
ok: true,
|
|
613
|
+
agent: entryToCard(entry),
|
|
614
|
+
heartbeat_interval_ms: HEARTBEAT_MS,
|
|
615
|
+
sse_url,
|
|
616
|
+
};
|
|
617
|
+
return json(resp);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function handleEvents(req: Request, url: URL): Response {
|
|
621
|
+
const projectName = url.searchParams.get("project") ?? "default";
|
|
622
|
+
const session_id = url.searchParams.get("session_id") ?? "";
|
|
623
|
+
if (!session_id) return errorJson("missing_session_id", 400);
|
|
624
|
+
const p = getOrCreateProject(projectName);
|
|
625
|
+
const entry = p.agents.get(session_id);
|
|
626
|
+
if (!entry) return errorJson("agent_not_found", 404);
|
|
627
|
+
|
|
628
|
+
const enc = new TextEncoder();
|
|
629
|
+
let writer: SseWriter | null = null;
|
|
630
|
+
|
|
631
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
632
|
+
start(controller) {
|
|
633
|
+
let closed = false;
|
|
634
|
+
let lastId = 0;
|
|
635
|
+
writer = {
|
|
636
|
+
session_id,
|
|
637
|
+
lastId,
|
|
638
|
+
enqueue(frame: string) {
|
|
639
|
+
if (closed) return;
|
|
640
|
+
try {
|
|
641
|
+
controller.enqueue(enc.encode(frame));
|
|
642
|
+
} catch {
|
|
643
|
+
closed = true;
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
close() {
|
|
647
|
+
if (closed) return;
|
|
648
|
+
closed = true;
|
|
649
|
+
try {
|
|
650
|
+
controller.close();
|
|
651
|
+
} catch {
|
|
652
|
+
// already closed
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
// Replace possibly-stale stream entry
|
|
657
|
+
const old = p.streams.get(session_id);
|
|
658
|
+
if (old && old !== writer) {
|
|
659
|
+
try {
|
|
660
|
+
old.close();
|
|
661
|
+
} catch {
|
|
662
|
+
// noop
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
p.streams.set(session_id, writer);
|
|
666
|
+
logSseOpen(entry.name, p.streams.size);
|
|
667
|
+
|
|
668
|
+
// hello
|
|
669
|
+
const helloId = ++writer.lastId;
|
|
670
|
+
try {
|
|
671
|
+
controller.enqueue(
|
|
672
|
+
enc.encode(
|
|
673
|
+
sseFrame(
|
|
674
|
+
"hello",
|
|
675
|
+
{ server_time: nowIso(), server_id: state.server_id },
|
|
676
|
+
helloId,
|
|
677
|
+
),
|
|
678
|
+
),
|
|
679
|
+
);
|
|
680
|
+
} catch {
|
|
681
|
+
closed = true;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// pool_snapshot
|
|
685
|
+
const agents: AgentCard[] = [];
|
|
686
|
+
for (const a of p.agents.values()) {
|
|
687
|
+
if (a.session_id === session_id) continue;
|
|
688
|
+
if (a.explicit) continue;
|
|
689
|
+
agents.push(entryToCard(a));
|
|
690
|
+
}
|
|
691
|
+
const snapId = ++writer.lastId;
|
|
692
|
+
try {
|
|
693
|
+
controller.enqueue(
|
|
694
|
+
enc.encode(
|
|
695
|
+
sseFrame(
|
|
696
|
+
"pool_snapshot",
|
|
697
|
+
{ project: projectName, agents },
|
|
698
|
+
snapId,
|
|
699
|
+
),
|
|
700
|
+
),
|
|
701
|
+
);
|
|
702
|
+
} catch {
|
|
703
|
+
closed = true;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// abort handler
|
|
707
|
+
const onAbort = () => {
|
|
708
|
+
if (closed) return;
|
|
709
|
+
closed = true;
|
|
710
|
+
const cur = p.streams.get(session_id);
|
|
711
|
+
if (cur === writer) p.streams.delete(session_id);
|
|
712
|
+
try {
|
|
713
|
+
controller.close();
|
|
714
|
+
} catch {
|
|
715
|
+
// noop
|
|
716
|
+
}
|
|
717
|
+
const left = p.agents.get(session_id);
|
|
718
|
+
if (left) {
|
|
719
|
+
logSseClose(left.name, "connection_closed");
|
|
720
|
+
broadcast(
|
|
721
|
+
p,
|
|
722
|
+
"agent_left",
|
|
723
|
+
{
|
|
724
|
+
project: projectName,
|
|
725
|
+
session_id,
|
|
726
|
+
name: left.name,
|
|
727
|
+
reason: "connection_closed",
|
|
728
|
+
},
|
|
729
|
+
session_id,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
try {
|
|
734
|
+
req.signal.addEventListener("abort", onAbort);
|
|
735
|
+
} catch {
|
|
736
|
+
// noop
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
cancel() {
|
|
740
|
+
const cur = p.streams.get(session_id);
|
|
741
|
+
if (cur === writer) p.streams.delete(session_id);
|
|
742
|
+
},
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
return new Response(stream, {
|
|
746
|
+
status: 200,
|
|
747
|
+
headers: {
|
|
748
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
749
|
+
"cache-control": "no-cache, no-transform",
|
|
750
|
+
connection: "keep-alive",
|
|
751
|
+
"x-accel-buffering": "no",
|
|
752
|
+
},
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function handleHeartbeat(
|
|
757
|
+
req: Request,
|
|
758
|
+
sessionId: string,
|
|
759
|
+
): Promise<Response> {
|
|
760
|
+
let body: HeartbeatRequest;
|
|
761
|
+
try {
|
|
762
|
+
body = (await req.json()) as HeartbeatRequest;
|
|
763
|
+
} catch {
|
|
764
|
+
return errorJson("invalid_json", 400);
|
|
765
|
+
}
|
|
766
|
+
const projectName = body?.project ?? "default";
|
|
767
|
+
const p = state.projects.get(projectName);
|
|
768
|
+
if (!p) return errorJson("agent_not_found", 404);
|
|
769
|
+
const entry = p.agents.get(sessionId);
|
|
770
|
+
if (!entry) return errorJson("agent_not_found", 404);
|
|
771
|
+
|
|
772
|
+
const before: Partial<AgentCard> = {
|
|
773
|
+
context_used_pct: entry.context_used_pct,
|
|
774
|
+
queue_depth: entry.queue_depth,
|
|
775
|
+
model: entry.model,
|
|
776
|
+
status: entry.status,
|
|
777
|
+
};
|
|
778
|
+
if (typeof body.context_used_pct === "number")
|
|
779
|
+
entry.context_used_pct = body.context_used_pct;
|
|
780
|
+
if (typeof body.queue_depth === "number")
|
|
781
|
+
entry.queue_depth = body.queue_depth;
|
|
782
|
+
if (typeof body.model === "string") entry.model = body.model;
|
|
783
|
+
if (
|
|
784
|
+
body.status === "online" ||
|
|
785
|
+
body.status === "stale" ||
|
|
786
|
+
body.status === "offline"
|
|
787
|
+
) {
|
|
788
|
+
entry.status = body.status;
|
|
789
|
+
} else {
|
|
790
|
+
entry.status = "online";
|
|
791
|
+
}
|
|
792
|
+
entry.last_seen_at = nowIso();
|
|
793
|
+
|
|
794
|
+
logHeartbeat(entry.name, entry.context_used_pct, entry.queue_depth);
|
|
795
|
+
|
|
796
|
+
const changed =
|
|
797
|
+
before.context_used_pct !== entry.context_used_pct ||
|
|
798
|
+
before.queue_depth !== entry.queue_depth ||
|
|
799
|
+
before.model !== entry.model ||
|
|
800
|
+
before.status !== entry.status;
|
|
801
|
+
if (changed) {
|
|
802
|
+
broadcast(
|
|
803
|
+
p,
|
|
804
|
+
"agent_updated",
|
|
805
|
+
{
|
|
806
|
+
project: projectName,
|
|
807
|
+
agent: {
|
|
808
|
+
session_id: entry.session_id,
|
|
809
|
+
name: entry.name,
|
|
810
|
+
context_used_pct: entry.context_used_pct,
|
|
811
|
+
queue_depth: entry.queue_depth,
|
|
812
|
+
model: entry.model,
|
|
813
|
+
status: entry.status,
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
sessionId,
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
return json({ ok: true });
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function handleListAgents(_req: Request, url: URL): Response {
|
|
823
|
+
const projectName = url.searchParams.get("project") ?? "default";
|
|
824
|
+
const includeExplicit =
|
|
825
|
+
(url.searchParams.get("include_explicit") ?? "false").toLowerCase() ===
|
|
826
|
+
"true";
|
|
827
|
+
const p = state.projects.get(projectName);
|
|
828
|
+
const out: AgentCard[] = [];
|
|
829
|
+
if (p) {
|
|
830
|
+
for (const e of p.agents.values()) {
|
|
831
|
+
if (!includeExplicit && e.explicit) continue;
|
|
832
|
+
out.push(entryToCard(e));
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return json({ agents: out });
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async function handleSendMessage(req: Request): Promise<Response> {
|
|
839
|
+
let body: SendRequest;
|
|
840
|
+
try {
|
|
841
|
+
body = (await req.json()) as SendRequest;
|
|
842
|
+
} catch {
|
|
843
|
+
return errorJson("invalid_json", 400);
|
|
844
|
+
}
|
|
845
|
+
if (
|
|
846
|
+
!body ||
|
|
847
|
+
typeof body !== "object" ||
|
|
848
|
+
typeof body.sender_session !== "string" ||
|
|
849
|
+
typeof body.prompt !== "string"
|
|
850
|
+
) {
|
|
851
|
+
return errorJson("invalid_request", 400);
|
|
852
|
+
}
|
|
853
|
+
if (body.prompt.length > MAX_PROMPT_CHARS) {
|
|
854
|
+
return errorJson("prompt_too_large", 413, {
|
|
855
|
+
max_chars: MAX_PROMPT_CHARS,
|
|
856
|
+
actual_chars: body.prompt.length,
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
if (body.response_schema && typeof body.response_schema === "object") {
|
|
860
|
+
const schemaChars = jsonCharLength(body.response_schema);
|
|
861
|
+
if (schemaChars > MAX_SCHEMA_CHARS) {
|
|
862
|
+
return errorJson("response_schema_too_large", 413, {
|
|
863
|
+
max_chars: MAX_SCHEMA_CHARS,
|
|
864
|
+
actual_chars: schemaChars,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const projectName = body.project ?? "default";
|
|
870
|
+
const p = state.projects.get(projectName);
|
|
871
|
+
if (!p) return errorJson("agent_not_found", 404);
|
|
872
|
+
|
|
873
|
+
const sender = p.agents.get(body.sender_session);
|
|
874
|
+
if (!sender) return errorJson("sender_not_registered", 404);
|
|
875
|
+
|
|
876
|
+
const hops = typeof body.hops === "number" ? body.hops : 0;
|
|
877
|
+
if (hops >= MAX_HOPS) {
|
|
878
|
+
logRejected("hop_limit", `${sender.name} hops=${hops} max=${MAX_HOPS}`);
|
|
879
|
+
return errorJson("hop_limit_exceeded", 409, { hops, max_hops: MAX_HOPS });
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Resolve target.
|
|
883
|
+
let target: RegistryEntry | undefined;
|
|
884
|
+
if (body.target_session && typeof body.target_session === "string") {
|
|
885
|
+
target = p.agents.get(body.target_session);
|
|
886
|
+
if (!target) {
|
|
887
|
+
logRejected("target_not_found", `${sender.name} → ${body.target_session.slice(-6)}`);
|
|
888
|
+
return errorJson("target_not_found", 404);
|
|
889
|
+
}
|
|
890
|
+
} else {
|
|
891
|
+
const desired = (body.target ?? "").trim();
|
|
892
|
+
if (!desired) return errorJson("missing_target", 400);
|
|
893
|
+
// Direct session_id match first.
|
|
894
|
+
const directSid = p.agents.get(desired);
|
|
895
|
+
if (directSid) {
|
|
896
|
+
target = directSid;
|
|
897
|
+
} else {
|
|
898
|
+
const bag = p.nameIndex.get(desired);
|
|
899
|
+
if (!bag || bag.size === 0) {
|
|
900
|
+
logRejected("target_not_found", `${sender.name} → "${desired}"`);
|
|
901
|
+
return errorJson("target_not_found", 404, { target: desired });
|
|
902
|
+
}
|
|
903
|
+
if (bag.size > 1) {
|
|
904
|
+
logRejected("ambiguous", `${sender.name} → "${desired}" matches ${bag.size}`);
|
|
905
|
+
return errorJson("ambiguous_target", 409, {
|
|
906
|
+
target: desired,
|
|
907
|
+
candidates: [...bag],
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
const onlySid = [...bag][0];
|
|
911
|
+
target = p.agents.get(onlySid);
|
|
912
|
+
if (!target) return errorJson("target_not_found", 404);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Inbox cap.
|
|
917
|
+
const depth = inboxDepthFor(p, target.session_id);
|
|
918
|
+
if (depth >= MAX_INBOX) {
|
|
919
|
+
logRejected("inbox_full", `${sender.name} → ${target.name} depth=${depth}`);
|
|
920
|
+
return errorJson("inbox_full", 429, { depth, max_inbox: MAX_INBOX });
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const created = nowIso();
|
|
924
|
+
const expires = new Date(Date.now() + MESSAGE_TTL_MS).toISOString();
|
|
925
|
+
const msg: ComsMessage = {
|
|
926
|
+
msg_id: ulid(),
|
|
927
|
+
project: projectName,
|
|
928
|
+
sender_session: body.sender_session,
|
|
929
|
+
target_session: target.session_id,
|
|
930
|
+
prompt: body.prompt,
|
|
931
|
+
conversation_id:
|
|
932
|
+
body.conversation_id && typeof body.conversation_id === "string"
|
|
933
|
+
? body.conversation_id
|
|
934
|
+
: null,
|
|
935
|
+
response_schema:
|
|
936
|
+
body.response_schema && typeof body.response_schema === "object"
|
|
937
|
+
? body.response_schema
|
|
938
|
+
: null,
|
|
939
|
+
hops,
|
|
940
|
+
status: "queued",
|
|
941
|
+
response: null,
|
|
942
|
+
error: null,
|
|
943
|
+
created_at: created,
|
|
944
|
+
expires_at: expires,
|
|
945
|
+
};
|
|
946
|
+
p.messages.set(msg.msg_id, msg);
|
|
947
|
+
|
|
948
|
+
// Notify sender: queued
|
|
949
|
+
sendToStream(p, body.sender_session, "message_status", {
|
|
950
|
+
msg_id: msg.msg_id,
|
|
951
|
+
status: "queued",
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
// Emit prompt to target if its stream is open.
|
|
955
|
+
const targetWriter = p.streams.get(target.session_id);
|
|
956
|
+
if (targetWriter) {
|
|
957
|
+
sendToStream(p, target.session_id, "prompt", {
|
|
958
|
+
msg_id: msg.msg_id,
|
|
959
|
+
project: projectName,
|
|
960
|
+
sender: {
|
|
961
|
+
session_id: sender.session_id,
|
|
962
|
+
name: sender.name,
|
|
963
|
+
cwd: sender.cwd,
|
|
964
|
+
},
|
|
965
|
+
prompt: msg.prompt,
|
|
966
|
+
conversation_id: msg.conversation_id,
|
|
967
|
+
response_schema: msg.response_schema,
|
|
968
|
+
hops: msg.hops,
|
|
969
|
+
});
|
|
970
|
+
msg.status = "delivered";
|
|
971
|
+
msg.delivered_at = nowIso();
|
|
972
|
+
// Notify sender: delivered
|
|
973
|
+
sendToStream(p, body.sender_session, "message_status", {
|
|
974
|
+
msg_id: msg.msg_id,
|
|
975
|
+
status: "delivered",
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
logMessageSend(
|
|
980
|
+
sender.name,
|
|
981
|
+
target.name,
|
|
982
|
+
msg.msg_id,
|
|
983
|
+
msg.prompt,
|
|
984
|
+
hops,
|
|
985
|
+
msg.status === "delivered",
|
|
986
|
+
);
|
|
987
|
+
|
|
988
|
+
const resp: SendResponse = {
|
|
989
|
+
ok: true,
|
|
990
|
+
msg_id: msg.msg_id,
|
|
991
|
+
status: msg.status,
|
|
992
|
+
target_session: target.session_id,
|
|
993
|
+
};
|
|
994
|
+
return json(resp);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function requesterOwnsMessage(url: URL, msg: ComsMessage): Response | null {
|
|
998
|
+
const requester = url.searchParams.get("requester_session") ?? "";
|
|
999
|
+
if (!requester) return errorJson("missing_requester_session", 400);
|
|
1000
|
+
if (requester !== msg.sender_session) return errorJson("not_sender", 403);
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function handleGetMessage(_req: Request, url: URL, msg_id: string): Response {
|
|
1005
|
+
const projectName = url.searchParams.get("project");
|
|
1006
|
+
const projects = projectName
|
|
1007
|
+
? [state.projects.get(projectName)].filter(Boolean) as ProjectState[]
|
|
1008
|
+
: [...state.projects.values()];
|
|
1009
|
+
for (const p of projects) {
|
|
1010
|
+
const m = p.messages.get(msg_id);
|
|
1011
|
+
if (m) {
|
|
1012
|
+
const authError = requesterOwnsMessage(url, m);
|
|
1013
|
+
if (authError) return authError;
|
|
1014
|
+
return json({
|
|
1015
|
+
msg_id: m.msg_id,
|
|
1016
|
+
status: m.status,
|
|
1017
|
+
response: m.response ?? null,
|
|
1018
|
+
error: m.error ?? null,
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return errorJson("message_not_found", 404);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function handleAwaitMessage(req: Request, url: URL, msg_id: string): Response {
|
|
1026
|
+
let project: ProjectState | undefined;
|
|
1027
|
+
let msg: ComsMessage | undefined;
|
|
1028
|
+
for (const p of state.projects.values()) {
|
|
1029
|
+
const m = p.messages.get(msg_id);
|
|
1030
|
+
if (m) {
|
|
1031
|
+
project = p;
|
|
1032
|
+
msg = m;
|
|
1033
|
+
break;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
if (!project || !msg) {
|
|
1037
|
+
return new Response(
|
|
1038
|
+
JSON.stringify({ ok: false, error: "message_not_found" }),
|
|
1039
|
+
{ status: 404, headers: { "content-type": "application/json" } },
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
const authError = requesterOwnsMessage(url, msg);
|
|
1043
|
+
if (authError) return authError;
|
|
1044
|
+
// Already terminal? Resolve immediately.
|
|
1045
|
+
if (
|
|
1046
|
+
msg.status === "complete" ||
|
|
1047
|
+
msg.status === "error" ||
|
|
1048
|
+
msg.status === "timeout"
|
|
1049
|
+
) {
|
|
1050
|
+
return json({
|
|
1051
|
+
msg_id: msg.msg_id,
|
|
1052
|
+
status: msg.status,
|
|
1053
|
+
response: msg.response ?? null,
|
|
1054
|
+
error: msg.error ?? null,
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const requested = Number(url.searchParams.get("timeout_ms") ?? "");
|
|
1059
|
+
let timeout_ms =
|
|
1060
|
+
Number.isFinite(requested) && requested > 0
|
|
1061
|
+
? requested
|
|
1062
|
+
: DEFAULT_AWAIT_TIMEOUT_MS;
|
|
1063
|
+
// Clamp to TTL.
|
|
1064
|
+
if (timeout_ms > MESSAGE_TTL_MS) timeout_ms = MESSAGE_TTL_MS;
|
|
1065
|
+
|
|
1066
|
+
const proj = project; // for closure
|
|
1067
|
+
const id = msg_id;
|
|
1068
|
+
|
|
1069
|
+
return new Response(
|
|
1070
|
+
new ReadableStream<Uint8Array>({
|
|
1071
|
+
start(controller) {
|
|
1072
|
+
const enc = new TextEncoder();
|
|
1073
|
+
let done = false;
|
|
1074
|
+
|
|
1075
|
+
const set = proj.awaiters.get(id) ?? new Set<Awaiter>();
|
|
1076
|
+
proj.awaiters.set(id, set);
|
|
1077
|
+
|
|
1078
|
+
const finalize = (payload: any, status = 200) => {
|
|
1079
|
+
if (done) return;
|
|
1080
|
+
done = true;
|
|
1081
|
+
try {
|
|
1082
|
+
controller.enqueue(enc.encode(JSON.stringify(payload)));
|
|
1083
|
+
controller.close();
|
|
1084
|
+
} catch {
|
|
1085
|
+
// already closed
|
|
1086
|
+
}
|
|
1087
|
+
void status;
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
const awaiter: Awaiter = {
|
|
1091
|
+
resolve: (m: ComsMessage) => {
|
|
1092
|
+
finalize({
|
|
1093
|
+
msg_id: m.msg_id,
|
|
1094
|
+
status: m.status,
|
|
1095
|
+
response: m.response ?? null,
|
|
1096
|
+
error: m.error ?? null,
|
|
1097
|
+
});
|
|
1098
|
+
},
|
|
1099
|
+
timer: null,
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
awaiter.timer = setTimeout(() => {
|
|
1103
|
+
const cur = proj.awaiters.get(id);
|
|
1104
|
+
if (cur) {
|
|
1105
|
+
cur.delete(awaiter);
|
|
1106
|
+
if (cur.size === 0) proj.awaiters.delete(id);
|
|
1107
|
+
}
|
|
1108
|
+
finalize({
|
|
1109
|
+
msg_id: id,
|
|
1110
|
+
status: "timeout",
|
|
1111
|
+
response: null,
|
|
1112
|
+
error: "timeout",
|
|
1113
|
+
});
|
|
1114
|
+
}, timeout_ms);
|
|
1115
|
+
try {
|
|
1116
|
+
(awaiter.timer as any).unref?.();
|
|
1117
|
+
} catch {
|
|
1118
|
+
// noop
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
set.add(awaiter);
|
|
1122
|
+
|
|
1123
|
+
// Connection abort: clean the awaiter.
|
|
1124
|
+
try {
|
|
1125
|
+
req.signal.addEventListener("abort", () => {
|
|
1126
|
+
if (done) return;
|
|
1127
|
+
const cur = proj.awaiters.get(id);
|
|
1128
|
+
if (cur) {
|
|
1129
|
+
cur.delete(awaiter);
|
|
1130
|
+
if (cur.size === 0) proj.awaiters.delete(id);
|
|
1131
|
+
}
|
|
1132
|
+
if (awaiter.timer) clearTimeout(awaiter.timer);
|
|
1133
|
+
done = true;
|
|
1134
|
+
try {
|
|
1135
|
+
controller.close();
|
|
1136
|
+
} catch {
|
|
1137
|
+
// noop
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
} catch {
|
|
1141
|
+
// noop
|
|
1142
|
+
}
|
|
1143
|
+
},
|
|
1144
|
+
}),
|
|
1145
|
+
{
|
|
1146
|
+
status: 200,
|
|
1147
|
+
headers: { "content-type": "application/json" },
|
|
1148
|
+
},
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async function handleSubmitResponse(
|
|
1153
|
+
req: Request,
|
|
1154
|
+
msg_id: string,
|
|
1155
|
+
): Promise<Response> {
|
|
1156
|
+
let body: ResponseSubmitRequest;
|
|
1157
|
+
try {
|
|
1158
|
+
body = (await req.json()) as ResponseSubmitRequest;
|
|
1159
|
+
} catch {
|
|
1160
|
+
return errorJson("invalid_json", 400);
|
|
1161
|
+
}
|
|
1162
|
+
if (
|
|
1163
|
+
!body ||
|
|
1164
|
+
typeof body !== "object" ||
|
|
1165
|
+
typeof body.responder_session !== "string"
|
|
1166
|
+
) {
|
|
1167
|
+
return errorJson("invalid_request", 400);
|
|
1168
|
+
}
|
|
1169
|
+
let project: ProjectState | undefined;
|
|
1170
|
+
let msg: ComsMessage | undefined;
|
|
1171
|
+
for (const p of state.projects.values()) {
|
|
1172
|
+
const m = p.messages.get(msg_id);
|
|
1173
|
+
if (m) {
|
|
1174
|
+
project = p;
|
|
1175
|
+
msg = m;
|
|
1176
|
+
break;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
if (!project || !msg) return errorJson("message_not_found", 404);
|
|
1180
|
+
if (body.responder_session !== msg.target_session) {
|
|
1181
|
+
return errorJson("not_target", 403);
|
|
1182
|
+
}
|
|
1183
|
+
if (
|
|
1184
|
+
msg.status === "complete" ||
|
|
1185
|
+
msg.status === "error" ||
|
|
1186
|
+
msg.status === "timeout"
|
|
1187
|
+
) {
|
|
1188
|
+
return errorJson("already_terminal", 409, { status: msg.status });
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const responseChars = jsonCharLength(body.response ?? null);
|
|
1192
|
+
if (responseChars > MAX_RESPONSE_CHARS) {
|
|
1193
|
+
return errorJson("response_too_large", 413, {
|
|
1194
|
+
max_chars: MAX_RESPONSE_CHARS,
|
|
1195
|
+
actual_chars: responseChars,
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
const isError = body.error !== null && body.error !== undefined;
|
|
1199
|
+
msg.status = isError ? "error" : "complete";
|
|
1200
|
+
msg.response = body.response ?? null;
|
|
1201
|
+
msg.error = isError ? String(body.error) : null;
|
|
1202
|
+
msg.completed_at = nowIso();
|
|
1203
|
+
|
|
1204
|
+
// Look up responder name for the SSE response payload.
|
|
1205
|
+
const responder = project.agents.get(body.responder_session);
|
|
1206
|
+
const responderName = responder?.name ?? "unknown";
|
|
1207
|
+
|
|
1208
|
+
// Notify sender (if its stream is open).
|
|
1209
|
+
sendToStream(project, msg.sender_session, "response", {
|
|
1210
|
+
msg_id: msg.msg_id,
|
|
1211
|
+
project: msg.project,
|
|
1212
|
+
responder: { session_id: body.responder_session, name: responderName },
|
|
1213
|
+
response: msg.response,
|
|
1214
|
+
error: msg.error,
|
|
1215
|
+
status: msg.status,
|
|
1216
|
+
});
|
|
1217
|
+
// Also push a final message_status for completeness.
|
|
1218
|
+
sendToStream(project, msg.sender_session, "message_status", {
|
|
1219
|
+
msg_id: msg.msg_id,
|
|
1220
|
+
status: msg.status,
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
releaseAwaiters(project, msg_id);
|
|
1224
|
+
|
|
1225
|
+
const senderName = project.agents.get(msg.sender_session)?.name ?? "(gone)";
|
|
1226
|
+
const responseSize =
|
|
1227
|
+
typeof msg.response === "string"
|
|
1228
|
+
? msg.response.length
|
|
1229
|
+
: msg.response
|
|
1230
|
+
? JSON.stringify(msg.response).length
|
|
1231
|
+
: 0;
|
|
1232
|
+
logResponse(responderName, senderName, msg.msg_id, isError, msg.error, responseSize);
|
|
1233
|
+
|
|
1234
|
+
return json({ ok: true });
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function handleDeleteAgent(_req: Request, url: URL, sessionId: string): Response {
|
|
1238
|
+
const projectName = url.searchParams.get("project") ?? "default";
|
|
1239
|
+
const p = state.projects.get(projectName);
|
|
1240
|
+
if (!p) return errorJson("agent_not_found", 404);
|
|
1241
|
+
const entry = p.agents.get(sessionId);
|
|
1242
|
+
if (!entry) return errorJson("agent_not_found", 404);
|
|
1243
|
+
|
|
1244
|
+
// Close stream first; the abort handler may also fire.
|
|
1245
|
+
const stream = p.streams.get(sessionId);
|
|
1246
|
+
if (stream) {
|
|
1247
|
+
try {
|
|
1248
|
+
stream.close();
|
|
1249
|
+
} catch {
|
|
1250
|
+
// noop
|
|
1251
|
+
}
|
|
1252
|
+
p.streams.delete(sessionId);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
p.agents.delete(sessionId);
|
|
1256
|
+
nameIndexRemove(p, entry.name, sessionId);
|
|
1257
|
+
|
|
1258
|
+
logUnregister(entry.name, "shutdown");
|
|
1259
|
+
|
|
1260
|
+
broadcast(
|
|
1261
|
+
p,
|
|
1262
|
+
"agent_left",
|
|
1263
|
+
{
|
|
1264
|
+
project: projectName,
|
|
1265
|
+
session_id: sessionId,
|
|
1266
|
+
name: entry.name,
|
|
1267
|
+
reason: "shutdown",
|
|
1268
|
+
},
|
|
1269
|
+
sessionId,
|
|
1270
|
+
);
|
|
1271
|
+
|
|
1272
|
+
return json({ ok: true });
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1276
|
+
// Router
|
|
1277
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1278
|
+
|
|
1279
|
+
async function router(req: Request): Promise<Response> {
|
|
1280
|
+
let url: URL;
|
|
1281
|
+
try {
|
|
1282
|
+
url = new URL(req.url);
|
|
1283
|
+
} catch {
|
|
1284
|
+
return errorJson("invalid_url", 400);
|
|
1285
|
+
}
|
|
1286
|
+
const method = req.method.toUpperCase();
|
|
1287
|
+
const pathname = url.pathname;
|
|
1288
|
+
|
|
1289
|
+
// 1. /health (no auth)
|
|
1290
|
+
if (pathname === "/health" && method === "GET") {
|
|
1291
|
+
return handleHealth(req);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// All /v1/* require bearer auth.
|
|
1295
|
+
if (pathname.startsWith("/v1/")) {
|
|
1296
|
+
if (!authed(req)) return unauthorized();
|
|
1297
|
+
} else {
|
|
1298
|
+
// Unknown non-/v1 route.
|
|
1299
|
+
return errorJson("not_found", 404);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// 2. POST /v1/agents/register
|
|
1303
|
+
if (pathname === "/v1/agents/register" && method === "POST") {
|
|
1304
|
+
return handleRegister(req);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// 3. GET /v1/events
|
|
1308
|
+
if (pathname === "/v1/events" && method === "GET") {
|
|
1309
|
+
return handleEvents(req, url);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// 5. GET /v1/agents
|
|
1313
|
+
if (pathname === "/v1/agents" && method === "GET") {
|
|
1314
|
+
return handleListAgents(req, url);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// 6. POST /v1/messages
|
|
1318
|
+
if (pathname === "/v1/messages" && method === "POST") {
|
|
1319
|
+
return handleSendMessage(req);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// /v1/agents/:session_id/heartbeat (POST) and DELETE /v1/agents/:session_id
|
|
1323
|
+
const agentMatch = pathname.match(
|
|
1324
|
+
/^\/v1\/agents\/([^/]+)(?:\/(heartbeat))?$/,
|
|
1325
|
+
);
|
|
1326
|
+
if (agentMatch) {
|
|
1327
|
+
const sessionId = decodeURIComponent(agentMatch[1]);
|
|
1328
|
+
const tail = agentMatch[2];
|
|
1329
|
+
if (tail === "heartbeat" && method === "POST") {
|
|
1330
|
+
return handleHeartbeat(req, sessionId);
|
|
1331
|
+
}
|
|
1332
|
+
if (!tail && method === "DELETE") {
|
|
1333
|
+
return handleDeleteAgent(req, url, sessionId);
|
|
1334
|
+
}
|
|
1335
|
+
return errorJson("method_not_allowed", 405);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// /v1/messages/:id, /v1/messages/:id/await, /v1/messages/:id/response
|
|
1339
|
+
const msgMatch = pathname.match(
|
|
1340
|
+
/^\/v1\/messages\/([^/]+)(?:\/(await|response))?$/,
|
|
1341
|
+
);
|
|
1342
|
+
if (msgMatch) {
|
|
1343
|
+
const msg_id = decodeURIComponent(msgMatch[1]);
|
|
1344
|
+
const tail = msgMatch[2];
|
|
1345
|
+
if (!tail && method === "GET") {
|
|
1346
|
+
return handleGetMessage(req, url, msg_id);
|
|
1347
|
+
}
|
|
1348
|
+
if (tail === "await" && method === "GET") {
|
|
1349
|
+
return handleAwaitMessage(req, url, msg_id);
|
|
1350
|
+
}
|
|
1351
|
+
if (tail === "response" && method === "POST") {
|
|
1352
|
+
return handleSubmitResponse(req, msg_id);
|
|
1353
|
+
}
|
|
1354
|
+
return errorJson("method_not_allowed", 405);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
return errorJson("not_found", 404);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1361
|
+
// Cleanup loops (Phase 3)
|
|
1362
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1363
|
+
|
|
1364
|
+
let staleScanTimer: ReturnType<typeof setInterval> | null = null;
|
|
1365
|
+
let ttlScanTimer: ReturnType<typeof setInterval> | null = null;
|
|
1366
|
+
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
1367
|
+
let shuttingDown = false;
|
|
1368
|
+
|
|
1369
|
+
function staleScanTick(): void {
|
|
1370
|
+
const now = Date.now();
|
|
1371
|
+
for (const [projectName, p] of state.projects) {
|
|
1372
|
+
for (const [sid, entry] of p.agents) {
|
|
1373
|
+
const last = Date.parse(entry.last_seen_at);
|
|
1374
|
+
if (Number.isNaN(last)) continue;
|
|
1375
|
+
const dt = now - last;
|
|
1376
|
+
if (dt > OFFLINE_AFTER_MS) {
|
|
1377
|
+
// Remove agent, close stream, emit agent_left.
|
|
1378
|
+
p.agents.delete(sid);
|
|
1379
|
+
nameIndexRemove(p, entry.name, sid);
|
|
1380
|
+
const stream = p.streams.get(sid);
|
|
1381
|
+
if (stream) {
|
|
1382
|
+
try {
|
|
1383
|
+
stream.close();
|
|
1384
|
+
} catch {
|
|
1385
|
+
// noop
|
|
1386
|
+
}
|
|
1387
|
+
p.streams.delete(sid);
|
|
1388
|
+
}
|
|
1389
|
+
logOffline(entry.name);
|
|
1390
|
+
broadcast(
|
|
1391
|
+
p,
|
|
1392
|
+
"agent_left",
|
|
1393
|
+
{
|
|
1394
|
+
project: projectName,
|
|
1395
|
+
session_id: sid,
|
|
1396
|
+
name: entry.name,
|
|
1397
|
+
reason: "stale",
|
|
1398
|
+
},
|
|
1399
|
+
sid,
|
|
1400
|
+
);
|
|
1401
|
+
} else if (dt > STALE_AFTER_MS && entry.status !== "stale") {
|
|
1402
|
+
entry.status = "stale";
|
|
1403
|
+
logStale(entry.name, Math.round(dt / 1000));
|
|
1404
|
+
broadcast(
|
|
1405
|
+
p,
|
|
1406
|
+
"agent_stale",
|
|
1407
|
+
{
|
|
1408
|
+
project: projectName,
|
|
1409
|
+
session_id: sid,
|
|
1410
|
+
name: entry.name,
|
|
1411
|
+
last_seen_at: entry.last_seen_at,
|
|
1412
|
+
},
|
|
1413
|
+
sid,
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function ttlScanTick(): void {
|
|
1421
|
+
const now = Date.now();
|
|
1422
|
+
for (const p of state.projects.values()) {
|
|
1423
|
+
for (const [id, m] of [...p.messages]) {
|
|
1424
|
+
const expires = Date.parse(m.expires_at);
|
|
1425
|
+
const completedAt = m.completed_at ? Date.parse(m.completed_at) : 0;
|
|
1426
|
+
if (
|
|
1427
|
+
m.status === "queued" ||
|
|
1428
|
+
m.status === "delivered"
|
|
1429
|
+
) {
|
|
1430
|
+
if (Number.isFinite(expires) && now > expires) {
|
|
1431
|
+
m.status = "error";
|
|
1432
|
+
m.error = "expired";
|
|
1433
|
+
m.completed_at = nowIso();
|
|
1434
|
+
releaseAwaiters(p, id);
|
|
1435
|
+
logExpired(id);
|
|
1436
|
+
p.messages.delete(id);
|
|
1437
|
+
}
|
|
1438
|
+
} else if (m.status === "complete" || m.status === "error") {
|
|
1439
|
+
if (
|
|
1440
|
+
Number.isFinite(completedAt) &&
|
|
1441
|
+
now - completedAt > MESSAGE_TTL_MS
|
|
1442
|
+
) {
|
|
1443
|
+
p.messages.delete(id);
|
|
1444
|
+
}
|
|
1445
|
+
} else if (m.status === "timeout") {
|
|
1446
|
+
if (Number.isFinite(expires) && now > expires) {
|
|
1447
|
+
p.messages.delete(id);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function keepaliveTick(): void {
|
|
1455
|
+
const ts = nowIso();
|
|
1456
|
+
const frame = `: ping ${ts}\n\n`;
|
|
1457
|
+
for (const p of state.projects.values()) {
|
|
1458
|
+
for (const [, w] of p.streams) {
|
|
1459
|
+
try {
|
|
1460
|
+
w.enqueue(frame);
|
|
1461
|
+
} catch {
|
|
1462
|
+
// dead; abort handler will reap
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function startLoops(): void {
|
|
1469
|
+
staleScanTimer = setInterval(staleScanTick, STALE_SCAN_INTERVAL_MS);
|
|
1470
|
+
ttlScanTimer = setInterval(ttlScanTick, TTL_SCAN_INTERVAL_MS);
|
|
1471
|
+
keepaliveTimer = setInterval(keepaliveTick, SSE_KEEPALIVE_MS);
|
|
1472
|
+
for (const t of [staleScanTimer, ttlScanTimer, keepaliveTimer]) {
|
|
1473
|
+
try {
|
|
1474
|
+
(t as any).unref?.();
|
|
1475
|
+
} catch {
|
|
1476
|
+
// noop
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function stopLoops(): void {
|
|
1482
|
+
if (staleScanTimer) clearInterval(staleScanTimer);
|
|
1483
|
+
if (ttlScanTimer) clearInterval(ttlScanTimer);
|
|
1484
|
+
if (keepaliveTimer) clearInterval(keepaliveTimer);
|
|
1485
|
+
staleScanTimer = null;
|
|
1486
|
+
ttlScanTimer = null;
|
|
1487
|
+
keepaliveTimer = null;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
async function listenWithNode(fetchHandler: (req: Request) => Promise<Response>): Promise<{
|
|
1491
|
+
port: number;
|
|
1492
|
+
stop(force?: boolean): void;
|
|
1493
|
+
}> {
|
|
1494
|
+
const server = http.createServer(async (incoming, outgoing) => {
|
|
1495
|
+
const ac = new AbortController();
|
|
1496
|
+
incoming.on("close", () => ac.abort());
|
|
1497
|
+
try {
|
|
1498
|
+
const url = `http://${incoming.headers.host ?? `${HOST}:${PORT}`}${incoming.url ?? "/"}`;
|
|
1499
|
+
const headers = new Headers();
|
|
1500
|
+
for (const [key, value] of Object.entries(incoming.headers)) {
|
|
1501
|
+
if (Array.isArray(value)) {
|
|
1502
|
+
for (const item of value) headers.append(key, item);
|
|
1503
|
+
} else if (value !== undefined) {
|
|
1504
|
+
headers.set(key, value);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
const method = incoming.method ?? "GET";
|
|
1508
|
+
const hasBody = method !== "GET" && method !== "HEAD";
|
|
1509
|
+
const req = new Request(url, {
|
|
1510
|
+
method,
|
|
1511
|
+
headers,
|
|
1512
|
+
body: hasBody ? (incoming as any) : undefined,
|
|
1513
|
+
duplex: hasBody ? "half" : undefined,
|
|
1514
|
+
signal: ac.signal,
|
|
1515
|
+
} as RequestInit & { duplex?: "half" });
|
|
1516
|
+
const resp = await fetchHandler(req);
|
|
1517
|
+
outgoing.writeHead(resp.status, Object.fromEntries(resp.headers));
|
|
1518
|
+
if (!resp.body) {
|
|
1519
|
+
outgoing.end();
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
const reader = resp.body.getReader();
|
|
1523
|
+
while (true) {
|
|
1524
|
+
const { done, value } = await reader.read();
|
|
1525
|
+
if (done) break;
|
|
1526
|
+
if (value) outgoing.write(value);
|
|
1527
|
+
}
|
|
1528
|
+
outgoing.end();
|
|
1529
|
+
} catch (err) {
|
|
1530
|
+
if (!outgoing.headersSent) {
|
|
1531
|
+
outgoing.writeHead(500, { "content-type": "application/json" });
|
|
1532
|
+
}
|
|
1533
|
+
outgoing.end(JSON.stringify({ ok: false, error: "internal_error" }));
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
await new Promise<void>((resolve, reject) => {
|
|
1537
|
+
server.once("error", reject);
|
|
1538
|
+
server.listen(PORT, HOST, () => {
|
|
1539
|
+
server.off("error", reject);
|
|
1540
|
+
resolve();
|
|
1541
|
+
});
|
|
1542
|
+
});
|
|
1543
|
+
const address = server.address();
|
|
1544
|
+
const port = typeof address === "object" && address ? address.port : PORT;
|
|
1545
|
+
return {
|
|
1546
|
+
port,
|
|
1547
|
+
stop(force = false) {
|
|
1548
|
+
void force;
|
|
1549
|
+
server.close();
|
|
1550
|
+
},
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
async function serve(fetchHandler: (req: Request) => Promise<Response>) {
|
|
1555
|
+
const bun = (globalThis as any).Bun;
|
|
1556
|
+
if (bun?.serve) {
|
|
1557
|
+
return bun.serve({
|
|
1558
|
+
hostname: HOST,
|
|
1559
|
+
port: PORT,
|
|
1560
|
+
fetch: fetchHandler,
|
|
1561
|
+
// Bun's default idle timeout is 10s — bump it so SSE doesn't get cut.
|
|
1562
|
+
idleTimeout: 0,
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
return listenWithNode(fetchHandler);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1569
|
+
// main() — only runs when launched directly
|
|
1570
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1571
|
+
|
|
1572
|
+
export async function main(): Promise<void> {
|
|
1573
|
+
// Token policy.
|
|
1574
|
+
if (!TOKEN) {
|
|
1575
|
+
if (!isLoopback(HOST)) {
|
|
1576
|
+
console.error(
|
|
1577
|
+
`coms-net: refusing to bind ${HOST} without an explicit PI_COMS_NET_AUTH_TOKEN.`,
|
|
1578
|
+
);
|
|
1579
|
+
process.exit(1);
|
|
1580
|
+
}
|
|
1581
|
+
TOKEN = crypto.randomBytes(32).toString("hex");
|
|
1582
|
+
TOKEN_FILE_OWNED_BY_US = true;
|
|
1583
|
+
} else {
|
|
1584
|
+
TOKEN_FILE_OWNED_BY_US = false;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
const dir = projectDir(PROJECT);
|
|
1588
|
+
ensureDirSync(dir);
|
|
1589
|
+
|
|
1590
|
+
// Boot HTTP/SSE server.
|
|
1591
|
+
const server = await serve(router);
|
|
1592
|
+
const claimedPort: number = Number(server.port);
|
|
1593
|
+
const localHost = HOST === "0.0.0.0" || HOST === "::" ? "127.0.0.1" : HOST;
|
|
1594
|
+
const localUrl = `http://${localHost}:${claimedPort}`;
|
|
1595
|
+
const publicUrl = PUBLIC_URL ?? localUrl;
|
|
1596
|
+
|
|
1597
|
+
// Write server.json (NEVER include the token).
|
|
1598
|
+
const serverJsonPath = path.join(dir, "server.json");
|
|
1599
|
+
const serverJson = {
|
|
1600
|
+
version: 1,
|
|
1601
|
+
project: PROJECT,
|
|
1602
|
+
pid: process.pid,
|
|
1603
|
+
host: HOST,
|
|
1604
|
+
port: claimedPort,
|
|
1605
|
+
local_url: localUrl,
|
|
1606
|
+
public_url: publicUrl,
|
|
1607
|
+
started_at: state.started_at,
|
|
1608
|
+
server_id: state.server_id,
|
|
1609
|
+
};
|
|
1610
|
+
atomicWriteSync(serverJsonPath, JSON.stringify(serverJson, null, 2));
|
|
1611
|
+
|
|
1612
|
+
// Write server.secret.json only if we own the token.
|
|
1613
|
+
let secretPath: string | null = null;
|
|
1614
|
+
if (TOKEN_FILE_OWNED_BY_US) {
|
|
1615
|
+
secretPath = path.join(dir, "server.secret.json");
|
|
1616
|
+
atomicWriteSync(
|
|
1617
|
+
secretPath,
|
|
1618
|
+
JSON.stringify({ token: TOKEN }, null, 2),
|
|
1619
|
+
0o600,
|
|
1620
|
+
);
|
|
1621
|
+
try {
|
|
1622
|
+
fs.chmodSync(secretPath, 0o600);
|
|
1623
|
+
} catch {
|
|
1624
|
+
// best-effort
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// Boot banner — NEVER print the token. Path only.
|
|
1629
|
+
const bootDim = LOG_TTY ? C_DIM : "";
|
|
1630
|
+
const bootCyan = LOG_TTY ? C_CYAN : "";
|
|
1631
|
+
const bootReset = LOG_TTY ? C_RESET : "";
|
|
1632
|
+
console.log(`${bootCyan}coms-net${bootReset}: listening on ${bootCyan}${localUrl}${bootReset}`);
|
|
1633
|
+
console.log(`${bootDim} project=${PROJECT} pid=${process.pid}${bootReset}`);
|
|
1634
|
+
console.log(`${bootDim} server.json=${serverJsonPath}${bootReset}`);
|
|
1635
|
+
if (secretPath) {
|
|
1636
|
+
console.log(`${bootDim} server.secret.json=${secretPath} (chmod 0600)${bootReset}`);
|
|
1637
|
+
} else {
|
|
1638
|
+
console.log(`${bootDim} using token from PI_COMS_NET_AUTH_TOKEN${bootReset}`);
|
|
1639
|
+
}
|
|
1640
|
+
if (!LOG_QUIET) {
|
|
1641
|
+
console.log(`${bootDim} ─── events below (Ctrl-C to quit, set PI_COMS_NET_LOG_HEARTBEAT=1 for heartbeat noise) ───${bootReset}`);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// Start cleanup loops.
|
|
1645
|
+
startLoops();
|
|
1646
|
+
|
|
1647
|
+
// Synchronous unlink helper — must be safe to call multiple times and from any
|
|
1648
|
+
// termination path (signal handler, exit event, uncaught exception).
|
|
1649
|
+
let filesUnlinked = false;
|
|
1650
|
+
const unlinkStateFiles = () => {
|
|
1651
|
+
if (filesUnlinked) return;
|
|
1652
|
+
filesUnlinked = true;
|
|
1653
|
+
try {
|
|
1654
|
+
fs.unlinkSync(serverJsonPath);
|
|
1655
|
+
} catch {
|
|
1656
|
+
// noop
|
|
1657
|
+
}
|
|
1658
|
+
if (TOKEN_FILE_OWNED_BY_US && secretPath) {
|
|
1659
|
+
try {
|
|
1660
|
+
fs.unlinkSync(secretPath);
|
|
1661
|
+
} catch {
|
|
1662
|
+
// noop
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
};
|
|
1666
|
+
|
|
1667
|
+
// Signal handlers.
|
|
1668
|
+
const shutdown = (sig: string) => {
|
|
1669
|
+
if (shuttingDown) return;
|
|
1670
|
+
shuttingDown = true;
|
|
1671
|
+
// FIRST: unlink state files synchronously. This must happen before any other
|
|
1672
|
+
// work so that even if the process is hard-killed (SIGKILL from a parent
|
|
1673
|
+
// process manager racing the SIGINT handler) or the broadcast/stream-close
|
|
1674
|
+
// loop somehow stalls, the registry doesn't leak across runs.
|
|
1675
|
+
unlinkStateFiles();
|
|
1676
|
+
try {
|
|
1677
|
+
console.log(`coms-net: ${sig} received, shutting down`);
|
|
1678
|
+
} catch {
|
|
1679
|
+
// noop
|
|
1680
|
+
}
|
|
1681
|
+
// Notify all streams.
|
|
1682
|
+
for (const [projectName, p] of state.projects) {
|
|
1683
|
+
for (const [sid, entry] of p.agents) {
|
|
1684
|
+
broadcast(
|
|
1685
|
+
p,
|
|
1686
|
+
"agent_left",
|
|
1687
|
+
{
|
|
1688
|
+
project: projectName,
|
|
1689
|
+
session_id: sid,
|
|
1690
|
+
name: entry.name,
|
|
1691
|
+
reason: "shutdown",
|
|
1692
|
+
},
|
|
1693
|
+
sid,
|
|
1694
|
+
);
|
|
1695
|
+
}
|
|
1696
|
+
for (const [, w] of p.streams) {
|
|
1697
|
+
try {
|
|
1698
|
+
w.close();
|
|
1699
|
+
} catch {
|
|
1700
|
+
// noop
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
p.streams.clear();
|
|
1704
|
+
}
|
|
1705
|
+
// Stop loops.
|
|
1706
|
+
stopLoops();
|
|
1707
|
+
// Stop server.
|
|
1708
|
+
try {
|
|
1709
|
+
server.stop?.(true);
|
|
1710
|
+
} catch {
|
|
1711
|
+
// noop
|
|
1712
|
+
}
|
|
1713
|
+
// Allow IO to flush.
|
|
1714
|
+
setTimeout(() => process.exit(0), 50).unref?.();
|
|
1715
|
+
};
|
|
1716
|
+
|
|
1717
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
1718
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
1719
|
+
// Belt-and-suspenders: any other path to process termination (uncaught
|
|
1720
|
+
// exception, explicit process.exit, normal exit) gets a final synchronous
|
|
1721
|
+
// chance to unlink. Note: this does NOT fire on SIGKILL.
|
|
1722
|
+
process.on("exit", () => {
|
|
1723
|
+
unlinkStateFiles();
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// `import.meta.main` is defined only on Bun and Node >= 22.18 / 24.2; on older
|
|
1728
|
+
// `--experimental-strip-types` runtimes (Node 22.6+) it is undefined, so fall back
|
|
1729
|
+
// to comparing the resolved entrypoint path. Importing this module (e.g. from a
|
|
1730
|
+
// test) leaves both checks false and does NOT start the server.
|
|
1731
|
+
const isEntrypoint =
|
|
1732
|
+
import.meta.main ??
|
|
1733
|
+
(process.argv[1] !== undefined &&
|
|
1734
|
+
url.fileURLToPath(import.meta.url) === path.resolve(process.argv[1]));
|
|
1735
|
+
|
|
1736
|
+
if (isEntrypoint) {
|
|
1737
|
+
main().catch((err) => {
|
|
1738
|
+
console.error(err instanceof Error ? err.stack : String(err));
|
|
1739
|
+
process.exit(1);
|
|
1740
|
+
});
|
|
1741
|
+
}
|