@llblab/pi-actors 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/AGENTS.md +3 -3
  2. package/BACKLOG.md +22 -1
  3. package/CHANGELOG.md +31 -0
  4. package/README.md +5 -4
  5. package/dist/index.js +30 -51
  6. package/dist/lib/inspector-overlay.d.ts +84 -0
  7. package/dist/lib/inspector-overlay.js +627 -0
  8. package/dist/lib/inspector.d.ts +17 -33
  9. package/dist/lib/inspector.js +98 -151
  10. package/dist/lib/limits.d.ts +3 -0
  11. package/dist/lib/limits.js +3 -0
  12. package/dist/lib/observability.d.ts +1 -1
  13. package/dist/lib/observability.js +2 -2
  14. package/dist/lib/pi.d.ts +1 -1
  15. package/dist/lib/pi.js +2 -2
  16. package/dist/lib/prompts.d.ts +1 -1
  17. package/dist/lib/prompts.js +3 -3
  18. package/dist/lib/recipes-context.d.ts +5 -0
  19. package/dist/lib/recipes-context.js +15 -0
  20. package/dist/lib/session-evidence.d.ts +45 -0
  21. package/dist/lib/session-evidence.js +204 -0
  22. package/dist/lib/tools-response.d.ts +3 -0
  23. package/dist/lib/tools-response.js +31 -0
  24. package/dist/scripts/async-runner.mjs +40 -5
  25. package/dist/scripts/coordinator.mjs +55 -9
  26. package/dist/skills/actors/SKILL.md +10 -9
  27. package/dist/skills/swarm/SKILL.md +4 -2
  28. package/docs/README.md +1 -0
  29. package/docs/actor-inspector.md +72 -0
  30. package/docs/async-runs.md +2 -2
  31. package/index.ts +32 -63
  32. package/lib/inspector-overlay.ts +735 -0
  33. package/lib/inspector.ts +132 -204
  34. package/lib/limits.ts +3 -0
  35. package/lib/observability.ts +3 -3
  36. package/lib/pi.ts +3 -3
  37. package/lib/prompts.ts +3 -3
  38. package/lib/recipes-context.ts +25 -0
  39. package/lib/session-evidence.ts +302 -0
  40. package/lib/tools-response.ts +33 -0
  41. package/package.json +1 -1
  42. package/scripts/async-runner.mjs +40 -5
  43. package/scripts/coordinator.mjs +55 -9
  44. package/skills/actors/SKILL.md +10 -9
  45. package/skills/swarm/SKILL.md +4 -2
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Persisted Pi session evidence reader.
3
+ * Zones: subagent turns, active session branches, bounded/redacted previews
4
+ * Owns read-only normalization of session JSONL into inspector-ready turns.
5
+ */
6
+ import * as Limits from "./limits.js";
7
+ import { readJsonlFileResilient, } from "./state-readers.js";
8
+ const SENSITIVE_KEY = /(?:^|[_-])(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|secret[-_]?access[-_]?key|token)$|^(?:access|refresh|auth|api)Token$|^(?:clientSecret|privateKey|secretAccessKey)$/i;
9
+ const SENSITIVE_TEXT = /(bearer\s+)[A-Za-z0-9._~+/=-]+|["']?\b(api[-_]?key|authorization|clientSecret|cookie|password|private[-_]?key|privateKey|secret|secretAccessKey|token)["']?(\s*[:=]\s*)["']?([^\s,;"'}]+)/gi;
10
+ function asRecord(value) {
11
+ return value && typeof value === "object" && !Array.isArray(value)
12
+ ? value
13
+ : {};
14
+ }
15
+ function boundedText(value, maxChars) {
16
+ if (typeof value !== "string")
17
+ return undefined;
18
+ if (/^\s*[\[{]/.test(value)) {
19
+ try {
20
+ const structured = JSON.stringify(redactSessionEvidenceValue(JSON.parse(value), maxChars));
21
+ return structured.length > maxChars
22
+ ? `${structured.slice(0, Math.max(0, maxChars - 1))}…`
23
+ : structured;
24
+ }
25
+ catch {
26
+ // Fall through to bounded text redaction.
27
+ }
28
+ }
29
+ const redacted = value.replaceAll(SENSITIVE_TEXT, (match, bearer, key, separator) => bearer ? `${bearer}[REDACTED]` : `${key}${separator}[REDACTED]`);
30
+ return redacted.length > maxChars
31
+ ? `${redacted.slice(0, Math.max(0, maxChars - 1))}…`
32
+ : redacted;
33
+ }
34
+ export function redactSessionEvidenceValue(value, maxTextChars = Limits.SESSION_EVIDENCE_TEXT_CHARS, seen = new WeakSet()) {
35
+ if (typeof value === "string")
36
+ return boundedText(value, maxTextChars);
37
+ if (!value || typeof value !== "object")
38
+ return value;
39
+ if (seen.has(value))
40
+ return "[CIRCULAR]";
41
+ seen.add(value);
42
+ if (Array.isArray(value)) {
43
+ return value.map((item) => redactSessionEvidenceValue(item, maxTextChars, seen));
44
+ }
45
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
46
+ key,
47
+ SENSITIVE_KEY.test(key)
48
+ ? "[REDACTED]"
49
+ : redactSessionEvidenceValue(item, maxTextChars, seen),
50
+ ]));
51
+ }
52
+ function contentBlocks(message) {
53
+ return Array.isArray(message.content)
54
+ ? message.content.map(asRecord)
55
+ : typeof message.content === "string"
56
+ ? [{ type: "text", text: message.content }]
57
+ : [];
58
+ }
59
+ function contentText(message, type, maxChars) {
60
+ const text = contentBlocks(message)
61
+ .filter((block) => block.type === type && typeof block[type] === "string")
62
+ .map((block) => String(block[type]))
63
+ .join("\n");
64
+ return boundedText(text, maxChars);
65
+ }
66
+ function activeBranch(entries, path, diagnostics) {
67
+ const treeEntries = entries.filter((entry) => entry.type !== "session" && typeof entry.id === "string");
68
+ const leaf = treeEntries.at(-1);
69
+ if (!leaf || typeof leaf.id !== "string")
70
+ return [];
71
+ const byId = new Map(treeEntries.map((entry) => [String(entry.id), entry]));
72
+ const branch = [];
73
+ const visited = new Set();
74
+ let current = leaf;
75
+ while (current && typeof current.id === "string") {
76
+ if (visited.has(current.id)) {
77
+ diagnostics.push({ message: `session entry cycle at ${current.id}`, path });
78
+ break;
79
+ }
80
+ visited.add(current.id);
81
+ branch.push(current);
82
+ if (current.parentId === null || current.parentId === undefined)
83
+ break;
84
+ if (typeof current.parentId !== "string" || !byId.has(current.parentId)) {
85
+ diagnostics.push({
86
+ message: `missing parent ${String(current.parentId)} for ${current.id}`,
87
+ path,
88
+ });
89
+ break;
90
+ }
91
+ current = byId.get(current.parentId);
92
+ }
93
+ return branch.reverse();
94
+ }
95
+ function toolCalls(message, maxTextChars, maxToolCalls) {
96
+ return contentBlocks(message)
97
+ .filter((block) => block.type === "toolCall" &&
98
+ typeof block.id === "string" &&
99
+ typeof block.name === "string")
100
+ .slice(0, maxToolCalls)
101
+ .map((block) => ({
102
+ arguments: redactSessionEvidenceValue(block.arguments, maxTextChars),
103
+ id: String(block.id),
104
+ name: String(block.name),
105
+ }));
106
+ }
107
+ export function readSessionEvidence(path, options = {}) {
108
+ const maxTextChars = Math.max(1, options.maxTextChars ?? Limits.SESSION_EVIDENCE_TEXT_CHARS);
109
+ const maxToolCalls = Math.max(1, options.maxToolCalls ?? Limits.SESSION_EVIDENCE_MAX_TOOL_CALLS);
110
+ const maxTurns = Math.max(1, options.maxTurns ?? Limits.SESSION_EVIDENCE_MAX_TURNS);
111
+ const read = readJsonlFileResilient(path);
112
+ const diagnostics = [...read.diagnostics];
113
+ const header = read.records.find((entry) => entry.type === "session");
114
+ const branch = activeBranch(read.records, path, diagnostics);
115
+ const turns = [];
116
+ let pendingUser;
117
+ let currentTurn;
118
+ for (const entry of branch) {
119
+ if (entry.type !== "message")
120
+ continue;
121
+ const message = asRecord(entry.message);
122
+ const role = message.role;
123
+ if (role === "user") {
124
+ pendingUser = {
125
+ id: String(entry.id),
126
+ text: contentText(message, "text", maxTextChars),
127
+ };
128
+ currentTurn = undefined;
129
+ continue;
130
+ }
131
+ if (role === "assistant") {
132
+ currentTurn = {
133
+ ...(typeof entry.id === "string" ? { assistantEntryId: entry.id } : {}),
134
+ ...(contentText(message, "text", maxTextChars)
135
+ ? { assistantText: contentText(message, "text", maxTextChars) }
136
+ : {}),
137
+ ...(typeof message.errorMessage === "string"
138
+ ? { error: boundedText(message.errorMessage, maxTextChars) }
139
+ : {}),
140
+ index: turns.length + 1,
141
+ ...(typeof message.model === "string" ? { model: message.model } : {}),
142
+ ...(typeof message.provider === "string"
143
+ ? { provider: message.provider }
144
+ : {}),
145
+ ...(typeof message.stopReason === "string"
146
+ ? { stopReason: message.stopReason }
147
+ : {}),
148
+ ...(contentText(message, "thinking", maxTextChars)
149
+ ? { thinking: contentText(message, "thinking", maxTextChars) }
150
+ : {}),
151
+ ...(typeof entry.timestamp === "string"
152
+ ? { timestamp: entry.timestamp }
153
+ : {}),
154
+ toolCalls: toolCalls(message, maxTextChars, maxToolCalls),
155
+ unmatchedToolResults: 0,
156
+ ...(message.usage !== undefined
157
+ ? { usage: redactSessionEvidenceValue(message.usage, maxTextChars) }
158
+ : {}),
159
+ ...(pendingUser
160
+ ? {
161
+ userEntryId: pendingUser.id,
162
+ ...(pendingUser.text ? { userText: pendingUser.text } : {}),
163
+ }
164
+ : {}),
165
+ };
166
+ pendingUser = undefined;
167
+ turns.push(currentTurn);
168
+ continue;
169
+ }
170
+ if (role === "toolResult" && currentTurn) {
171
+ const callId = typeof message.toolCallId === "string" ? message.toolCallId : "";
172
+ const call = currentTurn.toolCalls.find((item) => item.id === callId);
173
+ if (!call) {
174
+ currentTurn.unmatchedToolResults += 1;
175
+ continue;
176
+ }
177
+ call.result = redactSessionEvidenceValue(message.content, maxTextChars);
178
+ call.resultError = message.isError === true;
179
+ }
180
+ }
181
+ if (pendingUser) {
182
+ turns.push({
183
+ index: turns.length + 1,
184
+ toolCalls: [],
185
+ unmatchedToolResults: 0,
186
+ userEntryId: pendingUser.id,
187
+ ...(pendingUser.text ? { userText: pendingUser.text } : {}),
188
+ });
189
+ }
190
+ const firstVisibleIndex = Math.max(0, turns.length - maxTurns);
191
+ const visibleTurns = turns.slice(firstVisibleIndex).map((turn, index) => ({
192
+ ...turn,
193
+ index: firstVisibleIndex + index + 1,
194
+ }));
195
+ return {
196
+ ...(branch.at(-1)?.id ? { activeLeafId: String(branch.at(-1)?.id) } : {}),
197
+ diagnostics,
198
+ path,
199
+ ...(header ? { session: asRecord(header) } : {}),
200
+ totalTurns: turns.length,
201
+ truncated: turns.length > visibleTurns.length,
202
+ turns: visibleTurns,
203
+ };
204
+ }
@@ -3,6 +3,9 @@
3
3
  * Zones: compact text summaries, verbose JSON switching, next-action rendering
4
4
  * Owns model-facing response helpers shared by public tool execution paths
5
5
  */
6
+ export declare function withLeadingBlankLine(text: string): string;
7
+ export declare function spaceToolResult<T>(result: T): T;
8
+ export declare function spaceToolError(error: unknown): unknown;
6
9
  export declare function asRecord(value: unknown): Record<string, unknown>;
7
10
  export declare function jsonText(value: unknown): string;
8
11
  export declare function compactPreview(value: unknown, maxLength?: number): string | undefined;
@@ -5,6 +5,37 @@
5
5
  */
6
6
  import * as Limits from "./limits.js";
7
7
  import * as ToolsMailbox from "./tools-mailbox.js";
8
+ export function withLeadingBlankLine(text) {
9
+ if (text.startsWith("\n\n"))
10
+ return text;
11
+ return text.startsWith("\n") ? `\n${text}` : `\n\n${text}`;
12
+ }
13
+ export function spaceToolResult(result) {
14
+ if (!result || typeof result !== "object")
15
+ return result;
16
+ const content = result.content;
17
+ if (!Array.isArray(content))
18
+ return result;
19
+ return {
20
+ ...result,
21
+ content: content.map((item) => item &&
22
+ typeof item === "object" &&
23
+ item.type === "text" &&
24
+ typeof item.text === "string"
25
+ ? {
26
+ ...item,
27
+ text: withLeadingBlankLine(item.text),
28
+ }
29
+ : item),
30
+ };
31
+ }
32
+ export function spaceToolError(error) {
33
+ if (error instanceof Error) {
34
+ error.message = withLeadingBlankLine(error.message);
35
+ return error;
36
+ }
37
+ return new Error(withLeadingBlankLine(String(error)));
38
+ }
8
39
  export function asRecord(value) {
9
40
  return value && typeof value === "object" && !Array.isArray(value)
10
41
  ? value
@@ -13,6 +13,7 @@ import {
13
13
  existsSync,
14
14
  mkdirSync,
15
15
  readFileSync,
16
+ readdirSync,
16
17
  statSync,
17
18
  } from "node:fs";
18
19
  import { dirname, join, relative } from "node:path";
@@ -31,8 +32,11 @@ async function importRuntimeModule(name) {
31
32
  );
32
33
  }
33
34
 
34
- const { appendRecipeContextToPiArgs, materializePiPrintPromptArg } =
35
- await importRuntimeModule("recipes-context");
35
+ const {
36
+ appendRecipeContextToPiArgs,
37
+ attachPiSessionDir,
38
+ materializePiPrintPromptArg,
39
+ } = await importRuntimeModule("recipes-context");
36
40
  const { buildReviewPreflightDiagnostic, formatReviewPreflightDiagnostic } =
37
41
  await importRuntimeModule("preflight-diagnostics");
38
42
  const { execCommandTemplate } = await importRuntimeModule("command-templates");
@@ -126,11 +130,24 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
126
130
  });
