@tmustier/pi-agent-teams 0.4.0-beta.0 → 0.4.0-beta.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 +50 -2
- package/docs/claude-parity.md +44 -39
- package/extensions/teams/hooks.ts +258 -0
- package/extensions/teams/leader-inbox.ts +42 -3
- package/extensions/teams/leader-lifecycle-commands.ts +18 -23
- package/extensions/teams/leader-spawn-command.ts +89 -7
- package/extensions/teams/leader-team-command.ts +1 -1
- package/extensions/teams/leader-teams-tool.ts +22 -0
- package/extensions/teams/leader.ts +150 -6
- package/extensions/teams/paths.ts +10 -0
- package/extensions/teams/spawn-types.ts +19 -4
- package/extensions/teams/teams-panel.ts +4 -4
- package/extensions/teams/teams-style.ts +50 -1
- package/package.json +1 -1
- package/scripts/integration-todo-test.mts +3 -2
- package/scripts/smoke-test.mts +64 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
|
1
2
|
import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
2
3
|
import { pickAgentNames, pickNamesFromPool } from "./names.js";
|
|
3
4
|
import type { TeammateRpc } from "./teammate-rpc.js";
|
|
@@ -5,6 +6,20 @@ import type { TeamsStyle } from "./teams-style.js";
|
|
|
5
6
|
import { formatMemberDisplayName, getTeamsNamingRules, getTeamsStrings } from "./teams-style.js";
|
|
6
7
|
import type { ContextMode, WorkspaceMode, SpawnTeammateFn } from "./spawn-types.js";
|
|
7
8
|
|
|
9
|
+
function isThinkingLevel(v: string): v is ThinkingLevel {
|
|
10
|
+
switch (v) {
|
|
11
|
+
case "off":
|
|
12
|
+
case "minimal":
|
|
13
|
+
case "low":
|
|
14
|
+
case "medium":
|
|
15
|
+
case "high":
|
|
16
|
+
case "xhigh":
|
|
17
|
+
return true;
|
|
18
|
+
default:
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
8
23
|
export async function handleTeamSpawnCommand(opts: {
|
|
9
24
|
ctx: ExtensionCommandContext;
|
|
10
25
|
rest: string[];
|
|
@@ -20,18 +35,85 @@ export async function handleTeamSpawnCommand(opts: {
|
|
|
20
35
|
let mode: ContextMode = "fresh";
|
|
21
36
|
let workspaceMode: WorkspaceMode = "shared";
|
|
22
37
|
let planRequired = false;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
let model: string | undefined;
|
|
39
|
+
let thinking: ThinkingLevel | undefined;
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < rest.length; i++) {
|
|
42
|
+
const a = rest[i];
|
|
43
|
+
if (!a) continue;
|
|
44
|
+
|
|
45
|
+
if (a === "fresh" || a === "branch") {
|
|
46
|
+
mode = a;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (a === "shared" || a === "worktree") {
|
|
50
|
+
workspaceMode = a;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (a === "plan") {
|
|
54
|
+
planRequired = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (a === "--model") {
|
|
59
|
+
const next = rest[i + 1];
|
|
60
|
+
if (!next) {
|
|
61
|
+
ctx.ui.notify("Usage: /team spawn <name> [fresh|branch] [shared|worktree] [plan] [--model <provider>/<modelId>] [--thinking <level>]", "error");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
model = next;
|
|
65
|
+
i++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (a.startsWith("--model=")) {
|
|
69
|
+
model = a.slice("--model=".length);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (a === "--thinking") {
|
|
74
|
+
const next = rest[i + 1];
|
|
75
|
+
if (!next) {
|
|
76
|
+
ctx.ui.notify("Usage: /team spawn <name> [fresh|branch] [shared|worktree] [plan] [--model <provider>/<modelId>] [--thinking <level>]", "error");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!isThinkingLevel(next)) {
|
|
80
|
+
ctx.ui.notify(
|
|
81
|
+
`Invalid thinking level '${next}'. Valid values: off, minimal, low, medium, high, xhigh`,
|
|
82
|
+
"error",
|
|
83
|
+
);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
thinking = next;
|
|
87
|
+
i++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (a.startsWith("--thinking=")) {
|
|
91
|
+
const next = a.slice("--thinking=".length);
|
|
92
|
+
if (!isThinkingLevel(next)) {
|
|
93
|
+
ctx.ui.notify(
|
|
94
|
+
`Invalid thinking level '${next}'. Valid values: off, minimal, low, medium, high, xhigh`,
|
|
95
|
+
"error",
|
|
96
|
+
);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
thinking = next;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!nameRaw && !a.startsWith("--")) nameRaw = a;
|
|
28
104
|
}
|
|
29
105
|
|
|
106
|
+
model = model?.trim();
|
|
107
|
+
if (model === "") model = undefined;
|
|
108
|
+
|
|
30
109
|
// Auto-pick a name when the current style allows it.
|
|
31
110
|
if (!nameRaw) {
|
|
32
111
|
const naming = getTeamsNamingRules(style);
|
|
33
112
|
if (naming.requireExplicitSpawnName) {
|
|
34
|
-
ctx.ui.notify(
|
|
113
|
+
ctx.ui.notify(
|
|
114
|
+
"Usage: /team spawn <name> [fresh|branch] [shared|worktree] [plan] [--model <provider>/<modelId>] [--thinking <level>]",
|
|
115
|
+
"error",
|
|
116
|
+
);
|
|
35
117
|
return;
|
|
36
118
|
}
|
|
37
119
|
|
|
@@ -52,7 +134,7 @@ export async function handleTeamSpawnCommand(opts: {
|
|
|
52
134
|
nameRaw = picked;
|
|
53
135
|
}
|
|
54
136
|
|
|
55
|
-
const res = await spawnTeammate(ctx, { name: nameRaw, mode, workspaceMode, planRequired });
|
|
137
|
+
const res = await spawnTeammate(ctx, { name: nameRaw, mode, workspaceMode, planRequired, model, thinking });
|
|
56
138
|
if (!res.ok) {
|
|
57
139
|
ctx.ui.notify(res.error, "error");
|
|
58
140
|
return;
|
|
@@ -33,7 +33,7 @@ const TEAM_HELP_TEXT = [
|
|
|
33
33
|
"Usage:",
|
|
34
34
|
" /team id",
|
|
35
35
|
" /team env <name>",
|
|
36
|
-
" /team spawn <name> [fresh|branch] [shared|worktree] [plan]",
|
|
36
|
+
" /team spawn <name> [fresh|branch] [shared|worktree] [plan] [--model <provider>/<modelId>] [--thinking <level>]",
|
|
37
37
|
" /team panel",
|
|
38
38
|
" /team send <name> <msg...>",
|
|
39
39
|
" /team dm <name> <msg...>",
|
|
@@ -29,6 +29,11 @@ const TeamsWorkspaceModeSchema = StringEnum(["shared", "worktree"] as const, {
|
|
|
29
29
|
default: "shared",
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
const TeamsThinkingLevelSchema = StringEnum(["off", "minimal", "low", "medium", "high", "xhigh"] as const, {
|
|
33
|
+
description:
|
|
34
|
+
"Thinking level to use for spawned comrades (defaults to the leader's current thinking level when omitted).",
|
|
35
|
+
});
|
|
36
|
+
|
|
32
37
|
const TeamsDelegateTaskSchema = Type.Object({
|
|
33
38
|
text: Type.String({ description: "Task / TODO text." }),
|
|
34
39
|
assignee: Type.Optional(Type.String({ description: "Optional comrade name. If omitted, assigned round-robin." })),
|
|
@@ -52,6 +57,13 @@ const TeamsToolParamsSchema = Type.Object({
|
|
|
52
57
|
),
|
|
53
58
|
contextMode: Type.Optional(TeamsContextModeSchema),
|
|
54
59
|
workspaceMode: Type.Optional(TeamsWorkspaceModeSchema),
|
|
60
|
+
model: Type.Optional(
|
|
61
|
+
Type.String({
|
|
62
|
+
description:
|
|
63
|
+
"Optional model override for spawned comrades. Use '<provider>/<modelId>' (e.g. 'anthropic/claude-sonnet-4'). If you pass only '<modelId>', the provider is inherited from the leader when available.",
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
thinking: Type.Optional(TeamsThinkingLevelSchema),
|
|
55
67
|
});
|
|
56
68
|
|
|
57
69
|
type TeamsToolParamsType = Static<typeof TeamsToolParamsSchema>;
|
|
@@ -73,6 +85,7 @@ export function registerTeamsTool(opts: {
|
|
|
73
85
|
"Spawn comrade agents and delegate tasks. Each comrade is a child Pi process that executes work autonomously and reports back.",
|
|
74
86
|
"Provide a list of tasks with optional assignees; comrades are spawned automatically and assigned round-robin if unspecified.",
|
|
75
87
|
"Options: contextMode=branch (clone session context), workspaceMode=worktree (git worktree isolation).",
|
|
88
|
+
"Optional overrides: model='<provider>/<modelId>' and thinking (off|minimal|low|medium|high|xhigh).",
|
|
76
89
|
"For governance, the user can run /team delegate on (leader restricted to coordination) or /team spawn <name> plan (worker needs plan approval).",
|
|
77
90
|
].join(" "),
|
|
78
91
|
parameters: TeamsToolParamsSchema,
|
|
@@ -96,6 +109,9 @@ export function registerTeamsTool(opts: {
|
|
|
96
109
|
|
|
97
110
|
const contextMode: ContextMode = params.contextMode === "branch" ? "branch" : "fresh";
|
|
98
111
|
const requestedWorkspaceMode: WorkspaceMode = params.workspaceMode === "worktree" ? "worktree" : "shared";
|
|
112
|
+
const modelOverride = params.model?.trim();
|
|
113
|
+
const spawnModel = modelOverride && modelOverride.length > 0 ? modelOverride : undefined;
|
|
114
|
+
const spawnThinking = params.thinking;
|
|
99
115
|
|
|
100
116
|
const teamId = ctx.sessionManager.getSessionId();
|
|
101
117
|
const teamDir = getTeamDir(teamId);
|
|
@@ -144,6 +160,8 @@ export function registerTeamsTool(opts: {
|
|
|
144
160
|
name,
|
|
145
161
|
mode: contextMode,
|
|
146
162
|
workspaceMode: requestedWorkspaceMode,
|
|
163
|
+
model: spawnModel,
|
|
164
|
+
thinking: spawnThinking,
|
|
147
165
|
});
|
|
148
166
|
if (!res.ok) {
|
|
149
167
|
warnings.push(`Failed to spawn '${name}': ${res.error}`);
|
|
@@ -178,6 +196,8 @@ export function registerTeamsTool(opts: {
|
|
|
178
196
|
name: assignee,
|
|
179
197
|
mode: contextMode,
|
|
180
198
|
workspaceMode: requestedWorkspaceMode,
|
|
199
|
+
model: spawnModel,
|
|
200
|
+
thinking: spawnThinking,
|
|
181
201
|
});
|
|
182
202
|
if (res.ok) {
|
|
183
203
|
spawned.push(res.name);
|
|
@@ -226,6 +246,8 @@ export function registerTeamsTool(opts: {
|
|
|
226
246
|
teamId,
|
|
227
247
|
contextMode,
|
|
228
248
|
workspaceMode: requestedWorkspaceMode,
|
|
249
|
+
model: spawnModel,
|
|
250
|
+
thinking: spawnThinking,
|
|
229
251
|
spawned,
|
|
230
252
|
assignments,
|
|
231
253
|
warnings,
|
|
@@ -6,7 +6,7 @@ import { SessionManager } from "@mariozechner/pi-coding-agent";
|
|
|
6
6
|
import { writeToMailbox } from "./mailbox.js";
|
|
7
7
|
import { sanitizeName } from "./names.js";
|
|
8
8
|
import { TEAM_MAILBOX_NS } from "./protocol.js";
|
|
9
|
-
import { listTasks, unassignTasksForAgent, type TeamTask } from "./task-store.js";
|
|
9
|
+
import { createTask, listTasks, unassignTasksForAgent, type TeamTask } from "./task-store.js";
|
|
10
10
|
import { TeammateRpc } from "./teammate-rpc.js";
|
|
11
11
|
import { ensureTeamConfig, loadTeamConfig, setMemberStatus, upsertMember, type TeamConfig } from "./team-config.js";
|
|
12
12
|
import { getTeamDir } from "./paths.js";
|
|
@@ -16,6 +16,7 @@ import { openInteractiveWidget } from "./teams-panel.js";
|
|
|
16
16
|
import { createTeamsWidget } from "./teams-widget.js";
|
|
17
17
|
import { getTeamsStyleFromEnv, type TeamsStyle, formatMemberDisplayName, getTeamsStrings } from "./teams-style.js";
|
|
18
18
|
import { pollLeaderInbox as pollLeaderInboxImpl } from "./leader-inbox.js";
|
|
19
|
+
import { getHookBaseName, runTeamsHook, type TeamsHookInvocation } from "./hooks.js";
|
|
19
20
|
import { handleTeamCommand } from "./leader-team-command.js";
|
|
20
21
|
import { registerTeamsTool } from "./leader-teams-tool.js";
|
|
21
22
|
import type { ContextMode, SpawnTeammateFn, SpawnTeammateResult, WorkspaceMode } from "./spawn-types.js";
|
|
@@ -151,6 +152,108 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
151
152
|
}
|
|
152
153
|
};
|
|
153
154
|
|
|
155
|
+
// Hooks / quality gates (serialized execution so multiple idle events don't overlap).
|
|
156
|
+
let hookChain: Promise<void> = Promise.resolve();
|
|
157
|
+
const seenHookEvents = new Set<string>();
|
|
158
|
+
|
|
159
|
+
const enqueueHook = (invocation: TeamsHookInvocation) => {
|
|
160
|
+
const taskId = invocation.completedTask?.id ?? "";
|
|
161
|
+
const ts = invocation.timestamp ?? "";
|
|
162
|
+
const key = `${invocation.teamId}:${invocation.event}:${taskId}:${ts}:${invocation.memberName ?? ""}`;
|
|
163
|
+
if (seenHookEvents.has(key)) return;
|
|
164
|
+
seenHookEvents.add(key);
|
|
165
|
+
|
|
166
|
+
hookChain = hookChain
|
|
167
|
+
.then(async () => {
|
|
168
|
+
// Only run hooks for the currently active team session.
|
|
169
|
+
if (!currentCtx) return;
|
|
170
|
+
if (currentCtx.sessionManager.getSessionId() !== invocation.teamId) return;
|
|
171
|
+
|
|
172
|
+
const res = await runTeamsHook({ invocation, cwd: currentCtx.cwd });
|
|
173
|
+
if (!res.ran) return;
|
|
174
|
+
|
|
175
|
+
// Persist a log for debugging.
|
|
176
|
+
try {
|
|
177
|
+
const logsDir = path.join(invocation.teamDir, "hook-logs");
|
|
178
|
+
await fs.promises.mkdir(logsDir, { recursive: true });
|
|
179
|
+
const name = `${new Date().toISOString().replace(/[:.]/g, "-")}_${invocation.event}.json`;
|
|
180
|
+
await fs.promises.writeFile(
|
|
181
|
+
path.join(logsDir, name),
|
|
182
|
+
JSON.stringify(
|
|
183
|
+
{
|
|
184
|
+
invocation,
|
|
185
|
+
result: res,
|
|
186
|
+
},
|
|
187
|
+
null,
|
|
188
|
+
2,
|
|
189
|
+
) + "\n",
|
|
190
|
+
"utf8",
|
|
191
|
+
);
|
|
192
|
+
} catch {
|
|
193
|
+
// ignore logging errors
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const ok = res.exitCode === 0 && !res.timedOut && !res.error;
|
|
197
|
+
const hookName = getHookBaseName(invocation.event);
|
|
198
|
+
|
|
199
|
+
// Idle hooks are intentionally quiet unless they fail.
|
|
200
|
+
if (invocation.event === "idle") {
|
|
201
|
+
if (!ok) {
|
|
202
|
+
currentCtx.ui.notify(
|
|
203
|
+
`Hook ${hookName} failed${res.timedOut ? " (timeout)" : ""}${res.exitCode !== null ? ` (code ${res.exitCode})` : ""}`,
|
|
204
|
+
"warning",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Task-completion hooks are visible.
|
|
211
|
+
if (ok) {
|
|
212
|
+
currentCtx.ui.notify(`Hook ${hookName} passed (${res.durationMs}ms)`, "info");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
currentCtx.ui.notify(
|
|
217
|
+
`Hook ${hookName} failed${res.timedOut ? " (timeout)" : ""}${res.exitCode !== null ? ` (code ${res.exitCode})` : ""}`,
|
|
218
|
+
"warning",
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// Optional: on failure, create a follow-up task so it shows up in the shared list.
|
|
222
|
+
const createOnFail = process.env.PI_TEAMS_HOOKS_CREATE_TASK_ON_FAILURE === "1";
|
|
223
|
+
const task = invocation.completedTask;
|
|
224
|
+
if (createOnFail && task?.id) {
|
|
225
|
+
const subject = `Quality gate failed: ${hookName} (task #${task.id})`;
|
|
226
|
+
const descParts: string[] = [];
|
|
227
|
+
descParts.push(`Hook: ${hookName}`);
|
|
228
|
+
if (res.command?.length) descParts.push(`Command: ${res.command.join(" ")}`);
|
|
229
|
+
descParts.push("");
|
|
230
|
+
if (task.subject) descParts.push(`Original task subject: ${task.subject}`);
|
|
231
|
+
descParts.push("");
|
|
232
|
+
if (res.stdout.trim()) {
|
|
233
|
+
descParts.push("STDOUT:");
|
|
234
|
+
descParts.push(res.stdout.trim());
|
|
235
|
+
descParts.push("");
|
|
236
|
+
}
|
|
237
|
+
if (res.stderr.trim()) {
|
|
238
|
+
descParts.push("STDERR:");
|
|
239
|
+
descParts.push(res.stderr.trim());
|
|
240
|
+
descParts.push("");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await createTask(invocation.teamDir, invocation.taskListId, {
|
|
244
|
+
subject,
|
|
245
|
+
description: descParts.join("\n"),
|
|
246
|
+
});
|
|
247
|
+
await refreshTasks();
|
|
248
|
+
renderWidget();
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
.catch((err: unknown) => {
|
|
252
|
+
if (!currentCtx) return;
|
|
253
|
+
currentCtx.ui.notify(err instanceof Error ? err.message : String(err), "warning");
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
|
|
154
257
|
const widgetFactory = createTeamsWidget({
|
|
155
258
|
getTeammates: () => teammates,
|
|
156
259
|
getTracker: () => tracker,
|
|
@@ -198,6 +301,40 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
198
301
|
return { ok: false, error: `${formatMemberDisplayName(style, name)} already exists (${strings.teamNoun})` };
|
|
199
302
|
}
|
|
200
303
|
|
|
304
|
+
// Spawn-time model / thinking overrides (optional).
|
|
305
|
+
const thinkingLevel = opts.thinking ?? pi.getThinkingLevel();
|
|
306
|
+
let childProvider: string | undefined;
|
|
307
|
+
let childModelId: string | undefined;
|
|
308
|
+
|
|
309
|
+
const modelOverrideRaw = opts.model?.trim();
|
|
310
|
+
if (modelOverrideRaw) {
|
|
311
|
+
const slashIdx = modelOverrideRaw.indexOf("/");
|
|
312
|
+
if (slashIdx >= 0) {
|
|
313
|
+
const provider = modelOverrideRaw.slice(0, slashIdx).trim();
|
|
314
|
+
const id = modelOverrideRaw.slice(slashIdx + 1).trim();
|
|
315
|
+
if (!provider || !id) {
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
error: `Invalid model override '${modelOverrideRaw}'. Expected <provider>/<modelId>.`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
childProvider = provider;
|
|
322
|
+
childModelId = id;
|
|
323
|
+
} else {
|
|
324
|
+
childModelId = modelOverrideRaw;
|
|
325
|
+
childProvider = ctx.model?.provider;
|
|
326
|
+
if (!childProvider) {
|
|
327
|
+
warnings.push(
|
|
328
|
+
`Model override '${modelOverrideRaw}' provided without a provider. ` +
|
|
329
|
+
`Teammate will use its default provider; use <provider>/<modelId> to force one.`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} else if (ctx.model) {
|
|
334
|
+
childProvider = ctx.model.provider;
|
|
335
|
+
childModelId = ctx.model.id;
|
|
336
|
+
}
|
|
337
|
+
|
|
201
338
|
const teamId = ctx.sessionManager.getSessionId();
|
|
202
339
|
const teamDir = getTeamDir(teamId);
|
|
203
340
|
const teamSessionsDir = getTeamSessionsDir(teamDir);
|
|
@@ -247,11 +384,12 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
247
384
|
argsForChild.push("--session-dir", teamSessionsDir);
|
|
248
385
|
if (tools.length) argsForChild.push("--tools", tools.join(","));
|
|
249
386
|
|
|
250
|
-
//
|
|
251
|
-
if (
|
|
252
|
-
argsForChild.push("--provider",
|
|
387
|
+
// Model + thinking for the child process.
|
|
388
|
+
if (childModelId) {
|
|
389
|
+
if (childProvider) argsForChild.push("--provider", childProvider);
|
|
390
|
+
argsForChild.push("--model", childModelId);
|
|
253
391
|
}
|
|
254
|
-
argsForChild.push("--thinking",
|
|
392
|
+
argsForChild.push("--thinking", thinkingLevel);
|
|
255
393
|
|
|
256
394
|
const teamsEntry = getTeamsExtensionEntryPath();
|
|
257
395
|
if (teamsEntry) {
|
|
@@ -320,7 +458,12 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
320
458
|
status: "online",
|
|
321
459
|
cwd: childCwd,
|
|
322
460
|
sessionFile,
|
|
323
|
-
meta: {
|
|
461
|
+
meta: {
|
|
462
|
+
workspaceMode,
|
|
463
|
+
sessionName,
|
|
464
|
+
thinkingLevel,
|
|
465
|
+
...(childModelId ? { model: childProvider ? `${childProvider}/${childModelId}` : childModelId } : {}),
|
|
466
|
+
},
|
|
324
467
|
});
|
|
325
468
|
|
|
326
469
|
await refreshTasks();
|
|
@@ -341,6 +484,7 @@ export function runLeader(pi: ExtensionAPI): void {
|
|
|
341
484
|
leadName: teamConfig?.leadName ?? "team-lead",
|
|
342
485
|
style,
|
|
343
486
|
pendingPlanApprovals,
|
|
487
|
+
enqueueHook,
|
|
344
488
|
});
|
|
345
489
|
};
|
|
346
490
|
|
|
@@ -25,3 +25,13 @@ export function getTeamDir(teamId: string): string {
|
|
|
25
25
|
export function getTeamsStylesDir(): string {
|
|
26
26
|
return path.join(getTeamsRootDir(), "_styles");
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
/** Directory for hook scripts and hook configuration (quality gates). */
|
|
30
|
+
export function getTeamsHooksDir(): string {
|
|
31
|
+
const override = process.env.PI_TEAMS_HOOKS_DIR;
|
|
32
|
+
if (override && override.trim()) {
|
|
33
|
+
const p = override.trim();
|
|
34
|
+
return path.isAbsolute(p) ? p : path.join(getTeamsRootDir(), p);
|
|
35
|
+
}
|
|
36
|
+
return path.join(getTeamsRootDir(), "_hooks");
|
|
37
|
+
}
|
|
@@ -1,8 +1,26 @@
|
|
|
1
|
+
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
|
1
2
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
3
|
|
|
3
4
|
export type ContextMode = "fresh" | "branch";
|
|
4
5
|
export type WorkspaceMode = "shared" | "worktree";
|
|
5
6
|
|
|
7
|
+
export interface SpawnTeammateOptions {
|
|
8
|
+
name: string;
|
|
9
|
+
mode?: ContextMode;
|
|
10
|
+
workspaceMode?: WorkspaceMode;
|
|
11
|
+
planRequired?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Optional model override for the spawned teammate.
|
|
14
|
+
*
|
|
15
|
+
* Supported forms:
|
|
16
|
+
* - "<provider>/<modelId>" (e.g. "anthropic/claude-sonnet-4")
|
|
17
|
+
* - "<modelId>" (provider inherited from leader when available)
|
|
18
|
+
*/
|
|
19
|
+
model?: string;
|
|
20
|
+
/** Optional thinking level override for the spawned teammate. */
|
|
21
|
+
thinking?: ThinkingLevel;
|
|
22
|
+
}
|
|
23
|
+
|
|
6
24
|
export type SpawnTeammateResult =
|
|
7
25
|
| {
|
|
8
26
|
ok: true;
|
|
@@ -15,7 +33,4 @@ export type SpawnTeammateResult =
|
|
|
15
33
|
}
|
|
16
34
|
| { ok: false; error: string };
|
|
17
35
|
|
|
18
|
-
export type SpawnTeammateFn = (
|
|
19
|
-
ctx: ExtensionContext,
|
|
20
|
-
opts: { name: string; mode?: ContextMode; workspaceMode?: WorkspaceMode; planRequired?: boolean },
|
|
21
|
-
) => Promise<SpawnTeammateResult>;
|
|
36
|
+
export type SpawnTeammateFn = (ctx: ExtensionContext, opts: SpawnTeammateOptions) => Promise<SpawnTeammateResult>;
|
|
@@ -549,7 +549,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
549
549
|
if (data === "a") {
|
|
550
550
|
if (sessionName) {
|
|
551
551
|
deps.abortMember(sessionName);
|
|
552
|
-
showNotification(
|
|
552
|
+
showNotification(`${formatMemberDisplayName(style, sessionName)} ${strings.abortRequestedVerb}`, "warning");
|
|
553
553
|
}
|
|
554
554
|
return;
|
|
555
555
|
}
|
|
@@ -557,7 +557,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
557
557
|
if (sessionName) {
|
|
558
558
|
const name = sessionName;
|
|
559
559
|
deps.killMember(name);
|
|
560
|
-
showNotification(`${formatMemberDisplayName(style, name)} ${strings.killedVerb}`, "
|
|
560
|
+
showNotification(`${formatMemberDisplayName(style, name)} ${strings.killedVerb} (SIGTERM)`, "warning");
|
|
561
561
|
mode = "overview";
|
|
562
562
|
sessionName = null;
|
|
563
563
|
tui.requestRender();
|
|
@@ -613,7 +613,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
613
613
|
const name = memberNames[cursorIndex];
|
|
614
614
|
if (name) {
|
|
615
615
|
deps.abortMember(name);
|
|
616
|
-
showNotification(
|
|
616
|
+
showNotification(`${formatMemberDisplayName(style, name)} ${strings.abortRequestedVerb}`, "warning");
|
|
617
617
|
}
|
|
618
618
|
return;
|
|
619
619
|
}
|
|
@@ -621,7 +621,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
|
|
|
621
621
|
const name = memberNames[cursorIndex];
|
|
622
622
|
if (name) {
|
|
623
623
|
deps.killMember(name);
|
|
624
|
-
showNotification(`${formatMemberDisplayName(style, name)} ${strings.killedVerb}`, "
|
|
624
|
+
showNotification(`${formatMemberDisplayName(style, name)} ${strings.killedVerb} (SIGTERM)`, "warning");
|
|
625
625
|
tui.requestRender();
|
|
626
626
|
}
|
|
627
627
|
return;
|
|
@@ -23,6 +23,17 @@ export type TeamsStrings = {
|
|
|
23
23
|
joinedVerb: string;
|
|
24
24
|
leftVerb: string;
|
|
25
25
|
killedVerb: string;
|
|
26
|
+
|
|
27
|
+
// Lifecycle copy (all shown as "<member> <verb...>")
|
|
28
|
+
shutdownRequestedVerb: string;
|
|
29
|
+
shutdownCompletedVerb: string;
|
|
30
|
+
shutdownRefusedVerb: string;
|
|
31
|
+
abortRequestedVerb: string;
|
|
32
|
+
|
|
33
|
+
// Templates (supports {members} and/or {count})
|
|
34
|
+
noMembersToShutdown: string;
|
|
35
|
+
shutdownAllPrompt: string;
|
|
36
|
+
teamEndedAllStopped: string;
|
|
26
37
|
};
|
|
27
38
|
|
|
28
39
|
export type TeamsAutoNameStrategy =
|
|
@@ -46,6 +57,10 @@ function isRecord(v: unknown): v is Record<string, unknown> {
|
|
|
46
57
|
return typeof v === "object" && v !== null;
|
|
47
58
|
}
|
|
48
59
|
|
|
60
|
+
export function formatTeamsTemplate(template: string, vars: Record<string, string>): string {
|
|
61
|
+
return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (_m, key: string) => vars[key] ?? "");
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
export function normalizeTeamsStyleId(raw: unknown): TeamsStyle | null {
|
|
50
65
|
if (typeof raw !== "string") return null;
|
|
51
66
|
const trimmed = raw.trim();
|
|
@@ -71,6 +86,15 @@ function builtinStyle(id: BuiltinTeamsStyle): TeamsStyleDefinition {
|
|
|
71
86
|
joinedVerb: "has joined the Party",
|
|
72
87
|
leftVerb: "has left the Party",
|
|
73
88
|
killedVerb: "sent to the gulag",
|
|
89
|
+
|
|
90
|
+
shutdownRequestedVerb: "was asked to stand down",
|
|
91
|
+
shutdownCompletedVerb: "stood down",
|
|
92
|
+
shutdownRefusedVerb: "refused to comply",
|
|
93
|
+
abortRequestedVerb: "was ordered to stop",
|
|
94
|
+
|
|
95
|
+
noMembersToShutdown: "No {members} to shut down",
|
|
96
|
+
shutdownAllPrompt: "Stop all {count} {members}?",
|
|
97
|
+
teamEndedAllStopped: "Team ended: all {members} stopped (leader session remains active)",
|
|
74
98
|
},
|
|
75
99
|
naming: {
|
|
76
100
|
requireExplicitSpawnName: false,
|
|
@@ -89,7 +113,16 @@ function builtinStyle(id: BuiltinTeamsStyle): TeamsStyleDefinition {
|
|
|
89
113
|
teamNoun: "crew",
|
|
90
114
|
joinedVerb: "joined the crew",
|
|
91
115
|
leftVerb: "abandoned ship",
|
|
92
|
-
killedVerb: "
|
|
116
|
+
killedVerb: "walked the plank",
|
|
117
|
+
|
|
118
|
+
shutdownRequestedVerb: "was ordered to strike the colors",
|
|
119
|
+
shutdownCompletedVerb: "struck their colors",
|
|
120
|
+
shutdownRefusedVerb: "defied the captain",
|
|
121
|
+
abortRequestedVerb: "was ordered to belay that",
|
|
122
|
+
|
|
123
|
+
noMembersToShutdown: "No {members} to send below decks",
|
|
124
|
+
shutdownAllPrompt: "Dismiss all {count} {members}?",
|
|
125
|
+
teamEndedAllStopped: "Crew dismissed: all {members} struck their colors (leader session remains active)",
|
|
93
126
|
},
|
|
94
127
|
naming: {
|
|
95
128
|
requireExplicitSpawnName: false,
|
|
@@ -110,6 +143,15 @@ function builtinStyle(id: BuiltinTeamsStyle): TeamsStyleDefinition {
|
|
|
110
143
|
joinedVerb: "joined the team",
|
|
111
144
|
leftVerb: "left the team",
|
|
112
145
|
killedVerb: "stopped",
|
|
146
|
+
|
|
147
|
+
shutdownRequestedVerb: "was asked to shut down",
|
|
148
|
+
shutdownCompletedVerb: "shut down",
|
|
149
|
+
shutdownRefusedVerb: "refused to shut down",
|
|
150
|
+
abortRequestedVerb: "was asked to stop",
|
|
151
|
+
|
|
152
|
+
noMembersToShutdown: "No {members} to shut down",
|
|
153
|
+
shutdownAllPrompt: "Stop all {count} {members}?",
|
|
154
|
+
teamEndedAllStopped: "Team ended: all {members} stopped (leader session remains active)",
|
|
113
155
|
},
|
|
114
156
|
naming: {
|
|
115
157
|
requireExplicitSpawnName: true,
|
|
@@ -151,6 +193,13 @@ function coerceStringsPartial(obj: unknown): Partial<TeamsStrings> {
|
|
|
151
193
|
"joinedVerb",
|
|
152
194
|
"leftVerb",
|
|
153
195
|
"killedVerb",
|
|
196
|
+
"shutdownRequestedVerb",
|
|
197
|
+
"shutdownCompletedVerb",
|
|
198
|
+
"shutdownRefusedVerb",
|
|
199
|
+
"abortRequestedVerb",
|
|
200
|
+
"noMembersToShutdown",
|
|
201
|
+
"shutdownAllPrompt",
|
|
202
|
+
"teamEndedAllStopped",
|
|
154
203
|
];
|
|
155
204
|
for (const k of keys) {
|
|
156
205
|
const v = obj[k];
|
package/package.json
CHANGED
|
@@ -34,7 +34,8 @@ import {
|
|
|
34
34
|
import { sleep, spawnTeamsWorkerRpc, terminateAll } from "./lib/pi-workers.js";
|
|
35
35
|
|
|
36
36
|
function parseArgs(argv: readonly string[]): { timeoutSec: number; pollMs: number } {
|
|
37
|
-
|
|
37
|
+
// Default is intentionally generous: model speed and provider availability vary.
|
|
38
|
+
let timeoutSec = 25 * 60;
|
|
38
39
|
let pollMs = 1500;
|
|
39
40
|
|
|
40
41
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -53,7 +54,7 @@ function parseArgs(argv: readonly string[]): { timeoutSec: number; pollMs: numbe
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
if (!Number.isFinite(timeoutSec) || timeoutSec < 60) timeoutSec =
|
|
57
|
+
if (!Number.isFinite(timeoutSec) || timeoutSec < 60) timeoutSec = 25 * 60;
|
|
57
58
|
if (!Number.isFinite(pollMs) || pollMs < 250) pollMs = 1500;
|
|
58
59
|
return { timeoutSec, pollMs };
|
|
59
60
|
}
|