@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.
@@ -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
- for (const a of rest) {
24
- if (a === "fresh" || a === "branch") mode = a;
25
- else if (a === "shared" || a === "worktree") workspaceMode = a;
26
- else if (a === "plan") planRequired = true;
27
- else if (!nameRaw) nameRaw = a;
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("Usage: /team spawn <name> [fresh|branch] [shared|worktree] [plan]", "error");
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
- // Inherit model + thinking level to keep teammates consistent and avoid surprises.
251
- if (ctx.model) {
252
- argsForChild.push("--provider", ctx.model.provider, "--model", ctx.model.id);
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", pi.getThinkingLevel());
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: { workspaceMode, sessionName },
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(`Abort sent to ${formatMemberDisplayName(style, sessionName)}`, "warning");
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}`, "error");
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(`Abort sent to ${formatMemberDisplayName(style, name)}`, "warning");
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}`, "error");
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: "sent overboard",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-agent-teams",
3
- "version": "0.4.0-beta.0",
3
+ "version": "0.4.0-beta.1",
4
4
  "description": "Claude Code agent teams style workflow for Pi.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
@@ -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
- let timeoutSec = 15 * 60;
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 = 15 * 60;
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
  }