127
131
  }
128
132
 
133
+ function commandSessionFiles(sessionDir) {
134
+ if (!sessionDir) return [];
135
+ try {
136
+ return readdirSync(sessionDir, { withFileTypes: true })
137
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
138
+ .map((entry) => relative(stateDir, join(sessionDir, entry.name)))
139
+ .sort();
140
+ } catch {
141
+ return [];
142
+ }
143
+ }
144
+
129
145
  function commandEvidenceStartRecord({
130
146
  commandDetail,
131
147
  commandId,
132
148
  materialized,
133
149
  options,
150
+ sessionDir,
134
151
  stage,
135
152
  occurrence,
136
153
  }) {
@@ -155,6 +172,7 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
155
172
  ...(materialized.promptBytes
156
173
  ? { prompt_bytes: materialized.promptBytes }
157
174
  : {}),
175
+ ...(sessionDir ? { session_dir: relative(stateDir, sessionDir) } : {}),
158
176
  attempts: [],
159
177
  semantic_acceptance:
160
178
  options?.evidenceContext?.acceptOutput === "review_evidence" ||
@@ -172,6 +190,7 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
172
190
  options,
173
191
  result,
174
192
  rawExitCode,
193
+ sessionDir,
175
194
  stage,
176
195
  occurrence,
177
196
  startedAt,
@@ -234,6 +253,10 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
234
253
  ...(materialized.promptBytes
235
254
  ? { prompt_bytes: materialized.promptBytes }
236
255
  : {}),
256
+ ...(sessionDir ? { session_dir: relative(stateDir, sessionDir) } : {}),
257
+ ...(commandSessionFiles(sessionDir).length > 0
258
+ ? { session_files: commandSessionFiles(sessionDir) }
259
+ : {}),
237
260
  attempts,
238
261
  exit_code: rawExitCode ?? result.code,
239
262
  effective_exit_code: result.code,
@@ -301,21 +324,26 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
301
324
  }
