@aeondave/pi-persona 1.15.2 → 1.16.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.
Files changed (44) hide show
  1. package/AGENTS.md +32 -6
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +27 -8
  4. package/SECURITY.md +42 -0
  5. package/docs/ARCHITECTURE.md +52 -7
  6. package/docs/EXPERIENCE.md +21 -5
  7. package/docs/README.md +2 -0
  8. package/docs/REFERENCE.md +84 -14
  9. package/docs/STRATEGIES.md +58 -31
  10. package/docs/TELEMETRY.md +14 -0
  11. package/package.json +18 -11
  12. package/src/bus/broker/paths.ts +3 -2
  13. package/src/core/brief.ts +1 -1
  14. package/src/core/nudge.ts +3 -4
  15. package/src/core/pi-compat.ts +3 -3
  16. package/src/engine/adapter.ts +11 -6
  17. package/src/engine/async.ts +12 -6
  18. package/src/engine/inproc.ts +14 -6
  19. package/src/extension/engine.ts +25 -4
  20. package/src/extension/hooks.ts +68 -21
  21. package/src/extension.ts +233 -57
  22. package/src/orchestration/model-retry.ts +32 -61
  23. package/src/orchestration/params.ts +27 -1
  24. package/src/orchestration/roster.ts +80 -3
  25. package/src/orchestration/sdk.ts +150 -36
  26. package/src/orchestration/strategies/compete.ts +14 -8
  27. package/src/orchestration/strategies/council-rounds.ts +8 -1
  28. package/src/orchestration/strategies/critic-loop.ts +25 -11
  29. package/src/orchestration/strategies/judge.ts +18 -9
  30. package/src/orchestration/strategies/magi.ts +42 -93
  31. package/src/orchestration/strategies/map.ts +41 -19
  32. package/src/orchestration/strategies/pipeline.ts +2 -2
  33. package/src/orchestration/strategies/synthesize.ts +18 -11
  34. package/src/orchestration/types.ts +15 -0
  35. package/src/persona/model-participants.ts +240 -0
  36. package/src/persona/orchestrate.ts +44 -2
  37. package/src/persona/persona.ts +86 -14
  38. package/src/telemetry/contract.ts +1 -1
  39. package/src/telemetry/producer.ts +241 -8
  40. package/src/tools/council.ts +36 -8
  41. package/src/tools/delegate-tool.ts +162 -24
  42. package/src/tools/intercom-tool.ts +119 -32
  43. package/src/ui/agent-overlay.ts +15 -6
  44. package/src/ui/agent-tree.ts +39 -2
@@ -1,6 +1,7 @@
1
- import { appendFile, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync } from "node:fs";
3
- import { join } from "node:path";
1
+ import { appendFile, lstat, mkdir, opendir, readFile, readdir, realpath, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, unlinkSync } from "node:fs";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { randomUUID } from "node:crypto";
4
5
 
5
6
  import {
6
7
  TELEMETRY_PRODUCER_ID,
@@ -27,6 +28,7 @@ export interface TelemetryProducerOptions {
27
28
  heartbeat?: () => Partial<InstanceDescriptor>;
28
29
  onError?: (error: unknown) => void;
29
30
  maxFileBytes?: number;
31
+ retentionMs?: number;
30
32
  }
31
33
 
32
34
  /** Richer caller input. Prompt/activity/output fields are accepted only so the sink can drop them. */
@@ -37,6 +39,15 @@ const STRING_LIMIT = 512;
37
39
  const ARRAY_LIMIT = 256;
38
40
  const MAX_DEPTH = 8;
39
41
  const DEFAULT_MAX_FILE_BYTES = 4 * 1024 * 1024;
42
+ export const DEFAULT_TELEMETRY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
43
+
44
+ export class TelemetryAdmissionError extends Error {
45
+ constructor(message: string) { super(message); this.name = "TelemetryAdmissionError"; }
46
+ }
47
+
48
+ const markerName = (kind: "writer" | "prune") => `${kind}-${process.pid}-${randomUUID()}`;
49
+ const markerPattern = /^(writer|prune)-(\d+)-([0-9a-f-]{36})$/;
50
+ const workspacePattern = /^[0-9a-f]{24}$/;
40
51
 
41
52
  const TERMINAL_AGENT_STATUSES = new Set(["done", "failed", "stopped"]);
42
53
 
@@ -279,9 +290,194 @@ function sanitizeAgentPatch(value: Partial<TelemetryAgentInput>): Partial<AgentD
279
290
  };
