@fradser/pi-agent-teams 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.memory/team-centric-design.md +1 -1
- package/README.md +26 -10
- package/package.json +2 -2
- package/src/agents.ts +100 -6
- package/src/guidance.ts +28 -16
- package/src/index.ts +4 -24
- package/src/picker-keys.ts +30 -0
- package/src/state.ts +42 -5
- package/src/team-machine.ts +381 -51
- package/src/tools.ts +7 -2
- package/src/types.ts +23 -4
- package/src/ui.ts +158 -15
|
@@ -14,7 +14,7 @@ Exploratory / competitive-hypothesis work (multi-hypothesis debugging, three-way
|
|
|
14
14
|
2. **Unified messaging**: `send_message(to="leader", message, status?)` is a worker report written to the validated outbox; `send_message(to="<peer>", message)` writes the recipient inbox directly; leader-addressed teammate messages route through a running control stream or idle wake queue. `status` is invalid except for `to="leader"`. The first non-empty message line is the console title.
|
|
15
15
|
3. **Declarative role attributes**: agent frontmatter carries `model`, optional `verify`, and boolean `worktree`.Definitions live in one project directory with filename-based scopes: `<name>.local.md` is project-local (personal, never committed) and dedupes against its `<name>.md` counterpart (project scope, git-managed, team-shared); user-level sits in `~/.pi/agent/agents`; precedence is project-local > project > user > bundled and `resolveAgent` exposes scope + gitManaged for provenance. Spawn has no cwd/model/worktree override; create a role variant instead. `worktree: true` gives every instance of that role a dedicated Git worktree and diff capture at shutdown.
|
|
16
16
|
4. **Residents/wake-ups (constitution: detect and notify, never terminate)**: long-lived RPC children, 8-process session cap; wake-up sequences have NO turn-count or duration caps — no configuration may automatically terminate a working teammate. Turn counts and silence durations are telemetry only. The 500ms harness poll drains outboxes, applies intents, routes inboxes, wakes only for queued deliveries or a paced claimable-task notice, and runs the stall heartbeat: 30 minutes without any RPC output (`PI_TEAMMATE_STALL_NOTICE_MS`, 0 disables) sends the leader one actionable notice per silence episode — that notice is the LAST automatic action; continuing, steering, shutting down, or respawning a context-carrying successor (original kickoff + mailbox reports + board claims + liveText tail) belongs to the leader model alone. Any stream event or prompt delivery resets `lastOutputAt` and clears `stallNoticeSentAt`; steering a silent working teammate returns `stalledMs` so the send_message result warns delivery is uncertain.
|
|
17
|
-
5. **Board**: only leader writes `~/.pi/agent/tasks/<sessionKey>/board.json`; workers use exclusive-create claim/submission marker files. Task completion uses effective task verify or agent verify; failed verification returns
|
|
17
|
+
5. **Board**: only leader writes `~/.pi/agent/tasks/<sessionKey>/board.json`; workers use exclusive-create claim/submission marker files. Task completion uses effective task verify or agent verify; failed verification returns the reviewer's findings to the claimer. The board persists across restarts; runtime state does not.
|
|
18
18
|
6. **Reliability/UI**: per-spawn identity validation, event/message dedup, crash releases claimed tasks, role worktree diff at shutdown. Widget above the input box shows ONLY working/starting teammates as spinner rows with live activity and hides when nobody is working; idle/stopped teammates live only in the /teammate console roster; spawn prints a legacy-style `[agent] started · @name · task` transcript line; `/teammate` offers roster/board pages and message/task detail.
|
|
19
19
|
7. **Node-native TS**: relative imports use explicit `.ts` extensions (root tsconfig permits them), allowing node to strip-types modules in package tests.
|
|
20
20
|
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Claude-Code-style collaborative agent teams for Pi: named resident teammates, a
|
|
|
6
6
|
|
|
7
7
|
## What This Package Does
|
|
8
8
|
|
|
9
|
-
The team leader (your Pi session) spawns named teammates as long-lived child Pi processes in RPC mode. Each teammate has an isolated context, a peer inbox, and access to a shared task board. The harness—not the leader model—polls for activity: it drains leader reports, routes peer mail, applies task claims and submissions,
|
|
9
|
+
The team leader (your Pi session) spawns named teammates as long-lived child Pi processes in RPC mode. Each teammate has an isolated context, a peer inbox, and access to a shared task board. The harness—not the leader model—polls for activity: it drains leader reports, routes peer mail, applies task claims and submissions, judges verify prompts with fresh one-shot reviewers, and wakes idle teammates only when work or mail exists.
|
|
10
10
|
|
|
11
11
|
The message surface is deliberately singular: `send_message` is the only messaging primitive. Workers report with `to: "leader"`; teammates address peers by name; the leader addresses teammates by name. The routing implementation varies by destination (outbox, inbox, or control stream), but callers use one schema.
|
|
12
12
|
|
|
@@ -27,25 +27,39 @@ Agent definitions are Markdown files with frontmatter; the body is the role prom
|
|
|
27
27
|
name: isolated-security-auditor
|
|
28
28
|
description: Read-only security reviewer in an isolated worktree
|
|
29
29
|
tools: read,bash
|
|
30
|
-
model: provider/model # optional
|
|
31
|
-
verify:
|
|
30
|
+
model: provider/model # optional; "inherit" = the leader's current model at spawn time
|
|
31
|
+
verify: review prompt # optional role-default gate; a fresh reviewer answers VERDICT: PASS/FAIL
|
|
32
32
|
worktree: true # optional role-default Git isolation
|
|
33
33
|
---
|
|
34
34
|
Review the assigned scope for exploitable security problems. Do not edit files.
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
Teammate model resolution at spawn time, in precedence order: an explicit
|
|
38
|
+
`provider/model` pin wins; `inherit` pins the leader session's current model;
|
|
39
|
+
without a pin, the unified teammate model set from `/agent-teams` (press `m`
|
|
40
|
+
in the roster page for a type-to-filter picker) applies to this session;
|
|
41
|
+
otherwise Pi picks its own default.
|
|
42
|
+
|
|
43
|
+
Discovery precedence per name (later overrides earlier): user < project <
|
|
44
|
+
project-local, with generated session roles filling only the names no file
|
|
45
|
+
defines. A re-spawn that supplies an explicit inline definition replaces a
|
|
46
|
+
previously generated session role of the same name; definition files are never
|
|
47
|
+
overwritten by inline input.
|
|
38
48
|
|
|
39
49
|
There are no built-in roles. When a needed role has no definition, the
|
|
40
|
-
leader creates
|
|
41
|
-
the abstract role reference in `references/agent-roles.md`
|
|
42
|
-
anatomy, archetype axes, invariants),
|
|
50
|
+
leader creates it in memory for the current session: it derives the definition
|
|
51
|
+
from the task using the abstract role reference in `references/agent-roles.md`
|
|
52
|
+
(definition anatomy, archetype axes, invariants), registers it, and then spawns.
|
|
53
|
+
The role is not written to disk or reused in a later session unless the user
|
|
54
|
+
explicitly requests persistence; only then may the leader set `definition.persist`
|
|
55
|
+
and choose a project or project-local scope.
|
|
43
56
|
|
|
44
57
|
| Scope | Location | Git semantics |
|
|
45
58
|
|---|---|---|
|
|
46
59
|
| user | `~/.pi/agent/agents/*.md` | system-level, never committed |
|
|
47
60
|
| project | `<cwd>/.pi/agents/<name>.md` | **git-managed** — commit for the team |
|
|
48
61
|
| project-local | `<cwd>/.pi/agents/<name>.local.md` | **local** — personal override, gitignore by convention |
|
|
62
|
+
| session | in-memory registry | **ephemeral** — disappears at session start, no source file |
|
|
49
63
|
|
|
50
64
|
A teammate definition is just a Markdown file you own. Shared roles live in `.pi/agents/<name>.md`; a personal tweak to that exact role is `<name>.local.md` in the SAME directory — same teammate name, local scope wins, and discovery deduplicates the pair into one entry (never two). `resolveAgent` reports each definition's `scope` and `gitManaged`; guidance lists both so the leader knows provenance.
|
|
51
65
|
|
|
@@ -80,7 +94,7 @@ task_create({
|
|
|
80
94
|
subject: "Fix auth middleware findings",
|
|
81
95
|
description: "Address the confirmed security findings in packages/api/src/auth.ts",
|
|
82
96
|
dependsOn: ["t_1"],
|
|
83
|
-
verify: "
|
|
97
|
+
verify: "Every scenario in features/gallery.feature holds in the built gallery; no horizontal overflow at 400px."
|
|
84
98
|
})
|
|
85
99
|
```
|
|
86
100
|
|
|
@@ -96,7 +110,7 @@ send_message({ to, message, status? })
|
|
|
96
110
|
- The first non-empty line of `message` becomes the console title.
|
|
97
111
|
- Peer traffic never enters the leader model context; inspect it in `/agent-teams` instead.
|
|
98
112
|
|
|
99
|
-
Only the leader creates tasks. Idle teammates self-claim pending tasks whose dependencies are met. A task-level `verify`
|
|
113
|
+
Only the leader creates tasks. Idle teammates self-claim pending tasks whose dependencies are met. A task-level `verify` prompt overrides the claiming agent's frontmatter `verify`. The harness runs it as a fresh one-shot reviewer that inspects the work independently and answers `VERDICT: PASS` or `VERDICT: FAIL - <reasons>`; PASS completes the task, FAIL feeds the findings back to the claimer for fix-and-resubmit. Write acceptance criteria a reviewer can check, not shell commands.
|
|
100
114
|
|
|
101
115
|
## Tools
|
|
102
116
|
|
|
@@ -118,8 +132,10 @@ That is **7 unique tool names**. There are no `teammate_run`, `teammate_fanout`,
|
|
|
118
132
|
- **Per-spawn identity validation**: every leader report must match the teammate's current spawn id; stale callbacks and events cannot affect a replacement with the same name.
|
|
119
133
|
- **Sent means written**: peer `send_message` succeeds only after the recipient inbox write succeeds. The harness owns delivery into a recipient turn.
|
|
120
134
|
- **One writer per state file**: only the leader process writes runtime and board snapshots (atomic tmp+rename). Workers append leader reports to their outbox, peer mail to recipient inboxes, and task intent via exclusive-create marker files.
|
|
121
|
-
- **Completion is gated, not self-reported**: a task completes only after its effective verify gate passes when one exists; no gate means the submission itself completes it.
|
|
135
|
+
- **Completion is gated, not self-reported**: a task completes only after its effective verify gate passes when one exists; no gate means the submission itself completes it. A gate that keeps failing parks the task with its holder after the second consecutive failure and escalates to the leader once instead of looping.
|
|
122
136
|
- **No caps, heartbeat only**: teammates run without turn-count or duration ceilings. The harness heartbeat tracks silence per working teammate and — after 30 minutes without any RPC output (`PI_TEAMMATE_STALL_NOTICE_MS`, 0 disables) — sends the leader one actionable notice per silence episode. The notice is the last automatic action: continuing, steering, shutting down, or respawning a context-carrying successor belongs to the leader alone. Any output or prompt delivery re-arms it, and steering a silent teammate warns that delivery is uncertain.
|
|
137
|
+
- **One-shot board notices**: an idle teammate is told about a claimable task exactly once; declined tasks never re-wake it, and released tasks re-arm. Notices are paced at least five minutes apart per teammate (`PI_TEAMMATE_NOTICE_PACE_MS` overrides in milliseconds).
|
|
138
|
+
- **One end-of-life line per teammate**: the first terminal report of a spawn incarnation renders the finish entry; shutting that incarnation down afterwards adds no second event row.
|
|
123
139
|
- **Failure semantics**: an unexpected crash marks the teammate stopped, reports a diagnostic, and releases its claimed tasks.
|
|
124
140
|
|
|
125
141
|
## State and Sessions
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fradser/pi-agent-teams",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Multi-agent team system for Pi — resident teammates, shared task board, and peer-to-peer messaging",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -39,6 +39,6 @@
|
|
|
39
39
|
]
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@fradser/pi-kit": "0.
|
|
42
|
+
"@fradser/pi-kit": "0.4.0"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/agents.ts
CHANGED
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
* name — unique agent id (required)
|
|
14
14
|
* description — routing contract: when the leader should choose this agent
|
|
15
15
|
* tools — comma-separated list or YAML list of Pi tool ids
|
|
16
|
-
* model — optional provider/model pin
|
|
17
|
-
*
|
|
16
|
+
* model — optional provider/model pin, or "inherit" for the leader's
|
|
17
|
+
* current model at spawn time
|
|
18
|
+
* verify — optional role-default completion gate: a review prompt a
|
|
19
|
+
* fresh one-shot reviewer answers with VERDICT: PASS/FAIL
|
|
18
20
|
* worktree — optional role-default Git worktree isolation (true/false)
|
|
19
21
|
* The Markdown body is the worker's role prompt.
|
|
20
22
|
*/
|
|
@@ -24,7 +26,7 @@ import * as path from "node:path";
|
|
|
24
26
|
import { fileURLToPath } from "node:url";
|
|
25
27
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
26
28
|
|
|
27
|
-
export type AgentScope = "user" | "project" | "project-local";
|
|
29
|
+
export type AgentScope = "user" | "project" | "project-local" | "session";
|
|
28
30
|
|
|
29
31
|
export interface AgentDefinition {
|
|
30
32
|
name: string;
|
|
@@ -42,8 +44,97 @@ export interface AgentDefinition {
|
|
|
42
44
|
/** True only for the shared project scope that is expected to be git-managed.
|
|
43
45
|
* user and project-local definitions are machine/personal by definition. */
|
|
44
46
|
gitManaged: boolean;
|
|
45
|
-
/** Absolute path of the definition file. */
|
|
46
|
-
source
|
|
47
|
+
/** Absolute path of the definition file; absent for session-only roles. */
|
|
48
|
+
source?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AgentDefinitionInput {
|
|
52
|
+
name: string;
|
|
53
|
+
description: string;
|
|
54
|
+
tools: string[];
|
|
55
|
+
model?: string;
|
|
56
|
+
verify?: string;
|
|
57
|
+
worktree?: boolean;
|
|
58
|
+
prompt: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Reserved model value meaning "use the leader session's current model",
|
|
62
|
+
* resolved at spawn time (Claude-Code-style inherit). */
|
|
63
|
+
export const MODEL_INHERIT_ALIAS = "inherit";
|
|
64
|
+
|
|
65
|
+
const sessionAgents = new Map<string, AgentDefinition>();
|
|
66
|
+
const AGENT_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/i;
|
|
67
|
+
|
|
68
|
+
function normalizeAgentInput(input: AgentDefinitionInput): AgentDefinitionInput {
|
|
69
|
+
const name = input.name.trim();
|
|
70
|
+
if (!AGENT_NAME_PATTERN.test(name)) {
|
|
71
|
+
throw new Error(`Invalid agent name "${name}". Use letters, digits, dots, dashes, underscores.`);
|
|
72
|
+
}
|
|
73
|
+
const prompt = input.prompt.trim();
|
|
74
|
+
if (!prompt) throw new Error(`Agent "${name}" prompt must not be empty.`);
|
|
75
|
+
return {
|
|
76
|
+
...input,
|
|
77
|
+
name,
|
|
78
|
+
description: input.description.trim(),
|
|
79
|
+
tools: input.tools.map((tool) => tool.trim()).filter(Boolean),
|
|
80
|
+
model: input.model?.trim() || undefined,
|
|
81
|
+
verify: input.verify?.trim() || undefined,
|
|
82
|
+
worktree: input.worktree ?? false,
|
|
83
|
+
prompt,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Register a generated role for this session without writing a definition file. */
|
|
88
|
+
export function registerSessionAgent(input: AgentDefinitionInput): AgentDefinition {
|
|
89
|
+
const normalized = normalizeAgentInput(input);
|
|
90
|
+
const definition: AgentDefinition = {
|
|
91
|
+
...normalized,
|
|
92
|
+
worktree: normalized.worktree ?? false,
|
|
93
|
+
scope: "session",
|
|
94
|
+
gitManaged: false,
|
|
95
|
+
source: undefined,
|
|
96
|
+
};
|
|
97
|
+
sessionAgents.set(definition.name, definition);
|
|
98
|
+
return definition;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Persist a generated role only when the user explicitly requests it. */
|
|
102
|
+
export function persistAgentDefinition(
|
|
103
|
+
input: AgentDefinitionInput,
|
|
104
|
+
scope: "project" | "project-local",
|
|
105
|
+
cwd = process.cwd(),
|
|
106
|
+
): AgentDefinition {
|
|
107
|
+
const normalized = normalizeAgentInput(input);
|
|
108
|
+
const directory = agentsDir("project", cwd);
|
|
109
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
110
|
+
const filename = `${normalized.name}${scope === "project-local" ? LOCAL_DEFINITION_SUFFIX : ".md"}`;
|
|
111
|
+
const source = path.join(directory, filename);
|
|
112
|
+
const frontmatter = [
|
|
113
|
+
"---",
|
|
114
|
+
`name: ${normalized.name}`,
|
|
115
|
+
`description: ${normalized.description.replace(/[\\r\\n]+/g, " ")}`,
|
|
116
|
+
`tools: ${normalized.tools.join(", ")}`,
|
|
117
|
+
...(normalized.model ? [`model: ${normalized.model}`] : []),
|
|
118
|
+
...(normalized.verify ? [`verify: ${normalized.verify.replace(/[\\r\\n]+/g, " ")}`] : []),
|
|
119
|
+
`worktree: ${normalized.worktree ? "true" : "false"}`,
|
|
120
|
+
"---",
|
|
121
|
+
normalized.prompt,
|
|
122
|
+
"",
|
|
123
|
+
].join("\n");
|
|
124
|
+
fs.writeFileSync(source, frontmatter, { encoding: "utf8", mode: 0o600 });
|
|
125
|
+
sessionAgents.delete(normalized.name);
|
|
126
|
+
return {
|
|
127
|
+
...normalized,
|
|
128
|
+
worktree: normalized.worktree ?? false,
|
|
129
|
+
scope,
|
|
130
|
+
gitManaged: scope === "project",
|
|
131
|
+
source,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Drop generated session roles; persistent user/project definitions remain discoverable. */
|
|
136
|
+
export function clearSessionAgents(): void {
|
|
137
|
+
sessionAgents.clear();
|
|
47
138
|
}
|
|
48
139
|
|
|
49
140
|
/** Shipped role templates: reference material for generating new definitions
|
|
@@ -172,6 +263,9 @@ export function discoverAgents(cwd?: string): Map<string, AgentDefinition> {
|
|
|
172
263
|
const agents = new Map<string, AgentDefinition>();
|
|
173
264
|
loadDir("user", agentsDir("user", cwd), agents);
|
|
174
265
|
loadDir("project", agentsDir("project", cwd), agents);
|
|
266
|
+
// Generated roles fill gaps only: filesystem scopes keep precedence over
|
|
267
|
+
// session memory.
|
|
268
|
+
for (const [name, definition] of sessionAgents) if (!agents.has(name)) agents.set(name, definition);
|
|
175
269
|
return agents;
|
|
176
270
|
}
|
|
177
271
|
|
|
@@ -197,7 +291,7 @@ export function formatAgentGuidance(cwd?: string): string {
|
|
|
197
291
|
agent.model ? `Model: ${agent.model}` : "",
|
|
198
292
|
agent.verify ? `Verify: ${agent.verify}` : "",
|
|
199
293
|
agent.worktree ? "Worktree: true" : "",
|
|
200
|
-
agent.gitManaged ? "git-managed" : "local",
|
|
294
|
+
agent.scope === "session" ? "in-memory" : agent.gitManaged ? "git-managed" : "local",
|
|
201
295
|
].filter(Boolean);
|
|
202
296
|
lines.push(`- **${agent.name}** (${agent.scope})`);
|
|
203
297
|
if (agent.description) lines.push(` ${agent.description}`);
|
package/src/guidance.ts
CHANGED
|
@@ -21,8 +21,9 @@ you consume nothing.
|
|
|
21
21
|
- The shared task board is coordination state. Read it with task_list, claim
|
|
22
22
|
pending tasks whose dependencies are met with task_claim (claims are
|
|
23
23
|
atomic; losing a race means try another), and submit outcomes with
|
|
24
|
-
task_submit. Completion may pass through a verify gate:
|
|
25
|
-
|
|
24
|
+
task_submit. Completion may pass through a verify gate: a fresh reviewer
|
|
25
|
+
checks the work independently, and on VERDICT: FAIL its findings arrive in
|
|
26
|
+
your inbox — fix and resubmit.
|
|
26
27
|
- Coordinate file ownership with peers through send_message before writing.
|
|
27
28
|
|
|
28
29
|
Do not use leader tools (spawning or shutting down teammates, creating
|
|
@@ -39,11 +40,16 @@ delegation, synthesis, and the final user-facing answer. Teammates are named
|
|
|
39
40
|
resident child processes with isolated contexts; they do not see this
|
|
40
41
|
conversation unless you put the needed context in their prompts.
|
|
41
42
|
|
|
42
|
-
### Agents are declarative files,
|
|
43
|
+
### Agents are declarative files, with ephemeral generated roles by default
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
optional model
|
|
46
|
-
|
|
45
|
+
Persistent agents live in Markdown files with frontmatter (name, description,
|
|
46
|
+
tools, optional model — a provider/model pin or "inherit" for the leader's
|
|
47
|
+
current model at spawn time — optional verify, optional worktree); the body
|
|
48
|
+
is the role prompt. There are no built-in roles. Generated roles are session-scoped
|
|
49
|
+
and held in memory by default: they have no filesystem source and disappear on
|
|
50
|
+
the next session. Do not write
|
|
51
|
+
an agent definition unless the user explicitly asks to keep the role for future
|
|
52
|
+
sessions. Discovery precedence per name:
|
|
47
53
|
project-local \`<cwd>/.pi/agents/<name>.local.md\` > project
|
|
48
54
|
\`<cwd>/.pi/agents/<name>.md\` > user \`~/.pi/agent/agents\`. Same-name
|
|
49
55
|
project/project-local pairs deduplicate into one definition, with
|
|
@@ -55,14 +61,18 @@ to differ.
|
|
|
55
61
|
Available agents:
|
|
56
62
|
${agents}
|
|
57
63
|
|
|
64
|
+
Definitions are resolved live at spawn time: if a spawn says a listed role is
|
|
65
|
+
gone, its definition file changed mid-session — recreate it on demand instead
|
|
66
|
+
of retrying.
|
|
67
|
+
|
|
58
68
|
When an assignment or user request needs an agent whose name has no
|
|
59
|
-
definition yet, create it first: derive it from the shipped abstract
|
|
60
|
-
reference at \`${AGENT_REFERENCE_PATH}\` — its definition anatomy,
|
|
61
|
-
axes, and invariants (read it before inventing a novel role) — tailor
|
|
62
|
-
the task, and
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
definition yet, create it in memory first: derive it from the shipped abstract
|
|
70
|
+
role reference at \`${AGENT_REFERENCE_PATH}\` — its definition anatomy,
|
|
71
|
+
archetype axes, and invariants (read it before inventing a novel role) — tailor
|
|
72
|
+
it to the task, register the session role, and spawn immediately. Only after an
|
|
73
|
+
explicit request to keep the role for future sessions should you set
|
|
74
|
+
\`definition.persist=true\` (optionally choosing \`persistScope\`) so the spawn
|
|
75
|
+
writes \`<cwd>/.pi/agents/<name>.md\` or \`<name>.local.md\`.
|
|
66
76
|
|
|
67
77
|
### Build a team in one step per teammate
|
|
68
78
|
|
|
@@ -87,9 +97,11 @@ context — inspect it in /agent-teams instead.
|
|
|
87
97
|
|
|
88
98
|
Create shared work with task_create(subject, description?, dependsOn?,
|
|
89
99
|
verify?). Idle teammates notice claimable work automatically and self-claim;
|
|
90
|
-
dependencies unlock downstream tasks without your involvement.
|
|
91
|
-
|
|
92
|
-
feeds
|
|
100
|
+
dependencies unlock downstream tasks without your involvement. The verify
|
|
101
|
+
prompt is judged by a fresh one-shot reviewer that inspects the work itself:
|
|
102
|
+
VERDICT: PASS completes, FAIL feeds the reviewer's findings back to the
|
|
103
|
+
claimer for fix-and-resubmit. Write gates as acceptance criteria a reviewer
|
|
104
|
+
can check (behavior, constraints, evidence), not as shell commands.
|
|
93
105
|
|
|
94
106
|
### Teammates are autonomous: recover, never punish
|
|
95
107
|
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { buildTeamLeaderGuidance, WORKER_GUIDANCE } from "./guidance.ts";
|
|
11
|
-
import {
|
|
11
|
+
import { clearSessionAgents } from "./agents.ts";
|
|
12
|
+
import { initTeamMachine, markTeammateFinished, removeRuntimeDir, shutdownTeamMachine, teardownTeammates } from "./team-machine.ts";
|
|
12
13
|
import { cleanupExpiredStateDirs } from "./statefile.ts";
|
|
13
14
|
import { resetState } from "./state.ts";
|
|
14
15
|
import { ensureTeamWidget, refreshTeamUI, stopUiTimers } from "./ui.ts";
|
|
@@ -23,9 +24,6 @@ const STATE_DIR_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
23
24
|
let leaderPi: ExtensionAPI | undefined;
|
|
24
25
|
let leaderCtx: ExtensionContext | undefined;
|
|
25
26
|
let followUpQueue: FollowUpQueue | undefined;
|
|
26
|
-
// One "Teammate finished" announcement per spawn incarnation; repeated
|
|
27
|
-
// terminal reports from the same resident stay ordinary report rows.
|
|
28
|
-
const announcedFinishKeys = new Set<string>();
|
|
29
27
|
|
|
30
28
|
const REPORT_COLORS = ["success", "warning", "error", "mdLink"] as const;
|
|
31
29
|
export const TEAMMATE_FINISHED_ENTRY_TYPE = "agent-teams-teammate-finished";
|
|
@@ -91,7 +89,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
91
89
|
if (event.message.role !== "custom" || event.message.customType !== TEAMMATE_REPORT_MESSAGE_TYPE) return;
|
|
92
90
|
const reports = extractReports(event.message.details);
|
|
93
91
|
for (const report of reports) {
|
|
94
|
-
if (!markTeammateFinished(
|
|
92
|
+
if (!markTeammateFinished(report)) continue;
|
|
95
93
|
pi.appendEntry(TEAMMATE_FINISHED_ENTRY_TYPE, {
|
|
96
94
|
teammate: report.teammate ?? report.agent,
|
|
97
95
|
agent: report.agent,
|
|
@@ -100,9 +98,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
100
98
|
});
|
|
101
99
|
|
|
102
100
|
pi.on("session_start", async (_event, ctx) => {
|
|
101
|
+
clearSessionAgents();
|
|
103
102
|
resetState();
|
|
104
103
|
followUpQueue?.reset();
|
|
105
|
-
announcedFinishKeys.clear();
|
|
106
104
|
leaderCtx = ctx;
|
|
107
105
|
followUpQueue = new FollowUpQueue({
|
|
108
106
|
isIdle: () => Boolean(leaderCtx?.isIdle()),
|
|
@@ -162,21 +160,3 @@ function extractReports(details: unknown): FollowUpReport[] {
|
|
|
162
160
|
if (typed && "teammate" in typed) return [typed as FollowUpReport];
|
|
163
161
|
return [];
|
|
164
162
|
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Announce a finished entry only on the first terminal report of a spawn
|
|
168
|
-
* incarnation. Reports without a spawn identity key on the session scope so
|
|
169
|
-
* crash diagnostics always stay visible. Returns true when the caller should
|
|
170
|
-
* append the finished entry.
|
|
171
|
-
*/
|
|
172
|
-
export function markTeammateFinished(
|
|
173
|
-
seen: Set<string>,
|
|
174
|
-
report: Pick<FollowUpReport, "teammate" | "agent" | "spawnId" | "finished">,
|
|
175
|
-
): boolean {
|
|
176
|
-
if (!report.finished) return false;
|
|
177
|
-
const name = report.teammate ?? report.agent ?? "teammate";
|
|
178
|
-
const key = `${name}:${report.spawnId ?? "session"}`;
|
|
179
|
-
if (seen.has(key)) return false;
|
|
180
|
-
seen.add(key);
|
|
181
|
-
return true;
|
|
182
|
-
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keystroke routing for the teammate-model picker. Pure mapping from raw
|
|
3
|
+
* terminal input to picker actions so the collision-prone decision table is
|
|
4
|
+
* unit-testable without a TUI: every printable character (letters included)
|
|
5
|
+
* must route to typing, never to a shortcut.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
export type PickerKeyAction =
|
|
11
|
+
| { kind: "type"; text: string }
|
|
12
|
+
| { kind: "backspace" }
|
|
13
|
+
| { kind: "up" }
|
|
14
|
+
| { kind: "down" }
|
|
15
|
+
| { kind: "confirm" }
|
|
16
|
+
| { kind: "cancel" };
|
|
17
|
+
|
|
18
|
+
/** Map one raw input event to a picker action, or undefined when the event
|
|
19
|
+
* is not a picker key (multi-byte pastes, unhandled control sequences). */
|
|
20
|
+
export function mapPickerKey(data: string): PickerKeyAction | undefined {
|
|
21
|
+
if (matchesKey(data, Key.escape)) return { kind: "cancel" };
|
|
22
|
+
if (matchesKey(data, Key.enter)) return { kind: "confirm" };
|
|
23
|
+
if (matchesKey(data, Key.up)) return { kind: "up" };
|
|
24
|
+
if (matchesKey(data, Key.down)) return { kind: "down" };
|
|
25
|
+
if (matchesKey(data, Key.backspace) || matchesKey(data, Key.delete)) return { kind: "backspace" };
|
|
26
|
+
// Printable characters are always search text — letters like "c" must
|
|
27
|
+
// never double as shortcuts in a type-to-filter list.
|
|
28
|
+
if (data.length === 1 && data >= " ") return { kind: "type", text: data };
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type TeamState,
|
|
14
14
|
type WorkerReportEvent,
|
|
15
15
|
} from "./types.ts";
|
|
16
|
+
import { nonEmpty } from "@fradser/pi-kit";
|
|
16
17
|
|
|
17
18
|
export const MAX_LEADER_MAILBOX_MESSAGES = 4096;
|
|
18
19
|
/** FIFO cap of remembered peer message ids per inbox (dedup guard). */
|
|
@@ -22,10 +23,9 @@ export const MAX_TASK_DEPENDENCIES = 32;
|
|
|
22
23
|
function emptyState(): TeamState {
|
|
23
24
|
return {
|
|
24
25
|
teammates: {},
|
|
25
|
-
tasks:
|
|
26
|
+
tasks: emptyTaskMap(),
|
|
26
27
|
leaderMailbox: [],
|
|
27
28
|
messageCounter: 0,
|
|
28
|
-
taskCounter: 0,
|
|
29
29
|
workerEventOffsets: {},
|
|
30
30
|
workerEventIds: {},
|
|
31
31
|
peerInboxOffsets: {},
|
|
@@ -58,11 +58,30 @@ function nextMessageId(): string {
|
|
|
58
58
|
return `msg_${++state.messageCounter}`;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Task maps must not inherit Object.prototype: slug ids like "constructor"
|
|
62
|
+
* are legal task ids and must never alias inherited properties. */
|
|
63
|
+
function emptyTaskMap(): Record<string, BoardTask> {
|
|
64
|
+
return Object.create(null);
|
|
65
|
+
}
|
|
66
|
+
|
|
61
67
|
export function resetState(): void {
|
|
62
68
|
state = emptyState();
|
|
63
69
|
markStateDirty();
|
|
64
70
|
}
|
|
65
71
|
|
|
72
|
+
// ── Team default model ──────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/** The unified teammate model for this session, or undefined when Pi picks. */
|
|
75
|
+
export function getTeamDefaultModel(): string | undefined {
|
|
76
|
+
return state.defaultModel;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Set (or clear with undefined) the unified teammate model for later spawns. */
|
|
80
|
+
export function setTeamDefaultModel(ref: string | undefined): void {
|
|
81
|
+
state.defaultModel = nonEmpty(ref);
|
|
82
|
+
markStateDirty();
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
// ── Roster queries ────────────────────────────────────────────────
|
|
67
86
|
|
|
68
87
|
const NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/i;
|
|
@@ -204,6 +223,26 @@ export function setPeerInboxOffset(inboxName: string, offset: number): void {
|
|
|
204
223
|
|
|
205
224
|
// ── Board: creation and queries ───────────────────────────────────
|
|
206
225
|
|
|
226
|
+
/** Maximum characters in a generated slug task id (suffix included). */
|
|
227
|
+
const MAX_TASK_ID_LENGTH = 48;
|
|
228
|
+
|
|
229
|
+
/** A readable, filesystem-safe id derived from the subject:
|
|
230
|
+
* "Polish login flow" -> "polish-login-flow"; duplicates get -2, -3, ... */
|
|
231
|
+
export function taskIdFromSubject(subject: string, taken: ReadonlySet<string>): string {
|
|
232
|
+
const base = subject
|
|
233
|
+
.toLowerCase()
|
|
234
|
+
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
|
235
|
+
.replace(/^-+|-+$/g, "")
|
|
236
|
+
.slice(0, MAX_TASK_ID_LENGTH)
|
|
237
|
+
.replace(/-+$/g, "") || "task";
|
|
238
|
+
let id = base;
|
|
239
|
+
for (let n = 2; taken.has(id); n++) {
|
|
240
|
+
const suffix = `-${n}`;
|
|
241
|
+
id = base.slice(0, Math.max(1, MAX_TASK_ID_LENGTH - suffix.length)).replace(/-+$/g, "") + suffix;
|
|
242
|
+
}
|
|
243
|
+
return id;
|
|
244
|
+
}
|
|
245
|
+
|
|
207
246
|
export function createTask(input: {
|
|
208
247
|
subject: string;
|
|
209
248
|
description?: string;
|
|
@@ -219,7 +258,7 @@ export function createTask(input: {
|
|
|
219
258
|
for (const dep of dependsOn) {
|
|
220
259
|
if (!state.tasks[dep]) return { ok: false, error: `Task depends on unknown task "${dep}".` };
|
|
221
260
|
}
|
|
222
|
-
const id =
|
|
261
|
+
const id = taskIdFromSubject(subject, new Set(Object.keys(state.tasks)));
|
|
223
262
|
const task: BoardTask = {
|
|
224
263
|
id,
|
|
225
264
|
subject,
|
|
@@ -350,8 +389,6 @@ export function loadBoard(tasks: Record<string, BoardTask>): number {
|
|
|
350
389
|
updatedAt: Date.now(),
|
|
351
390
|
};
|
|
352
391
|
state.tasks[restored.id] = restored;
|
|
353
|
-
const numeric = Number(restored.id.slice(2));
|
|
354
|
-
if (Number.isFinite(numeric)) state.taskCounter = Math.max(state.taskCounter, numeric);
|
|
355
392
|
reloaded++;
|
|
356
393
|
}
|
|
357
394
|
if (reloaded > 0) markStateDirty();
|