@tranhoangnguyen0310/pi-flow-external 2.9.0-external.0 → 2.10.0-external.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.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,20 @@ All notable changes to pi-flow external are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [2.10.0-external.0] - 2026-09-25
8
+
9
+ ### Added
10
+ - Add paged Launch inspection through `external_runs` and `/external runs` for queued, active, and historical assignments, including recorded prompts, context, and execution configuration.
11
+ - Record requested settings and applied Pi SDK tools/thinking/context, and preserve workflow source and arguments in redacted launch evidence.
12
+ - Document the delegation experience north star and visual guidelines.
13
+
14
+ ### Changed
15
+ - Make background Agent and workflow cards explicit launch receipts, with inspection routes and clearer workflow purpose, workspace, and access disclosure.
16
+ - Improve card legibility with consistent label/value contrast, bounded source paths and run references, and expanded-only usage accounting.
17
+ - Add run-detail refresh and recover from stale inspection pages without closing navigation.
18
+
7
19
  ## [2.9.0-external.0] - 2026-09-25
20
+
8
21
  ### Added
9
22
 
10
23
  - OpenCode (`opencode`) as a sixth CLI backend, with JSONL progress, verified-final-step completion, session resume, native root-step usage, cancellation, and durable receipts. Targets OpenCode 2 only (verified against `@opencode/cli` 2.0.16); OpenCode 1.x is not supported. Every run uses `--standalone`, a private server the run owns, and never touches the shared background service. Success is verified from the persisted session export: a new user turn for this prompt, a `succeeded` idle outcome, and a clean final assistant message whose text is the result. It never relies on the zero exit code or streamed narration alone. Restricted tiers use native deny-by-default tool rules, not an OS sandbox, and select their agent with `--agent` on every run, including resumes. Unsafe resumes (a restricted resume of a session with its own permission rules, or a danger resume of a restricted session) are refused. A pinned thinking level is passed as the pinned model's `#variant`, which OpenCode validates. Structured schemas are rejected. Nested-subagent total cost is unknown, and budgets are unenforceable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tranhoangnguyen0310/pi-flow-external",
3
- "version": "2.9.0-external.0",
3
+ "version": "2.10.0-external.0",
4
4
  "description": "External Claude Code, Codex CLI, Antigravity, Grok Build CLI, Muse Code, and OpenCode delegation for pi.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -17,7 +17,7 @@ const MAX_CURSOR_CHARS = 4096;
17
17
  const MAX_LIST_SCAN = 200;
18
18
  const RUN_ID_PATTERN = /^run_[A-Za-z0-9_-]{1,128}$/;
19
19
 
20
- export type RunInspectionView = "output" | "diagnostics" | "summary" | "final";
20
+ export type RunInspectionView = "output" | "diagnostics" | "summary" | "final" | "launch";
21
21
  export type RunRecordIntegrity = "complete" | "incomplete" | "damaged";
22
22
  export type RunOutputStatus = "preliminary" | "final" | "interrupted";
23
23
 