280
291
  }
281
292
 
293
+ function validProducerId(value: string): boolean {
294
+ return value !== "." && value !== ".." && /^[A-Za-z0-9._-]{1,96}$/.test(value);
295
+ }
296
+
282
297
  function producerSegment(value: string | undefined, fallback: string): string {
283
298
  const safe = value?.trim().replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 96);
284
- return safe || fallback;
299
+ return safe && validProducerId(safe) ? safe : fallback;
300
+ }
301
+
302
+ function pidState(pid: number): "alive" | "dead" | "unknown" {
303
+ if (!Number.isSafeInteger(pid) || pid <= 0) return "unknown";
304
+ if (pid === process.pid) return "alive";
305
+ try { process.kill(pid, 0); return "alive"; }
306
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH" ? "dead" : "unknown"; }
307
+ }
308
+
309
+ function isMissing(error: unknown): boolean {
310
+ return (error as NodeJS.ErrnoException).code === "ENOENT";
311
+ }
312
+
313
+ /** The supplied agent directory is trusted; linked descendants of its canonical root are not. */
314
+ function namespaceParts(root: string, path: string): string[] {
315
+ const parts: string[] = [];
316
+ for (let current = path; current !== root; current = dirname(current)) {
317
+ if (dirname(current) === current) throw new TelemetryAdmissionError("Unsafe telemetry namespace");
318
+ parts.unshift(current);
319
+ }
320
+ return parts;
321
+ }
322
+
323
+ function ensureDirectoriesSync(root: string, path: string, created: string[]): void {
324
+ for (const part of namespaceParts(root, path)) {
325
+ try { mkdirSync(part, { mode: 0o700 }); created.push(part); }
326
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
327
+ if (!lstatSync(part).isDirectory()) throw new TelemetryAdmissionError("Unsafe linked telemetry directory");
328
+ }
329
+ }
330
+
331
+ /** Roll back only directories created by this admission attempt, from leaf to root. */
332
+ function removeCreatedDirectoriesSync(created: readonly string[], onError?: (error: unknown) => void): void {
333
+ for (let index = created.length - 1; index >= 0; index -= 1) {
334
+ try { rmdirSync(created[index]!); }
335
+ catch (error) {
336
+ const code = (error as NodeJS.ErrnoException).code;
337
+ if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST") reportRetentionError(onError, error);
338
+ }
339
+ }
340
+ }
341
+
342
+ async function safeDirectories(root: string, path: string): Promise<boolean> {
343
+ for (const part of namespaceParts(root, path)) {
344
+ try { if (!(await lstat(part)).isDirectory()) return false; }
345
+ catch (error) { if (isMissing(error)) return false; throw error; }
346
+ }
347
+ return true;
348
+ }
349
+
350
+ function assertSafeArtifactSync(file: string): void {
351
+ try {
352
+ const info = lstatSync(file);
353
+ if (!info.isFile() || info.nlink !== 1) throw new TelemetryAdmissionError("Unsafe linked telemetry artifact");
354
+ } catch (error) { if (!isMissing(error)) throw error; }
355
+ }
356
+
357
+ function assertNoPruneClaimsSync(dir: string): void {
358
+ for (const name of readdirSync(dir)) {
359
+ const match = markerPattern.exec(name);
360
+ if (!match) throw new TelemetryAdmissionError("Unknown telemetry lease marker");
361
+ const marker = join(dir, name);
362
+ let info;
363
+ try { info = lstatSync(marker); } catch (error) { if (isMissing(error)) continue; throw error; }
364
+ if (!info.isFile() || info.nlink !== 1 || info.size !== 0) throw new TelemetryAdmissionError("Unsafe telemetry lease marker");
365
+ if (match[1] === "prune") {
366
+ if (pidState(Number(match[2])) !== "dead") throw new TelemetryAdmissionError("Active telemetry prune claim");
367
+ try { unlinkSync(marker); } catch (error) { if (!isMissing(error)) throw error; }
368
+ }
369
+ }
370
+ }
371
+
372
+ type RetentionErrorSink = ((error: unknown) => void) | undefined;
373
+ function reportRetentionError(sink: RetentionErrorSink, error: unknown): void {
374
+ try { sink?.(error); } catch { /* diagnostics must not break the host session */ }
375
+ }
376
+
377
+ const retentionJobs = new Map<string, Promise<void>>();
378
+ function scheduleTelemetryRetention(agentDir: string, producerId: string, retentionMs: number, onError: RetentionErrorSink): Promise<void> {
379
+ const key = `${resolve(agentDir)}\0${producerId}`;
380
+ const existing = retentionJobs.get(key);
381
+ if (existing) return existing;
382
+ const job = flushTelemetryRetention(agentDir, producerId, retentionMs, Date.now(), onError)
383
+ .catch((error) => reportRetentionError(onError, error))
384
+ .finally(() => retentionJobs.delete(key));
385
+ retentionJobs.set(key, job);
386
+ return job;
387
+ }
388
+
389
+ async function removeEmptyDir(dir: string): Promise<void> {
390
+ try { await rmdir(dir); }
391
+ catch (error) {
392
+ const code = (error as NodeJS.ErrnoException).code;
393
+ if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST") throw error;
394
+ }
395
+ }
396
+
397
+ const artifactPattern = /^([0-9a-f]{16})\.jsonl(?:\.previous|\.trim-\d+-\d+)?$/;
398
+ const leaseDirPattern = /^([0-9a-f]{16})\.jsonl\.leases$/;
399
+
400
+ async function oldArtifacts(dir: string, session: string, cutoff: number): Promise<string[] | undefined> {
401
+ const group = (await readdir(dir)).filter((name) => artifactPattern.exec(name)?.[1] === session);
402
+ for (const name of group) {
403
+ let info;
404
+ try { info = await lstat(join(dir, name)); }
405
+ catch (error) { if (isMissing(error)) return undefined; throw error; }
406
+ if (!info.isFile() || info.nlink !== 1 || info.mtimeMs >= cutoff) return undefined;
407
+ }
408
+ return group;
409
+ }
410
+
411
+ async function hasProtectedWriter(leaseDir: string, ownClaim: string): Promise<boolean> {
412
+ for (const name of await readdir(leaseDir)) {
413
+ if (name === basename(ownClaim)) continue;
414
+ const match = markerPattern.exec(name);
415
+ if (!match) return true;
416
+ const path = join(leaseDir, name);
417
+ let info;
418
+ try { info = await lstat(path); } catch (error) { if (isMissing(error)) continue; throw error; }
419
+ if (!info.isFile() || info.nlink !== 1 || info.size !== 0) return true;
420
+ const state = pidState(Number(match[2]));
421
+ if (state === "unknown" || (match[1] === "writer" && state === "alive")) return true;
422
+ if (state === "dead") {
423
+ try { await unlink(path); } catch (error) { if (!isMissing(error)) throw error; }
424
+ }
425
+ }
426
+ return false;
427
+ }
428
+
429
+ /** Cooperative same-host retention; unknown/linked objects and live writer leases are retained.
430
+ * Pre-protocol processes are protected only by recent writes, not by these leases. */
431
+ export async function flushTelemetryRetention(
432
+ agentDir: string, producerId: string, retentionMs: number, now = Date.now(), onError?: (error: unknown) => void,
433
+ ): Promise<void> {
434
+ if (!validProducerId(producerId)) throw new RangeError("Unsafe telemetry producerId");
435
+ if (!Number.isSafeInteger(retentionMs) || retentionMs < 0) throw new RangeError("retentionMs must be a safe integer >= 0");
436
+ if (retentionMs === 0) return;
437
+ let base: string;
438
+ try { base = await realpath(agentDir); } catch (error) { if (isMissing(error)) return; throw error; }
439
+ const root = join(base, "telemetry", "v2");
440
+ if (!(await safeDirectories(base, root))) return;
441
+ for await (const entry of await opendir(root)) {
442
+ if (!workspacePattern.test(entry.name) || !entry.isDirectory()) continue;
443
+ const workspaceDir = join(root, entry.name);
444
+ const producerDir = join(workspaceDir, producerId);
445
+ try {
446
+ if (!(await safeDirectories(base, producerDir))) continue;
447
+ const sessions = new Set((await readdir(producerDir)).flatMap((name) => {
448
+ const session = artifactPattern.exec(name)?.[1] ?? leaseDirPattern.exec(name)?.[1];
449
+ return session ? [session] : [];
450
+ }));
451
+ for (const session of sessions) {
452
+ const leaseDir = join(producerDir, `${session}.jsonl.leases`);
453
+ let claim: string | undefined;
454
+ try {
455
+ if (await oldArtifacts(producerDir, session, now - retentionMs) === undefined) continue;
456
+ try { await mkdir(leaseDir, { mode: 0o700 }); }
457
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
458
+ if (!(await safeDirectories(base, leaseDir))) continue;
459
+ claim = join(leaseDir, markerName("prune"));
460
+ await writeFile(claim, "", { flag: "wx", mode: 0o600 });
461
+ if (await hasProtectedWriter(leaseDir, claim)) continue;
462
+ // Re-enumerate AFTER the claim: a fresh backup/scratch retains the whole group.
463
+ const group = await oldArtifacts(producerDir, session, now - retentionMs);
464
+ if (!group || !(await safeDirectories(base, producerDir))) continue;
465
+ for (const name of group) {
466
+ try { await unlink(join(producerDir, name)); }
467
+ catch (error) { if (!isMissing(error)) throw error; }
468
+ }
469
+ } catch (error) { if (!isMissing(error)) reportRetentionError(onError, error); }
470
+ finally {
471
+ if (claim) {
472
+ try { await unlink(claim); } catch (error) { if (!isMissing(error)) reportRetentionError(onError, error); }
473
+ try { await removeEmptyDir(leaseDir); } catch (error) { reportRetentionError(onError, error); }
474
+ }
475
+ }
476
+ }
477
+ await removeEmptyDir(producerDir);
478
+ await removeEmptyDir(workspaceDir);
479
+ } catch (error) { if (!isMissing(error)) reportRetentionError(onError, error); }
480
+ }
285
481
  }