302
325
 
303
326
  async function observedExec(command, args, options) {
327
+ captureCounter += 1;
328
+ const commandId = `command-${String(captureCounter).padStart(3, "0")}`;
304
329
  const contextArgs = appendRecipeContextToPiArgs(
305
330
  command,
306
331
  args,
307
332
  meta.recipe_context_records,
308
333
  options?.actorRecipeContext,
309
334
  );
310
- const materialized = materializePiPrintPromptArg(
335
+ const session = attachPiSessionDir(
311
336
  command,
312
337
  contextArgs,
338
+ join(stateDir, "sessions", commandId),
339
+ );
340
+ const materialized = materializePiPrintPromptArg(
341
+ command,
342
+ session.args,
313
343
  promptFilePath,
314
344
  );
315
345
  const execArgs = materialized.args;
316
346
  const commandDetail = formatCommandDetail(command, execArgs);
317
- captureCounter += 1;
318
- const commandId = `command-${String(captureCounter).padStart(3, "0")}`;
319
347
  const stage =
320
348
  options?.actorRecipeContext?.alias ||
321
349
  options?.actorRecipeContext?.name ||
@@ -346,6 +374,7 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
346
374
  commandId,
347
375
  materialized,
348
376
  options,
377
+ sessionDir: session.sessionDir,
349
378
  stage,
350
379
  occurrence,
351
380
  });
