@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.
@@ -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
+ }