@@ -98,6 +98,8 @@ interface EvidenceEvent { runId?: string; sequence?: number; timestamp?: string;
98
98
  interface CompleteLine { start: number; end: number; text: string }
99
99
  interface ScanResult { missing: boolean; stopped: boolean; truncatedTail: boolean; malformed: boolean; endPosition: number }
100
100
  interface RunObservation {
101
+ launch?: Record<string, unknown>;
102
+ applied?: Record<string, unknown>;
101
103
  metadata?: Record<string, unknown>;
102
104
  queuedAt?: string;
103
105
  executionStartedAt?: string;
@@ -115,10 +117,12 @@ export async function inspectRun({
115
117
  view,
116
118
  limitBytes = DEFAULT_PAGE_BYTES,
117
119
  cursor,
120
+ live = false,
118
121
  }: {
119
122
  runsDirectory: string;
120
123
  runId: string;
121
124
  view: RunInspectionView;
125
+ live?: boolean;
122
126
  limitBytes?: number;
123
127
  cursor?: string;
124
128
  }): Promise<RunInspectionPage> {
@@ -136,10 +140,16 @@ export async function inspectRun({
136
140
  const terminalStatus = asString(terminal?.status);
137
141
  const finalAvailable = isFinalAvailable(terminal);
138
142
 
139
- if (view === "summary") {
143
+ if (view === "summary" || view === "launch") {
140
144
  if (decoded?.kind === "inspect" && decoded.source !== "summary") throw new Error("Cursor source does not match summary view");
141
145
  const observation = await readObservation(eventsPath, runId);
142
- const text = JSON.stringify(summaryProjection(runId, summary, observation));
146
+ const text = JSON.stringify(view === "launch" ? {
147
+ runId,
148
+ intent: asRecord(summary.document?.metadata) ?? observation.metadata ?? "Evidence pending or unavailable",
149
+ execution: observation.launch ?? (terminal?.backendStarted === false ? "Never started" : summary.document ? "Not recorded" : live ? "Pending — execution settings not recorded yet" : "Interrupted or uncertain — launch settings unavailable"),
150
+ applied: observation.applied ?? "Not recorded; see execution for CLI settings",
151
+ backendInternalContext: "Not observable by this extension",
152
+ } : summaryProjection(runId, summary, observation), null, view === "launch" ? 2 : undefined);
143
153
  const revision = contentRevision(text);
144
154
  if (decoded?.kind === "inspect" && decoded.revision !== revision) throw staleCursorError();
145
155
  const offset = decoded?.kind === "inspect" ? decoded.textOffset : 0;
@@ -402,6 +412,10 @@ async function readObservation(eventsPath: string, runId: string): Promise<RunOb
402
412
  if (event.type === "run_started") {
403
413
  observation.metadata = asRecord(event.data);
404
414
  observation.queuedAt = asString(observation.metadata?.queuedAt) ?? event.timestamp;
415
+ } else if (event.type === "launch_resolved") {
416
+ observation.launch ??= asRecord(event.data);
417
+ } else if (event.type === "launch_applied") {
418
+ observation.applied ??= asRecord(event.data);
405
419
  } else if (event.type === "execution_started") {
406
420
  observation.executionStartedAt ??= event.timestamp;
407
421
  } else if (event.type === "process_started") {
@@ -839,7 +853,7 @@ function hasKeys(value: Record<string, unknown>, keys: string[]): boolean {
839
853
  }
840
854
 
841
855
  function isView(value: unknown): value is RunInspectionView {
842
- return value === "output" || value === "diagnostics" || value === "summary" || value === "final";
856
+ return value === "output" || value === "diagnostics" || value === "summary" || value === "final" || value === "launch";
843
857
  }
844
858
 
845
859
  function isNonNegativeInteger(value: unknown): value is number {
package/src/core/spawn.ts CHANGED
@@ -22,9 +22,9 @@ import {
22
22
  } from "./progress.ts";
23
23
  import { spawnClaudeSubagent } from "./claude.ts";
24
24
  import { spawnCodexSubagent } from "./codex.ts";
25
- import { spawnAgySubagent, isTransientAgyFailure } from "./agy.ts";
26
- import { spawnGrokSubagent } from "./grok.ts";
27
- import { museHasNestedAgentActivity, spawnMuseSubagent } from "./muse.ts";
25
+ import { spawnAgySubagent, isTransientAgyFailure, normalizeAgyEffort } from "./agy.ts";
26
+ import { spawnGrokSubagent, normalizeGrokReasoningEffort } from "./grok.ts";
27
+ import { museHasNestedAgentActivity, spawnMuseSubagent, normalizeMuseReasoningEffort } from "./muse.ts";
28
28
  import { opencodeHasNestedAgentActivity, spawnOpencodeSubagent } from "./opencode.ts";
29
29
  import type {
30
30
  PermissionTier,
@@ -392,6 +392,34 @@ export async function spawnSubagent(params: SpawnSubagentParams): Promise<AgentT
392
392
  }
393
393
  return result;
394
394
  }
395
+ await record?.event("launch_resolved", {
396
+ roleInstructions: params.profile.systemPrompt,
397
+ appendedInstructions: params.appendInstructions ?? null,
398
+ outputSchema: params.outputSchema ?? params.customTools?.find(tool => tool.name === "structured_output")?.parameters ?? null,
399
+ context: params.context ?? { mode: "none" },
400
+ configuration: {
401
+ harness: selectorHarness(params.profile),
402
+ backend: params.profile.backend,
403
+ model: params.model ? `${params.model.provider}/${params.model.id}` : params.profile.model ?? "Backend default (not resolved by extension)",
404
+ thinkingRequested: params.thinkingLevel,
405
+ thinkingApplied: params.profile.backend === "pi" ? "Pending SDK initialization" :
406
+ params.profile.backend === "agy" ? normalizeAgyEffort(params.thinkingLevel) ?? "Backend default" :
407
+ params.profile.backend === "grok" ? normalizeGrokReasoningEffort(params.thinkingLevel) ?? "Backend default" :
408
+ params.profile.backend === "muse" ? normalizeMuseReasoningEffort(params.thinkingLevel) ?? "Backend default" : params.thinkingLevel ?? "Backend default",
409
+ thinkingApplication: "CLI effort argument, or SDK setting; not a claim about provider-internal reasoning",
410
+ requestedTools: params.profile.tools ?? "Backend defaults",
411
+ resourceLoading: params.profile.backend === "pi" ? { preset: effectivePiResourcePreset(params.profile.preset), projectTrusted: params.projectTrusted === true, contextFiles: "SDK-loaded; recorded after initialization", skills: effectivePiResourcePreset(params.profile.preset) === "skills" ? "Enabled; project skills require trust" : "Disabled", extensions: false, promptTemplates: false, themes: false } : "Backend-controlled; not fully observable",
412
+ tools: params.profile.backend === "pi" ? "Pending SDK initialization" : "Backend-controlled; requested tools recorded separately",
413
+ excludedTools: params.excludeTools,
414
+ workspace: params.ctx.cwd,
415
+ permissionRequested: params.permission ?? "default",
416
+ permissionEffective: permission,
417
+ timeoutMs: params.timeoutMs,
418
+ maxBudgetUsd: params.maxBudgetUsd ?? null,
419
+ budgetEnforced: (params.maxBudgetUsd ?? 0) > 0 && params.profile.backend === "claude",
420
+ resumeRunId: params.resumeRunId ?? null,
421
+ },
422
+ });
395
423
  let resumeSession: Awaited<ReturnType<typeof resolveResume>>["session"];
396
424
  if (params.resumeRunId) {
397
425
  const resolved = await resolveResume(runRecordsDirectory(), params.resumeRunId, params.profile.backend);
@@ -439,6 +467,7 @@ export async function spawnSubagent(params: SpawnSubagentParams): Promise<AgentT
439
467
  let result = await spawnSubagentRuntime({
440
468
  ...params,
441
469
  permission: effectiveTier,
470
+ runRecord: record,
442
471
  signal: timeout.signal,
443
472
  onBackendEvent,
444
473
  onProcessStart,
@@ -906,6 +935,12 @@ async function spawnSubagentRuntime(params: SpawnSubagentRuntimeParams): Promise
906
935
  thinkingClamped = { requested: thinkingLevel, effective: session.thinkingLevel };
907
936
  }
908
937
 
938
+ await params.runRecord?.event("launch_applied", {
939
+ thinkingApplied: session.thinkingLevel,
940
+ tools: session.getActiveToolNames(),
941
+ contextFiles: resourceLoader.getAgentsFiles(),
942
+ systemPrompt: session.systemPrompt,
943
+ });
909
944
  if (signal) {
910
945
  abortHandler = () => {
911
946
  void session?.abort();
@@ -253,7 +253,7 @@ export function renderCompactSubagentNode(
253
253
  ? ` ${theme.fg("muted", `${status === "running" ? "--" : "->"} ${formatActivityLineForDisplay(summary)}`)}`
254
254
  : "";
255
255
  return new Text(
256
- `${indent}${subagentMarker(status, theme, frame, node.timedOut)} ${theme.fg(bodyColor, compactTitle(node))}${meta ? ` ${theme.fg("dim", meta)}` : ""}${preview}`,
256
+ `${indent}${subagentMarker(status, theme, frame, node.timedOut)} ${status === "error" || status === "aborted" ? theme.fg(bodyColor, compactTitle(node)) : compactTitle(node)}${meta ? ` ${theme.fg("dim", meta)}` : ""}${preview}`,
257
257
  0,
258
258
  0,
259
259
  );
@@ -305,13 +305,18 @@ export function renderSubagentNode(
305
305
  expanded = false,
306
306
  showAccess = true,
307
307
  ): Text | Container {
308
+ const displayNode = expanded ? node : { ...node, usage: undefined };
308
309
  const rendered = shouldRenderRichSubagent(node, runningCount)
309
- ? renderRichSubagentNode(node, theme, frame, indent, now, showAccess)
310
- : renderCompactSubagentNode(node, theme, frame, indent, now, showAccess);
311
- if (!expanded || isActiveSubagentStatus(node.status)) {
312
- return rendered;
310
+ ? renderRichSubagentNode(displayNode, theme, frame, indent, now, showAccess)
311
+ : renderCompactSubagentNode(displayNode, theme, frame, indent, now, showAccess);
312
+ if (isActiveSubagentStatus(node.status)) {
313
+ const active = new Container();
314
+ active.addChild(rendered);
315
+ active.addChild(new Text(`${indent} ${theme.fg("muted", "Inspect")} ${expanded ? node.runId ?? node.externalRunId ?? "pending" : (node.runId ?? node.externalRunId)?.slice(-8) ?? "pending"} · ${theme.fg("accent", "/external runs")}`, 0, 0));
316
+ return active;
313
317
  }
314
318
 
319
+ if (!expanded) return rendered;
315
320
  const container = new Container();
316
321
  container.addChild(rendered);
317
322
  const output = node.result ?? node.assistantOutput?.messages.map((message) => message.text).join("\n\n");
@@ -696,10 +696,18 @@ async function readSummary(options: ExternalCommandOptions, runId: string, ctx:
696
696
  return object(JSON.parse(text));
697
697
  }
698
698
 
699
- async function showPages(options: ExternalCommandOptions, runId: string, view: "output" | "diagnostics" | "final", ctx: ExtensionCommandContext): Promise<void> {
699
+ async function showPages(options: ExternalCommandOptions, runId: string, view: "output" | "diagnostics" | "final" | "launch", ctx: ExtensionCommandContext): Promise<void> {
700
700
  let cursor: string | undefined;
701
701
  do {
702
- const page = await runAction(options, { action: "inspect", runId, view, ...(cursor ? { cursor } : {}) }, ctx);
702
+ let page: ExternalRunsResult;
703
+ try {
704
+ page = await runAction(options, { action: "inspect", runId, view, ...(cursor ? { cursor } : {}) }, ctx);
705
+ } catch (error) {
706
+ if (!cursor || !(error instanceof Error) || !error.message.includes("Run changed while paging")) throw error;
707
+ ctx.ui.notify("Run changed while reading. Reopening the latest snapshot from page 1.", "info");
708
+ cursor = undefined;
709
+ page = await runAction(options, { action: "inspect", runId, view }, ctx);
710
+ }
703
711
  // The tool's `final` projection is deliberately a clean, narration-free
704
712
  // canonical-answer surface: unavailable is a bounded EMPTY page with
705
713
  // finalAvailable:false (true for both an agent run and a workflow — see
@@ -738,15 +746,17 @@ function formatSummaryHeader(summary: JsonObject): string {
738
746
 
739
747
  async function navigateRun(options: ExternalCommandOptions, runId: string, ctx: ExtensionCommandContext): Promise<void> {
740
748
  const summary = await readSummary(options, runId, ctx);
749
+ const observedAt = new Date().toISOString();
741
750
  const children = Array.isArray(summary.children) ? summary.children.map(object).filter((child) => typeof child.runId === "string") : [];
742
751
  const state = object(summary.state);
743
752
  const childChoices = children.map((child) => `Child ${String(child.runId)}${child.label ? ` · ${String(child.label)}` : ""}`);
744
- const actions = ["Summary", "Output", "Final", "Diagnostics", ...childChoices, ...(state.status === "running" || state.status === "queued" ? ["Cancel run"] : []), "Back"];
753
+ const actions = ["Refresh", "Launch", "Summary", "Output", "Final", "Diagnostics", ...childChoices, ...(state.status === "running" || state.status === "queued" ? ["Cancel run"] : []), "Back"];
745
754
  while (true) {
746
755
  const choice = await ctx.ui.select(`Run ${runId}`, actions);
747
756
  if (!choice || choice === "Back") return;
748
- if (choice === "Summary") await ctx.ui.editor(`summary ${runId}`, `${formatSummaryHeader(summary)}\n\n${JSON.stringify(summary, null, 2)}`);
749
- else if (choice === "Output" || choice === "Final" || choice === "Diagnostics") await showPages(options, runId, choice.toLowerCase() as "output" | "final" | "diagnostics", ctx);
757
+ if (choice === "Refresh") return navigateRun(options, runId, ctx);
758
+ if (choice === "Summary") await ctx.ui.editor(`summary ${runId}`, `Snapshot ${observedAt} · Refresh for current state\n${formatSummaryHeader(summary)}\n\n${JSON.stringify(summary, null, 2)}`);
759
+ else if (choice === "Launch" || choice === "Output" || choice === "Final" || choice === "Diagnostics") await showPages(options, runId, choice.toLowerCase() as "output" | "final" | "diagnostics" | "launch", ctx);
750
760
  else if (choice === "Cancel run") {
751
761
  if (await ctx.ui.confirm("Cancel external run?", `${runId}\n\nStopping execution does not roll back side effects.`)) {
752
762
  const result = await runAction(options, { action: "cancel", runId, reason: "cancelled from /external runs" }, ctx);
@@ -32,8 +32,8 @@ const externalRunsParameters = Type.Object({
32
32
  maxItems: MAX_TARGETS,
33
33
  description: "Target run IDs (run_... agent, wf_... workflow). For inspect: a single-entry list [\"run_...\"] inspects that run with any view (summary, output, diagnostics, final); multiple entries (1-20 targets) batch summary inspect. For cancel: a single-entry list [\"run_...\"]. For wait: any|all of up to 100 targets.",
34
34
  })),
35
- view: Type.Optional(StringEnum(["summary", "output", "diagnostics", "final"] as const, {
36
- description: "inspect view: summary (state/timing/freshness/refs, default), output (assistant text plus canonical result, partial or final), diagnostics (tool activity/errors), final (only the verified canonical terminal answer, empty until a successful terminal boundary exists). Batch inspect (runIds) only supports summary.",
35
+ view: Type.Optional(StringEnum(["summary", "output", "diagnostics", "final", "launch"] as const, {
36
+ description: "inspect view: launch (recorded prompt, context and execution configuration; sensitive, explicit inspection only), summary (state/timing/freshness/refs, default), output (assistant text plus canonical result, partial or final), diagnostics (tool activity/errors), final (only the verified canonical terminal answer, empty until a successful terminal boundary exists). Batch inspect (runIds) only supports summary.",
37
37
  })),
38
38
  mode: Type.Optional(StringEnum(["any", "all"] as const, {
39
39
  description: "wait mode: any returns on the first terminal outcome; all waits for every target. Neither cancels pending work; an unsuccessful selected workflow returns early even in all mode.",
@@ -797,7 +797,9 @@ export function createExternalRunsTool(
797
797
  const nextCursor = page.nextOffset === undefined ? undefined : encodeProjectionCursor(params.runId, view, page.nextOffset, text);
798
798
  return result(page.text, { runId: params.runId, view, text: page.text, finalAvailable: true, nextCursor });
799
799
  }
800
- const source = view === "summary"
800
+ const source = view === "launch"
801
+ ? { runId: params.runId, launch: (entry?.observation as WorkflowToolDetails | undefined)?.launch ?? historical?.launch ?? "Not recorded" }
802
+ : view === "summary"
801
803
  ? entry ? liveSummary(entry, true) : journalSummary(historical!, true)
802
804
  : view === "output"
803
805
  // Ternary, not ??: entry and historical are mutually exclusive, but a
@@ -835,7 +837,7 @@ export function createExternalRunsTool(
835
837
  return result(portion.text, { runId: params.runId, view, text: portion.text, outputStatus: entry.state === "running" ? "preliminary" : entry.outcome?.outcome === "succeeded" ? "final" : "interrupted", nextCursor });
836
838
  }
837
839
  }
838
- const page = await inspectRun({ runsDirectory, runId: params.runId, view, limitBytes: params.limitBytes, cursor: params.cursor });
840
+ const page = await inspectRun({ runsDirectory, runId: params.runId, view, limitBytes: params.limitBytes, cursor: params.cursor, live: entry?.state === "running" });
839
841
  const text = page.items.map((item) => item.text).join("\n");
840
842
  // view: "final" stays a clean, narration-free canonical-answer
841
843
  // surface — a bounded empty page (finalAvailable:false) when
@@ -448,12 +448,16 @@ function createAgentTool(
448
448
  project,
449
449
  description: params.description,
450
450
  prompt: briefing.prompt,
451
+ authoredPrompt: params.prompt,
452
+ plannedConfiguration: { roleInstructions: profile.systemPrompt, model: profile.model, thinking: thinkingLevel, tools: profile.tools, permissionRequested: params.permission ?? "default", timeoutMs, maxBudgetUsd },
453
+ context: briefing.context ?? { mode: "none" },
451
454
  profile: profile.name,
452
455
  backend: profile.backend,
453
456
  harness: selectorHarness(profile),
454
457
  queuedAt: new Date(queuedAt).toISOString(),
455
458
  },
456
459
  });
460
+ await runRecord.event("intent_ready");
457
461
  const progress = createProgressNode(toolCallId, params.description, subagentType, "queued", profile.backend, selectorHarness(profile));
458
462
  progress.context = briefing.context;
459
463
  progress.queuedAt = queuedAt;
@@ -589,6 +593,7 @@ function createAgentTool(
589
593
  runId: runRecord.runId,
590
594
  recordPath: runRecord.directory,
591
595
  progress,
596
+ backgroundReceipt: true,
592
597
  },
593
598
  );
594
599
  },
@@ -639,14 +644,16 @@ function createAgentTool(
639
644
  const lines = [
640
645
  `${theme.bold("Delegating")} ${theme.bold(getBackendAgentLabel(backend))} ${theme.fg("muted", `→ ${subagentType}`)} · ${theme.fg("warning", tierLabel)}`,
641
646
  description ? `${theme.fg("muted", "Task")} ${description}` : "",
642
- profile?.description ? `${theme.fg("muted", "Why")} ${profile.description}` : "",
647
+ profile?.description ? `${theme.fg("muted", "Role")} ${profile.description}` : "",
643
648
  formatSharedContext(args.context) ? `${theme.fg("muted", "Context")} ${formatSharedContext(args.context)}` : "",
644
649
  context.cwd ? `${theme.fg("muted", "Workspace")} ${context.cwd}` : "",
650
+ `${theme.fg("muted", "Mode")} ${args.background ? "Background" : "Foreground"}`,
645
651
  ].filter(Boolean);
646
652
  return new Text(lines.join("\n"), 0, 0);
647
653
  },
648
654
  renderResult(result, { expanded }, theme) {
649
655
  const details = result.details as SubagentToolDetails;
656
+ if (details.backgroundReceipt) return new Text(`${theme.bold("Started in background")}\n${theme.fg("muted", "Launch receipt — not a live monitor")}\n${theme.fg("muted", "Run")} ${expanded ? details.runId : details.runId?.slice(-8)} · ${theme.fg("accent", "/external runs")} ${theme.fg("muted", "to inspect or follow")}`, 0, 0);
650
657
  return renderSubagentNode(
651
658
  details.progress ?? details,
652
659
  theme,
package/src/types.ts CHANGED
@@ -143,6 +143,8 @@ export interface WorkflowAgentSnapshot {
143
143
  }
144
144
 
145
145
  export interface WorkflowToolDetails {
146
+ launch?: unknown;
147
+ backgroundReceipt?: boolean;
146
148
  name: string;
147
149
  status: "running" | "completed" | "error" | "aborted";
148
150
  /**
@@ -243,6 +245,7 @@ export interface SubagentProgressNode {
243
245
  }
244
246
 
245
247
  export interface SubagentToolDetails {
248
+ backgroundReceipt?: boolean;
246
249
  context?: ParentContextReceipt;
247
250
  description: string;
248
251
  subagentType: SubagentType | "unknown";
@@ -27,6 +27,7 @@ export interface WorkflowRunIdentity {
27
27
  }
28
28
 
29
29
  export interface LoadedWorkflowJournal {
30
+ launch?: unknown;
30
31
  runId: string;
31
32
  path: string;
32
33
  agentResults: WorkflowCachedAgentResult[];
@@ -106,6 +107,7 @@ export async function loadWorkflowJournal(dir: string, runId: string): Promise<L
106
107
 
107
108
  const agentResults: WorkflowCachedAgentResult[] = [];
108
109
  const children: LoadedWorkflowJournal["children"] = [];
110
+ let launch: unknown;
109
111
  let seenRunStart = false;
110
112
  let name: string | undefined;
111
113
  let source: string | undefined;
@@ -131,6 +133,7 @@ export async function loadWorkflowJournal(dir: string, runId: string): Promise<L
131
133
  );
132
134
  }
133
135
  seenRunStart = entry.runId === runId;
136
+ launch = entry.launch;
134
137
  name = typeof entry.name === "string" ? entry.name : undefined;
135
138
  source = typeof entry.source === "string" ? entry.source : undefined;
136
139
  project = typeof entry.project === "string" ? entry.project : undefined;
@@ -189,7 +192,7 @@ export async function loadWorkflowJournal(dir: string, runId: string): Promise<L
189
192
  if (!seenRunStart) {
190
193
  throw new Error(`Workflow journal ${path} does not match run id ${runId}`);
191
194
  }
192
- return { runId, path, agentResults, name, source, project, status, outcome, result, error: terminalError, children: children.filter(Boolean) };
195
+ return { launch, runId, path, agentResults, name, source, project, status, outcome, result, error: terminalError, children: children.filter(Boolean) };
193
196
  }
194
197
 
195
198
  export interface WorkflowJournalPage {
@@ -243,6 +246,7 @@ export async function listWorkflowJournals(dir: string, project: string, limit =
243
246
  }
244
247
 
245
248
  export async function createWorkflowJournalWriter(params: {
249
+ launch?: unknown;
246
250
  dir: string;
247
251
  identity: WorkflowRunIdentity;
248
252
  name: string;
@@ -251,12 +255,13 @@ export async function createWorkflowJournalWriter(params: {
251
255
  scriptPath?: string;
252
256
  resumeFromRunId?: string;
253
257
  }): Promise<WorkflowJournalWriter> {
254
- await mkdir(params.dir, { recursive: true });
258
+ await mkdir(params.dir, { recursive: true, mode: 0o700 });
255
259
  const path = workflowJournalPath(params.dir, params.identity.runId);
256
260
  await writeFile(
257
261
  path,
258
262
  `${JSON.stringify({
259
263
  type: "run_start",
264
+ launch: params.launch,
260
265
  version: JOURNAL_VERSION,
261
266
  apiVersion: params.identity.apiVersion,
262
267
  runId: params.identity.runId,
@@ -268,7 +273,7 @@ export async function createWorkflowJournalWriter(params: {
268
273
  scriptHash: params.identity.scriptHash,
269
274
  argsHash: params.identity.argsHash,
270
275
  })}\n`,
271
- "utf8",
276
+ { encoding: "utf8", mode: 0o600 },
272
277
  );
273
278
 
274
279
  let appendQueue = Promise.resolve();
@@ -235,7 +235,7 @@ export async function runWorkflow<T = unknown>(
235
235
  state.resumePrefixActive = false;
236
236
 
237
237
  // Queue on the shared global cap. May reject if aborted while waiting.
238
- const queuedEvent: WorkflowAgentQueuedEvent = { index, label, phase: assignedPhase, subagentType, prompt: taskPrompt, context: briefing.context };
238
+ const queuedEvent: WorkflowAgentQueuedEvent = { permission: call.permission, maxBudgetUsd: call.maxBudgetUsd, schema: call.schema, authoredPrompt: originalPrompt, index, label, phase: assignedPhase, subagentType, prompt: taskPrompt, context: briefing.context };
239
239
  await options.onAgentQueued?.(queuedEvent);
240
240
  const runRecord = queuedEvent.runRecord;
241
241
  if (runRecord) call.runRecord = runRecord;
@@ -12,6 +12,7 @@ import {
12
12
  type WorkflowRunIdentity,
13
13
  } from "./journal.ts";
14
14
  import { loadSavedWorkflowRegistry, loadWorkflowScriptPath } from "./registry.ts";
15
+ import { redactSecrets } from "../core/run-record.ts";
15
16
  import { parseWorkflowScript } from "./script-validation.ts";
16
17
  import type { WorkflowCachedAgentResult, WorkflowMetaPhase } from "./types.ts";
17
18
 
@@ -56,6 +57,7 @@ export type WorkflowToolParams = Static<typeof workflowToolParameters>;
56
57
 
57
58
  export type PreparedWorkflowToolSource = {
58
59
  script: string;
60
+ launch: unknown;
59
61
  metaName: string;
60
62
  plannedPhases?: WorkflowMetaPhase[];
61
63
  source: "inline" | "saved" | "path";
@@ -309,10 +311,17 @@ export async function prepareWorkflowToolSource(
309
311
  resumeAgentResults = journal.agentResults;
310
312
  }
311
313
 
314
+ const launch = redactSecrets({
315
+ name: metaName, description: parseWorkflowScript(script).meta.description,
316
+ source: source.source, sourcePath: source.sourcePath, script, args: params.args,
317
+ workspace: project, mode: params.background ? "background" : "foreground",
318
+ plannedPhases, resumeFromRunId,
319
+ });
312
320
  let journalWriter: WorkflowJournalWriter | undefined;
313
321
  if (sessionWorkflowDir) {
314
322
  try {
315
323
  journalWriter = await createWorkflowJournalWriter({
324
+ launch,
316
325
  dir: sessionWorkflowDir,
317
326
  identity,
318
327
  name: metaName,
@@ -339,6 +348,7 @@ export async function prepareWorkflowToolSource(
339
348
  return {
340
349
  ok: true,
341
350
  value: {
351
+ launch,
342
352
  script,
343
353
  metaName,
344
354
  plannedPhases,
@@ -7,7 +7,7 @@ import {
7
7
  type ToolDefinition,
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
  import { Container, Text } from "@earendil-works/pi-tui";
10
- import { resolve } from "node:path";
10
+ import { basename, resolve } from "node:path";
11
11
  import type { ConcurrencyLimiter } from "../core/concurrency.ts";
12
12
  import { isActiveSubagentStatus, isCompletedSubagentStatus, renderOutputText, renderSubagentNode } from "../core/subagent-render.ts";
13
13
  import { SPINNER_INTERVAL_MS } from "../core/spinner.ts";
@@ -24,6 +24,7 @@ import { loadExternalCatalog, resolveExternalProfile, selectorHarness, unknownEx
24
24
  import { effectivePiResourcePreset, loadHarnessConfigs } from "../harnesses.ts";
25
25
  import { WORKFLOW_PROMPT_SNIPPET } from "../prompts.ts";
26
26
  import { EXTERNAL_HARNESSES, type PermissionTier, type SubagentProfile, type SubagentToolDetails, type SubagentUsage, type WorkflowAgentSnapshot, type WorkflowToolDetails } from "../types.ts";
27
+ import { parseWorkflowScript } from "./script-validation.ts";
27
28
  import { isWorkflowAbortError, runWorkflow } from "./runtime.ts";
28
29
  import { prepareWorkflowToolSource, workflowToolParameters } from "./source.ts";
29
30
  import { ChildRunError, type ChildRunOutcome, type WorkflowAgentRunner, type WorkflowSubagentDescriptor } from "./types.ts";
@@ -173,6 +174,7 @@ export function createWorkflowTool(
173
174
  } = prepared.value;
174
175
 
175
176
  const snapshot: WorkflowToolDetails = {
177
+ launch: prepared.value.launch,
176
178
  name: metaName,
177
179
  status: "running",
178
180
  agentCount: 0,
@@ -428,12 +430,16 @@ export function createWorkflowTool(
428
430
  workflowRunId: identity.runId,
429
431
  description: event.label,
430
432
  prompt: event.prompt,
433
+ authoredPrompt: event.authoredPrompt,
434
+ plannedConfiguration: { roleInstructions: profile?.systemPrompt, model: profile?.model, thinking: profile?.thinking ?? thinkingLevel, tools: profile?.tools, timeoutMs, permissionRequested: event.permission ?? "default", maxBudgetUsd: event.maxBudgetUsd ?? profile?.maxBudgetUsd ?? defaultMaxBudgetUsd, outputSchema: event.schema ?? null, outputInstructions: event.schema ? "Schema-constrained output; backend-specific instructions resolved at start" : WORKFLOW_PLAIN_TEXT_OUTPUT_NOTE },
435
+ context: event.context ?? { mode: "none" },
431
436
  profile: event.subagentType,
432
437
  backend: profile?.backend,
433
438
  harness: profile ? selectorHarness(profile) : undefined,
434
439
  queuedAt: new Date(queuedAt).toISOString(),
435
440
  },
436
441
  });
442
+ await runRecord.event("intent_ready");
437
443
  event.runRecord = runRecord;
438
444
  snapshot.agents.push({
439
445
  index: event.index,
@@ -588,23 +594,41 @@ export function createWorkflowTool(
588
594
  : result.details.error ? { error: result.details.error } : {}),
589
595
  }),
590
596
  });
597
+ emit();
591
598
  if (!background) return await registered.result;
592
599
  return workflowResult(
593
600
  `Workflow "${metaName}" queued as ${identity.runId}. Use external_runs to inspect, wait, or cancel it.`,
594
- cloneSnapshot(snapshot),
601
+ { ...cloneSnapshot(snapshot), backgroundReceipt: true },
595
602
  );
596
603
  },
597
- renderCall(args, theme, _context) {
598
- const name = typeof args.name === "string" && args.name.trim() ? ` ${theme.fg("muted", args.name.trim())}` : "";
599
- return new Text(`${theme.bold("Workflow")}${name} · ${theme.fg("warning", "unsandboxed external agents")}`, 0, 0);
604
+ renderCall(args, theme, context) {
605
+ let name = args.name || (args.scriptPath ? basename(args.scriptPath) : "Preparing workflow");
606
+ let purpose = "";
607
+ const state = context.state as { script?: string; meta?: { name: string; description: string } };
608
+ if (typeof args.script === "string") {
609
+ if (state.script !== args.script) {
610
+ state.script = args.script;
611
+ state.meta = undefined;
612
+ try { state.meta = parseWorkflowScript(args.script).meta; } catch { /* Streamed arguments may be incomplete. */ }
613
+ }
614
+ if (state.meta) { name = state.meta.name; purpose = state.meta.description; }
615
+ }
616
+ return new Text([
617
+ `${theme.bold("Workflow")} ${name}`,
618
+ purpose ? `${theme.fg("muted", "Purpose")} ${purpose}` : "",
619
+ `${theme.fg("muted", "Source")} ${args.scriptPath ? (context.expanded ? args.scriptPath : basename(args.scriptPath)) : args.name ?? "Ad-hoc workflow"}`,
620
+ `${theme.fg("muted", "Workspace")} ${context.cwd}`,
621
+ `${theme.fg("muted", "Mode")} ${args.background ? "Background" : "Foreground"}`,
622
+ theme.fg("warning", "Host access · children may execute unsandboxed; inspect effective permissions"),
623
+ ].filter(Boolean).join("\n"), 0, 0);
600
624
  },
601
- renderResult(result, { expanded }, theme) {
625
+ renderResult(result, { expanded }, theme, context) {
602
626
  // Pure function of the snapshot: the spinner frame is carried on the
603
627
  // snapshot itself and advanced by the runtime heartbeat in execute(), so
604
628
  // there is no UI-side timer to leak when a row is torn down or rendered in
605
629
  // a non-live context (e.g. HTML export).
606
630
  const details = result.details as WorkflowToolDetails;
607
- return renderWorkflowSnapshot(details, theme, details.frame ?? 0, expanded);
631
+ return renderWorkflowSnapshot(details, theme, details.frame ?? 0, expanded, Boolean((context.state as { meta?: unknown } | undefined)?.meta));
608
632
  },
609
633
  });
610
634
  }
@@ -733,14 +757,23 @@ function renderFlatAgents(container: Container, details: WorkflowToolDetails, th
733
757
  }
734
758
  }
735
759
 
736
- function renderWorkflowSnapshot(details: WorkflowToolDetails, theme: Theme, frame: number, expanded: boolean): Container {
760
+ function renderWorkflowSnapshot(details: WorkflowToolDetails, theme: Theme, frame: number, expanded: boolean, purposeShown = false): Container {
737
761
  const container = new Container();
762
+ const launch = details.launch as { description?: string } | undefined;
763
+ if (!purposeShown && launch?.description) container.addChild(new Text(`${theme.fg("muted", "Purpose")} ${launch.description}`, 0, 0));
764
+ const inspect = `${theme.fg("muted", "Run")} ${expanded ? details.runId : details.runId?.slice(-8)} · ${theme.fg("accent", "/external runs")} ${theme.fg("muted", "to inspect or follow")}`;
765
+ if (details.backgroundReceipt) {
766
+ container.addChild(new Text(`${theme.bold("Started in background")}\n${theme.fg("muted", "Launch receipt — not a live monitor")}\n${inspect}`, 0, 0));
767
+ return container;
768
+ }
769
+ if (details.runId) container.addChild(new Text(inspect, 0, 0));
770
+ if (!details.agents.length && details.status === "running") container.addChild(new Text("Preparing workflow · No assignments launched yet", 0, 0));
738
771
  const done = details.agents.filter((agent) => isCompletedSubagentStatus(agent.status)).length;
739
772
  const active = workflowRunningCount(details);
740
773
  const queued = details.agents.filter((agent) => agent.status === "queued").length;
741
774
  const failed = details.agents.filter(isFailedWorkflowAgent).length;
742
775
  const counts = formatAgentCounts(done, active, queued, failed, details.agents.length);
743
- const access = details.status === "running" ? "external host access · " : "";
776
+ const access = "";
744
777
  container.addChild(
745
778
  new Text(
746
779
  `${theme.bold(`Workflow(${details.name})`)} ${theme.fg("dim", `${normalizeRunStatus(details.status)} · ${access}${counts}`)}`,
@@ -1,7 +1,7 @@
1
1
  import type { ParentContextMessages, ParentContextReceipt } from "../core/parent-context.ts";
2
2
  import type { ConcurrencyLimiter } from "../core/concurrency.ts";
3
3
  import type { RunRecord } from "../core/run-record.ts";
4
- import type { SubagentAssistantOutput } from "../types.ts";
4
+ import type { PermissionTier, SubagentAssistantOutput } from "../types.ts";
5
5
 
6
6
  export type ChildRunOutcome = "failed" | "cancelled" | "timed_out";
7
7
 
@@ -112,6 +112,10 @@ export interface WorkflowAgentResultEvent extends WorkflowCachedAgentResult {
112
112
  }
113
113
 
114
114
  export interface WorkflowAgentQueuedEvent {
115
+ permission?: PermissionTier;
116
+ maxBudgetUsd?: number;
117
+ schema?: unknown;
118
+ authoredPrompt?: string;
115
119
  index: number;
116
120
  label: string;
117
121
  phase?: string;