pi-subagents 0.51.0 → 0.52.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 (53) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/agents/oracle.md +3 -1
  3. package/agents/reviewer.md +1 -0
  4. package/agents/scout.md +2 -2
  5. package/agents/worker.md +2 -1
  6. package/docs/agents.md +8 -3
  7. package/docs/extension-api.md +1 -1
  8. package/docs/models.md +2 -0
  9. package/docs/observability.md +37 -1
  10. package/docs/workflows.md +2 -0
  11. package/package.json +1 -1
  12. package/skills/pi-subagents/SKILL.md +1 -0
  13. package/skills/pi-subagents/references/constraints-and-recipes.md +6 -2
  14. package/skills/pi-subagents/references/execution-controls.md +12 -1
  15. package/skills/pi-subagents/references/prompting-and-roles.md +2 -0
  16. package/src/agents/agent-management.ts +24 -6
  17. package/src/agents/agents.ts +37 -11
  18. package/src/agents/skills.ts +1 -1
  19. package/src/api/preflight.ts +5 -2
  20. package/src/extension/index.ts +1 -0
  21. package/src/extension/public-execution.ts +3 -1
  22. package/src/intercom/native-supervisor-channel.ts +2 -1
  23. package/src/missions/workflow-state.ts +2 -2
  24. package/src/runs/background/async-execution.ts +19 -9
  25. package/src/runs/background/async-resume.ts +19 -2
  26. package/src/runs/background/async-retention.ts +2 -4
  27. package/src/runs/background/completion-dedupe.ts +5 -1
  28. package/src/runs/background/completion-replay.ts +22 -12
  29. package/src/runs/background/fleet-view.ts +68 -19
  30. package/src/runs/background/inspect-rpc.ts +443 -0
  31. package/src/runs/background/notify.ts +28 -4
  32. package/src/runs/background/result-watcher.ts +44 -0
  33. package/src/runs/background/resume-guidance.ts +8 -5
  34. package/src/runs/background/scheduled-runs.ts +2 -0
  35. package/src/runs/background/subagent-runner.ts +3 -2
  36. package/src/runs/background/subagent-wait.ts +1 -1
  37. package/src/runs/background/wait-subscriptions.ts +1 -1
  38. package/src/runs/foreground/execution.ts +1 -1
  39. package/src/runs/foreground/subagent-executor.ts +239 -76
  40. package/src/runs/foreground/workflow-detach-reconcile.ts +194 -0
  41. package/src/runs/shared/acceptance.ts +4 -4
  42. package/src/runs/shared/external-job-bridge.ts +0 -6
  43. package/src/runs/shared/model-fallback.ts +77 -34
  44. package/src/runs/shared/session-lease.ts +0 -6
  45. package/src/runs/shared/worktree.ts +2 -1
  46. package/src/shared/types.ts +11 -0
  47. package/src/slash/slash-bridge.ts +2 -1
  48. package/src/slash/slash-commands.ts +18 -0
  49. package/src/watchdog/change-signature.ts +1 -1
  50. package/src/watchdog/lsp-diagnostics.ts +1 -0
  51. package/src/workflows/chat-progress.ts +1 -1
  52. package/src/workflows/scripted-workflow.ts +13 -2
  53. package/agents/gpt-pro.md +0 -17
@@ -24,7 +24,7 @@ import { resolveNodeExecutable } from "../../shared/node-executable.ts";
24
24
  import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
25
25
  import { buildAgentMemoryInjection } from "../../agents/agent-memory.ts";
26
26
  import { PI_CODING_AGENT_PACKAGE_ROOT_ENV, PROMPT_REDACTED, resolveChildCwd } from "../../shared/utils.ts";
