@chorus-aidlc/chorus-pi 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -0
- package/agents/chorus-code-reviewer.md +168 -0
- package/agents/chorus-proposal-reviewer.md +137 -0
- package/agents/chorus-task-reviewer.md +160 -0
- package/bin/chorus-mcp-call.sh +182 -0
- package/extensions/chorus.ts +452 -0
- package/extensions/subagent/agents.ts +180 -0
- package/extensions/subagent/index.ts +1038 -0
- package/lib/lib.ts +409 -0
- package/package.json +62 -0
- package/skills/brainstorm/SKILL.md +166 -0
- package/skills/chorus/SKILL.md +432 -0
- package/skills/chorus-cli/SKILL.md +57 -0
- package/skills/develop/SKILL.md +444 -0
- package/skills/docs/SKILL.md +68 -0
- package/skills/idea/SKILL.md +349 -0
- package/skills/openspec-aware/SKILL.md +493 -0
- package/skills/orchestrate/SKILL.md +127 -0
- package/skills/proposal/SKILL.md +386 -0
- package/skills/quick-dev/SKILL.md +197 -0
- package/skills/review/SKILL.md +363 -0
- package/skills/yolo/SKILL.md +549 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# chorus-mcp-call.sh — Stateless MCP-over-HTTP helper for Codex hooks.
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# chorus-mcp-call.sh TOOL_NAME '<json_arguments>'
|
|
6
|
+
#
|
|
7
|
+
# Environment:
|
|
8
|
+
# CHORUS_URL — Full Chorus MCP endpoint URL
|
|
9
|
+
# (e.g., https://chorus.example.com/api/mcp).
|
|
10
|
+
# If only a host is provided (no path), /api/mcp is
|
|
11
|
+
# appended automatically for backward compatibility.
|
|
12
|
+
# CHORUS_API_KEY — Agent API key (cho_xxx)
|
|
13
|
+
#
|
|
14
|
+
# Writes MCP tool result text to stdout. Exits non-zero on error.
|
|
15
|
+
# No filesystem state — Codex port is stateless (no .chorus/ directory).
|
|
16
|
+
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
TOOL_NAME="${1:?tool name required}"
|
|
20
|
+
# NOTE: avoid `${2:-{}}` — bash mis-parses the literal `{}` inside the
|
|
21
|
+
# parameter expansion and produces `{}}` (an extra `}`), which makes the
|
|
22
|
+
# server return -32700 "Parse error: Invalid JSON". Assign plainly instead.
|
|
23
|
+
ARGS="${2-}"
|
|
24
|
+
if [ -z "$ARGS" ]; then
|
|
25
|
+
ARGS='{}'
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# Decide ONCE whether we can delegate to the native `chorus` CLI. `chorus mcp`
|
|
29
|
+
# (and profile-by-name/uuid selection) only exists in chorus >= 0.17.0, so
|
|
30
|
+
# version-gate: `chorus --version` prints a bare X.Y.Z; parse the first
|
|
31
|
+
# MAJOR.MINOR and accept major>0 OR (major==0 && minor>=17). Bash 3.2-safe.
|
|
32
|
+
# CHORUS_MCP_NO_CLI forces the curl path.
|
|
33
|
+
_cli_usable=0
|
|
34
|
+
_cli_ver=""
|
|
35
|
+
if [ -z "${CHORUS_MCP_NO_CLI:-}" ] && command -v chorus >/dev/null 2>&1; then
|
|
36
|
+
_cli_ver=$(chorus --version 2>/dev/null | head -1 | tr -d '\r' || true)
|
|
37
|
+
_cli_major=$(printf '%s' "$_cli_ver" | sed -n 's/^[^0-9]*\([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1/p')
|
|
38
|
+
_cli_minor=$(printf '%s' "$_cli_ver" | sed -n 's/^[^0-9]*\([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\2/p')
|
|
39
|
+
if [ -n "$_cli_major" ] && [ -n "$_cli_minor" ] && { [ "$_cli_major" -gt 0 ] || [ "$_cli_minor" -ge 17 ]; }; then
|
|
40
|
+
_cli_usable=1
|
|
41
|
+
fi
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# Profile path (PREFERRED): CHORUS_AGENT_PROFILE + a usable CLI -> delegate by
|
|
45
|
+
# profile. The CLI reads this agent's key from ~/.chorus/daemon.json, so
|
|
46
|
+
# CHORUS_URL/CHORUS_API_KEY (and the .mcp.json search below) are NOT needed.
|
|
47
|
+
# When the CLI is absent/old we fall through to the url+key path.
|
|
48
|
+
if [ -n "${CHORUS_AGENT_PROFILE:-}" ] && [ "$_cli_usable" -eq 1 ]; then
|
|
49
|
+
_cli_status=0
|
|
50
|
+
chorus mcp call "$TOOL_NAME" "$ARGS" --agent "$CHORUS_AGENT_PROFILE" || _cli_status=$?
|
|
51
|
+
exit "$_cli_status"
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
# Connection resolution: CHORUS_URL / CHORUS_API_KEY env vars take
|
|
55
|
+
# precedence. When either is unset, fall back to the .mcp.json that
|
|
56
|
+
# pi-mcp-adapter auto-discovers (project-root .mcp.json, then
|
|
57
|
+
# ~/.pi/agent/mcp.json) so a single config source covers both the
|
|
58
|
+
# MCP gateway (literal URL+Bearer) and this wrapper. The .mcp.json
|
|
59
|
+
# chorus server entry uses the standard shape:
|
|
60
|
+
# { "url": "…/api/mcp", "headers": { "Authorization": "Bearer cho_…" } }
|
|
61
|
+
#
|
|
62
|
+
# A PARTIAL entry (e.g. only url, no Authorization) does NOT count — the
|
|
63
|
+
# search keeps going so a partial project .mcp.json cannot shadow a complete
|
|
64
|
+
# ~/.pi/agent/mcp.json. Only a COMPLETE candidate (both url AND Authorization
|
|
65
|
+
# from the SAME source) is accepted; fields are never merged across candidates,
|
|
66
|
+
# matching the TS resolver in lib/lib.ts exactly (no credential mismatch when
|
|
67
|
+
# project and global point at different Chorus servers).
|
|
68
|
+
if [ -z "${CHORUS_URL:-}" ] || [ -z "${CHORUS_API_KEY:-}" ]; then
|
|
69
|
+
# Search candidate .mcp.json paths. PWD covers plain bash invocations
|
|
70
|
+
# (the project root is the working directory); the global path covers
|
|
71
|
+
# ~/.pi/agent/mcp.json (user-level, shared across projects).
|
|
72
|
+
for _cfg in "${PWD}/.mcp.json" "${HOME}/.pi/agent/mcp.json"; do
|
|
73
|
+
# Both fields already filled (env or an earlier candidate) — stop.
|
|
74
|
+
[ -z "${CHORUS_URL:-}" ] || [ -z "${CHORUS_API_KEY:-}" ] || break
|
|
75
|
+
[ -f "$_cfg" ] || continue
|
|
76
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
77
|
+
echo "chorus-mcp-call: jq required to parse $_cfg but not on PATH" >&2
|
|
78
|
+
break
|
|
79
|
+
fi
|
|
80
|
+
_srv=$(jq -r '.mcpServers.chorus // empty' "$_cfg" 2>/dev/null) || _srv=""
|
|
81
|
+
[ -n "$_srv" ] || continue
|
|
82
|
+
# Read BOTH fields from THIS candidate only (no cross-candidate merge).
|
|
83
|
+
_c_url=$(printf '%s' "$_srv" | jq -r '.url // empty' 2>/dev/null) || _c_url=""
|
|
84
|
+
_c_auth=$(printf '%s' "$_srv" | jq -r '.headers.Authorization // empty' 2>/dev/null) || _c_auth=""
|
|
85
|
+
_c_key=""
|
|
86
|
+
case "$_c_auth" in
|
|
87
|
+
Bearer\ *) _c_key="${_c_auth#Bearer }" ;;
|
|
88
|
+
cho_*) _c_key="$_c_auth" ;;
|
|
89
|
+
esac
|
|
90
|
+
# Accept this candidate only if BOTH url and key are present (complete).
|
|
91
|
+
if [ -n "$_c_url" ] && [ -n "$_c_key" ]; then
|
|
92
|
+
[ -z "${CHORUS_URL:-}" ] && CHORUS_URL="$_c_url"
|
|
93
|
+
[ -z "${CHORUS_API_KEY:-}" ] && CHORUS_API_KEY="$_c_key"
|
|
94
|
+
fi
|
|
95
|
+
done
|
|
96
|
+
if [ -z "${CHORUS_URL:-}" ] || [ -z "${CHORUS_API_KEY:-}" ]; then
|
|
97
|
+
echo "chorus-mcp-call: CHORUS_URL or CHORUS_API_KEY not set, and no usable chorus server in .mcp.json" >&2
|
|
98
|
+
echo " Set CHORUS_URL + CHORUS_API_KEY, add a 'chorus' server to .mcp.json, or set CHORUS_AGENT_PROFILE with the chorus CLI installed" >&2
|
|
99
|
+
exit 1
|
|
100
|
+
fi
|
|
101
|
+
fi
|
|
102
|
+
|
|
103
|
+
# url+key path. This wrapper stays the single credential resolver (env or the
|
|
104
|
+
# .mcp.json discovered above), so pass CHORUS_URL/CHORUS_API_KEY explicitly (the
|
|
105
|
+
# CLI does its own /api/mcp normalization, same as the curl path below). Prefer
|
|
106
|
+
# the CLI when usable; the fallback never triggers on a call *failure* — a
|
|
107
|
+
# present-but-erroring `chorus mcp call` propagates its stdout, stderr, and exit
|
|
108
|
+
# code verbatim.
|
|
109
|
+
if [ "$_cli_usable" -eq 1 ]; then
|
|
110
|
+
_cli_status=0
|
|
111
|
+
chorus mcp call "$TOOL_NAME" "$ARGS" \
|
|
112
|
+
--url "$CHORUS_URL" --api-key "$CHORUS_API_KEY" || _cli_status=$?
|
|
113
|
+
exit "$_cli_status"
|
|
114
|
+
fi
|
|
115
|
+
if [ -z "${CHORUS_MCP_NO_CLI:-}" ] && command -v chorus >/dev/null 2>&1; then
|
|
116
|
+
# `chorus` is present but too old for `chorus mcp` -> actionable upgrade error
|
|
117
|
+
# (no silent curl fallback).
|
|
118
|
+
echo "ERROR: chorus CLI version '${_cli_ver:-unknown}' is too old; 'chorus mcp' requires chorus >= 0.17.0. Upgrade with: npm install -g @chorus-aidlc/chorus" >&2
|
|
119
|
+
exit 1
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
# Derive the MCP endpoint URL. Accept both:
|
|
123
|
+
# 1) Full endpoint: https://host/api/mcp (preferred, installer writes this)
|
|
124
|
+
# 2) Bare host: https://host (legacy — auto-append /api/mcp)
|
|
125
|
+
_url="${CHORUS_URL%/}"
|
|
126
|
+
case "$_url" in
|
|
127
|
+
http://*/*|https://*/*)
|
|
128
|
+
# Has a path segment beyond the host → assume it's already the full endpoint.
|
|
129
|
+
_rest="${_url#http*://}"
|
|
130
|
+
_rest="${_rest#*/}"
|
|
131
|
+
if [ -n "$_rest" ]; then
|
|
132
|
+
MCP_URL="$_url"
|
|
133
|
+
else
|
|
134
|
+
MCP_URL="${_url}/api/mcp"
|
|
135
|
+
fi
|
|
136
|
+
;;
|
|
137
|
+
*)
|
|
138
|
+
MCP_URL="${_url}/api/mcp"
|
|
139
|
+
;;
|
|
140
|
+
esac
|
|
141
|
+
AUTH="Authorization: Bearer ${CHORUS_API_KEY}"
|
|
142
|
+
ACCEPT="Accept: application/json, text/event-stream"
|
|
143
|
+
CT="Content-Type: application/json"
|
|
144
|
+
|
|
145
|
+
INIT=$(cat <<JSON
|
|
146
|
+
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"chorus-codex-hook","version":"0.17.0"}}}
|
|
147
|
+
JSON
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
HEADERS_FILE=$(mktemp)
|
|
151
|
+
trap 'rm -f "$HEADERS_FILE"' EXIT
|
|
152
|
+
|
|
153
|
+
curl -s -S -X POST -H "$AUTH" -H "$CT" -H "$ACCEPT" -D "$HEADERS_FILE" \
|
|
154
|
+
-d "$INIT" "$MCP_URL" >/dev/null || { echo "MCP initialize failed" >&2; exit 2; }
|
|
155
|
+
|
|
156
|
+
SESSION_ID=$(grep -i '^mcp-session-id:' "$HEADERS_FILE" 2>/dev/null | tr -d '\r' | awk '{print $2}') || true
|
|
157
|
+
SESSION_HEADER=()
|
|
158
|
+
if [ -n "$SESSION_ID" ]; then
|
|
159
|
+
SESSION_HEADER=(-H "Mcp-Session-Id: ${SESSION_ID}")
|
|
160
|
+
fi
|
|
161
|
+
|
|
162
|
+
# Fire 'initialized' notification (no reply expected)
|
|
163
|
+
curl -s -S -X POST -H "$AUTH" -H "$CT" -H "$ACCEPT" "${SESSION_HEADER[@]}" \
|
|
164
|
+
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
|
|
165
|
+
"$MCP_URL" >/dev/null || true
|
|
166
|
+
|
|
167
|
+
# Call the tool
|
|
168
|
+
CALL=$(printf '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"%s","arguments":%s}}' "$TOOL_NAME" "$ARGS")
|
|
169
|
+
|
|
170
|
+
RAW=$(curl -s -S -X POST -H "$AUTH" -H "$CT" -H "$ACCEPT" "${SESSION_HEADER[@]}" \
|
|
171
|
+
-d "$CALL" "$MCP_URL" 2>/dev/null) || { echo "MCP tool call failed" >&2; exit 3; }
|
|
172
|
+
|
|
173
|
+
# Streamable transport may return SSE framing; strip 'data: ' prefix if present
|
|
174
|
+
if printf '%s' "$RAW" | head -1 | grep -q '^event:\|^data:'; then
|
|
175
|
+
RAW=$(printf '%s' "$RAW" | sed -n 's/^data: //p' | head -1)
|
|
176
|
+
fi
|
|
177
|
+
|
|
178
|
+
if command -v jq >/dev/null 2>&1; then
|
|
179
|
+
printf '%s' "$RAW" | jq -r '.result.content[0].text // .result // .'
|
|
180
|
+
else
|
|
181
|
+
printf '%s\n' "$RAW"
|
|
182
|
+
fi
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chorus AI-DLC extension for the Pi coding agent.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the Claude Code plugin (public/chorus-plugin/) and the Codex
|
|
5
|
+
* port (plugins/chorus/). Where those shipped bash hook scripts driven by a
|
|
6
|
+
* hooks.json manifest, Pi ships a single TypeScript extension that subscribes
|
|
7
|
+
* to Pi's native events. See docs/CONNECT_PI.md for the design rationale.
|
|
8
|
+
*
|
|
9
|
+
* Capabilities (mirrors the Claude Code plugin):
|
|
10
|
+
* - session_start → chorus_checkin + context injection (SessionStart hook)
|
|
11
|
+
* - before_agent_start → inject checkin result once (replaces UserPromptSubmit noise)
|
|
12
|
+
* - tool_call (subagent, pre-execution, MUTABLE input)
|
|
13
|
+
* → for each WORKER task in the `subagent` invocation
|
|
14
|
+
* (single / parallel / chain), create a Chorus session and
|
|
15
|
+
* inject its UUID + the session workflow into that task. This
|
|
16
|
+
* is the Pi-native equivalent of Claude's SubagentStart hook
|
|
17
|
+
* injecting session context — a capability the Codex port
|
|
18
|
+
* lacks (Codex has no pre-spawn mutation channel, so its
|
|
19
|
+
* workers must manage sessions manually).
|
|
20
|
+
* - tool_result → close the ephemeral worker session(s) once the `subagent`
|
|
21
|
+
* tool call returns (the official children are ephemeral:
|
|
22
|
+
* spawn → run → exit within one tool call, so there is no
|
|
23
|
+
* persistent agentId and no separate close tool).
|
|
24
|
+
* → reviewer nudges after submit_proposal / submit_for_verify
|
|
25
|
+
* / admin_verify_task (the 3 PostToolUse hooks)
|
|
26
|
+
* - tool_execution_end → fallback close of the worker session(s) if tool_result
|
|
27
|
+
* did not fire (idempotent — a successful close deletes the
|
|
28
|
+
* bookkeeping entry)
|
|
29
|
+
* - session_shutdown → close stray sessions (SessionEnd hook)
|
|
30
|
+
*
|
|
31
|
+
* MCP: no installer needed. pi-mcp-adapter auto-discovers the repo's .mcp.json
|
|
32
|
+
* (or ~/.pi/agent/mcp.json) and exposes the chorus_* tools to the main agent.
|
|
33
|
+
* This extension only calls chorus_* for its own bookkeeping (checkin, session
|
|
34
|
+
* create/close) over a direct MCP-over-HTTP fetch — it does NOT rely on the
|
|
35
|
+
* main agent's MCP gateway for that, so hooks fire even before the first turn.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
39
|
+
import {
|
|
40
|
+
isWorkerAgent,
|
|
41
|
+
subagentTaskItems,
|
|
42
|
+
sessionWorkflow,
|
|
43
|
+
detectOpenSpec,
|
|
44
|
+
buildSessionBanner,
|
|
45
|
+
parseMaxCodeReviewRounds,
|
|
46
|
+
resolveChorusBin,
|
|
47
|
+
resolveChorusConfigFromMcpJson,
|
|
48
|
+
resolveChorusToolName,
|
|
49
|
+
NUDGE_TOOL_NAMES,
|
|
50
|
+
} from "../lib/lib.js";
|
|
51
|
+
|
|
52
|
+
// ─── Config ────────────────────────────────────────────────────────────
|
|
53
|
+
// Connection: CHORUS_URL + CHORUS_API_KEY env vars take precedence. When
|
|
54
|
+
// either is unset, fall back to the .mcp.json that pi-mcp-adapter auto-
|
|
55
|
+
// discovers (project-root .mcp.json, then ~/.pi/agent/mcp.json) so a single
|
|
56
|
+
// config source covers both the MCP gateway (literal URL+Bearer) and this
|
|
57
|
+
// extension's own checkin / the OpenSpec wrapper script.
|
|
58
|
+
const _envUrl = process.env.CHORUS_URL ?? "";
|
|
59
|
+
const _envKey = process.env.CHORUS_API_KEY ?? "";
|
|
60
|
+
const _mcp = _envUrl && _envKey
|
|
61
|
+
? { url: "", apiKey: "" }
|
|
62
|
+
: (() => {
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
64
|
+
const _fs = require("node:fs");
|
|
65
|
+
const _home = process.env.HOME || "";
|
|
66
|
+
return resolveChorusConfigFromMcpJson(
|
|
67
|
+
[`${process.cwd()}/.mcp.json`, `${_home}/.pi/agent/mcp.json`],
|
|
68
|
+
{ existsSync: _fs.existsSync },
|
|
69
|
+
(p: string) => _fs.readFileSync(p, "utf-8"),
|
|
70
|
+
);
|
|
71
|
+
})();
|
|
72
|
+
const CHORUS_URL = _envUrl || _mcp.url;
|
|
73
|
+
const CHORUS_API_KEY = _envKey || _mcp.apiKey;
|
|
74
|
+
const OPENSPEC_OPTOUT = process.env.CHORUS_OPENSPEC_MODE === "off";
|
|
75
|
+
|
|
76
|
+
// Reviewer toggle envs (mirror Claude Code plugin userConfig; Pi has no plugin
|
|
77
|
+
// settings UI, so env vars drive them). Defaults: all enabled.
|
|
78
|
+
const ENABLE_PROPOSAL_REVIEWER = process.env.CHORUS_ENABLE_PROPOSAL_REVIEWER !== "false";
|
|
79
|
+
const ENABLE_TASK_REVIEWER = process.env.CHORUS_ENABLE_TASK_REVIEWER !== "false";
|
|
80
|
+
const ENABLE_CODE_REVIEWER = process.env.CHORUS_ENABLE_CODE_REVIEWER !== "false";
|
|
81
|
+
|
|
82
|
+
// Max code-review rounds before escalating to a human. 0 = unlimited.
|
|
83
|
+
// Mirrors the Claude plugin's `maxCodeReviewRounds` userConfig (default 3).
|
|
84
|
+
// Parsed from CHORUS_MAX_CODE_REVIEW_ROUNDS; invalid/empty falls back to 3.
|
|
85
|
+
const MAX_CODE_REVIEW_ROUNDS = parseMaxCodeReviewRounds(process.env.CHORUS_MAX_CODE_REVIEW_ROUNDS);
|
|
86
|
+
|
|
87
|
+
// Resolve the bundled `bin/chorus-mcp-call.sh` wrapper relative to this extension's
|
|
88
|
+
// install location. Local-path installs (`pi install ./packages/chorus-pi`) don't link the bin
|
|
89
|
+
// onto PATH and don't live under ~/.pi/agent/npm, so the skill's `find` fallback
|
|
90
|
+
// misses it — the extension knows its own dir and can resolve it for the agent.
|
|
91
|
+
// Computed once at load; empty string if not found (skill falls back to PATH/find).
|
|
92
|
+
const CHORUS_BIN = resolveChorusBin(import.meta.url, {
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
94
|
+
existsSync: require("node:fs").existsSync,
|
|
95
|
+
});
|
|
96
|
+
const CONFIGURED = CHORUS_URL !== "" && CHORUS_API_KEY !== "";
|
|
97
|
+
|
|
98
|
+
// Package version — single source of truth is the bundled package.json (kept in
|
|
99
|
+
// lockstep with the Chorus app version at release), never a hardcoded literal.
|
|
100
|
+
// Read once at load; falls back to "0.0.0" if unreadable so a broken read never
|
|
101
|
+
// crashes the extension.
|
|
102
|
+
const PKG_VERSION: string = (() => {
|
|
103
|
+
try {
|
|
104
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
105
|
+
const _fs = require("node:fs");
|
|
106
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
107
|
+
const _path = require("node:path");
|
|
108
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
109
|
+
const _url = require("node:url");
|
|
110
|
+
const _dir = _path.dirname(_url.fileURLToPath(import.meta.url));
|
|
111
|
+
const _pkg = JSON.parse(_fs.readFileSync(_path.join(_dir, "..", "package.json"), "utf-8"));
|
|
112
|
+
return typeof _pkg.version === "string" ? _pkg.version : "0.0.0";
|
|
113
|
+
} catch {
|
|
114
|
+
return "0.0.0";
|
|
115
|
+
}
|
|
116
|
+
})();
|
|
117
|
+
|
|
118
|
+
// ─── MCP-over-HTTP helper (TS replacement for chorus-mcp-call.sh) ───────────
|
|
119
|
+
let mcpSessionId: string | null = null;
|
|
120
|
+
|
|
121
|
+
function endpoint(): string {
|
|
122
|
+
const base = CHORUS_URL.replace(/\/$/, "");
|
|
123
|
+
return base.includes("/api/mcp") ? base : `${base}/api/mcp`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function mcpCall<T = unknown>(tool: string, args: Record<string, unknown> = {}): Promise<T> {
|
|
127
|
+
const url = endpoint();
|
|
128
|
+
const headers: Record<string, string> = {
|
|
129
|
+
Authorization: `Bearer ${CHORUS_API_KEY}`,
|
|
130
|
+
"Content-Type": "application/json",
|
|
131
|
+
Accept: "application/json, text/event-stream",
|
|
132
|
+
};
|
|
133
|
+
if (mcpSessionId) headers["Mcp-Session-Id"] = mcpSessionId;
|
|
134
|
+
|
|
135
|
+
// 1. initialize
|
|
136
|
+
const init = await fetch(url, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers,
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
jsonrpc: "2.0",
|
|
141
|
+
id: 1,
|
|
142
|
+
method: "initialize",
|
|
143
|
+
params: {
|
|
144
|
+
protocolVersion: "2025-03-26",
|
|
145
|
+
capabilities: {},
|
|
146
|
+
clientInfo: { name: "chorus-pi", version: PKG_VERSION },
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
mcpSessionId = init.headers.get("mcp-session-id") ?? mcpSessionId;
|
|
151
|
+
|
|
152
|
+
// 2. initialized notification (no reply expected)
|
|
153
|
+
await fetch(url, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers,
|
|
156
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// 3. tools/call
|
|
160
|
+
const res = await fetch(url, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers,
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
jsonrpc: "2.0",
|
|
165
|
+
id: 2,
|
|
166
|
+
method: "tools/call",
|
|
167
|
+
params: { name: tool, arguments: args },
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
let raw = await res.text();
|
|
171
|
+
// Streamable transport may wrap in SSE framing; strip 'data: ' prefix.
|
|
172
|
+
if (/^(event:|data:)/m.test(raw)) {
|
|
173
|
+
raw = raw
|
|
174
|
+
.split("\n")
|
|
175
|
+
.find((l) => l.startsWith("data: "))
|
|
176
|
+
?.slice(6) ?? raw;
|
|
177
|
+
}
|
|
178
|
+
const json = JSON.parse(raw);
|
|
179
|
+
if (json.error) throw new Error(`MCP ${tool}: ${json.error.message ?? JSON.stringify(json.error)}`);
|
|
180
|
+
const text = json.result?.content?.[0]?.text ?? "{}";
|
|
181
|
+
try {
|
|
182
|
+
return JSON.parse(text) as T;
|
|
183
|
+
} catch {
|
|
184
|
+
return text as unknown as T;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── Session bookkeeping (ephemeral subagent model) ────────────────────────
|
|
189
|
+
// The official `subagent` tool spawns EPHEMERAL child pi processes (single /
|
|
190
|
+
// parallel / chain) that run to completion within one tool call — there is no
|
|
191
|
+
// persistent agentId and no separate `subagent_manage close` tool. So we create
|
|
192
|
+
// a Chorus session for each WORKER task when the `subagent` tool call starts
|
|
193
|
+
// (tool_call, mutable input → inject the session UUID + workflow into that task)
|
|
194
|
+
// and close those sessions when the tool call finishes (tool_result, with
|
|
195
|
+
// tool_execution_end as an idempotent fallback).
|
|
196
|
+
//
|
|
197
|
+
// toolCallId → the Chorus session UUIDs created for that `subagent` invocation.
|
|
198
|
+
const callSessions = new Map<string, string[]>();
|
|
199
|
+
let checkinContext: string | null = null;
|
|
200
|
+
let injectedOnce = false;
|
|
201
|
+
|
|
202
|
+
// Close a Chorus session, retaining the caller's bookkeeping entry on failure so
|
|
203
|
+
// session_shutdown can retry the close (Reviewer P1: a transient network/server
|
|
204
|
+
// error must NOT permanently leak the backend session). Only on success does
|
|
205
|
+
// this run onSuccess (which drops the sessionMap/pendingSessions entry) and
|
|
206
|
+
// report success. Returns whether the close succeeded.
|
|
207
|
+
type NotifyCtx = { ui: { notify(msg: string, level: "info" | "warning" | "error"): void } };
|
|
208
|
+
|
|
209
|
+
async function closeSessionOrRetain(
|
|
210
|
+
sid: string,
|
|
211
|
+
ctx: NotifyCtx,
|
|
212
|
+
msgs: { fail: string; success: string; successLevel?: "info" | "warning" },
|
|
213
|
+
onSuccess: () => void,
|
|
214
|
+
): Promise<boolean> {
|
|
215
|
+
try {
|
|
216
|
+
await mcpCall("chorus_close_session", { sessionUuid: sid });
|
|
217
|
+
} catch (e) {
|
|
218
|
+
ctx.ui.notify(`${msgs.fail} — ${(e as Error).message}`, "warning");
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
onSuccess();
|
|
222
|
+
ctx.ui.notify(msgs.success, msgs.successLevel ?? "info");
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Close every Chorus session created for a `subagent` tool call. Idempotent:
|
|
227
|
+
// both tool_result and tool_execution_end call this for the same toolCallId, so
|
|
228
|
+
// the entry is deleted only once all its sessions close. A session whose close
|
|
229
|
+
// fails is retained in callSessions so a later event (or session_shutdown) can
|
|
230
|
+
// retry it — a transient network/server error must NOT leak the backend session.
|
|
231
|
+
async function closeCallSessions(
|
|
232
|
+
toolCallId: string,
|
|
233
|
+
ctx: NotifyCtx,
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
const sids = callSessions.get(toolCallId);
|
|
236
|
+
if (!sids || sids.length === 0) return;
|
|
237
|
+
const retained: string[] = [];
|
|
238
|
+
for (const sid of sids) {
|
|
239
|
+
const ok = await closeSessionOrRetain(
|
|
240
|
+
sid,
|
|
241
|
+
ctx,
|
|
242
|
+
{
|
|
243
|
+
fail: `Chorus: close failed for session ${sid.slice(0, 8)}… (will retry on shutdown)`,
|
|
244
|
+
success: `Chorus: closed session ${sid.slice(0, 8)}…`,
|
|
245
|
+
},
|
|
246
|
+
() => {},
|
|
247
|
+
);
|
|
248
|
+
if (!ok) retained.push(sid);
|
|
249
|
+
}
|
|
250
|
+
if (retained.length > 0) callSessions.set(toolCallId, retained);
|
|
251
|
+
else callSessions.delete(toolCallId);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
// ─── Extension ────────────────────────────────────────────────────────────
|
|
256
|
+
export default function (pi: ExtensionAPI) {
|
|
257
|
+
// SessionStart → checkin + build context (replaces Claude's on-session-start.sh)
|
|
258
|
+
// Emits a user-visible one-line banner (ctx.ui.notify) mirroring the Claude
|
|
259
|
+
// plugin's SessionStart `systemMessage` / the Codex `$chorus` toast (#442):
|
|
260
|
+
// connected + active -> "Chorus connected at <url> (OpenSpec Enabled)"
|
|
261
|
+
// connected + opt-out -> "Chorus connected at <url> (OpenSpec off)"
|
|
262
|
+
// connected + unset -> "Chorus connected at <url> (OpenSpec off — run /skill:chorus enable openspec to set it up)"
|
|
263
|
+
// not configured -> warning (env vars missing)
|
|
264
|
+
// connection failed -> error (checkin couldn't reach Chorus)
|
|
265
|
+
pi.on("session_start", async (event, ctx) => {
|
|
266
|
+
// Not configured — emit the warning banner and bail (no checkin to attempt).
|
|
267
|
+
if (!CONFIGURED) {
|
|
268
|
+
const banner = buildSessionBanner({
|
|
269
|
+
configured: false,
|
|
270
|
+
connected: false,
|
|
271
|
+
chorusUrl: CHORUS_URL,
|
|
272
|
+
openspec: { active: false, reason: "not configured", optout: false, hint: "" },
|
|
273
|
+
});
|
|
274
|
+
ctx.ui.notify(banner.message, banner.level);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
let connected = false;
|
|
278
|
+
try {
|
|
279
|
+
const checkin = await mcpCall("chorus_checkin");
|
|
280
|
+
connected = true;
|
|
281
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
282
|
+
const os = detectOpenSpec(
|
|
283
|
+
ctx.cwd,
|
|
284
|
+
OPENSPEC_OPTOUT,
|
|
285
|
+
require("node:fs"),
|
|
286
|
+
require("node:child_process").execSync,
|
|
287
|
+
);
|
|
288
|
+
checkinContext = [
|
|
289
|
+
"# Chorus Plugin — Active",
|
|
290
|
+
"",
|
|
291
|
+
`Chorus is connected at ${CHORUS_URL}. Session lifecycle hooks are enabled.`,
|
|
292
|
+
"",
|
|
293
|
+
"## Checkin",
|
|
294
|
+
"",
|
|
295
|
+
"```json",
|
|
296
|
+
JSON.stringify(checkin, null, 2),
|
|
297
|
+
"```",
|
|
298
|
+
"",
|
|
299
|
+
"## OpenSpec Mode",
|
|
300
|
+
"",
|
|
301
|
+
`CHORUS_OPENSPEC_ACTIVE=${os.active} (${os.reason})`,
|
|
302
|
+
os.active
|
|
303
|
+
? "OpenSpec mode is **active**. proposal/develop/yolo skills follow the openspec-aware path."
|
|
304
|
+
: os.optout
|
|
305
|
+
? "OpenSpec was **explicitly turned off** — do not nag."
|
|
306
|
+
: os.hint
|
|
307
|
+
? `Note: this repo has an \`openspec/\` directory but the \`openspec\` CLI is not installed — ${os.hint}. Run \`/skill:chorus enable openspec\` to set it up.`
|
|
308
|
+
: "OpenSpec is not set up in this repo. Spec-driven authoring is optional — free-form works fine. If the user wants spec-driven mode, run `/skill:chorus enable openspec` (§6 walks the install + re-launch).",
|
|
309
|
+
"",
|
|
310
|
+
"## Quick Reference",
|
|
311
|
+
"- **Sessions**: auto-managed. When you dispatch a WORKER via the `subagent` tool (single/parallel/chain), the extension creates a Chorus session per worker task and injects its UUID + the session workflow into that task automatically; the session is closed when the `subagent` tool call returns (children are ephemeral). Do NOT call chorus_create_session/close_session yourself.",
|
|
312
|
+
"- **Notifications**: chorus_get_notifications() fetches and auto-marks read.",
|
|
313
|
+
"- **Reviewer sub-agents**: after submit_proposal/submit_for_verify the extension nudges you to spawn chorus-proposal-reviewer / chorus-task-reviewer. Use the blocking `subagent` tool so it waits for the VERDICT; reviewers do NOT get a Chorus session.",
|
|
314
|
+
"- **Code-review gateway**: bounded by `CHORUS_MAX_CODE_REVIEW_ROUNDS` (current: " + (MAX_CODE_REVIEW_ROUNDS === 0 ? "unlimited" : String(MAX_CODE_REVIEW_ROUNDS)) + "; on FAIL, fix via /skill:quick-dev and re-run — after the limit, escalate the Idea's feature-level BLOCKERs to a human instead of shipping.",
|
|
315
|
+
(CHORUS_BIN
|
|
316
|
+
? "- **OpenSpec wrapper**: `bin/chorus-mcp-call.sh` is at `" + CHORUS_BIN + "` — the CLI-absent fallback for OpenSpec-mode document mirrors. Prefer `chorus mcp call <tool> '<json>' --arg-file content=<file>` (chorus >= 0.17.0); use this wrapper only when `chorus` is not on PATH (a bare `chorus-mcp-call.sh` will NOT be on PATH for local-path installs). See /skill:openspec-aware §2."
|
|
317
|
+
: "- **OpenSpec wrapper**: `bin/chorus-mcp-call.sh` was not resolved relative to the extension — it is the CLI-absent fallback for OpenSpec-mode document mirrors (prefer `chorus mcp call <tool> '<json>' --arg-file content=<file>`). If you need it, locate it with `find ~/.pi/agent/npm -path '*chorus-pi/bin/chorus-mcp-call.sh'`. See /skill:openspec-aware §2."),
|
|
318
|
+
"- **Skills**: /skill:chorus, /skill:idea, /skill:proposal, /skill:develop, /skill:review, /skill:quick-dev, /skill:yolo",
|
|
319
|
+
].join("\n");
|
|
320
|
+
const banner = buildSessionBanner({
|
|
321
|
+
configured: true,
|
|
322
|
+
connected: true,
|
|
323
|
+
chorusUrl: CHORUS_URL,
|
|
324
|
+
openspec: os,
|
|
325
|
+
});
|
|
326
|
+
ctx.ui.notify(banner.message, banner.level);
|
|
327
|
+
} catch (e) {
|
|
328
|
+
checkinContext = `# Chorus: connection failed (${CHORUS_URL})\n\n${(e as Error).message}`;
|
|
329
|
+
const banner = buildSessionBanner({
|
|
330
|
+
configured: true,
|
|
331
|
+
connected: false,
|
|
332
|
+
chorusUrl: CHORUS_URL,
|
|
333
|
+
openspec: { active: false, reason: "connection failed", optout: false, hint: "" },
|
|
334
|
+
});
|
|
335
|
+
ctx.ui.notify(banner.message, banner.level);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Inject checkin context once per session, before the first agent run
|
|
340
|
+
// (replaces Claude's additionalContext + the noisy UserPromptSubmit hook)
|
|
341
|
+
pi.on("before_agent_start", async () => {
|
|
342
|
+
if (injectedOnce || !checkinContext) return;
|
|
343
|
+
injectedOnce = true;
|
|
344
|
+
return {
|
|
345
|
+
message: { customType: "chorus", content: checkinContext, display: false },
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// tool_call (pre-execution, MUTABLE input) → for each WORKER task in the
|
|
350
|
+
// `subagent` invocation (single / parallel / chain), create a Chorus session
|
|
351
|
+
// and inject its UUID + the session workflow into that task. The ephemeral
|
|
352
|
+
// child pi subprocess spawned for that task receives the UUID in its prompt.
|
|
353
|
+
pi.on("tool_call", async (event, _ctx) => {
|
|
354
|
+
if (!CONFIGURED || event.toolName !== "subagent") return;
|
|
355
|
+
// Positive worker classification: only canonical worker agents get a Chorus
|
|
356
|
+
// session + task-lifecycle injection. The three Chorus reviewers are not
|
|
357
|
+
// workers (read-only), and the example scout/planner/reviewer agents are
|
|
358
|
+
// read-only too — injecting the session workflow into them adds irrelevant
|
|
359
|
+
// instructions and unnecessary chorus_create_session traffic. See isWorkerAgent().
|
|
360
|
+
const created: string[] = [];
|
|
361
|
+
for (const item of subagentTaskItems(event.input)) {
|
|
362
|
+
if (!isWorkerAgent(item.agent)) continue;
|
|
363
|
+
try {
|
|
364
|
+
const session = await mcpCall<{ uuid?: string }>("chorus_create_session", { name: item.agent });
|
|
365
|
+
if (!session?.uuid) continue;
|
|
366
|
+
created.push(session.uuid);
|
|
367
|
+
// Mutate the task in place — the ephemeral child receives the UUID.
|
|
368
|
+
item.setTask(item.task + sessionWorkflow(session.uuid));
|
|
369
|
+
} catch {
|
|
370
|
+
// Non-fatal: worker runs without observability (same as Codex fallback).
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (created.length > 0) callSessions.set(event.toolCallId, created);
|
|
374
|
+
});
|
|
375
|
+
// tool_result (fires first; has input + details + content as first-class fields)
|
|
376
|
+
// → PRIMARY handler that closes the ephemeral worker session(s) once a `subagent`
|
|
377
|
+
// tool call returns. The official subagent children are ephemeral (spawn → run
|
|
378
|
+
// → exit within one tool call), so the session lifecycle collapses to
|
|
379
|
+
// "create on tool_call start, close on tool_result".
|
|
380
|
+
// → Also fires reviewer nudges after the 3 chorus_* submit/verify tools.
|
|
381
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
382
|
+
if (!CONFIGURED) return;
|
|
383
|
+
|
|
384
|
+
// ── subagent tool finished → close the worker session(s) ───────────
|
|
385
|
+
// Close on success OR error: the sessions were created at tool_call start,
|
|
386
|
+
// so they must be closed either way. closeCallSessions is idempotent and
|
|
387
|
+
// retains any session whose close fails for a shutdown retry.
|
|
388
|
+
if (event.toolName === "subagent") {
|
|
389
|
+
await closeCallSessions(event.toolCallId, ctx);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Reviewer nudges only fire on a successful chorus_* call.
|
|
394
|
+
if (event.isError) return;
|
|
395
|
+
|
|
396
|
+
// ── Reviewer nudges (the 3 Claude PostToolUse hooks) ──────────────
|
|
397
|
+
// In MCP gateway mode event.toolName === "mcp" and the real chorus tool
|
|
398
|
+
// name is in event.input.tool. resolveChorusToolName handles both gateway
|
|
399
|
+
// and direct modes and returns the native name (e.g. "chorus_submit_for_verify").
|
|
400
|
+
const native = resolveChorusToolName(event);
|
|
401
|
+
if (native && (NUDGE_TOOL_NAMES as readonly string[]).includes(native)) {
|
|
402
|
+
const nudges: Record<string, { spawn: string; enabled: boolean }> = {
|
|
403
|
+
chorus_pm_submit_proposal: {
|
|
404
|
+
spawn: "spawn chorus-proposal-reviewer to review the proposal (blocking subagent tool), then close the agent",
|
|
405
|
+
enabled: ENABLE_PROPOSAL_REVIEWER,
|
|
406
|
+
},
|
|
407
|
+
chorus_submit_for_verify: {
|
|
408
|
+
spawn: "spawn chorus-task-reviewer to review the task (blocking subagent tool), then close the agent",
|
|
409
|
+
enabled: ENABLE_TASK_REVIEWER,
|
|
410
|
+
},
|
|
411
|
+
chorus_admin_verify_task: {
|
|
412
|
+
spawn: "if this was the last task of an idea-rooted proposal: spawn chorus-code-reviewer over the idea's aggregate change (blocking subagent tool), then remind to archive the openspec change",
|
|
413
|
+
enabled: ENABLE_CODE_REVIEWER,
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
const nudge = nudges[native];
|
|
417
|
+
if (nudge?.enabled) {
|
|
418
|
+
pi.sendUserMessage(nudge.spawn, { deliverAs: "steer" });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// tool_execution_end → idempotent FALLBACK close of the worker session(s).
|
|
424
|
+
// tool_result normally fires first and already closed (and deleted) them, so
|
|
425
|
+
// this is a no-op in the common case. It exists so that if tool_result did not
|
|
426
|
+
// fire — or its close failed and retained the session — the sessions are still
|
|
427
|
+
// closed (or retried) here rather than leaking until session_shutdown.
|
|
428
|
+
// NOTE: this event has NO `input` field (per pi ToolExecutionEndEvent type),
|
|
429
|
+
// so reviewer nudges (which need event.input to resolve the chorus tool name in
|
|
430
|
+
// MCP gateway mode) are handled in tool_result above, not here.
|
|
431
|
+
pi.on("tool_execution_end", async (event, ctx) => {
|
|
432
|
+
if (!CONFIGURED) return;
|
|
433
|
+
if (event.toolName === "subagent") {
|
|
434
|
+
await closeCallSessions(event.toolCallId, ctx);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// SessionEnd → close any stray worker sessions (replaces Claude's on-session-end.sh).
|
|
439
|
+
// Retries every session still tracked in callSessions (e.g. a subagent call whose
|
|
440
|
+
// close failed and was retained, or that never saw a tool_result/tool_execution_end).
|
|
441
|
+
pi.on("session_shutdown", async () => {
|
|
442
|
+
for (const sids of callSessions.values()) {
|
|
443
|
+
for (const sid of sids) {
|
|
444
|
+
await mcpCall("chorus_close_session", { sessionUuid: sid }).catch(() => {});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
callSessions.clear();
|
|
448
|
+
injectedOnce = false;
|
|
449
|
+
checkinContext = null;
|
|
450
|
+
mcpSessionId = null;
|
|
451
|
+
});
|
|
452
|
+
}
|