@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.
@@ -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 stderr to the claimer. The board persists across restarts; runtime state does not.
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, runs deterministic verify gates, and wakes idle teammates only when work or mail exists.
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: npm test # optional role-default completion gate
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
- Discovery precedence per name (later overrides earlier):
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 one on demand: it derives the definition from the task using
41
- the abstract role reference in `references/agent-roles.md` (definition
42
- anatomy, archetype axes, invariants), writes the file, and then spawns.
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: "npm test"
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` command overrides the claiming agent's frontmatter `verify`; zero exit completes the task, while failure returns stderr to the claimer for fix-and-resubmit.
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.7.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.3.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
- * verify — optional role-default completion gate command (zero exit passes)
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: string;
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: if it fails, stderr
25
- feedback arrives in your inbox — fix and resubmit.
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, created on demand
43
+ ### Agents are declarative files, with ephemeral generated roles by default
43
44
 
44
- Agents live in Markdown files with frontmatter (name, description, tools,
45
- optional model, optional verify, optional worktree); the body is the role
46
- prompt. There are no built-in roles. Discovery precedence per name:
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 role
60
- reference at \`${AGENT_REFERENCE_PATH}\` — its definition anatomy, archetype
61
- axes, and invariants (read it before inventing a novel role) — tailor it to
62
- the task, and write
63
- the definition to \`<cwd>/.pi/agents/<name>.md\` for a git-managed project
64
- role or \`<name>.local.md\` for a personal override; discovery picks it up
65
- immediately and spawning can proceed.
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. An explicit
91
- verify command makes completion deterministic: zero exit completes, failure
92
- feeds stderr back to the claimer for fix-and-resubmit.
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 { initTeamMachine, removeRuntimeDir, shutdownTeamMachine, teardownTeammates } from "./team-machine.ts";
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(announcedFinishKeys, report)) continue;
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 = `t_${++state.taskCounter}`;
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();