@@ -357,6 +386,7 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
357
386
  command: commandDetail,
358
387
  ...(materialized.promptFile ? { prompt_file: materialized.promptFile } : {}),
359
388
  ...(materialized.promptBytes ? { prompt_bytes: materialized.promptBytes } : {}),
389
+ ...(session.sessionDir ? { session_dir: relative(stateDir, session.sessionDir) } : {}),
360
390
  });
361
391
  progressRunning();
362
392
  const captureDir = join(stateDir, "captures", commandId);
@@ -400,6 +430,7 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
400
430
  options,
401
431
  result,
402
432
  rawExitCode: rawResult.code,
433
+ sessionDir: session.sessionDir,
403
434
  stage,
404
435
  occurrence,
405
436
  startedAt: startedEvidence.started_at,
@@ -426,6 +457,10 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
426
457
  ...captureDetails(result),
427
458
  ...(materialized.promptFile ? { prompt_file: materialized.promptFile } : {}),
428
459
  ...(materialized.promptBytes ? { prompt_bytes: materialized.promptBytes } : {}),
460
+ ...(session.sessionDir ? { session_dir: relative(stateDir, session.sessionDir) } : {}),
461
+ ...(commandSessionFiles(session.sessionDir).length > 0
462
+ ? { session_files: commandSessionFiles(session.sessionDir) }
463
+ : {}),
429
464
  ...(preflightDiagnostic ? { preflight: preflightDiagnostic } : {}),