27
- import { buildModelCandidates, resolveEffectiveSubagentModel, resolveModelCandidate, resolveSubagentModelOverride, type AvailableModelInfo, type ParentModel } from "../shared/model-fallback.ts";
27
+ import { buildModelCandidates, inheritsParentModel, resolveEffectiveSubagentModel, resolveModelCandidate, resolveSubagentModelOverride, type AvailableModelInfo, type ParentModel } from "../shared/model-fallback.ts";
28
28
  import { resolveToolTimeoutMs, toolTimeoutFromEnv } from "../shared/tool-timeout.ts";
29
29
  import type { ModelScopeConfig } from "../shared/model-scope.ts";
30
30
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
@@ -220,6 +220,7 @@ interface AsyncSingleParams {
220
220
  agentContract?: AgentContract;
221
221
  structuredOutputSchema?: JsonSchemaObject;
222
222
  modelOverride?: string;
223
+ modelOverrideFromParent?: boolean;
223
224
  thinkingOverride?: AgentConfig["thinking"];
224
225
  availableModels?: AvailableModelInfo[];
225
226
  maxSubagentDepth: number;
@@ -837,7 +838,10 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
837
838
  model,
838
839
  thinking: resolveEffectiveThinking(model, effectiveThinking),
839
840
  launchResolvedExtensions,
840
- modelCandidates: externalRunner ? undefined : buildModelCandidates(primaryModel, a.fallbackModels, availableModels, ctx.currentModelProvider, { scope: ctx.modelScope }).map((candidate) =>
841
+ modelCandidates: externalRunner ? undefined : buildModelCandidates(primaryModel, a.fallbackModels, availableModels, ctx.currentModelProvider, {
842
+ scope: ctx.modelScope,
843
+ primaryModelFromParent: inheritsParentModel(s.model, a.model, ctx.currentModel),
844
+ }).map((candidate) =>
841
845
  applyThinkingSuffix(candidate, effectiveThinking, thinkingOverride !== undefined),
842
846
  ),
843
847
  tools: a.tools,
@@ -1414,12 +1418,14 @@ export function executeAsyncSingle(
1414
1418
  ? `[Read from: ${readPaths.join(", ")}]\n\n`
1415
1419
  : "";
1416
1420
  const taskText = readsInstruction + taskWithOutputInstruction;
1417
- const primaryModel = externalRunner ? undefined : resolveSubagentModelOverride(
1418
- params.modelOverride ?? agentConfig.model,
1419
- ctx.currentModel,
1420
- availableModels,
1421
- ctx.currentModelProvider,
1422
- );
1421
+ const primaryModel = externalRunner ? undefined : params.modelOverrideFromParent
1422
+ ? params.modelOverride
1423
+ : resolveSubagentModelOverride(
1424
+ params.modelOverride ?? agentConfig.model,
1425
+ ctx.currentModel,
1426
+ availableModels,
1427
+ ctx.currentModelProvider,
1428
+ );
1423
1429
  const effectiveThinking = externalRunner ? undefined : params.thinkingOverride ?? agentConfig.thinking;
1424
1430
  const model = externalRunner ? undefined : applyThinkingSuffix(primaryModel, effectiveThinking, params.thinkingOverride !== undefined);
1425
1431
  const toolBudgetInput = params.toolBudget ?? agentConfig.toolBudget ?? params.configToolBudget;
@@ -1446,7 +1452,10 @@ export function executeAsyncSingle(
1446
1452
  : undefined;
1447
1453
  const modelCandidates = externalRunner
1448
1454
  ? []
1449
- : buildModelCandidates(primaryModel, agentConfig.fallbackModels, availableModels, ctx.currentModelProvider, { scope: ctx.modelScope })
1455
+ : buildModelCandidates(primaryModel, agentConfig.fallbackModels, availableModels, ctx.currentModelProvider, {
1456
+ scope: ctx.modelScope,
1457
+ primaryModelFromParent: params.modelOverrideFromParent,
1458
+ })
1450
1459
  .flatMap((candidate) => {
1451
1460
  const resolved = applyThinkingSuffix(candidate, effectiveThinking, params.thinkingOverride !== undefined);
1452
1461
  return resolved ? [resolved] : [];
@@ -1504,6 +1513,7 @@ export function executeAsyncSingle(
1504
1513
  ...(sessionFile ? { sessionFile } : {}),
1505
1514
  cwd: runnerCwd,
1506
1515
  ...(model ? { model } : {}),
1516
+ ...(params.modelOverrideFromParent ? { modelOverrideFromParent: true } : {}),
1507
1517
  ...(recoveryAgentConfig.fallbackModels ? { fallbackModels: [...recoveryAgentConfig.fallbackModels] } : {}),
1508
1518
  ...(effectiveThinking ? { thinking: resolveEffectiveThinking(model, effectiveThinking) } : {}),
1509
1519
  ...(recoveryAgentConfig.tools ? { tools: [...recoveryAgentConfig.tools] } : {}),
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { DIRS, type AcceptanceInput, type AsyncStatus, type ResolvedTurnBudget, type SteeringRecoveryDescriptor } from "../../shared/types.ts";
3
+ import { DIRS, type AcceptanceInput, type AsyncStatus, type ResolvedTurnBudget, type SteeringRecoveryDescriptor, type SubagentRunMode } from "../../shared/types.ts";
4
4
  import type { AgentConfig } from "../../agents/agents.ts";
5
5
  import { validateAcceptanceInput } from "../shared/acceptance.ts";
6
6
  import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
@@ -36,6 +36,7 @@ export type AsyncResumeTarget = {
36
36
  runId: string;
37
37
  asyncDir?: string;
38
38
  state: AsyncStatus["state"];
39
+ mode?: SubagentRunMode;
39
40
  agent: string;
40
41
  index: number;
41
42
  cwd?: string;
@@ -293,6 +294,17 @@ function normalizeRecoveryTurnBudget(value: unknown, descriptorPath: string): Re
293
294
  return result.turnBudget;
294
295
  }
295
296
 
297
+ export function asyncReviveRequiresRecoveryDescriptor(target: Pick<AsyncResumeTarget, "recoveryDescriptor" | "mode" | "sessionFile">): boolean {
298
+ if (target.recoveryDescriptor) return false;
299
+ return !(target.mode === "workflow" && Boolean(target.sessionFile));
300
+ }
301
+
302
+ function resumeTargetMode(status: AsyncStatus | null, result: AsyncResultFile | undefined): SubagentRunMode | undefined {
303
+ if (status?.mode) return status.mode;
304
+ if (result?.mode === "single" || result?.mode === "parallel" || result?.mode === "chain" || result?.mode === "workflow") return result.mode;
305
+ return undefined;
306
+ }
307
+
296
308
  export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): SteeringRecoveryDescriptor | undefined {
297
309
  if (!asyncDir) return undefined;
298
310
  const descriptorPath = path.join(asyncDir, "recovery-descriptor.json");
@@ -306,7 +318,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
306
318
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': expected an object.`);
307
319
  const parsed = value as Record<string, unknown>;
308
320
  const allowedFields = new Set([
309
- "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "fallbackModels", "thinking", "tools", "extensions",
321
+ "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelOverrideFromParent", "fallbackModels", "thinking", "tools", "extensions",
310
322
  "subagentOnlyExtensions", "mcpDirectTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritSkills", "skills",
311
323
  "skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "structuredOutputSchema", "acceptance", "sessionDir", "artifactConfig",
312
324
  "artifactsDir", "maxOutput", "controlConfig", "intercomBridge", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share", "capabilityCeiling",
@@ -333,6 +345,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
333
345
  }
334
346
  if (parsed.systemPromptMode !== "append" && parsed.systemPromptMode !== "replace") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': systemPromptMode is invalid.`);
335
347
  if (parsed.outputMode !== "inline" && parsed.outputMode !== "file-only") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': outputMode is invalid.`);
348
+ if (parsed.modelOverrideFromParent !== undefined && typeof parsed.modelOverrideFromParent !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': modelOverrideFromParent must be a boolean.`);
336
349
  for (const field of ["inheritProjectContext", "inheritSkills", "share"] as const) {
337
350
  if (typeof parsed[field] !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must be a boolean.`);
338
351
  }
@@ -438,6 +451,7 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
438
451
  const recoveryDescriptor = readAsyncRecoveryDescriptor(location.asyncDir ?? undefined);
439
452
  const result = location.resultPath ? readResultFile(location.resultPath) : undefined;
440
453
  const runId = status?.runId ?? result?.runId ?? result?.id ?? location.resolvedId ?? (location.asyncDir ? path.basename(location.asyncDir) : "unknown");
454
+ const mode = resumeTargetMode(status, result);
441
455
  if (options.sessionId && ((status && status.sessionId !== options.sessionId) || (result && result.sessionId !== options.sessionId))) {
442
456
  throw new Error(`Async run '${runId}' was not found in the active session.`);
443
457
  }
@@ -464,6 +478,7 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
464
478
  runId,
465
479
  asyncDir: location.asyncDir ?? undefined,
466
480
  state,
481
+ ...(mode ? { mode } : {}),
467
482
  agent: selectedStep.agent,
468
483
  index: requestedIndex,
469
484
  cwd: status?.cwd ?? result?.cwd,
@@ -491,6 +506,7 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
491
506
  runId,
492
507
  asyncDir: location.asyncDir ?? undefined,
493
508
  state,
509
+ ...(mode ? { mode } : {}),
494
510
  agent: selected.step.agent,
495
511
  index: selected.index,
496
512
  cwd: status?.cwd ?? result?.cwd,
@@ -531,6 +547,7 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
531
547
  runId,
532
548
  asyncDir: location.asyncDir ?? undefined,
533
549
  state,
550
+ ...(mode ? { mode } : {}),
534
551
  agent,
535
552
  index,
536
553
  ...(resumeCwd ? { cwd: resumeCwd } : {}),
@@ -330,13 +330,11 @@ function processStartIdentity(pid: number): string | undefined {
330
330
  }
331
331
  }
332
332
  if (process.platform === "win32") {
333
- const result = spawnSync("powershell.exe", ["-NoProfile", "-Command", `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate`], { encoding: "utf-8" });
333
+ const result = spawnSync("powershell.exe", ["-NoProfile", "-Command", `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate`], { encoding: "utf-8", windowsHide: true });
334
334
  const started = result.status === 0 ? result.stdout.trim() : "";
335
335
  return started ? `win:${started}` : undefined;
336
336
  }
337
- const result = spawnSync("/bin/ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf-8" });
338
- const started = result.status === 0 ? result.stdout.trim() : "";
339
- return started ? `${process.platform}:${started}` : undefined;
337
+ return undefined;
340
338
  }
341
339
 
342
340
  function processIsAlive(pid: number): boolean | undefined {
@@ -3,6 +3,7 @@ interface CompletionDataLike {
3
3
  agent?: unknown;
4
4
  timestamp?: unknown;
5
5
  sessionId?: unknown;
6
+ state?: unknown;
6
7
  taskIndex?: unknown;
7
8
  totalTasks?: unknown;
8
9
  success?: unknown;
@@ -22,7 +23,10 @@ function asFiniteNumber(value: unknown): number | undefined {
22
23
  export function buildCompletionKey(data: CompletionDataLike, fallback: string): string {
23
24
  const sessionId = asNonEmptyString(data.sessionId) ?? "no-session";
24
25
  const id = asNonEmptyString(data.id);
25
- if (id) return `session:${sessionId}:id:${id}`;
26
+ const state = asNonEmptyString(data.state);
27
+ if (id) return state
28
+ ? `session:${sessionId}:id:${id}:state:${state}`
29
+ : `session:${sessionId}:id:${id}`;
26
30
  const agent = asNonEmptyString(data.agent) ?? "unknown";
27
31
  const timestamp = asFiniteNumber(data.timestamp);
28
32
  const taskIndex = asFiniteNumber(data.taskIndex);
@@ -14,6 +14,7 @@ const lastCleanupByResultsDir = new Map<string, number>();
14
14
 
15
15
  export interface CompletionArchiveEntry {
16
16
  agent?: string;
17
+ resultIndex?: number;
17
18
  source: "output-artifact" | "session" | "result-tail";
18
19
  path?: string;
19
20
  text?: string;
@@ -71,39 +72,45 @@ function outputArtifactPath(child: Record<string, unknown>): string | undefined
71
72
  /** Create a small archive that references saved child artifacts and retains only bounded fallback output text. */
72
73
  export function writeCompletionArchive(resultsDir: string, runId: string, data: Record<string, unknown>, createdAt: number): string {
73
74
  const entries: CompletionArchiveEntry[] = [];
74
- const fallback: string[] = [];
75
75
  const results = Array.isArray(data.results) ? data.results : [];
76
- for (const value of results) {
76
+ for (let resultIndex = 0; resultIndex < results.length; resultIndex++) {
77
+ const value = results[resultIndex];
77
78
  if (!value || typeof value !== "object" || Array.isArray(value)) continue;
78
79
  const child = value as Record<string, unknown>;
79
80
  const agent = nonEmptyString(child.agent);
80
81
  const artifactPath = outputArtifactPath(child);
81
82
  if (artifactPath) {
82
- entries.push({ ...(agent ? { agent } : {}), source: "output-artifact", path: artifactPath });
83
+ entries.push({ ...(agent ? { agent } : {}), resultIndex, source: "output-artifact", path: artifactPath });
83
84
  continue;
84
85
  }
85
86
  const sessionPath = existingFile(child.sessionFile);
86
87
  if (sessionPath) {
87
- entries.push({ ...(agent ? { agent } : {}), source: "session", path: sessionPath });
88
+ entries.push({ ...(agent ? { agent } : {}), resultIndex, source: "session", path: sessionPath });
88
89
  continue;
89
90
  }
90
91
  const output = nonEmptyString(child.output);
91
92
  const error = nonEmptyString(child.error);
92
93
  if (output || error) {
93
- fallback.push([agent ? `[${agent}]` : undefined, error ? `Error: ${error}` : undefined, output].filter(Boolean).join("\n"));
94
+ const bounded = utf8Tail([error ? `Error: ${error}` : undefined, output].filter(Boolean).join("\n"), ARCHIVE_TEXT_LIMIT_BYTES);
95
+ entries.push({
96
+ ...(agent ? { agent } : {}),
97
+ resultIndex,
98
+ source: "result-tail",
99
+ text: bounded.text,
100
+ ...(bounded.truncated ? { truncated: true } : {}),
101
+ });
94
102
  }
95
103
  }
96
104
  if (results.length === 0) {
97
105
  const sessionPath = existingFile(data.sessionFile);
98
106
  if (sessionPath) entries.push({ source: "session", path: sessionPath });
99
107
  }
100
- if (entries.length === 0 && fallback.length === 0) {
108
+ if (entries.length === 0) {
101
109
  const summary = nonEmptyString(data.summary);
102
- if (summary) fallback.push(summary);
103
- }
104
- if (fallback.length > 0) {
105
- const bounded = utf8Tail(fallback.join("\n\n"), ARCHIVE_TEXT_LIMIT_BYTES);
106
- entries.push({ source: "result-tail", text: bounded.text, ...(bounded.truncated ? { truncated: true } : {}) });
110
+ if (summary) {
111
+ const bounded = utf8Tail(summary, ARCHIVE_TEXT_LIMIT_BYTES);
112
+ entries.push({ source: "result-tail", text: bounded.text, ...(bounded.truncated ? { truncated: true } : {}) });
113
+ }
107
114
  }
108
115
  const archive: CompletionArchive = { version: ARCHIVE_VERSION, runId, createdAt, entries };
109
116
  const archivePath = completionArchivePath(resultsDir, runId);
@@ -165,6 +172,7 @@ function parseArchive(value: unknown): CompletionArchive | undefined {
165
172
  if (entry.source !== "output-artifact" && entry.source !== "session" && entry.source !== "result-tail") return [];
166
173
  return [{
167
174
  ...(typeof entry.agent === "string" ? { agent: entry.agent } : {}),
175
+ ...(typeof entry.resultIndex === "number" && Number.isSafeInteger(entry.resultIndex) && entry.resultIndex >= 0 ? { resultIndex: entry.resultIndex } : {}),
168
176
  source: entry.source,
169
177
  ...(typeof entry.path === "string" ? { path: entry.path } : {}),
170
178
  ...(typeof entry.text === "string" ? { text: entry.text } : {}),
@@ -228,7 +236,9 @@ export function readCompletionReplay(resultsDir: string, runId: string, options:
228
236
 
229
237
  export function readCompletionArchive(archivePath: string): CompletionArchive | undefined {
230
238
  try {
231
- return parseArchive(JSON.parse(fs.readFileSync(archivePath, "utf-8")));
239
+ const archive = parseArchive(JSON.parse(fs.readFileSync(archivePath, "utf-8")));
240
+ if (!archive) throw new Error("Completion archive is malformed.");
241
+ return archive;
232
242
  } catch (error) {
233
243
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
234
244
  throw error;
@@ -156,34 +156,83 @@ function stringifyJsonPreview(value: unknown, maxLength = 240): string {
156
156
  return raw.length > maxLength ? `${raw.slice(0, maxLength)}…` : raw;
157
157
  }
158
158
 
159
- function contentText(content: unknown): string {
160
- if (typeof content === "string") return content;
161
- if (!Array.isArray(content)) return "";
162
- return content.map((part) => {
163
- if (!part || typeof part !== "object") return "";
164
- const entry = part as { type?: unknown; text?: unknown; name?: unknown; toolName?: unknown; args?: unknown; result?: unknown; content?: unknown };
165
- if (typeof entry.text === "string") return entry.text;
159
+ /** One structured content part of a session JSONL record. Shared by the prose transcript formatter and inspect RPC. */
160
+ export interface SessionTranscriptMessage {
161
+ role: string;
162
+ kind: "text" | "toolCall" | "toolResult";
163
+ text: string;
164
+ name?: string;
165
+ isError?: boolean;
166
+ /** Ordinal of the session record this part came from, so consumers can
167
+ * regroup multi-part messages (one session record can yield several parts). */
168
+ recordIndex?: number;
169
+ }
170
+
171
+ function sessionMessageParts(record: unknown): SessionTranscriptMessage[] {
172
+ if (!record || typeof record !== "object") return [];
173
+ const outer = record as { message?: unknown; role?: unknown; content?: unknown; type?: unknown };
174
+ const message = outer.message && typeof outer.message === "object" ? outer.message as { role?: unknown; content?: unknown } : outer;
175
+ const role = typeof message.role === "string" ? message.role : undefined;
176
+ if (!role) return [];
177
+ const content = message.content;
178
+ if (typeof content === "string") return content.trim() ? [{ role, kind: "text", text: content }] : [];
179
+ if (!Array.isArray(content)) return [];
180
+ const parts: SessionTranscriptMessage[] = [];
181
+ for (const part of content) {
182
+ if (!part || typeof part !== "object") continue;
183
+ const entry = part as { type?: unknown; text?: unknown; name?: unknown; toolName?: unknown; args?: unknown; result?: unknown; content?: unknown; isError?: unknown };
184
+ if (typeof entry.text === "string") {
185
+ if (entry.text.trim()) parts.push({ role, kind: "text", text: entry.text });
186
+ continue;
187
+ }
166
188
  if (entry.type === "toolCall" || entry.type === "tool_call") {
167
189
  const name = typeof entry.name === "string" ? entry.name : typeof entry.toolName === "string" ? entry.toolName : "tool";
168
- return `[tool: ${name}${entry.args === undefined ? "" : ` ${stringifyJsonPreview(entry.args)}`}]`;
190
+ parts.push({ role, kind: "toolCall", name, text: `[tool: ${name}${entry.args === undefined ? "" : ` ${stringifyJsonPreview(entry.args)}`}]` });
191
+ continue;
169
192
  }
170
193
  if (entry.type === "toolResult" || entry.type === "tool_result") {
171
- return `[tool result${entry.result === undefined ? "" : `: ${stringifyJsonPreview(entry.result)}`}]`;
194
+ const name = typeof entry.name === "string" ? entry.name : typeof entry.toolName === "string" ? entry.toolName : undefined;
195
+ parts.push({ role, kind: "toolResult", ...(name ? { name } : {}), ...(entry.isError === true ? { isError: true } : {}), text: `[tool result${entry.result === undefined ? "" : `: ${stringifyJsonPreview(entry.result)}`}]` });
196
+ continue;
197
+ }
198
+ if (entry.content !== undefined) {
199
+ const preview = stringifyJsonPreview(entry.content);
200
+ if (preview.trim()) parts.push({ role, kind: "text", text: preview });
172
201
  }
173
- if (entry.content !== undefined) return stringifyJsonPreview(entry.content);
174
- return "";
175
- }).filter(Boolean).join("\n");
202
+ }
203
+ return parts;
176
204
  }
177
205
 
178
206
  function sessionMessageLine(record: unknown): string | undefined {
179
- if (!record || typeof record !== "object") return undefined;
180
- const outer = record as { message?: unknown; role?: unknown; content?: unknown; type?: unknown };
181
- const message = outer.message && typeof outer.message === "object" ? outer.message as { role?: unknown; content?: unknown } : outer;
182
- const role = typeof message.role === "string" ? message.role : undefined;
183
- if (!role) return undefined;
184
- const text = contentText(message.content).trim();
207
+ const parts = sessionMessageParts(record);
208
+ if (parts.length === 0) return undefined;
209
+ const text = parts.map((part) => part.text).join("\n").trim();
185
210
  if (!text) return undefined;
186
- return `${role}: ${text}`;
211
+ return `${parts[0]!.role}: ${text}`;
212
+ }
213
+
214
+ /** Structured session tail: parsed content parts, newest last, bounded by
215
+ * maxMessages. Same trusted-root containment as the prose transcript tail. */
216
+ export function readSessionMessagesTail(sessionFile: string, maxMessages: number, trustedRoots: string[]): { messages: SessionTranscriptMessage[]; warnings: string[]; truncated: boolean } {
217
+ const tail = readContainedTextTail(sessionFile, Math.max(maxMessages * 4, maxMessages), trustedRoots, "session");
218
+ const warnings: string[] = [];
219
+ if (tail.error) warnings.push(`Session read failed for ${sessionFile}: ${tail.error}`);
220
+ const parsedMessages: SessionTranscriptMessage[] = [];
221
+ let malformed = 0;
222
+ let recordIndex = 0;
223
+ for (const line of tail.lines) {
224
+ if (!line.trim()) continue;
225
+ try {
226
+ const parsed = JSON.parse(line) as unknown;
227
+ for (const part of sessionMessageParts(parsed)) parsedMessages.push({ ...part, recordIndex });
228
+ } catch {
229
+ malformed++;
230
+ }
231
+ recordIndex++;
232
+ }
233
+ if (malformed > 0) warnings.push(`Skipped ${malformed} malformed session tail line${malformed === 1 ? "" : "s"}.`);
234
+ const messages = parsedMessages.slice(-maxMessages);
235
+ return { messages, warnings, truncated: tail.truncated || parsedMessages.length > messages.length };
187
236
  }
188
237
 
189
238
  function readSessionTranscriptTail(sessionFile: string, maxLines: number, trustedRoots: string[]): { lines: string[]; warnings: string[] } {