286
482
 
287
483
  export class TelemetryProducer {
@@ -293,6 +489,8 @@ export class TelemetryProducer {
293
489
  private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
294
490
  private instance: InstanceDescriptor | undefined;
295
491
  private stopped = false;
492
+ private leasePath: string;
493
+ private retentionSweep: Promise<void> = Promise.resolve();
296
494
  readonly producerId: string;
297
495
  readonly producerVersion: string;
298
496
 
@@ -301,11 +499,35 @@ export class TelemetryProducer {
301
499
  this.producerId = producerSegment(options.producerId, TELEMETRY_PRODUCER_ID);
302
500
  this.producerVersion = producerSegment(options.producerVersion, TELEMETRY_PRODUCER_VERSION);
303
501
  if (options.maxFileBytes !== undefined && (!Number.isSafeInteger(options.maxFileBytes) || options.maxFileBytes < 512)) throw new RangeError("maxFileBytes must be at least 512");
502
+ const retentionMs = options.retentionMs ?? DEFAULT_TELEMETRY_RETENTION_MS;
503
+ if (!Number.isSafeInteger(retentionMs) || retentionMs < 0) throw new RangeError("retentionMs must be a safe integer >= 0");
304
504
  this.workspaceId = telemetryWorkspaceId(options.cwd);
305
- const dir = join(options.agentDir, "telemetry", "v2", this.workspaceId, this.producerId);
306
- mkdirSync(dir, { recursive: true, mode: 0o700 });
505
+ mkdirSync(options.agentDir, { recursive: true, mode: 0o700 });
506
+ const base = realpathSync(options.agentDir);
507
+ const dir = join(base, "telemetry", "v2", this.workspaceId, this.producerId);
307
508
  const fileKey = telemetrySessionFileKey(options.sessionId);
308
509
  this.filePath = join(dir, `${fileKey}.jsonl`);
510
+ const leaseDir = `${this.filePath}.leases`;
511
+ this.leasePath = join(leaseDir, markerName("writer"));
512
+ const createdDirectories: string[] = [];
513
+ let leaseCreated = false;
514
+ try {
515
+ ensureDirectoriesSync(base, dir, createdDirectories);
516
+ ensureDirectoriesSync(base, leaseDir, createdDirectories);
517
+ const fd = openSync(this.leasePath, "wx", 0o600);
518
+ leaseCreated = true;
519
+ closeSync(fd);
520
+ assertNoPruneClaimsSync(leaseDir);
521
+ for (const name of readdirSync(dir)) {
522
+ if (artifactPattern.exec(name)?.[1] === fileKey) assertSafeArtifactSync(join(dir, name));
523
+ }
524
+ } catch (error) {
525
+ if (leaseCreated) {
526
+ try { unlinkSync(this.leasePath); } catch { /* remove only our unique marker */ }
527
+ }
528
+ removeCreatedDirectoriesSync(createdDirectories, options.onError);
529
+ throw new TelemetryAdmissionError(`Cannot acquire telemetry writer lease: ${error instanceof Error ? error.message : String(error)}`);
530
+ }
309
531
  const backup = `${this.filePath}.previous`;
310
532
  if (!existsSync(this.filePath) && existsSync(backup)) {
311
533
  try { renameSync(backup, this.filePath); } catch { /* a concurrent activation will retry on its own */ }
@@ -313,7 +535,7 @@ export class TelemetryProducer {
313
535
  try { unlinkSync(backup); } catch { /* best effort */ }
314
536
  }
315
537
  try {
316
- for (const name of readdirSync(dir)) if (name.startsWith(`${fileKey}.jsonl.trim-`)) unlinkSync(join(dir, name));
538
+ for (const name of readdirSync(dir)) if (artifactPattern.exec(name)?.[1] === fileKey && name.includes(".trim-")) unlinkSync(join(dir, name));
317
539
  } catch { /* best effort cleanup of interrupted compaction scratch files */ }
318
540
  const { seq, needsNewline } = lastSequence(this.filePath);
319
541
  this.seq = seq;
@@ -323,6 +545,7 @@ export class TelemetryProducer {
323
545
  if (needsNewline) {
324
546
  try { appendFileSync(this.filePath, "\n", { encoding: "utf8", mode: 0o600 }); } catch { /* the write chain reports a real append failure */ }
325
547
  }
548
+ if (retentionMs > 0) this.retentionSweep = scheduleTelemetryRetention(base, this.producerId, retentionMs, options.onError);
326
549
  }
327
550
 
328
551
  start(instance: InstanceDescriptor): void {
@@ -379,12 +602,22 @@ export class TelemetryProducer {
379
602
  await this.writeChain;
380
603
  }
381
604
 
605
+ async flushRetention(): Promise<void> { await this.retentionSweep; }
606
+
382
607
  async stop(reason?: string): Promise<void> {
383
608
  if (this.stopped) return;
384
609
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
385
610
  this.heartbeatTimer = undefined;
386
611
  this.publish("instance.stopped", { reason: reason ? boundedText(reason, 120) : "shutdown" });
387
612
  this.stopped = true;
388
- await this.flush();
613
+ try {
614
+ await this.flush();
615
+ // Retention is shared background maintenance, not part of this writer's lifetime.
616
+ // Keep the lease through terminal writes, but do not delay host shutdown/reload
617
+ // on a sweep of unrelated sessions. flushRetention() remains an explicit join.
618
+ } finally {
619
+ try { await unlink(this.leasePath); } catch (error) { if (!isMissing(error)) reportRetentionError(this.options.onError, error); }
620
+ try { await removeEmptyDir(dirname(this.leasePath)); } catch (error) { reportRetentionError(this.options.onError, error); }
621
+ }
389
622
  }
390
623
  }
@@ -4,6 +4,7 @@ import { Type } from "typebox";
4
4
  import { fenceUntrusted } from "../core/fence.ts";
5
5
  import { expandDetailHint, failureDetails, formatCouncilCallLabel } from "../extension/shared.ts";
6
6
  import { knownParams, strategyNames } from "../orchestration/strategy.ts";
7
+ import { memberBaseLabel, type RosterMember } from "../orchestration/roster.ts";
7
8
  import { formatCouncilResult, humanizeAggregateResult } from "../orchestration/render.ts";
8
9
  import type { FailureKind } from "../orchestration/types.ts";
9
10
  import type { AgentResult } from "../orchestration/types.ts";
@@ -34,6 +35,13 @@ export interface CouncilToolDeps {
34
35
  publishPersonaCost(): void;
35
36
  }
36
37
 
38
+ /** What the card names as the panel: the team, or the ad-hoc members themselves — an
39
+ * ephemeral call has no team name, so the resolver leaves `roster` empty for it. */
40
+ function panelLabel(value: { roster: string; members?: RosterMember[] | undefined }): string {
41
+ if (!value.members || value.members.length === 0) return value.roster;
42
+ return value.members.map((m) => memberBaseLabel(m)).join(", ");
43
+ }
44
+
37
45
  export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void {
38
46
  // ── council tool (deliberate → vote → ruling; the executor then applies it) ───
39
47
  const CouncilParams = Type.Object({
@@ -48,6 +56,12 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
48
56
  Type.String({ description: 'Per-call strategy override (default: the selected or active council profile\'s strategy, or built-in "magi")' }),
49
57
  ),
50
58
  roster: Type.Optional(Type.String({ description: 'Per-call roster override (default: the selected or active profile\'s roster, then its orchestration roster, or built-in "magi")' })),
59
+ members: Type.Optional(
60
+ Type.Array(Type.Unknown(), {
61
+ description:
62
+ 'Ephemeral panel members for THIS call — each an installed agent name ("scout") or an inline { agent, role?, model?, skills? } specialisation. They win `roster` and the profile\'s own members for this call only: no team file, persona file, or inherited model/tool is touched. Omit to use the named roster.',
63
+ }),
64
+ ),
51
65
  params: Type.Optional(
52
66
  Type.Record(Type.String(), Type.Unknown(), {
53
67
  description:
@@ -61,9 +75,15 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
61
75
  description: [
62
76
  "Convene a council of specialists with controlled, complementary biases to deliberate a",
63
77
  "decision — returns the selected strategy's result, preserving vote tally and dissent when produced.",
64
- "Use it before any significant choice; then EXECUTE the ruling yourself and re-convene when",
65
- "execution surfaces a new decision. Patterns: adversarial vote (magi, council-rounds), best-of-N",
66
- "with an impartial arbiter (judge, compete), batch map, merged synthesis (synthesize).",
78
+ "Scale it to the decision: convene for a genuine fork, a contested or high-stakes call, or a",
79
+ "pick between rival answers — NOT before every decision, and a routine task does not",
80
+ "deserve a whole panel. Then EXECUTE the ruling yourself and re-convene only when execution",
81
+ "surfaces a NEW decision. Do NOT use it to get work done: `delegate` (and your own tools)",
82
+ "produce the work; the council decides, the executor acts.",
83
+ "Patterns: adversarial vote with dissent (magi, council-rounds); a reasoned pick between rival",
84
+ "COMPLETE answers by an impartial arbiter (judge); adversarial critique then revise",
85
+ "(critic-loop); head-to-head argument (debate); many findings merged into ONE deliverable",
86
+ "(synthesize, map); rival implementations judged blind (compete); a single pair (pair).",
67
87
  'Use `persona: "magi"` to invoke an installed persona\'s declared council without switching away',
68
88
  "from the active caller; its prompt, model, tools, and permissions are never inherited.",
69
89
  `Strategies: ${strategyNames()
@@ -73,7 +93,8 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
73
93
  return keys.length > 0 ? `${n}(${keys.join(", ")})` : n;
74
94
  })
75
95
  .join(" · ")}.`,
76
- 'Pass `params` to vary the persona\'s default council for one call — e.g. { "reflect": false }.',
96
+ "Pass `members` to seat an ad-hoc panel for one call, or `params` to vary the profile's default",
97
+ 'council — e.g. { "reflect": false }.',
77
98
  ].join(" "),
78
99
  parameters: CouncilParams,
79
100
  async execute(_id, params, signal, _onUpdate, ctx) {
@@ -82,6 +103,7 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
82
103
  persona: params.persona,
83
104
  strategy: params.strategy,
84
105
  roster: params.roster,
106
+ members: params.members,
85
107
  params: params.params as Record<string, unknown> | undefined,
86
108
  });
87
109
  if (!resolved.ok) {
@@ -91,7 +113,8 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
91
113
  isError: true,
92
114
  };
93
115
  }
94
- const { strategy, roster, params: mergedParams, persona } = resolved.value;
116
+ const { strategy, roster, members, params: mergedParams, persona } = resolved.value;
117
+ const panel = panelLabel({ roster, members });
95
118
  try {
96
119
  // Fully persona-driven: a persona's `council:` block picks the strategy, roster,
97
120
  // and params — a new ensemble (more members, supermajority, multi-round) needs no
@@ -111,6 +134,9 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
111
134
  }
112
135
  }
113
136
  const orch: OrchestrationGrammar = { mode: "strategy", strategy, roster, params: mergedParams };
137
+ // Ephemeral members ride the SAME grammar path as a named team (see
138
+ // `resolveOrchestrationMembers`) — nothing about the profile is inherited.
139
+ if (members) orch.members = members;
114
140
  const result = await d.runStrategyVisible(ctx, orch, params.question, `council:${_id}`, signal);
115
141
  const s = (result?.structured ?? {}) as {
116
142
  headline?: string;
@@ -133,7 +159,7 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
133
159
  items: s.items,
134
160
  body: uiBody,
135
161
  strategy,
136
- roster,
162
+ roster: panel,
137
163
  persona,
138
164
  ...(result?.error ? { error: result.error } : {}),
139
165
  ...(result?.failureKind ? { failureKind: result.failureKind } : {}),
@@ -151,7 +177,7 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
151
177
  };
152
178
  } catch (err) {
153
179
  const message = err instanceof Error ? err.message : String(err);
154
- return { content: [{ type: "text", text: `council failed: ${message}` }], details: failureDetails({ error: message, strategy, roster }), isError: true };
180
+ return { content: [{ type: "text", text: `council failed: ${message}` }], details: failureDetails({ error: message, strategy, roster: panel }), isError: true };
155
181
  }
156
182
  },
157
183
  renderCall(args, theme) {
@@ -159,10 +185,12 @@ export function registerCouncilTool(pi: ExtensionAPI, d: CouncilToolDeps): void
159
185
  persona: args.persona,
160
186
  strategy: args.strategy,
161
187
  roster: args.roster,
188
+ members: args.members,
162
189
  params: args.params as Record<string, unknown> | undefined,
163
190
  });
164
191
  const strategy = resolved.ok ? resolved.value.strategy : (args.strategy ?? "?");
165
- const roster = resolved.ok ? resolved.value.roster : (args.roster ?? args.persona ?? "?");
192
+ // With inline members there is no named team to name — label the panel itself.
193
+ const roster = resolved.ok ? panelLabel(resolved.value) : (args.roster ?? args.persona ?? "?");
166
194
  return new Text(theme.fg("toolTitle", theme.bold(formatCouncilCallLabel(strategy, roster))), 0, 0);
167
195
  },
168
196
  renderResult(result, { expanded }, theme) {