430
465
  });
431
466
  outbox(
@@ -238,14 +238,24 @@ function terminateProcessGroup(child, signal) {
238
238
  }
239
239
  }
240
240
 
241
- function runPi(prompt, model, thinking, ttlMs = 0) {
241
+ function sessionKey(value) {
242
+ return String(value).replaceAll(/[^A-Za-z0-9_.-]+/g, "-");
243
+ }
244
+
245
+ function runPi(prompt, model, thinking, ttlMs = 0, sessionDir, ownerSessionId) {
242
246
  return new Promise((resolve) => {
243
247
  const args = [
244
248
  "--tools",
245
249
  "inspect,message",
246
250
  "--no-context-files",
247
251
  "--no-skills",
248
- "--no-session",
252
+ ...(sessionDir
253
+ ? [
254
+ "--session-dir",
255
+ sessionDir,
256
+ ...(ownerSessionId ? ["--session-id", ownerSessionId] : []),
257
+ ]
258
+ : ["--no-session"]),
249
259
  ];
250
260
  if (model) {
251
261
  args.push("--model", model);
@@ -346,7 +356,14 @@ async function synthesize(config, locker) {
346
356
  }
347
357
  const transcript = await readRoomTranscript(config);
348
358
  const prompt = `Synthesize this transcript into a concise Markdown artifact. Mission: ${config.mission}. Include: Title, Consensus, Roles, Protocol, Final Artifact Shape, Next Actions, Open Questions. Use only the transcript evidence below.\n\nTRANSCRIPT:\n${transcript.slice(-24000)}`;
349
- const result = await runPi(prompt, config.model, config.thinking, config.subagentTtlMs);
359
+ const result = await runPi(
360
+ prompt,
361
+ config.model,
362
+ config.thinking,
363
+ config.subagentTtlMs,
364
+ `${runStateDir(config.runId)}/sessions/coordinator-synthesis`,
365
+ config.ownerSessionId,
366
+ );
350
367
  const output = result.stdout.trim();
351
368
  const diagnostics = config.stats
352
369
  ? `\n\n## Diagnostics\n\nparticipant_attempts=${config.stats.participantAttempts}\nparticipant_success=${config.stats.participantSuccess}\nparticipant_failures=${config.stats.participantFailures}\nsynthesis_code=${result.code}\ntranscript_messages=${transcript.trim() ? transcript.split("\n").length : 0}\n`
@@ -443,7 +460,7 @@ async function updateInboxMessagesStatus(runId, branchName, ids, status) {
443
460
  });
444
461
  }
445
462
 
446
- async function executeParticipantPrompt(role, basePrompt, config) {
463
+ async function executeParticipantPrompt(role, basePrompt, config, phase) {
447
464
  const branchName = role.name;
448
465
  const queuedMessages = await claimQueuedInboxMessages(
449
466
  config.runId,
@@ -469,7 +486,14 @@ async function executeParticipantPrompt(role, basePrompt, config) {
469
486
  finalPrompt += inboxSection;
470
487
  }
471
488
 
472
- const result = await runPi(finalPrompt, config.model, config.thinking, config.subagentTtlMs);
489
+ const result = await runPi(
490
+ finalPrompt,
491
+ config.model,
492
+ config.thinking,
493
+ config.subagentTtlMs,
494
+ `${runStateDir(config.runId)}/sessions/${sessionKey(`${role.name}-${phase}`)}`,
495
+ config.ownerSessionId,
496
+ );
473
497
 
474
498
  if (claimedIds.length > 0) {
475
499
  const finalStatus = result.code === 0 ? "handled" : "failed";
@@ -488,7 +512,7 @@ async function participantRound(role, round, config) {
488
512
  const displayName = role.name;
489
513
  const address = `branch:${config.runId}/${role.name}`;
490
514
  const prompt = `You are ${displayName} (${address}), ${role.persona}. Mission: ${config.mission}. Round ${round}/${config.rounds}. First call inspect target=${config.room} view=previews lines=30 and inspect target=${config.room} view=contacts. Then call message once to ${config.room} from ${address} type=chat.message. Body: 2-4 sentences that react to a named participant, propose the next coordination step, and refine the shared artifact. Use contacts for peer names and addresses. End stdout with summary <=160 chars.`;
491
- const result = await executeParticipantPrompt(role, prompt, config);
515
+ const result = await executeParticipantPrompt(role, prompt, config, `round-${round}`);
492
516
  if (config.stats) {
493
517
  config.stats.participantAttempts += 1;
494
518
  if (result.code === 0) config.stats.participantSuccess += 1;
@@ -506,14 +530,28 @@ async function participantJoin(role, config) {
506
530
  const displayName = role.name;
507
531
  const address = `branch:${config.runId}/${role.name}`;
508
532
  const joinPrompt = `You are ${displayName}, ${role.persona}. Mission: ${config.mission}. Call tool message exactly once with to=${shellQuote(config.room)}, from=${shellQuote(address)}, type='actor.join', summary='${displayName} joined', body JSON {"role":${JSON.stringify(role.persona)},"display":${JSON.stringify(displayName)},"caps":["coordination","synthesis"],"claim":"coordinate on mission"}. Then print one short line.`;
509
- await runPi(joinPrompt, config.model, config.thinking, config.subagentTtlMs);
533
+ await runPi(
534
+ joinPrompt,
535
+ config.model,
536
+ config.thinking,
537
+ config.subagentTtlMs,
538
+ `${runStateDir(config.runId)}/sessions/${sessionKey(`${role.name}-join`)}`,
539
+ config.ownerSessionId,
540
+ );
510
541
  }
511
542
 
512
543
  async function participantLeave(role, config) {
513
544
  const displayName = role.name;
514
545
  const address = `branch:${config.runId}/${role.name}`;
515
546
  const leavePrompt = `Call tool message exactly once with to=${shellQuote(config.room)}, from=${shellQuote(address)}, type='actor.leave', summary='${displayName} left', body='finished coordinated work'. Then print goodbye.`;
516
- await runPi(leavePrompt, config.model, config.thinking, config.subagentTtlMs);
547
+ await runPi(
548
+ leavePrompt,
549
+ config.model,
550
+ config.thinking,
551
+ config.subagentTtlMs,
552
+ `${runStateDir(config.runId)}/sessions/${sessionKey(`${role.name}-leave`)}`,
553
+ config.ownerSessionId,
554
+ );
517
555
  }
518
556
 
519
557
  // 1. consensus / swarm mode: iterative chat in a room
@@ -601,7 +639,12 @@ async function runPool(config, locker) {
601
639
 
602
640
  const taskId = assignedTask.task;
603
641
  const prompt = `You are ${role.name}, ${role.persona}. You have been assigned task ${taskId}: "${assignedTask.task}". Solve it as part of the overall mission: ${config.mission}. End your response with a clear summary.`;
604
- const result = await executeParticipantPrompt(role, prompt, config);
642
+ const result = await executeParticipantPrompt(
643
+ role,
644
+ prompt,
645
+ config,
646
+ `task-${taskId}`,
647
+ );
605
648
 
606
649
  process.stdout.write(
607
650
  `[${role.name} completed ${taskId}] code=${result.code}\n${result.stdout}\n`,
@@ -711,6 +754,9 @@ if (!config.model) {
711
754
  process.exit(2);
712
755
  }
713
756
  config.room = `room:${config.runId}`;
757
+ config.ownerSessionId = String(
758
+ (await readJsonFile(`${runStateDir(config.runId)}/run.json`)).ownerId ?? "",
759
+ );
714
760
 
715
761
  const failures = [];
716
762
  const locker = await startLocker(config);
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: actors
3
- description: Required practical guide for non-trivial pi-actors use. Read before using or changing spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
3
+ description: Required practical guide for non-trivial pi-actors use, including parallel actor launches, subagent fanout, and autonomous coordinator workflows. Read before using or changing spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
4
4
  metadata:
5
- version: 0.40.0
5
+ version: 0.41.0
6
6
  ---
7
7
 
8
8
  # Actors (pi-actors)
@@ -53,6 +53,8 @@ Actor-mode trigger: if work may outlive this turn, needs steering/follow-up/arti
53
53
 
54
54
  Use for long work, background services, subagents, fanout, pipelines, and reusable recipes.
55
55
 
56
+ Before a parallel launch, the coordinator should verify five things once: each actor owns a disjoint mutation scope, every run has a stable id, each durable result has an artifact path, every command template respects shell-free argv execution, and completion can return through follow-up delivery without polling. For multiple delegated implementation or review actors, also load the bundled Swarm skill before choosing decomposition, locks, or quorum shape.
57
+
56
58
  ```json
57
59
  {
58
60
  "as": "run:repo-health",
@@ -64,6 +66,7 @@ Use for long work, background services, subagents, fanout, pipelines, and reusab
64
66
 
65
67
  Rules:
66
68
 
69
+ - Command-template strings execute directly without a shell. Operators such as `&&`, `||`, pipes, redirects, and `cd` remain literal argv unless an explicit trusted shell is the executable. Prefer absolute paths or template arrays for sequencing; put non-trivial shell behavior in a reviewed script.
67
70
  - Use `file`/`recipe` for saved recipes; bare names resolve under `~/.pi/agent/recipes`.
68
71
  - Use inline `template` for one-off experiments; promote useful repeats to recipes.
69
72
  - When a successful actor follow-up suggests persistence, decide whether the pattern deserves durable tool memory; call `register_tool` yourself only when the evidence is strong, and ask before writing the user recipe root.
@@ -122,15 +125,11 @@ Views:
122
125
  - `artifacts`: declared artifact paths/status plus the same bounded owned review-evidence manifest when present.
123
126
  - `recipes` target: registry summary for active, shadowed, invalid, disabled, and diagnostic recipe entries.
124
127
 
125
- Actor inspector commands:
126
-
127
- - `/actors-inspector-toggle [rows]`: open/close the compact table or set row count; default is 12 log rows when no size is supplied.
128
- - `/actors-inspector-filter all|room|direct|broadcast|unread|branch <name>|current-branch <name>|mention <text>`: narrow table previews without changing room/run state.
129
- - `/actors-inspect <number>`: open one visible row as a full-message view.
128
+ Actor inspector navigation uses one command: `/actors-inspector-toggle` opens the centered overlay. It exposes explicit Tabs → Filters → List → Detail focus zones over the first run owned by the current session. Tabs use ←/→; ↓ enters Filters; ↑ at the top boundary does nothing. The filter bar contains only selectable controls; arrows move focus, Enter opens a compact value popup anchored beneath that filter, ↑/↓ hover a value, and Enter applies it. A filter cannot move down into an empty list. Accent text marks current values, a light background marks keyboard focus, and opening a nested popup preserves both its blue parent filter and the striped timeline outside the occluded cells. Lists use ↑/↓ and Enter; detail uses ↑/↓ and Enter/←. Escape cancels options or closes the overlay. The list body carries the selected run and live status above compact striped evidence rows; tabs, live refresh, empty states, and key hints remain visible so no subcommand grammar is required.
130
129
 
131
- The table is compact and optimistic by default: bounded body previews, capped noisy room rows, branch-local inbox previews, stable event ids in selected-message details, and an inline roster summary in the form `name/role` that wraps only when needed. Use `unread` for queued branch inbox work and `branch <name>` / `current-branch <name>` for one branch's room/direct/inbox traffic. Rows with `metadata.requires_response=true` show a `!` attention marker. `/actors-inspect <number>` marks that row read for the current session filter. Active roster members use the target color; members that sent `actor.leave` stay visible as inactive/muted participants from the current run. Actor display names come from `actor.join` bodies (`display`) or branch addresses, keeping debugger output plain and name-driven.
130
+ Communication rows retain bounded body previews, capped noisy room traffic, branch-local inbox state, stable event ids, attention markers, and compact roster summaries. Turn rows come only from persisted owned child-session evidence and show source model/text at a glance. Turn detail remains bounded and includes command/stage, session/prompt/recipe provenance, user/assistant text, persisted thinking or explicit reasoning unavailability, stop/usage/error metadata, correlated tool arguments/results, truncation, and parse diagnostics. Active roster members use the target color, departed members stay muted, and display names come from `actor.join` bodies or branch addresses.
132
131
 
133
- Let terminal notifications arrive. When a deferred actor result gates the next step, wait for that terminal steering notification instead of scheduling continuation loops, repeatedly inspecting, or mutating the actor's reviewed scope. Busy coordinators receive it at the next safe tool boundary; idle coordinators start a normal turn. Inspect early only for an operator request, a meaningful actor event, or diagnosis of an overdue or stuck run.
132
+ Let terminal notifications arrive. They queue through Pi's follow-up delivery mode, so a busy coordinator finishes its current work before receiving concurrently completed actor results; the host's `followUpMode` controls whether queued results arrive together or one at a time. When a deferred actor result gates the next step, wait for that terminal follow-up instead of scheduling continuation loops, repeatedly inspecting, or mutating the actor's reviewed scope. Idle coordinators still start a normal turn through `triggerTurn: true`. Inspect early only for an operator request, a meaningful actor event, or diagnosis of an overdue or stuck run.
134
133
 
135
134
  ## Runtime Communication Rules
136
135
 
@@ -163,6 +162,8 @@ Controls:
163
162
  - `repeat`: repeated node expansion.
164
163
  - `output`: output behavior selection.
165
164
  - Command stdout/stderr use bounded tails plus complete spill files; tool/run diagnostics expose byte counts, truncation, and spill paths, while pipelines fail with `incomplete pipeline stdin` rather than consuming a partial tail.
165
+ - Detached child `pi -p` commands receive isolated session storage under `sessions/command-NNN` in their owned run state, and command evidence records any resulting JSONL files. Coordinator-managed room/swarm participants use role/phase-scoped directories under the same run-local `sessions/` root so their turns remain discoverable too. Explicit `--no-session`, `--session`, `--session-id`, `--session-dir`, or `--fork` policy remains caller-owned and is never replaced.
166
+ - Persisted child-session inspection follows the latest JSONL entry branch, correlates tool results by call id, bounds previews, and redacts common secret-bearing fields/text. Thinking content is evidence only when Pi persisted an explicit `thinking` block; never infer or advertise hidden reasoning.
166
167
 
167
168
  Placeholders:
168
169
 
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: swarm
3
- description: Subagent orchestration with scoped locks and quorum consensus. Use for multi-model review, parallel scoped work, delegated audit, and coordinated subagent execution.
3
+ description: Subagent and actor orchestration with scoped locks, fanout, and quorum consensus. Use before launching multiple parallel actors or subagents for independent implementation, artifact generation, review, delegated audit, coordinated execution, or any workflow that needs autonomous coordinator decomposition and integration.
4
4
  metadata:
5
- version: 0.40.0
5
+ version: 0.41.0
6
6
  ---
7
7
 
8
8
  # Swarm
@@ -13,6 +13,8 @@ Subagent orchestration: delegated review, quorum consensus, scoped locks, clean-
13
13
 
14
14
  Run subagents safely and predictably through reusable orchestration contracts.
15
15
 
16
+ Activation rule: load this skill before launching multiple independent actors or subagents, even when the work is creative artifact generation rather than code review. The coordinator owns decomposition, disjoint scopes, launch correctness, result integration, and final validation; participants may choose local content or implementation details independently inside their assigned boundaries.
17
+
16
18
  Swarm is independent. It must not require concrete sibling skill names, private repositories, local model aliases, or a specific tool registry layout. Local agents may bind the contracts to their own tools, command templates, model names, and review protocols.
17
19
 
18
20
  Maintain this skill as a living orchestration standard. When real swarm work exposes better decomposition shapes, lock etiquette, quorum rules, checkpoint semantics, failure modes, or trade-offs, fold those lessons back here as durable guidance instead of leaving them only in one-off transcripts or backlog notes.
package/docs/README.md CHANGED
@@ -9,6 +9,7 @@ Living index of all documentation in the `/docs` directory.
9
9
  - [template-recipes.md](./template-recipes.md) — Saved JSON/Markdown recipe standard, imports, and reusable command-template graph composition
10
10
  - [async-runs.md](./async-runs.md) — Detached run lifecycle, state files, actor messages, cancellation, and ambient indicators
11
11
  - [actor-messages.md](./actor-messages.md) — Actor/message protocol for symmetric communication primitives
12
+ - [actor-inspector.md](./actor-inspector.md) — Manual owned-run navigation across communication and persisted subagent turns
12
13
  - [tool-registry.md](./tool-registry.md) — Local `pi-actors` registry storage and `register_tool` adaptation
13
14
  - [recipe-library.md](./recipe-library.md) — Packaged standard recipe library such as async subagents, coordinator pipelines, utilities, and music playback
14
15
  - [task-first-recipes.md](./task-first-recipes.md) — Task-first design map for deriving high-level recipes and missing component cells