@tranhoangnguyen0310/pi-flow-external 2.3.0-external.0 → 2.3.1-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/AGENTS.md CHANGED
@@ -13,6 +13,7 @@ This fork changes the original pi-flow contract: `Agent` is not a generic Pi sub
13
13
  - A `backend: pi` profile is external-delegation-eligible only when it also declares `harness: <name>` for a name present in the live `harnesses.json` registry; a bare `backend: pi` profile (no `harness`, or one naming an unregistered config) is filtered out and rejected exactly as before — it belongs to Pi's native subagent system and this extension never modifies it.
14
14
  - **Named Pi harness configuration:** a `pi-<label>` entry in `harnesses.json` pinning a `provider/model` id (resolved through Pi's own model registry) and a thinking level (`off|minimal|low|medium|high|xhigh`, always persisted explicitly). It runs in-process via Pi's own SDK, not as a spawned CLI. A pi child loads no project/user extensions, skills, prompt templates, or themes — its entire tool surface is the SDK's own builtins (`read`/`bash`/`edit`/`write`, plus `grep`/`find`/`ls` where a tier's default allow-list adds them), curated but never claimed to be an OS sandbox: `danger` tier `bash` is exactly as exposed as on any external CLI. Retry is disabled per child (in-memory, call-scoped, never touching the user's real settings) to honor this extension's no-auto-retry contract, since the underlying SDK otherwise retries transient provider errors on its own. Pi children cannot resume (no persisted session) and have no enforced budget cap — both are deliberate v1 limitations, not oversights. Custom (non-canonical) roles are still authored per harness via `/external profile create`, not shared across harnesses automatically. Follow-up capability expansion (trusted extensions/MCP/skills, shared custom roles, resumable sessions, real budget controls) is tracked in issue #43.
15
15
  - Use Pi's native subagent system for Pi-backed scout/reviewer/planner/worker/oracle work.
16
+ - Permission resolution takes the least restrictive of the profile tier (or default when absent) and the caller's request: `readonly < edit < danger`. Profile permissions are a floor, not an overridable default; a parent cannot handcuff a `danger` worker by requesting `readonly`. Existing backend-specific execution floors still apply. Disclosure and receipts show the resolved tier.
16
17
  - External CLI backends use their own tools and permission mechanisms. Codex tiers map to its `--sandbox` axis. Grok tiers also map to its `--sandbox` axis (`read-only`/`workspace`/`off`), always paired with `--permission-mode bypassPermissions`; bypass only skips the interactive prompt, and the kernel sandbox remains the enforced boundary at every tier. Grok's readonly network-blocking guarantee is Linux-only (a no-op on macOS), and sandbox startup can fail closed rather than silently downgrading on some macOS hosts (for example when `/var/run/docker.sock` resolves to a symlink). Claude falls back to `--permission-mode auto` when its effective UID is 0 because Claude refuses bypass mode under root. Execution lanes (implementer, qa, worker) require shell command authority to inspect repositories and run tests; on Claude and on pi harnesses (where `edit` tier excludes `bash`), execution lanes maintain a danger floor so agents are not artificially handcuffed. Grok needs no such floor: its `edit` tier (`--sandbox workspace`) already permits shell execution, sandboxed to writes within the workspace. Antigravity (`agy`) has no granular headless permission mode — its default sandbox denies even read-only tools — so every agy run is unsandboxed (`--dangerously-skip-permissions`) and `readonly`/`edit` on agy are advisory profile-body instructions, not a boundary. Run them only in trusted repositories and state whether the task is read-only or may edit files.
17
18
  - Grok supports `resume` via its own `--resume <sessionId>` flag and reports native cost (`total_cost_usd`) rather than an estimate. It has no budget-enforcement mechanism, so `max_budget_usd` is recorded but unenforceable, and — like codex — it is never automatically retried (agy's one infra-failure retry exception does not apply to Grok).
18
19
  - Children receive no parent history by default. `Agent` and workflow `agent()` can opt into `context: {mode: "recent", turns: N}` (last N user turns, including the current one) or `{mode: "full"}` (available post-compaction conversation). Snapshots exclude system instructions, thinking, tool-result metadata, and pending calls; unsupported content/images and more than 1 MiB fail explicitly. Use the smallest sufficient snapshot plus a clear task, absolute paths, and read-only/edit intent. Shared context goes to the external harness and private local evidence; avoid unnecessary sensitive history. `resume` continues an existing child and cannot be combined with sharing. Workflow children select from one frozen parent snapshot, and replay fingerprints include the transferred context.
@@ -37,7 +38,8 @@ This fork changes the original pi-flow contract: `Agent` is not a generic Pi sub
37
38
  - Settings retention automatically prunes eligible completed records beyond `maxRunRecords`; active, interrupted, incomplete, and damaged records are not eligible.
38
39
  - Structured nested-agent activity may extend the wall-clock deadline once, by one fresh base timeout, capped at twice the original deadline.
39
40
  - Do not automatically retry failed or aborted external runs. Preserve the receipt and retry only when the user asks. Exception: the agy backend retries once on infrastructure-classified failures (auth, eligibility, network); the retry is disclosed in the receipt details (`retries`, `retryOf`) and never applies to agent-level failures, aborts, or timeouts.
40
- - `external_runs` is session/project scoped. `list` pages runs and workflow roots, each carrying a shared timing projection (`queuedAt`/`executionStartedAt`/`processStartedAt`/activity timestamps plus derived `queueDelayMs`/`elapsedMs`/`activityAgeMs`/`processDurationMs`); `activityAgeMs` and a running `elapsedMs` are computed only for a target independently confirmed live by the registry, never inferred from a durable record's status field alone (an unowned "running"-looking record may be an orphaned/crashed run, not a live one). `inspect` pages `summary`/`output`/`diagnostics`/`final` for one `runId`; `final` returns only the same canonical terminal result every other view already reads (no separate backend parser, no narration promoted to final) and is empty with `finalAvailable: false` until a verified successful terminal boundary exists — including for workflows, where an intentional JSON `null` result still counts as available. `inspect` also accepts a bounded batch `runIds` (up to 20, deduplicated, order preserved, summary-only, mutually exclusive with `runId`): every requested ID's ownership is validated before any page returns, entries share one consistent nested shape regardless of live/durable/agent/workflow origin (a workflow entry omits its unbounded children array in favor of `outputRef`/`diagnosticsRef`), reuses the existing per-inspect `limitBytes` cap with no second budget, and fails with an actionable error naming the run rather than ever silently dropping a target or serving a page over the caller's own byte budget. `wait` returns outcomes only for the selected one/any/all target set and never cancels pending work; `cancel` targets one stable ID and preserves its reason. An unresolvable `wf_...` ID fails as unknown/unavailable across `inspect`/`cancel`/`wait`/batch `inspect`, never falling through to the agent-only durable reader. No routine run event sends a parent message or notification.
41
+ - `external_runs` is session/project scoped. `list` pages runs and workflow roots, each carrying a shared timing projection (`queuedAt`/`executionStartedAt`/`processStartedAt`/activity timestamps plus derived `queueDelayMs`/`elapsedMs`/`activityAgeMs`/`processDurationMs`); `activityAgeMs` and a running `elapsedMs` are computed only for a target independently confirmed live by the registry, never inferred from a durable record's status field alone (an unowned "running"-looking record may be an orphaned/crashed run, not a live one). `inspect` pages `summary`/`output`/`diagnostics`/`final` for one `runId`; `final` returns only the same canonical terminal result every other view already reads (no separate backend parser, no narration promoted to final) and is empty with `finalAvailable: false` until a verified successful terminal boundary exists — including for workflows, where an intentional JSON `null` result still counts as available. `inspect` also accepts a bounded batch `runIds` (up to 20, deduplicated, order preserved, summary-only, mutually exclusive with `runId`): every requested ID's ownership is validated before any page returns, entries share one consistent nested shape regardless of live/durable/agent/workflow origin (a workflow entry omits its unbounded children array in favor of `outputRef`/`diagnosticsRef`), reuses the existing per-inspect `limitBytes` cap with no second budget, and fails with an actionable error naming the run rather than ever silently dropping a target or serving a page over the caller's own byte budget. `wait` returns outcomes only for the selected one/any/all target set and never cancels pending work; while waiting it reports bounded live progress (watched targets, completed/pending counts, recent activity) through the tool's own update channel on a fixed heartbeat, stopped on settlement/error/interruption, that never mutates run state or affects cancellation; each settled outcome's `result` is spent from one shared byte budget (`limitBytes`, default 32768) across the whole response in the caller's requested order, never settlement race order — complete when it fits, `resultTruncated: true` plus the existing `outputRef`/`diagnosticsRef` when it does not, never a fixed-length teaser regardless of size; a target's evidence is never read from disk once that shared budget is already exhausted. `cancel` targets one stable ID and preserves its reason. An unresolvable `wf_...` ID fails as unknown/unavailable across `inspect`/`cancel`/`wait`/batch `inspect`, never falling through to the agent-only durable reader. No routine run event sends a parent message or notification.
42
+ - `list`, `inspect`, and `wait` share one run projection (`src/core/run-projection.ts`: `projectLiveAgent`/`projectDurableAgent` for agents, `projectWorkflowRun` for workflows) instead of independent per-surface field extraction. Task identity includes the resolved profile and an explicit `harness` (registered pi-* config, or equal to backend for agy/claude/codex), persisted at the source (run-record metadata, live progress nodes, workflow child snapshots) by every backend — never reparsed or guessed from a profile/subagentType name.
41
43
 
42
44
  ## Workflow contract
43
45
 
package/CHANGELOG.md CHANGED
@@ -4,8 +4,37 @@ All notable changes to pi-flow external are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [2.3.1-external.0] - 2026-09-21
8
+
9
+ ### Fixed
10
+ - Resolve permission requests against the profile's minimum authority (`readonly < edit < danger`) across all harnesses. Parent requests can increase permissions but cannot reduce a worker's calibrated permission floor. Agent, workflow, and disclosures share the same resolver; backend-specific floors remain in effect.
11
+
7
12
  ## [2.3.0-external.0] - 2026-09-21
8
13
 
14
+ Unifies the run experience across direct `Agent`, `workflow`, and `external_runs` per issue #52: a shared projection, shared rendering, and a corrected model/human contract, rather than isolated renderer patches.
15
+
16
+ ### Added
17
+
18
+ - `Agent`, `workflow`, and `external_runs` now share one normalized run projection (`src/core/run-projection.ts`) for identity/state/timing/output, covering both agents (`projectLiveAgent`/`projectDurableAgent`) and workflows (`projectWorkflowRun`). Every `external_runs` surface — `list`, single `inspect`, batch `inspect`, and `wait` — routes through it instead of independently hand-rolling its own shape; a durable `list`/`inspect` row now also carries the same nested `task`/`state`/`output` shape a live one already had, alongside its existing flat fields for compatibility.
19
+ - Run identity now includes the resolved profile and harness end-to-end: `Agent`, workflow children, and every backend (`claude`/`codex`/`agy`/pi) persist an explicit `harness` (registered pi-* config, or equal to backend for external CLIs) on `SubagentProgressNode`/`SubagentToolDetails`/`WorkflowAgentSnapshot` and in run-record metadata — never reparsed or guessed from a profile/subagentType name. `RunTaskProjection.profile`/`.harness` are populated from this real persisted data (`src/profiles.ts`'s `selectorHarness` is the one shared resolution).
20
+ - `external_runs` registers its own `renderCall`/`renderResult` for the first time, covering `list`, single/batch `inspect`, `wait`, and `cancel` with the same reusable header/row presentation `Agent`/`workflow` already use (`src/core/run-render.ts`, shared with `/external runs`' own text formatting).
21
+ - `external_runs wait` reports bounded live progress through its update channel while waiting — watched targets, completed/pending counts, and recent activity — on a fixed heartbeat independent of any single target settling; it stops on settlement, error, or interruption without ever cancelling watched work.
22
+ - Text answers now render as Markdown where a host theme is available (`renderOutputText` in `src/core/subagent-render.ts`, shared by `Agent`'s expanded output, `workflow`'s final text-answer output, and `external_runs`'s `output`/`final` views), falling back to plain text — never throwing — when no theme has been initialized (headless/RPC callers, tests). A workflow's structured (non-string) result stays formatted data, never Markdown-interpreted, per this issue's "no invented prose answer" contract.
23
+ - Workflow `agent()` accepts `description` as a compatible alias for `label`, matching direct `Agent`'s common task-name option; setting both to different values is rejected.
24
+ - `WorkflowToolDetails` gained an additive `lifecycleStatus` field normalizing the legacy internal `"completed"` state word to the same vocabulary `Agent`'s `details.status` already uses (`"done"`), for a coordinating model reading either tool's details. The internal `status` field and its literal `"completed"` value are unchanged for existing readers (journal replay, rendering internals).
25
+ - Documented ccstyle (`pi-cc-extensions`) host-renderer integration: the `excludeRenderers` setting is the supported way to preserve `Agent`/`workflow`/`external_runs`'s own rendering under that host (README "Host renderer integration").
26
+ - Added an authoritative cross-surface contract test (`test/run-contract.test.ts`) asserting the same run reports the same identity/status/output facts across `list`, single `inspect`, batch `inspect`, and `wait`, and that a workflow child's identity matches what `external_runs` independently reports for that same child runId.
27
+
28
+ ### Changed
29
+
30
+ - `external_runs wait` no longer clips every settled outcome to a fixed 512-character `preview`. Each response now spends one shared byte budget (`limitBytes`, default 32768) across all settled outcomes in the caller's REQUESTED order (never settlement race order — two calls with the same targets and final states now spend the budget identically regardless of which target happened to settle first): a result that fits is returned complete under the new `result` field; one that does not is truncated to what remains with `resultTruncated: true`, alongside the existing `outputRef`/`diagnosticsRef` to read the rest. A disk read of a target's evidence is now skipped entirely once the shared budget is already exhausted, rather than reading and then discarding it. The old `preview` field is removed.
31
+ - `liveSummary`/`journalSummary`/batch `resolveRunSummaryEntry`'s workflow branch now build their `task`/`state`/`output` through the single shared `projectWorkflowRun`, fixing a real drift: batch inspect previously omitted `output.status` entirely (present on the other two), and `liveSummary` never stated a top-level `live` flag.
32
+ - `WorkflowAgentSnapshot` field copying (`workflow/tool.ts`) now goes through shared helpers (`src/core/agent-snapshot.ts`) instead of an inline per-field copy. Fixes a real disclosure/evidence gap: a workflow child's `retries`, `retryOf`, and `permissionRequested` were never copied onto its snapshot at all (silently dropped the moment a retried/elevated child ran inside a workflow instead of as a direct `Agent` call), and `thinkingClamped` was dropped on the running-progress copy.
33
+ - Corrected the workflow help's background example: `background` is a `workflow` tool call parameter, never a `meta` field. The previous example's `meta.background: true` was silently ignored and did not run the workflow in the background.
34
+
35
+ ### Fixed
36
+
37
+ - `preview`'s hidden double-truncation: a full in-memory `outcome.result` under 512 characters was fine, but the exact bug reported in issue #52 (`external_runs wait` returning `kind: "agent"` with a clipped result) is now gone for any result that fits the shared budget.
9
38
  Adds `grok` as a fourth external CLI backend, delegating to the official Grok Build CLI alongside Claude Code, Codex CLI, and Antigravity. Investigation notes: `docs/plans/grok-backend-prep.md`; implementation plan: `docs/plans/2026-05-21-grok-cli-implementation.md`; ex-ante design issue: [#51](https://github.com/tranhoangnguyen03/pi-flow-external/issues/51).
10
39
 
11
40
  ### Added
package/README.md CHANGED
@@ -259,6 +259,24 @@ Completed rows show a short evidence identifier and result preview. Press **Ctrl
259
259
 
260
260
  Workflows show access once at the workflow level, retain done/active/queued/failed counts, and expose bounded final output plus the workflow journal when expanded. Hidden child rows remain reachable through `/external runs`. Active rows report last-activity freshness independently of the spinner. Raw backend events remain in local records rather than flooding the default terminal view.
261
261
 
262
+ `Agent`, `workflow`, and `external_runs` all register their own `renderCall`/`renderResult` — the same reusable header, status/timing/output presentation, and disclosure rules across all three, so a workflow's card is more hierarchy over the same visual language rather than a different one, and `external_runs` (list/inspect/wait) reads like the tools it observes rather than raw JSON. A text answer's final output — `Agent`'s own expanded result, a workflow's string result, and `external_runs`' `output`/`final` views — renders as Markdown where a host theme is available, falling back to plain text (never a crash) otherwise; a workflow's structured (object/array) result stays formatted data rather than Markdown-interpreted prose.
263
+
264
+ ### Host renderer integration
265
+
266
+ Stock Pi renders every tool's own `renderCall`/`renderResult` directly, so the cards above work with no configuration.
267
+
268
+ **Compatibility caveat — pi-cc / ccstyle:** our live progress renderers are not compatible with the default rendering overrides in [`pi-cc-extensions`](https://www.npmjs.com/package/pi-cc-extensions). Manual testing showed workflow and wait progress replaced by a generic `Pending…` display while Agent retained its dedicated renderer. Expanded generic output can also duplicate raw JSON. This is a display limitation, not evidence that the underlying run has stopped.
269
+
270
+ To preserve this extension's renderers, add its three tool names to pi-cc's `excludeRenderers` setting in `~/.pi/agent/pi-cc-extensions.json`. Merge these entries into your existing configuration; do not replace other settings or exclusions:
271
+
272
+ ```json
273
+ {
274
+ "excludeRenderers": ["Agent", "workflow", "external_runs"]
275
+ }
276
+ ```
277
+
278
+ Restart Pi after changing the configuration. Exclusions use **tool names**, not the extension's package name. All three tools above provide custom renderers. This is pi-cc's supported renderer-preservation mechanism; do not edit its installed package files. End-to-end progress rendering with these exclusions still needs confirmation in your installed pi-cc version. If it continues to show only `Pending…`, disable pi-cc when you need our live progress display. Full compatibility with pi-cc's default overrides is not claimed.
279
+
262
280
  ## Background runs and supervision
263
281
 
264
282
  `Agent` and `workflow` are blocking by default — an ordinary call waits for its child and returns the result, run everything this way unless the parent has other work to do first. Add `background: true` only when the parent can productively proceed before completion; it returns a stable `run_...` or `wf_...` handle after validation and registration while the originating Pi session continues to own the work:
@@ -277,7 +295,7 @@ Use `external_runs` with these actions:
277
295
  - `list`: current session/project runs; use `cursor` for run pages and `workflowCursor` for workflow pages. `workflowRunId` filters children of one workflow. Rows carry a `timing` projection (`queueDelayMs`, `elapsedMs`, `activityAgeMs` when live, `processDurationMs`) plus `outputAvailable`/`finalAvailable`.
278
296
  - `inspect`: single `runId` with `view: "summary" | "output" | "diagnostics" | "final"`, and optional opaque `cursor`/`limitBytes` (max 64 KiB, same cap for every view). Follow `nextCursor` to avoid truncation. `summary` includes the same `timing` projection as `list`, plus `output.finalAvailable`. `final` returns only the verified canonical terminal answer — empty with `finalAvailable: false` until a successful terminal boundary exists; it never promotes partial/narration text. `output` stays the combined stream (assistant messages plus canonical result) and is unchanged.
279
297
  - `inspect` with `runIds` instead of `runId` (up to 20, deduplicated, order preserved): a single bounded batch of `summary`-only projections — one cheap request to see whether several selected background children are queued, running, or terminal, each with `outputRef`/`diagnosticsRef` for follow-up detail. Ownership of every requested ID is validated before any page is returned. Reuses the same `limitBytes` cap as single-run inspection; pages contain whole target entries and continue through `nextCursor`, without invalidation from ordinary live progress. If one compact entry cannot fit, an actionable error asks you to increase `limitBytes` or inspect that run individually; no target is silently dropped. `runId` and `runIds` are mutually exclusive, and batch `view` must stay `summary`.
280
- - `wait`: one `runId` or selected `runIds`, with `mode: "any" | "all"`. It returns terminal outcomes plus still-pending IDs; an unsuccessful workflow returns early even in `all` mode. It never chooses a winner or cancels pending work.
298
+ - `wait`: one `runId` or selected `runIds`, with `mode: "any" | "all"`. It returns terminal outcomes plus still-pending IDs; an unsuccessful workflow returns early even in `all` mode. It never chooses a winner or cancels pending work. While waiting, a bounded heartbeat (independent of any single target settling) reports live progress — watched targets, completed/pending counts, and recent activity — through the tool's update channel; it stops automatically on settlement, error, or interruption. Each settled outcome's `result` is spent from one shared byte budget (`limitBytes`, default 32768) across the whole response, in the requested `runId`/`runIds` order — never settlement race order, so the same targets and final states spend the budget identically regardless of which one happened to settle first: a result that fits is returned complete, one that does not is truncated with `resultTruncated: true` and the existing `outputRef`/`diagnosticsRef` to continue reading it — not a fixed-length teaser regardless of size. A target's evidence is never read from disk once the shared budget is already exhausted.
281
299
  - `cancel`: one `runId` and optional reason. Whole-workflow cancellation stops active children; targeted child cancellation remains a catchable workflow outcome. Cancellation does not roll back edits or other side effects.
282
300
 
283
301
  Interrupting a blocking `Agent`/`workflow` call cancels its work. Interrupting `external_runs wait` stops only that wait. Background work survives its launching tool return and ordinary parent turns, but not the owning session: orderly session shutdown requests cancellation and waits for bounded cleanup. This is not a daemon. After a host crash or unconfirmed shutdown, unfinished evidence is `interrupted_or_uncertain`; restart restores evidence access, never live ownership or guaranteed retrospective process termination. No routine activity wakes the parent, and live steering is not supported.
@@ -321,7 +339,9 @@ const [a, b, c] = await parallel([
321
339
  return { a, b, c };
322
340
  ```
323
341
 
324
- Sequential `await agent(...)` stays serial by construction; `parallel([...])` is how parallel children share the single `maxConcurrentSubagents` limiter at once. Top-level direct `Agent` calls issued together also run together (`executionMode: "parallel"`, default under one limiter). `external_runs wait` and `external_runs inspect` cover both surfaces. Its first statement must be the current declaration `export const meta = { apiVersion: 1, name, description }`; missing or unsupported versions fail before any child launches. Every `agent()` child uses the same `role`/optional `harness` resolution as direct `Agent` calls, with legacy exact `subagent_type` also supported.
342
+ Sequential `await agent(...)` stays serial by construction; `parallel([...])` is how parallel children share the single `maxConcurrentSubagents` limiter at once. Top-level direct `Agent` calls issued together also run together (`executionMode: "parallel"`, default under one limiter). `external_runs wait` and `external_runs inspect` cover both surfaces. Its first statement must be the current declaration `export const meta = { apiVersion: 1, name, description }`; missing or unsupported versions fail before any child launches. Every `agent()` child uses the same `role`/optional `harness` resolution as direct `Agent` calls, with legacy exact `subagent_type` also supported. `agent()`'s second argument accepts `description` as the common task-name option shared with direct `Agent` calls; `label` remains a compatible alias for existing scripts. Setting both to different values is rejected rather than silently preferring one.
343
+
344
+ `background: true` belongs to the `workflow` tool call itself (`workflow({ script, background: true })`), never to `meta` — `meta` fields other than `apiVersion`/`name`/`description`/`phases` are ignored, so `export const meta = { ..., background: true }` does not run the workflow in the background.
325
345
 
326
346
  Saved workflows are discovered on demand through `external_help({ topic: "workflow" })`; project `.pi/workflows` entries are included only when Pi reports the project trusted.
327
347
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tranhoangnguyen0310/pi-flow-external",
3
- "version": "2.3.0-external.0",
3
+ "version": "2.3.1-external.0",
4
4
  "description": "External Claude Code, Codex CLI, Antigravity, and Grok Build CLI delegation for pi.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -0,0 +1,76 @@
1
+ import type { SubagentProgressNode, SubagentToolDetails, WorkflowAgentSnapshot } from "../types.ts";
2
+
3
+ /**
4
+ * Single source of truth for copying a running child's live progress onto its
5
+ * workflow snapshot row. Previously inlined per-field in `workflow/tool.ts`'s
6
+ * `onProgress` handler; kept here so the running and terminal copies
7
+ * (`applySubagentResultToWorkflowAgent` below) cannot silently drift apart on
8
+ * which fields they carry.
9
+ */
10
+ export function applySubagentProgressToWorkflowAgent(agent: WorkflowAgentSnapshot, progress: SubagentProgressNode): void {
11
+ agent.startedAt = progress.startedAt;
12
+ agent.endedAt = progress.endedAt;
13
+ agent.activity = [...progress.activity];
14
+ agent.activityCount = progress.activityCount;
15
+ agent.result = progress.result;
16
+ agent.error = progress.error;
17
+ agent.assistantOutput = progress.assistantOutput;
18
+ agent.processStartedAt = progress.processStartedAt;
19
+ agent.firstActivityAt = progress.firstActivityAt;
20
+ agent.lastActivityAt = progress.lastActivityAt;
21
+ agent.timedOut = progress.timedOut;
22
+ agent.usage = progress.usage;
23
+ agent.status = progress.status;
24
+ agent.sessionId = progress.sessionId;
25
+ agent.resumedFrom = progress.resumedFrom;
26
+ agent.context = progress.context;
27
+ if (progress.thinkingClamped) agent.thinkingClamped = progress.thinkingClamped;
28
+ }
29
+
30
+ /**
31
+ * Single source of truth for copying a finished child's full result details
32
+ * onto its workflow snapshot row. Fixes a real disclosure/evidence gap: the
33
+ * previous inline copy in `workflow/tool.ts` never carried `retries`,
34
+ * `retryOf`, `permissionRequested`, or `thinkingClamped` onto
35
+ * `WorkflowAgentSnapshot`, so a workflow child that retried an agy
36
+ * infrastructure failure (or had its thinking level clamped, or its
37
+ * requested permission tier elevated) silently lost that disclosure the
38
+ * moment it became part of a workflow — a direct `Agent` call never lost it.
39
+ */
40
+ export function applySubagentResultToWorkflowAgent(agent: WorkflowAgentSnapshot, resultDetails: SubagentToolDetails): void {
41
+ const progress = resultDetails.progress;
42
+ agent.status = resultDetails.status;
43
+ agent.result = resultDetails.result;
44
+ agent.error = resultDetails.error;
45
+ agent.assistantOutput = resultDetails.assistantOutput;
46
+ agent.processStartedAt = progress?.processStartedAt;
47
+ agent.firstActivityAt = progress?.firstActivityAt;
48
+ agent.lastActivityAt = progress?.lastActivityAt;
49
+ agent.timedOut = resultDetails.timedOut;
50
+ agent.usage = resultDetails.usage;
51
+ agent.externalRunId = resultDetails.runId;
52
+ agent.recordPath = resultDetails.recordPath;
53
+ agent.backendEventCount = resultDetails.backendEventCount;
54
+ agent.nestedActivitySeen = resultDetails.nestedActivitySeen;
55
+ agent.nestedTimeoutExtended = resultDetails.nestedTimeoutExtended;
56
+ agent.effectiveTimeoutMs = resultDetails.effectiveTimeoutMs;
57
+ agent.recordingError = resultDetails.recordingError;
58
+ agent.permission = resultDetails.permission;
59
+ agent.permissionEnforced = resultDetails.permissionEnforced;
60
+ agent.permissionDenials = resultDetails.permissionDenials;
61
+ agent.maxBudgetUsd = resultDetails.maxBudgetUsd;
62
+ agent.sessionId = resultDetails.sessionId;
63
+ agent.resumedFrom = resultDetails.resumedFrom;
64
+ agent.context = resultDetails.context;
65
+ if (resultDetails.permissionRequested !== undefined) agent.permissionRequested = resultDetails.permissionRequested;
66
+ if (resultDetails.retries !== undefined) agent.retries = resultDetails.retries;
67
+ if (resultDetails.retryOf !== undefined) agent.retryOf = resultDetails.retryOf;
68
+ const thinkingClamped = resultDetails.thinkingClamped ?? progress?.thinkingClamped;
69
+ if (thinkingClamped) agent.thinkingClamped = thinkingClamped;
70
+ if (progress) {
71
+ agent.startedAt = progress.startedAt;
72
+ agent.endedAt = progress.endedAt;
73
+ agent.activity = [...progress.activity];
74
+ agent.activityCount = progress.activityCount;
75
+ }
76
+ }
package/src/core/agy.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  MAX_STDOUT_LINE_CHARS,
8
8
  } from "./stream.ts";
9
9
  import type { PermissionTier, SubagentProfile, SubagentUsage, ThinkingLevel } from "../types.ts";
10
+ import { selectorHarness } from "../profiles.ts";
10
11
  import { buildPermissionArgs } from "./permissions.ts";
11
12
  import { abortChildTree } from "./process-tree.ts";
12
13
 
@@ -277,6 +278,7 @@ export async function spawnAgySubagent(params: {
277
278
  description: params.description,
278
279
  subagentType,
279
280
  backend: params.profile.backend,
281
+ harness: selectorHarness(params.profile),
280
282
  enabled: params.progressEnabled,
281
283
  onProgress: params.onProgress,
282
284
  executionStartedAt: params.executionStartedAt,
@@ -477,6 +479,7 @@ export async function spawnAgySubagent(params: {
477
479
  description: params.description,
478
480
  subagentType,
479
481
  backend: params.profile.backend,
482
+ harness: selectorHarness(params.profile),
480
483
  status: "done",
481
484
  result,
482
485
  usage: latestUsage,
@@ -503,6 +506,7 @@ export async function spawnAgySubagent(params: {
503
506
  description: params.description,
504
507
  subagentType,
505
508
  backend: params.profile.backend,
509
+ harness: selectorHarness(params.profile),
506
510
  status,
507
511
  error: message,
508
512
  usage: latestUsage,
@@ -13,6 +13,7 @@ import {
13
13
  MAX_STDOUT_LINE_CHARS,
14
14
  } from "./stream.ts";
15
15
  import type { PermissionTier, SubagentProfile, SubagentUsage, ThinkingLevel } from "../types.ts";
16
+ import { selectorHarness } from "../profiles.ts";
16
17
  import { buildPermissionArgs } from "./permissions.ts";
17
18
  import { abortChildTree } from "./process-tree.ts";
18
19
 
@@ -349,6 +350,7 @@ export async function spawnClaudeSubagent(params: {
349
350
  description: params.description,
350
351
  subagentType,
351
352
  backend: params.profile.backend,
353
+ harness: selectorHarness(params.profile),
352
354
  enabled: params.progressEnabled,
353
355
  onProgress: params.onProgress,
354
356
  executionStartedAt: params.executionStartedAt,
@@ -564,6 +566,7 @@ export async function spawnClaudeSubagent(params: {
564
566
  description: params.description,
565
567
  subagentType,
566
568
  backend: params.profile.backend,
569
+ harness: selectorHarness(params.profile),
567
570
  status: "done",
568
571
  result,
569
572
  usage: latestUsage,
@@ -591,6 +594,7 @@ export async function spawnClaudeSubagent(params: {
591
594
  description: params.description,
592
595
  subagentType,
593
596
  backend: params.profile.backend,
597
+ harness: selectorHarness(params.profile),
594
598
  status,
595
599
  error: message,
596
600
  usage: latestUsage,
package/src/core/codex.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  MAX_STDOUT_LINE_CHARS,
17
17
  } from "./stream.ts";
18
18
  import type { PermissionTier, SubagentProfile, SubagentUsage, ThinkingLevel } from "../types.ts";
19
+ import { selectorHarness } from "../profiles.ts";
19
20
  import { buildPermissionArgs } from "./permissions.ts";
20
21
  import { abortChildTree } from "./process-tree.ts";
21
22
 
@@ -320,6 +321,7 @@ export async function spawnCodexSubagent(params: {
320
321
  description: params.description,
321
322
  subagentType,
322
323
  backend: params.profile.backend,
324
+ harness: selectorHarness(params.profile),
323
325
  enabled: params.progressEnabled,
324
326
  onProgress: params.onProgress,
325
327
  executionStartedAt: params.executionStartedAt,
@@ -525,6 +527,7 @@ export async function spawnCodexSubagent(params: {
525
527
  description: params.description,
526
528
  subagentType,
527
529
  backend: params.profile.backend,
530
+ harness: selectorHarness(params.profile),
528
531
  status: "done",
529
532
  result,
530
533
  usage: latestUsage,
@@ -551,6 +554,7 @@ export async function spawnCodexSubagent(params: {
551
554
  description: params.description,
552
555
  subagentType,
553
556
  backend: params.profile.backend,
557
+ harness: selectorHarness(params.profile),
554
558
  status,
555
559
  error: message,
556
560
  usage: latestUsage,
@@ -55,7 +55,10 @@ export function resolveEffectivePermissionTier(
55
55
  profile: PermissionProfileRef | undefined,
56
56
  defaultTier: PermissionTier = "danger",
57
57
  ): PermissionTier {
58
- const baseTier = requestedTier ?? profile?.permission ?? defaultTier;
58
+ // Profile permissions are a floor: parent requests can grant more, never less.
59
+ const floor = profile?.permission ?? defaultTier;
60
+ const tiers: readonly PermissionTier[] = ["readonly", "edit", "danger"];
61
+ const baseTier = tiers[Math.max(tiers.indexOf(floor), tiers.indexOf(requestedTier ?? floor))]!;
59
62
  if (profile?.backend === "agy") {
60
63
  // Get out of the way: agy's only unsandboxed headless mode is
61
64
  // --dangerously-skip-permissions, and its default sandbox denies even
@@ -27,12 +27,14 @@ export function createProgressNode(
27
27
  subagentType: SubagentType,
28
28
  status: SubagentProgressNode["status"] = "running",
29
29
  backend?: SubagentBackend,
30
+ harness?: string,
30
31
  ): SubagentProgressNode {
31
32
  return {
32
33
  id,
33
34
  description,
34
35
  subagentType,
35
36
  ...(backend ? { backend } : {}),
37
+ ...(harness ? { harness } : {}),
36
38
  status,
37
39
  startedAt: Date.now(),
38
40
  activity: [],
@@ -184,6 +186,7 @@ export interface ProgressEmitterOptions {
184
186
  description: string;
185
187
  subagentType: SubagentType;
186
188
  backend?: SubagentBackend;
189
+ harness?: string;
187
190
  enabled: boolean;
188
191
  onProgress: ((result: AgentToolResult) => void) | undefined;
189
192
  /** Execution-start boundary captured by the caller right after the shared concurrency limiter granted a slot. */
@@ -214,8 +217,8 @@ export interface ProgressEmitter {
214
217
  * hand-synchronized copies that silently drift.
215
218
  */
216
219
  export function createProgressEmitter(options: ProgressEmitterOptions): ProgressEmitter {
217
- const { toolCallId, description, subagentType, backend, enabled, onProgress } = options;
218
- const progress = createProgressNode(toolCallId, description, subagentType, "running", backend);
220
+ const { toolCallId, description, subagentType, backend, harness, enabled, onProgress } = options;
221
+ const progress = createProgressNode(toolCallId, description, subagentType, "running", backend, harness);
219
222
  if (options.executionStartedAt !== undefined) {
220
223
  progress.executionStartedAt = options.executionStartedAt;
221
224
  }
@@ -239,6 +242,7 @@ export function createProgressEmitter(options: ProgressEmitterOptions): Progress
239
242
  description,
240
243
  subagentType,
241
244
  ...(backend ? { backend } : {}),
245
+ ...(harness ? { harness } : {}),
242
246
  status: progress.status,
243
247
  result: progress.result,
244
248
  error: progress.error,
@@ -70,6 +70,10 @@ export interface RunRecordListItem {
70
70
  error?: string;
71
71
  description?: string;
72
72
  backend?: string;
73
+ /** Resolved profile/subagentType name, when persisted in run-record metadata. Never reparsed from other fields. */
74
+ profile?: string;
75
+ /** Resolved harness (registered pi-* config, or equal to backend for agy/claude/codex), when persisted in run-record metadata. */
76
+ harness?: string;
73
77
  project?: string;
74
78
  parentSessionId?: string;
75
79
  workflowRunId?: string;
@@ -341,6 +345,8 @@ async function readListItem(runsDirectory: string, runId: string): Promise<RunRe
341
345
  ...(boundedString(terminal?.error) ? { error: boundedString(terminal?.error) } : {}),
342
346
  ...(boundedString(metadata?.description) ? { description: boundedString(metadata?.description) } : {}),
343
347
  ...(asString(metadata?.backend) ? { backend: asString(metadata?.backend) } : {}),
348
+ ...(metadataProfileName(metadata) ? { profile: metadataProfileName(metadata) } : {}),
349
+ ...(metadataHarness(metadata) ? { harness: metadataHarness(metadata) } : {}),
344
350
  ...(asString(metadata?.project) ? { project: asString(metadata?.project) } : {}),
345
351
  ...(asString(metadata?.parentSessionId) ? { parentSessionId: asString(metadata?.parentSessionId) } : {}),
346
352
  ...(asString(metadata?.workflowRunId) ? { workflowRunId: asString(metadata?.workflowRunId) } : {}),
@@ -423,7 +429,8 @@ function summaryProjection(runId: string, summary: SummaryState, observation: Ru
423
429
  task: compactObject({
424
430
  description: boundedString(metadata?.description),
425
431
  backend: metadata?.backend,
426
- profile: typeof metadata?.profile === "string" ? metadata.profile : asRecord(metadata?.profile)?.name,
432
+ profile: metadataProfileName(metadata),
433
+ harness: metadataHarness(metadata),
427
434
  project: boundedString(metadata?.project),
428
435
  parentSessionId: metadata?.parentSessionId,
429
436
  workflowRunId: metadata?.workflowRunId,
@@ -824,6 +831,31 @@ function asString(value: unknown): string | undefined {
824
831
  return typeof value === "string" ? value : undefined;
825
832
  }
826
833
 
834
+ /**
835
+ * Resolved profile/subagentType name from persisted run-record metadata.
836
+ * `metadata.profile` has shipped in two shapes historically: a plain string
837
+ * (the direct `Agent`/workflow-child metadata this package writes today) and
838
+ * a full profile object (spawn.ts's own fallback record creation, used when
839
+ * a caller does not pre-create its own run record). Both are read here, in
840
+ * ONE place, so `readListItem` and `summaryProjection` cannot independently
841
+ * drift on how they unpack it.
842
+ */
843
+ function metadataProfileName(metadata: Record<string, unknown> | undefined): string | undefined {
844
+ return typeof metadata?.profile === "string" ? metadata.profile : asString(asRecord(metadata?.profile)?.name);
845
+ }
846
+
847
+ /**
848
+ * Resolved harness (registered pi-* config, or equal to backend for
849
+ * agy/claude/codex) from persisted run-record metadata: an explicit
850
+ * `metadata.harness` field when present (every current call site writes
851
+ * one), falling back to the embedded profile object's own `harness` field
852
+ * for the historical spawn.ts fallback shape. Never reparsed/guessed from
853
+ * `profile`/`subagentType`'s name string.
854
+ */
855
+ function metadataHarness(metadata: Record<string, unknown> | undefined): string | undefined {
856
+ return asString(metadata?.harness) ?? asString(asRecord(metadata?.profile)?.harness);
857
+ }
858
+
827
859
  function isNotFound(error: unknown): boolean {
828
860
  return asRecord(error)?.code === "ENOENT";
829
861
  }