@sztlink/pi-ensemble 0.1.0-alpha.12

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,38 @@
1
+ # Claude Agent Teams lead prompt for pi-ensemble
2
+
3
+ Use this as a starting prompt for a Claude Code lead session that will coordinate native Agent Teams while mirroring durable milestones into `pi-ensemble`.
4
+
5
+ ```txt
6
+ You are the Claude Code lead for a pi-ensemble coordinated task.
7
+
8
+ Project root: <PROJECT_ROOT>
9
+ Your ensemble agent name: claude-lead
10
+ Sender to report back to: <pi|human|other-agent>
11
+
12
+ Protocol:
13
+ 1. `cd <PROJECT_ROOT>`
14
+ 2. Run `ensemble status`.
15
+ 3. Read new inbox items: `ensemble inbox --agent claude-lead --since-last-read`.
16
+ 4. Ack the handoff id: `ensemble ack msg_xxx --from claude-lead --body "<task summary>"`.
17
+ 5. Claim paths before any edits: `ensemble claim <path> --agent claude-lead`.
18
+ 6. If useful, create a Claude Code Agent Team internally. Use teammates only for independent work.
19
+ 7. Mirror only durable milestones into pi-ensemble:
20
+ - accepted task frame;
21
+ - claims/releases;
22
+ - durable findings;
23
+ - blocker/question;
24
+ - result pointer;
25
+ - final handoff.
26
+ 8. Do not mirror internal teammate chatter.
27
+ 9. When complete, send: `ensemble send <SENDER> "Result: <summary + exact paths/URLs>" --from claude-lead --type result`.
28
+ 10. Release claims.
29
+
30
+ Quality bar:
31
+ - exact file paths;
32
+ - no hidden state required to understand outcome;
33
+ - do not edit paths you did not claim;
34
+ - if Agent Teams state breaks, leave enough in pi-ensemble for Pi/human to resume.
35
+
36
+ Task:
37
+ <TASK>
38
+ ```
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Example tmux wake adapter for pi-ensemble.
5
+ # Core state stays in .pi-ensemble/. This script only writes messages via the
6
+ # CLI and optionally pastes shell-safe wake prompts into tmux panes.
7
+
8
+ CONFIG="${ENSEMBLE_TMUX_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/pi-ensemble/tmux.env}"
9
+ if [[ -f "$CONFIG" ]]; then
10
+ # shellcheck source=/dev/null
11
+ source "$CONFIG"
12
+ fi
13
+
14
+ ROOT="${ENSEMBLE_ROOT:-$(pwd)}"
15
+ FROM="${ENSEMBLE_FROM:-${ENSEMBLE_DEFAULT_FROM:-manual}}"
16
+
17
+ usage() {
18
+ cat <<'HELP'
19
+ ensemble-tmux — example tmux wake adapter for pi-ensemble
20
+
21
+ Commands:
22
+ ensemble-tmux status
23
+ ensemble-tmux panes
24
+ ensemble-tmux wake AGENT [--message TEXT] [--pane TARGET]
25
+ ensemble-tmux send AGENT MESSAGE [--from NAME] [--type note|handoff|question|result|ack] [--no-wake] [--pane TARGET]
26
+ ensemble-tmux note MESSAGE [--from NAME]
27
+
28
+ Config:
29
+ $XDG_CONFIG_HOME/pi-ensemble/tmux.env or $HOME/.config/pi-ensemble/tmux.env
30
+ ENSEMBLE_ROOT=/path/to/workspace
31
+ ENSEMBLE_TMUX_<AGENT>_PANE='session:window.pane'
32
+
33
+ Example:
34
+ ENSEMBLE_TMUX_CLAUDE_LEAD_PANE='myproject:1.1'
35
+ HELP
36
+ }
37
+
38
+ agent_pane() {
39
+ local agent="$1"
40
+ local upper
41
+ upper="$(printf '%s' "$agent" | tr '[:lower:]-' '[:upper:]_')"
42
+ local var="ENSEMBLE_TMUX_${upper}_PANE"
43
+ printf '%s' "${!var:-}"
44
+ }
45
+
46
+ send_keys() {
47
+ local pane="$1"
48
+ local text="$2"
49
+ printf '%s' "$text" | tmux load-buffer -
50
+ tmux paste-buffer -t "$pane"
51
+ tmux send-keys -t "$pane" Enter
52
+ }
53
+
54
+ wake_agent() {
55
+ local agent="$1"; shift || true
56
+ local pane=""
57
+ local message=""
58
+ while [[ $# -gt 0 ]]; do
59
+ case "$1" in
60
+ --pane) pane="${2:-}"; shift 2 ;;
61
+ --message) message="${2:-}"; shift 2 ;;
62
+ *) message="$*"; break ;;
63
+ esac
64
+ done
65
+
66
+ pane="${pane:-$(agent_pane "$agent")}"
67
+ [[ -n "$pane" ]] || { echo "No tmux pane configured for agent '$agent'" >&2; exit 2; }
68
+ tmux has-session -t "$pane" 2>/dev/null || { echo "tmux target not found: $pane" >&2; exit 1; }
69
+
70
+ # Shell-safe: if this lands in a bash pane, it is just a comment.
71
+ # Agent-safe: if this lands in a Pi/Claude prompt, it is readable markdown.
72
+ local prompt="# pi-ensemble: new inbox item. Run from any cwd: PI_ENSEMBLE_ROOT=$ROOT ensemble inbox --agent $agent --since-last-read"
73
+ if [[ -n "$message" ]]; then
74
+ prompt="$prompt — $message"
75
+ fi
76
+ send_keys "$pane" "$prompt"
77
+ echo "woke $agent ($pane)"
78
+ }
79
+
80
+ cmd="${1:-help}"
81
+ shift || true
82
+ case "$cmd" in
83
+ status)
84
+ echo "ROOT=$ROOT"
85
+ echo "CONFIG=$CONFIG"
86
+ (cd "$ROOT" && ensemble status)
87
+ echo
88
+ echo "tmux panes:"
89
+ tmux list-panes -a -F '#{session_name}:#{window_index}.#{pane_index} #{pane_current_command} #{pane_current_path} #{pane_title}' 2>/dev/null || true
90
+ ;;
91
+ panes)
92
+ tmux list-panes -a -F '#{session_name}:#{window_index}.#{pane_index} #{pane_current_command} #{pane_current_path} #{pane_title}'
93
+ ;;
94
+ wake)
95
+ [[ $# -ge 1 ]] || { echo "usage: ensemble-tmux wake AGENT [--message TEXT] [--pane TARGET]" >&2; exit 2; }
96
+ agent="$1"; shift
97
+ wake_agent "$agent" "$@"
98
+ ;;
99
+ send)
100
+ [[ $# -ge 2 ]] || { echo "usage: ensemble-tmux send AGENT MESSAGE [--from NAME] [--type TYPE] [--no-wake] [--pane TARGET]" >&2; exit 2; }
101
+ agent="$1"; shift
102
+ type="handoff"
103
+ no_wake=0
104
+ pane=""
105
+ args=()
106
+ while [[ $# -gt 0 ]]; do
107
+ case "$1" in
108
+ --from) FROM="${2:-}"; shift 2 ;;
109
+ --type) type="${2:-}"; shift 2 ;;
110
+ --no-wake) no_wake=1; shift ;;
111
+ --pane) pane="${2:-}"; shift 2 ;;
112
+ *) args+=("$1"); shift ;;
113
+ esac
114
+ done
115
+ message="${args[*]}"
116
+ [[ -n "$message" ]] || { echo "message is required" >&2; exit 2; }
117
+ (cd "$ROOT" && ensemble send "$agent" "$message" --from "$FROM" --type "$type")
118
+ if [[ "$no_wake" != 1 ]]; then
119
+ wake_agent "$agent" --pane "$pane" --message "from $FROM [$type]"
120
+ fi
121
+ ;;
122
+ note)
123
+ [[ $# -ge 1 ]] || { echo "usage: ensemble-tmux note MESSAGE [--from NAME]" >&2; exit 2; }
124
+ args=()
125
+ while [[ $# -gt 0 ]]; do
126
+ case "$1" in
127
+ --from) FROM="${2:-}"; shift 2 ;;
128
+ *) args+=("$1"); shift ;;
129
+ esac
130
+ done
131
+ (cd "$ROOT" && ensemble note "${args[*]}" --from "$FROM")
132
+ ;;
133
+ help|--help|-h|*)
134
+ usage
135
+ ;;
136
+ esac
@@ -0,0 +1,212 @@
1
+ import { StringEnum } from "@mariozechner/pi-ai";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import {
5
+ ack,
6
+ claim,
7
+ claims,
8
+ defaultAgent,
9
+ doctor,
10
+ done,
11
+ init,
12
+ messages,
13
+ note,
14
+ overview,
15
+ readAudit,
16
+ readBoard,
17
+ readInbox,
18
+ release,
19
+ requireWorkspaceRoot,
20
+ send,
21
+ status,
22
+ timeline,
23
+ } from "../lib/core.mjs";
24
+
25
+ const MessageType = StringEnum(["note", "handoff", "question", "result", "ack"] as const);
26
+ const ActionType = StringEnum(["init", "status", "note", "send", "ack", "done", "messages", "inbox", "board", "claims", "audit", "timeline", "overview", "doctor", "claim", "release"] as const);
27
+
28
+ function parseArgs(input: string): string[] {
29
+ const out: string[] = [];
30
+ const re = /"([^"]*)"|'([^']*)'|\S+/g;
31
+ let m: RegExpExecArray | null;
32
+ while ((m = re.exec(input))) out.push(m[1] ?? m[2] ?? m[0]);
33
+ return out;
34
+ }
35
+
36
+ function asText(value: unknown): string {
37
+ return typeof value === "string" ? value : JSON.stringify(value, null, 2);
38
+ }
39
+
40
+ function takeFlag(argv: string[], name: string, fallback?: string): string | undefined {
41
+ const i = argv.indexOf(name);
42
+ if (i === -1) return fallback;
43
+ const value = argv[i + 1];
44
+ argv.splice(i, 2);
45
+ return value ?? fallback;
46
+ }
47
+
48
+ function rootFromCwd(ctx: { cwd: string }, explicitRoot?: string) {
49
+ return requireWorkspaceRoot(explicitRoot || process.env.PI_ENSEMBLE_ROOT || ctx.cwd);
50
+ }
51
+
52
+ export default function (pi: ExtensionAPI) {
53
+ pi.registerCommand("ensemble", {
54
+ description: "Local blackboard/mailbox for parallel coding agents",
55
+ handler: async (args, ctx) => {
56
+ const argv = parseArgs(args || "");
57
+ let explicitRoot = takeFlag(argv, "--root", undefined);
58
+ const cmd = argv.shift() || "status";
59
+ if (!explicitRoot) explicitRoot = takeFlag(argv, "--root", undefined);
60
+ try {
61
+ if (cmd === "init") {
62
+ const agent = argv[0] || defaultAgent();
63
+ const r = init(explicitRoot || process.env.PI_ENSEMBLE_ROOT || ctx.cwd, { agent });
64
+ ctx.ui.notify(`pi-ensemble initialized: ${r.dir}`, "success");
65
+ return;
66
+ }
67
+ if (cmd === "status") {
68
+ ctx.ui.notify(asText(status(rootFromCwd(ctx, explicitRoot))), "info");
69
+ return;
70
+ }
71
+ if (cmd === "note") {
72
+ note(rootFromCwd(ctx, explicitRoot), { from: defaultAgent(), body: argv.join(" ") });
73
+ ctx.ui.notify("pi-ensemble note added", "success");
74
+ return;
75
+ }
76
+ if (cmd === "send") {
77
+ const type = takeFlag(argv, "--type", "handoff") as "note" | "handoff" | "question" | "result" | "ack";
78
+ const to = argv.shift();
79
+ const result = send(rootFromCwd(ctx, explicitRoot), { from: defaultAgent(), to, type, body: argv.join(" ") });
80
+ ctx.ui.notify(`pi-ensemble sent to ${to}: ${result.messageId}`, "success");
81
+ return;
82
+ }
83
+ if (cmd === "ack") {
84
+ const from = takeFlag(argv, "--from", defaultAgent()) || defaultAgent();
85
+ const body = takeFlag(argv, "--body", "") || "";
86
+ const messageId = argv.shift();
87
+ const result = ack(rootFromCwd(ctx, explicitRoot), { from, messageId, body: body || argv.join(" ") });
88
+ ctx.ui.notify(`pi-ensemble acked ${result.messageId}`, "success");
89
+ return;
90
+ }
91
+ if (cmd === "done") {
92
+ const from = takeFlag(argv, "--from", defaultAgent()) || defaultAgent();
93
+ const body = takeFlag(argv, "--body", "") || "";
94
+ const messageId = argv.shift();
95
+ const result = done(rootFromCwd(ctx, explicitRoot), { from, messageId, body: body || argv.join(" ") });
96
+ ctx.ui.notify(`pi-ensemble resolved ${result.messageId}`, "success");
97
+ return;
98
+ }
99
+ if (cmd === "messages") {
100
+ const limit = Number(takeFlag(argv, "--limit", "50"));
101
+ const open = argv.includes("--open");
102
+ ctx.ui.notify(asText(messages(rootFromCwd(ctx, explicitRoot), { limit: Number.isFinite(limit) ? limit : 50, open })), "info");
103
+ return;
104
+ }
105
+ if (cmd === "inbox") {
106
+ const sinceLastRead = argv.includes("--since-last-read");
107
+ const noClear = argv.includes("--no-clear") || sinceLastRead;
108
+ const agent = argv.find(arg => !arg.startsWith("--")) || defaultAgent();
109
+ const content = readInbox(rootFromCwd(ctx, explicitRoot), { agent, clear: !noClear, sinceLastRead });
110
+ ctx.ui.notify(content || "Inbox empty", "info");
111
+ return;
112
+ }
113
+ if (cmd === "board") {
114
+ ctx.ui.notify(readBoard(rootFromCwd(ctx, explicitRoot)), "info");
115
+ return;
116
+ }
117
+ if (cmd === "claims") {
118
+ ctx.ui.notify(asText(claims(rootFromCwd(ctx, explicitRoot))), "info");
119
+ return;
120
+ }
121
+ if (cmd === "audit") {
122
+ const limit = Number(takeFlag(argv, "--limit", "50"));
123
+ ctx.ui.notify(asText(readAudit(rootFromCwd(ctx, explicitRoot), { limit: Number.isFinite(limit) ? limit : 50 })), "info");
124
+ return;
125
+ }
126
+ if (cmd === "timeline") {
127
+ const limit = Number(takeFlag(argv, "--limit", "50"));
128
+ ctx.ui.notify(asText(timeline(rootFromCwd(ctx, explicitRoot), { limit: Number.isFinite(limit) ? limit : 50 })), "info");
129
+ return;
130
+ }
131
+ if (cmd === "overview") {
132
+ const limit = Number(takeFlag(argv, "--limit", "10"));
133
+ ctx.ui.notify(asText(overview(rootFromCwd(ctx, explicitRoot), { limit: Number.isFinite(limit) ? limit : 10 })), "info");
134
+ return;
135
+ }
136
+ if (cmd === "doctor") {
137
+ ctx.ui.notify(asText(doctor(rootFromCwd(ctx, explicitRoot))), "info");
138
+ return;
139
+ }
140
+ if (cmd === "claim") {
141
+ claim(rootFromCwd(ctx, explicitRoot), { agent: defaultAgent(), targetPath: argv.join(" ") });
142
+ ctx.ui.notify("pi-ensemble path claimed", "success");
143
+ return;
144
+ }
145
+ if (cmd === "release") {
146
+ release(rootFromCwd(ctx, explicitRoot), { agent: defaultAgent(), targetPath: argv.join(" ") });
147
+ ctx.ui.notify("pi-ensemble path released", "success");
148
+ return;
149
+ }
150
+ ctx.ui.notify("Usage: /ensemble init|status|note|send|ack|done|messages|inbox|board|claims|audit|timeline|overview|doctor|claim|release", "warning");
151
+ } catch (err) {
152
+ ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
153
+ }
154
+ },
155
+ });
156
+
157
+ pi.registerTool({
158
+ name: "ensemble",
159
+ label: "Ensemble",
160
+ description: "Read/write local .pi-ensemble blackboard, inboxes, and worktree claims. File-only: no network, no process spawning, no code execution.",
161
+ promptSnippet: "Coordinate with local coding agents via .pi-ensemble blackboard and inbox files",
162
+ promptGuidelines: [
163
+ "Use ensemble only for local coding-agent coordination inside a repository that has .pi-ensemble initialized.",
164
+ "Never put credentials, tokens, cookies, or private secrets into ensemble messages.",
165
+ "ensemble does not spawn agents, run commands, access the network, or wake remote sessions.",
166
+ ],
167
+ parameters: Type.Object({
168
+ action: ActionType,
169
+ agent: Type.Optional(Type.String({ description: "Current/local agent name" })),
170
+ to: Type.Optional(Type.String({ description: "Target agent for send" })),
171
+ type: Type.Optional(MessageType),
172
+ body: Type.Optional(Type.String({ description: "Message body" })),
173
+ messageId: Type.Optional(Type.String({ description: "Message id for ack/done" })),
174
+ path: Type.Optional(Type.String({ description: "Path to claim or release" })),
175
+ clear: Type.Optional(Type.Boolean({ description: "Clear inbox after reading", default: true })),
176
+ sinceLastRead: Type.Optional(Type.Boolean({ description: "Return only messages newer than this agent's last read timestamp", default: false })),
177
+ force: Type.Optional(Type.Boolean({ description: "Override claim ownership conflicts", default: false })),
178
+ limit: Type.Optional(Type.Number({ description: "Maximum audit/message records to return", default: 50 })),
179
+ open: Type.Optional(Type.Boolean({ description: "For messages: return only messages not marked done", default: false })),
180
+ root: Type.Optional(Type.String({ description: "Workspace root or descendant containing .pi-ensemble" })),
181
+ }),
182
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
183
+ try {
184
+ const agent = params.agent || defaultAgent();
185
+ if (params.action === "init") {
186
+ const result = init(params.root || process.env.PI_ENSEMBLE_ROOT || ctx.cwd, { agent });
187
+ return { content: [{ type: "text", text: `Initialized ${result.dir}` }], details: result };
188
+ }
189
+ const root = rootFromCwd(ctx, params.root);
190
+ let result: unknown;
191
+ if (params.action === "status") result = status(root);
192
+ else if (params.action === "note") result = note(root, { from: agent, body: params.body || "" });
193
+ else if (params.action === "send") result = send(root, { from: agent, to: params.to, type: params.type || "handoff", body: params.body || "" });
194
+ else if (params.action === "ack") result = ack(root, { from: agent, messageId: params.messageId, body: params.body || "" });
195
+ else if (params.action === "done") result = done(root, { from: agent, messageId: params.messageId, body: params.body || "" });
196
+ else if (params.action === "messages") result = messages(root, { limit: params.limit ?? 50, open: params.open === true });
197
+ else if (params.action === "inbox") result = readInbox(root, { agent, clear: params.sinceLastRead === true ? false : params.clear !== false, sinceLastRead: params.sinceLastRead === true });
198
+ else if (params.action === "board") result = readBoard(root);
199
+ else if (params.action === "claims") result = claims(root);
200
+ else if (params.action === "audit") result = readAudit(root, { limit: params.limit ?? 50 });
201
+ else if (params.action === "timeline") result = timeline(root, { limit: params.limit ?? 50 });
202
+ else if (params.action === "overview") result = overview(root, { limit: params.limit ?? 10 });
203
+ else if (params.action === "doctor") result = doctor(root);
204
+ else if (params.action === "claim") result = claim(root, { agent, targetPath: params.path, force: params.force === true });
205
+ else if (params.action === "release") result = release(root, { agent, targetPath: params.path, force: params.force === true });
206
+ return { content: [{ type: "text", text: asText(result) }], details: { result } };
207
+ } catch (err) {
208
+ return { content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], isError: true };
209
+ }
210
+ },
211
+ });
212
+ }