@tmustier/pi-agent-teams 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/docs/claude-parity.md +7 -5
- package/docs/smoke-test-plan.md +20 -4
- package/eslint.config.js +74 -0
- package/extensions/teams/activity-tracker.ts +2 -2
- package/extensions/teams/leader-lifecycle-commands.ts +151 -14
- package/extensions/teams/leader-messaging-commands.ts +0 -1
- package/extensions/teams/leader-plan-commands.ts +1 -1
- package/extensions/teams/leader-spawn-command.ts +3 -19
- package/extensions/teams/leader-task-commands.ts +11 -7
- package/extensions/teams/leader-team-command.ts +329 -0
- package/extensions/teams/leader-teams-tool.ts +5 -16
- package/extensions/teams/leader.ts +34 -310
- package/extensions/teams/protocol.ts +20 -0
- package/extensions/teams/spawn-types.ts +21 -0
- package/extensions/teams/task-store.ts +4 -0
- package/extensions/teams/teammate-rpc.ts +26 -2
- package/extensions/teams/teams-panel.ts +41 -95
- package/extensions/teams/teams-ui-shared.ts +89 -0
- package/extensions/teams/teams-widget.ts +15 -68
- package/extensions/teams/worker.ts +1 -1
- package/package.json +9 -4
- package/scripts/integration-claim-test.mts +16 -86
- package/scripts/integration-todo-test.mts +14 -65
- package/scripts/lib/pi-workers.ts +105 -0
- package/skills/agent-teams/SKILL.md +8 -4
- package/.github/workflows/ci.yml +0 -32
- package/scripts/smoke-test.mjs +0 -199
package/README.md
CHANGED
|
@@ -79,6 +79,7 @@ Or drive it manually:
|
|
|
79
79
|
/tw # open the interactive widget panel
|
|
80
80
|
|
|
81
81
|
/team shutdown alice # graceful shutdown (handshake)
|
|
82
|
+
/team shutdown # stop all teammates (leader session remains active)
|
|
82
83
|
/team cleanup # remove team artifacts when done
|
|
83
84
|
```
|
|
84
85
|
|
|
@@ -105,6 +106,7 @@ Or let the model drive it with the delegate tool:
|
|
|
105
106
|
| --- | --- |
|
|
106
107
|
| `/swarm [task]` | Tell the agent to spawn a team and work on a task |
|
|
107
108
|
| `/tw` | Open the interactive widget panel |
|
|
109
|
+
| `/team-widget` | Open the interactive widget panel (alias for `/tw`) |
|
|
108
110
|
|
|
109
111
|
### Team management
|
|
110
112
|
|
|
@@ -122,7 +124,8 @@ All management commands live under `/team`.
|
|
|
122
124
|
| `/team broadcast <msg>` | Message all teammates |
|
|
123
125
|
| `/team stop <name> [reason]` | Abort current work (resets task to pending) |
|
|
124
126
|
| `/team shutdown <name> [reason]` | Graceful shutdown (handshake) |
|
|
125
|
-
| `/team shutdown` |
|
|
127
|
+
| `/team shutdown` | Stop all teammates (RPC + best-effort manual) (leader session remains active) |
|
|
128
|
+
| `/team prune [--all]` | Mark stale manual teammates offline (hides them in widget) |
|
|
126
129
|
| `/team kill <name>` | Force-terminate |
|
|
127
130
|
| `/team cleanup [--force]` | Delete team artifacts |
|
|
128
131
|
| `/team id` | Print team/task-list IDs and paths |
|
|
@@ -166,13 +169,22 @@ All management commands live under `/team`.
|
|
|
166
169
|
|
|
167
170
|
## Development
|
|
168
171
|
|
|
172
|
+
### Quality gate
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
npm run check
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Runs strict TypeScript typechecking (`npm run typecheck`) and ESLint (`npm run lint`).
|
|
179
|
+
|
|
169
180
|
### Smoke test (no API keys)
|
|
170
181
|
|
|
171
182
|
```bash
|
|
172
|
-
|
|
183
|
+
npm run smoke-test
|
|
184
|
+
# or: npx tsx scripts/smoke-test.mts
|
|
173
185
|
```
|
|
174
186
|
|
|
175
|
-
Filesystem-level test of the task store, mailbox, and
|
|
187
|
+
Filesystem-level smoke test of the task store, mailbox, team config, and protocol parsers.
|
|
176
188
|
|
|
177
189
|
### E2E RPC test (spawns pi + one teammate)
|
|
178
190
|
|
package/docs/claude-parity.md
CHANGED
|
@@ -40,11 +40,11 @@ Legend: ✅ implemented • 🟡 partial • ❌ missing
|
|
|
40
40
|
| Display modes | In-process selection (Shift+Up/Down); split panes (tmux/iTerm) | ❌ | Pi has a widget + commands, but no terminal-level comrade navigation/panes. | P2 |
|
|
41
41
|
| Delegate mode | Lead restricted to coordination-only tools | ✅ | `/team delegate [on|off]` toggles; `pi.on("tool_call")` blocks `bash/edit/write`; `PI_TEAMS_DELEGATE_MODE=1` env. Widget shows `[delegate]`. | P1 |
|
|
42
42
|
| Plan approval | Comrade can be "plan required" and needs lead approval to implement | ✅ | `/team spawn <name> plan` sets `PI_TEAMS_PLAN_REQUIRED=1`; worker restricted to read-only tools; submits plan via `plan_approval_request`; `/team plan approve|reject <name>`. | P1 |
|
|
43
|
-
| Shutdown handshake | Lead requests shutdown; comrade can approve/reject | ✅ | Full protocol: `shutdown_request` → `shutdown_approved` or `shutdown_rejected` (when worker is busy). `/team shutdown <name>` (graceful) + `/team kill
|
|
43
|
+
| Shutdown handshake | Lead requests shutdown; comrade can approve/reject | ✅ | Full protocol: `shutdown_request` → `shutdown_approved` or `shutdown_rejected` (when worker is busy). `/team shutdown <name>` (graceful) + `/team kill <name>` (force). | P1 |
|
|
44
44
|
| Cleanup team | “Clean up the team” removes shared resources after comrades stopped | ✅ | `/team cleanup [--force]` deletes only `<teamsRoot>/<teamId>` after safety checks. | P1 |
|
|
45
45
|
| Hooks / quality gates | `ComradeIdle`, `TaskCompleted` hooks | ❌ | Add optional hook runner in leader on idle/task-complete events (script execution + exit-code gating). | P2 |
|
|
46
46
|
| Task list UX | Ctrl+T toggle; show all/clear tasks by asking | 🟡 | Widget + `/team task list` show blocked/deps; `/team task show <id>`; `/team task clear [completed|all]`. No Ctrl+T toggle yet. | P0 |
|
|
47
|
-
| Shared task list across sessions | `CLAUDE_CODE_TASK_LIST_ID=...` | ✅ | `PI_TEAMS_TASK_LIST_ID` env
|
|
47
|
+
| Shared task list across sessions | `CLAUDE_CODE_TASK_LIST_ID=...` | ✅ | `PI_TEAMS_TASK_LIST_ID` env is **worker-side** (for manually started workers). The leader switches task lists via `/team task use <taskListId>` (persisted in `config.json`). Newly spawned workers inherit the new task list ID; existing workers need a restart to pick up changes. | P1 |
|
|
48
48
|
|
|
49
49
|
## Prioritized roadmap
|
|
50
50
|
|
|
@@ -66,7 +66,7 @@ Legend: ✅ implemented • 🟡 partial • ❌ missing
|
|
|
66
66
|
4) **Shutdown handshake** ✅
|
|
67
67
|
- Full protocol: `shutdown_request` → `shutdown_approved` / `shutdown_rejected`
|
|
68
68
|
- Worker rejects when busy (streaming + active task), auto-approves when idle
|
|
69
|
-
- Leader command: `/team shutdown <name> [reason...]` (graceful), `/team kill
|
|
69
|
+
- Leader command: `/team shutdown <name> [reason...]` (graceful), `/team kill <name>` as force
|
|
70
70
|
|
|
71
71
|
5) **Plan approval** ✅
|
|
72
72
|
- `/team spawn <name> [fresh|branch] [shared|worktree] plan` sets `PI_TEAMS_PLAN_REQUIRED=1`
|
|
@@ -109,7 +109,9 @@ Legend: ✅ implemented • 🟡 partial • ❌ missing
|
|
|
109
109
|
|
|
110
110
|
## Where changes would land (code map)
|
|
111
111
|
|
|
112
|
-
- Leader orchestration
|
|
112
|
+
- Leader orchestration: `extensions/teams/leader.ts`
|
|
113
|
+
- Leader `/team` command dispatch: `extensions/teams/leader-team-command.ts`
|
|
114
|
+
- Leader LLM tool (`teams`): `extensions/teams/leader-teams-tool.ts`
|
|
113
115
|
- Worker mailbox polling + self-claim + protocols: `extensions/teams/worker.ts`
|
|
114
116
|
- Task store + locking: `extensions/teams/task-store.ts`, `extensions/teams/fs-lock.ts`
|
|
115
117
|
- Mailbox store + locking: `extensions/teams/mailbox.ts`
|
|
@@ -120,5 +122,5 @@ Legend: ✅ implemented • 🟡 partial • ❌ missing
|
|
|
120
122
|
|
|
121
123
|
- Keep tests hermetic by setting `PI_TEAMS_ROOT_DIR` to a temp directory.
|
|
122
124
|
- Extend:
|
|
123
|
-
- `scripts/smoke-test.
|
|
125
|
+
- `scripts/smoke-test.mts` (run via `npm run smoke-test`) for filesystem-only behaviors (deps, claiming, locking)
|
|
124
126
|
- `scripts/e2e-rpc-test.mjs` for protocol flows (shutdown handshake, plan approval)
|
package/docs/smoke-test-plan.md
CHANGED
|
@@ -12,9 +12,10 @@ Exercises all core primitives directly via `tsx`:
|
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
npx tsx scripts/smoke-test.mts
|
|
15
|
+
# or: npm run smoke-test
|
|
15
16
|
```
|
|
16
17
|
|
|
17
|
-
**What it tests** (
|
|
18
|
+
**What it tests** (overview):
|
|
18
19
|
|
|
19
20
|
| Module | Coverage |
|
|
20
21
|
|------------------|-----------------------------------------------------------------|
|
|
@@ -24,10 +25,10 @@ npx tsx scripts/smoke-test.mts
|
|
|
24
25
|
| `task-store.ts` | CRUD, `startAssignedTask`, `completeTask`, `claimNextAvailable`,|
|
|
25
26
|
| | `unassignTasksForAgent`, dependencies, `clearTasks` |
|
|
26
27
|
| `team-config.ts` | `ensureTeamConfig` (idempotent), `upsertMember`, `setMemberStatus`, `loadTeamConfig` |
|
|
27
|
-
| `protocol.ts` |
|
|
28
|
-
| Pi CLI | `pi --version` executes
|
|
28
|
+
| `protocol.ts` | Structured message parsers (valid + invalid JSON + wrong type) |
|
|
29
|
+
| Pi CLI | `pi --version` executes (skipped in CI if `pi` not on PATH) |
|
|
29
30
|
|
|
30
|
-
**Expected result:** `PASSED:
|
|
31
|
+
**Expected result:** `PASSED: <n> FAILED: 0`
|
|
31
32
|
|
|
32
33
|
## 2. Extension Loading Test
|
|
33
34
|
|
|
@@ -103,6 +104,21 @@ Ask the model:
|
|
|
103
104
|
|
|
104
105
|
**Expected:** agent1 goes offline, widget updates.
|
|
105
106
|
|
|
107
|
+
Optional: stop all teammates without ending the leader session:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
/team shutdown
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Expected:** all teammates stop; leader remains active until you exit it (e.g. ctrl+d).
|
|
114
|
+
|
|
115
|
+
If old/manual teammates still show as idle (stale config entries), prune them:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
/team prune
|
|
119
|
+
# or: /team prune --all
|
|
120
|
+
```
|
|
121
|
+
|
|
106
122
|
## 4. Worker-side Smoke (verifying child process)
|
|
107
123
|
|
|
108
124
|
To test the worker role directly:
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import eslint from "@eslint/js";
|
|
3
|
+
import tseslint from "typescript-eslint";
|
|
4
|
+
|
|
5
|
+
export default tseslint.config(
|
|
6
|
+
{
|
|
7
|
+
ignores: [
|
|
8
|
+
"node_modules/**",
|
|
9
|
+
"dist/**",
|
|
10
|
+
"build/**",
|
|
11
|
+
"coverage/**",
|
|
12
|
+
".artifacts/**",
|
|
13
|
+
".research/**",
|
|
14
|
+
".resume-sessions/**",
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
eslint.configs.recommended,
|
|
19
|
+
...tseslint.configs.recommended,
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
files: ["extensions/**/*.ts", "scripts/**/*.ts", "scripts/**/*.mts"],
|
|
23
|
+
rules: {
|
|
24
|
+
// ━━ Project invariants (AGENTS.md) ━━
|
|
25
|
+
"@typescript-eslint/no-explicit-any": "error",
|
|
26
|
+
"@typescript-eslint/no-non-null-assertion": "error",
|
|
27
|
+
"@typescript-eslint/ban-ts-comment": [
|
|
28
|
+
"error",
|
|
29
|
+
{
|
|
30
|
+
"ts-ignore": true,
|
|
31
|
+
"ts-expect-error": true,
|
|
32
|
+
"ts-nocheck": true,
|
|
33
|
+
"ts-check": false,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
// Imports/types
|
|
38
|
+
"@typescript-eslint/consistent-type-imports": [
|
|
39
|
+
"error",
|
|
40
|
+
{ prefer: "type-imports", disallowTypeAnnotations: false },
|
|
41
|
+
],
|
|
42
|
+
|
|
43
|
+
// General correctness
|
|
44
|
+
eqeqeq: ["error", "always"],
|
|
45
|
+
|
|
46
|
+
// Prefer TS-aware unused-vars
|
|
47
|
+
"no-unused-vars": "off",
|
|
48
|
+
"@typescript-eslint/no-unused-vars": [
|
|
49
|
+
"warn",
|
|
50
|
+
{
|
|
51
|
+
argsIgnorePattern: "^_",
|
|
52
|
+
varsIgnorePattern: "^_",
|
|
53
|
+
caughtErrorsIgnorePattern: "^_",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
// Scripts/tests: console is expected
|
|
60
|
+
{
|
|
61
|
+
files: ["scripts/**/*.ts", "scripts/**/*.mts"],
|
|
62
|
+
rules: {
|
|
63
|
+
"no-console": "off",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
// Extension source: discourage stray console.log
|
|
68
|
+
{
|
|
69
|
+
files: ["extensions/**/*.ts"],
|
|
70
|
+
rules: {
|
|
71
|
+
"no-console": "warn",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
);
|
|
@@ -68,7 +68,7 @@ export class TranscriptTracker {
|
|
|
68
68
|
if (ev.type === "tool_execution_end") {
|
|
69
69
|
const starts = this.toolStarts.get(name);
|
|
70
70
|
const startTs = starts?.get(ev.toolCallId);
|
|
71
|
-
const durationMs = startTs
|
|
71
|
+
const durationMs = startTs === undefined ? 0 : now - startTs;
|
|
72
72
|
starts?.delete(ev.toolCallId);
|
|
73
73
|
log.push({ kind: "tool_end", toolName: ev.toolName, durationMs, timestamp: now });
|
|
74
74
|
return;
|
|
@@ -135,7 +135,7 @@ export class TranscriptTracker {
|
|
|
135
135
|
// Flush all complete lines, keep the last (potentially incomplete) part
|
|
136
136
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
137
137
|
const part = parts[i];
|
|
138
|
-
if (part
|
|
138
|
+
if (part === undefined) continue;
|
|
139
139
|
const trimmed = part.trimEnd();
|
|
140
140
|
if (trimmed) log.push({ kind: "text", text: trimmed, timestamp });
|
|
141
141
|
}
|
|
@@ -6,7 +6,7 @@ import { sanitizeName } from "./names.js";
|
|
|
6
6
|
import { getTeamDir, getTeamsRootDir } from "./paths.js";
|
|
7
7
|
import { TEAM_MAILBOX_NS } from "./protocol.js";
|
|
8
8
|
import { unassignTasksForAgent, type TeamTask } from "./task-store.js";
|
|
9
|
-
import { setMemberStatus, setTeamStyle } from "./team-config.js";
|
|
9
|
+
import { setMemberStatus, setTeamStyle, type TeamConfig } from "./team-config.js";
|
|
10
10
|
import { TEAMS_STYLES, type TeamsStyle, getTeamsStrings, formatMemberDisplayName } from "./teams-style.js";
|
|
11
11
|
import type { TeammateRpc } from "./teammate-rpc.js";
|
|
12
12
|
|
|
@@ -141,11 +141,16 @@ export async function handleTeamShutdownCommand(opts: {
|
|
|
141
141
|
ctx: ExtensionCommandContext;
|
|
142
142
|
rest: string[];
|
|
143
143
|
teammates: Map<string, TeammateRpc>;
|
|
144
|
+
getTeamConfig: () => TeamConfig | null;
|
|
144
145
|
leadName: string;
|
|
145
146
|
style: TeamsStyle;
|
|
146
147
|
getCurrentCtx: () => ExtensionContext | null;
|
|
148
|
+
stopAllTeammates: (ctx: ExtensionContext, reason: string) => Promise<void>;
|
|
149
|
+
refreshTasks: () => Promise<void>;
|
|
150
|
+
getTasks: () => TeamTask[];
|
|
151
|
+
renderWidget: () => void;
|
|
147
152
|
}): Promise<void> {
|
|
148
|
-
const { ctx, rest, teammates, leadName, style, getCurrentCtx } = opts;
|
|
153
|
+
const { ctx, rest, teammates, getTeamConfig, leadName, style, getCurrentCtx, stopAllTeammates, refreshTasks, getTasks, renderWidget } = opts;
|
|
149
154
|
const strings = getTeamsStrings(style);
|
|
150
155
|
const nameRaw = rest[0];
|
|
151
156
|
|
|
@@ -216,21 +221,154 @@ export async function handleTeamShutdownCommand(opts: {
|
|
|
216
221
|
return;
|
|
217
222
|
}
|
|
218
223
|
|
|
219
|
-
// /team shutdown (no args) =
|
|
220
|
-
|
|
221
|
-
|
|
224
|
+
// /team shutdown (no args) = stop all teammates but keep the leader session alive
|
|
225
|
+
await refreshTasks();
|
|
226
|
+
const cfgBefore = getTeamConfig();
|
|
227
|
+
const cfgWorkersOnline = (cfgBefore?.members ?? []).filter((m) => m.role === "worker" && m.status === "online");
|
|
228
|
+
|
|
229
|
+
const activeNames = new Set<string>();
|
|
230
|
+
for (const name of teammates.keys()) activeNames.add(name);
|
|
231
|
+
for (const m of cfgWorkersOnline) activeNames.add(m.name);
|
|
232
|
+
|
|
233
|
+
if (activeNames.size === 0) {
|
|
234
|
+
ctx.ui.notify(`No ${strings.memberTitle.toLowerCase()}s to shut down`, "info");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
222
238
|
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
223
239
|
const msg =
|
|
224
240
|
style === "soviet"
|
|
225
|
-
? `
|
|
226
|
-
:
|
|
227
|
-
const ok = await ctx.ui.confirm("Shutdown", msg);
|
|
241
|
+
? `Dismiss all ${strings.memberTitle.toLowerCase()}s from the ${strings.teamNoun}?`
|
|
242
|
+
: `Stop all ${String(activeNames.size)} teammate${activeNames.size === 1 ? "" : "s"}?`;
|
|
243
|
+
const ok = await ctx.ui.confirm("Shutdown team", msg);
|
|
228
244
|
if (!ok) return;
|
|
229
245
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
246
|
+
|
|
247
|
+
const reason =
|
|
248
|
+
style === "soviet"
|
|
249
|
+
? `The ${strings.teamNoun} is dissolved by the chairman`
|
|
250
|
+
: "Stopped by /team shutdown";
|
|
251
|
+
// Stop RPC teammates we own
|
|
252
|
+
await stopAllTeammates(ctx, reason);
|
|
253
|
+
|
|
254
|
+
// Best-effort: ask *manual* workers (persisted in config.json) to shut down too.
|
|
255
|
+
// Also mark them offline so they stop cluttering the UI if they were left behind from old runs.
|
|
256
|
+
await refreshTasks();
|
|
257
|
+
const cfg = getTeamConfig();
|
|
258
|
+
const teamId = ctx.sessionManager.getSessionId();
|
|
259
|
+
const teamDir = getTeamDir(teamId);
|
|
260
|
+
|
|
261
|
+
const inProgressOwners = new Set<string>();
|
|
262
|
+
for (const t of getTasks()) {
|
|
263
|
+
if (t.owner && t.status === "in_progress") inProgressOwners.add(t.owner);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const manualWorkers = (cfg?.members ?? []).filter((m) => m.role === "worker" && m.status === "online");
|
|
267
|
+
for (const m of manualWorkers) {
|
|
268
|
+
// If it's an RPC teammate we already stopped above, skip mailbox request.
|
|
269
|
+
if (teammates.has(m.name)) continue;
|
|
270
|
+
// If a manual worker still owns an in-progress task, don't force it offline in the UI.
|
|
271
|
+
if (inProgressOwners.has(m.name)) continue;
|
|
272
|
+
|
|
273
|
+
const requestId = randomUUID();
|
|
274
|
+
const ts = new Date().toISOString();
|
|
275
|
+
try {
|
|
276
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, m.name, {
|
|
277
|
+
from: leadName,
|
|
278
|
+
text: JSON.stringify({
|
|
279
|
+
type: "shutdown_request",
|
|
280
|
+
requestId,
|
|
281
|
+
from: leadName,
|
|
282
|
+
timestamp: ts,
|
|
283
|
+
reason,
|
|
284
|
+
}),
|
|
285
|
+
timestamp: ts,
|
|
286
|
+
});
|
|
287
|
+
} catch {
|
|
288
|
+
// ignore mailbox errors
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
void setMemberStatus(teamDir, m.name, "offline", {
|
|
292
|
+
meta: { shutdownRequestedAt: ts, shutdownRequestId: requestId, stoppedReason: reason },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
renderWidget();
|
|
297
|
+
ctx.ui.notify(
|
|
298
|
+
`Team ended: all ${strings.memberTitle.toLowerCase()}s stopped (leader session remains active)`,
|
|
299
|
+
"info",
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function handleTeamPruneCommand(opts: {
|
|
304
|
+
ctx: ExtensionCommandContext;
|
|
305
|
+
rest: string[];
|
|
306
|
+
teammates: Map<string, TeammateRpc>;
|
|
307
|
+
getTeamConfig: () => TeamConfig | null;
|
|
308
|
+
refreshTasks: () => Promise<void>;
|
|
309
|
+
getTasks: () => TeamTask[];
|
|
310
|
+
style: TeamsStyle;
|
|
311
|
+
renderWidget: () => void;
|
|
312
|
+
}): Promise<void> {
|
|
313
|
+
const { ctx, rest, teammates, getTeamConfig, refreshTasks, getTasks, style, renderWidget } = opts;
|
|
314
|
+
const strings = getTeamsStrings(style);
|
|
315
|
+
|
|
316
|
+
const flags = rest.filter((a) => a.startsWith("--"));
|
|
317
|
+
const argsOnly = rest.filter((a) => !a.startsWith("--"));
|
|
318
|
+
const all = flags.includes("--all");
|
|
319
|
+
const unknownFlags = flags.filter((f) => f !== "--all");
|
|
320
|
+
if (unknownFlags.length) {
|
|
321
|
+
ctx.ui.notify(`Unknown flag(s): ${unknownFlags.join(", ")}`, "error");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (argsOnly.length) {
|
|
325
|
+
ctx.ui.notify("Usage: /team prune [--all]", "error");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
await refreshTasks();
|
|
330
|
+
const cfg = getTeamConfig();
|
|
331
|
+
const members = (cfg?.members ?? []).filter((m) => m.role === "worker");
|
|
332
|
+
if (!members.length) {
|
|
333
|
+
ctx.ui.notify(`No ${strings.memberTitle.toLowerCase()}s to prune`, "info");
|
|
334
|
+
renderWidget();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const inProgressOwners = new Set<string>();
|
|
339
|
+
for (const t of getTasks()) {
|
|
340
|
+
if (t.owner && t.status === "in_progress") inProgressOwners.add(t.owner);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const cutoffMs = 60 * 60 * 1000; // 1h
|
|
344
|
+
const now = Date.now();
|
|
345
|
+
|
|
346
|
+
const pruned: string[] = [];
|
|
347
|
+
for (const m of members) {
|
|
348
|
+
if (teammates.has(m.name)) continue; // still tracked as RPC
|
|
349
|
+
if (inProgressOwners.has(m.name)) continue; // still actively working
|
|
350
|
+
if (!all) {
|
|
351
|
+
const lastSeen = m.lastSeenAt ? Date.parse(m.lastSeenAt) : NaN;
|
|
352
|
+
if (!Number.isFinite(lastSeen)) continue;
|
|
353
|
+
if (now - lastSeen < cutoffMs) continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const teamId = ctx.sessionManager.getSessionId();
|
|
357
|
+
const teamDir = getTeamDir(teamId);
|
|
358
|
+
await setMemberStatus(teamDir, m.name, "offline", {
|
|
359
|
+
meta: { prunedAt: new Date().toISOString(), prunedBy: "leader" },
|
|
360
|
+
});
|
|
361
|
+
pruned.push(m.name);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
await refreshTasks();
|
|
365
|
+
renderWidget();
|
|
366
|
+
ctx.ui.notify(
|
|
367
|
+
pruned.length
|
|
368
|
+
? `Pruned ${pruned.length} stale ${strings.memberTitle.toLowerCase()}(s): ${pruned.join(", ")}`
|
|
369
|
+
: `No stale ${strings.memberTitle.toLowerCase()}s to prune (use --all to force)`,
|
|
370
|
+
"info",
|
|
371
|
+
);
|
|
234
372
|
}
|
|
235
373
|
|
|
236
374
|
export async function handleTeamStopCommand(opts: {
|
|
@@ -244,7 +382,6 @@ export async function handleTeamStopCommand(opts: {
|
|
|
244
382
|
renderWidget: () => void;
|
|
245
383
|
}): Promise<void> {
|
|
246
384
|
const { ctx, rest, teammates, leadName, style, refreshTasks, getTasks, renderWidget } = opts;
|
|
247
|
-
const strings = getTeamsStrings(style);
|
|
248
385
|
|
|
249
386
|
const nameRaw = rest[0];
|
|
250
387
|
const reason = rest.slice(1).join(" ").trim();
|
|
@@ -300,7 +437,7 @@ export async function handleTeamKillCommand(opts: {
|
|
|
300
437
|
refreshTasks: () => Promise<void>;
|
|
301
438
|
renderWidget: () => void;
|
|
302
439
|
}): Promise<void> {
|
|
303
|
-
const { ctx, rest, teammates, taskListId, leadName, style, refreshTasks, renderWidget } = opts;
|
|
440
|
+
const { ctx, rest, teammates, taskListId, leadName: _leadName, style, refreshTasks, renderWidget } = opts;
|
|
304
441
|
const strings = getTeamsStrings(style);
|
|
305
442
|
|
|
306
443
|
const nameRaw = rest[0];
|
|
@@ -71,7 +71,6 @@ export async function handleTeamDmCommand(opts: {
|
|
|
71
71
|
style: TeamsStyle;
|
|
72
72
|
}): Promise<void> {
|
|
73
73
|
const { ctx, rest, leadName, style } = opts;
|
|
74
|
-
const strings = getTeamsStrings(style);
|
|
75
74
|
|
|
76
75
|
const nameRaw = rest[0];
|
|
77
76
|
const msg = rest.slice(1).join(" ").trim();
|
|
@@ -4,7 +4,7 @@ import { sanitizeName } from "./names.js";
|
|
|
4
4
|
import { getTeamDir } from "./paths.js";
|
|
5
5
|
import { TEAM_MAILBOX_NS } from "./protocol.js";
|
|
6
6
|
import type { TeamsStyle } from "./teams-style.js";
|
|
7
|
-
import { formatMemberDisplayName
|
|
7
|
+
import { formatMemberDisplayName } from "./teams-style.js";
|
|
8
8
|
|
|
9
9
|
export async function handleTeamPlanCommand(opts: {
|
|
10
10
|
ctx: ExtensionCommandContext;
|
|
@@ -1,32 +1,16 @@
|
|
|
1
|
-
import type { ExtensionCommandContext
|
|
1
|
+
import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import { pickComradeNames } from "./names.js";
|
|
3
3
|
import type { TeammateRpc } from "./teammate-rpc.js";
|
|
4
4
|
import type { TeamsStyle } from "./teams-style.js";
|
|
5
5
|
import { formatMemberDisplayName, getTeamsStrings, isSovietStyle } from "./teams-style.js";
|
|
6
|
-
|
|
7
|
-
export type ContextMode = "fresh" | "branch";
|
|
8
|
-
export type WorkspaceMode = "shared" | "worktree";
|
|
9
|
-
|
|
10
|
-
export type SpawnTeammateResult =
|
|
11
|
-
| {
|
|
12
|
-
ok: true;
|
|
13
|
-
name: string;
|
|
14
|
-
mode: ContextMode;
|
|
15
|
-
workspaceMode: WorkspaceMode;
|
|
16
|
-
note?: string;
|
|
17
|
-
warnings: string[];
|
|
18
|
-
}
|
|
19
|
-
| { ok: false; error: string };
|
|
6
|
+
import type { ContextMode, WorkspaceMode, SpawnTeammateFn } from "./spawn-types.js";
|
|
20
7
|
|
|
21
8
|
export async function handleTeamSpawnCommand(opts: {
|
|
22
9
|
ctx: ExtensionCommandContext;
|
|
23
10
|
rest: string[];
|
|
24
11
|
teammates: Map<string, TeammateRpc>;
|
|
25
12
|
style: TeamsStyle;
|
|
26
|
-
spawnTeammate:
|
|
27
|
-
ctx: ExtensionContext,
|
|
28
|
-
opts: { name: string; mode?: ContextMode; workspaceMode?: WorkspaceMode; planRequired?: boolean },
|
|
29
|
-
) => Promise<SpawnTeammateResult>;
|
|
13
|
+
spawnTeammate: SpawnTeammateFn;
|
|
30
14
|
}): Promise<void> {
|
|
31
15
|
const { ctx, rest, teammates, style, spawnTeammate } = opts;
|
|
32
16
|
const strings = getTeamsStrings(style);
|
|
@@ -3,7 +3,7 @@ import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
|
3
3
|
import { writeToMailbox } from "./mailbox.js";
|
|
4
4
|
import { sanitizeName } from "./names.js";
|
|
5
5
|
import { getTeamDir } from "./paths.js";
|
|
6
|
-
import {
|
|
6
|
+
import { taskAssignmentPayload } from "./protocol.js";
|
|
7
7
|
import {
|
|
8
8
|
addTaskDependency,
|
|
9
9
|
clearTasks,
|
|
@@ -17,7 +17,16 @@ import {
|
|
|
17
17
|
} from "./task-store.js";
|
|
18
18
|
import { ensureTeamConfig } from "./team-config.js";
|
|
19
19
|
import type { TeamsStyle } from "./teams-style.js";
|
|
20
|
-
import { formatMemberDisplayName
|
|
20
|
+
import { formatMemberDisplayName } from "./teams-style.js";
|
|
21
|
+
|
|
22
|
+
function parseAssigneePrefix(text: string): { assignee?: string; text: string } {
|
|
23
|
+
const m = text.match(/^([a-zA-Z0-9_-]+):\s*(.+)$/);
|
|
24
|
+
if (!m) return { text };
|
|
25
|
+
const assignee = m[1];
|
|
26
|
+
const rest = m[2];
|
|
27
|
+
if (!assignee || !rest) return { text };
|
|
28
|
+
return { assignee, text: rest };
|
|
29
|
+
}
|
|
21
30
|
|
|
22
31
|
export async function handleTeamTaskCommand(opts: {
|
|
23
32
|
ctx: ExtensionCommandContext;
|
|
@@ -29,8 +38,6 @@ export async function handleTeamTaskCommand(opts: {
|
|
|
29
38
|
getTasks: () => TeamTask[];
|
|
30
39
|
refreshTasks: () => Promise<void>;
|
|
31
40
|
renderWidget: () => void;
|
|
32
|
-
parseAssigneePrefix: (text: string) => { assignee?: string; text: string };
|
|
33
|
-
taskAssignmentPayload: (task: TeamTask, assignedBy: string) => unknown;
|
|
34
41
|
}): Promise<void> {
|
|
35
42
|
const {
|
|
36
43
|
ctx,
|
|
@@ -42,10 +49,7 @@ export async function handleTeamTaskCommand(opts: {
|
|
|
42
49
|
getTasks,
|
|
43
50
|
refreshTasks,
|
|
44
51
|
renderWidget,
|
|
45
|
-
parseAssigneePrefix,
|
|
46
|
-
taskAssignmentPayload,
|
|
47
52
|
} = opts;
|
|
48
|
-
const strings = getTeamsStrings(style);
|
|
49
53
|
|
|
50
54
|
const [taskSub, ...taskRest] = rest;
|
|
51
55
|
const teamId = ctx.sessionManager.getSessionId();
|