@signalridge/pi-workflows 1.4.0 → 1.5.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/src/rpc-client.ts CHANGED
@@ -1,15 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type {
3
+ ManagedRoutingPolicy,
3
4
  ManagedSpawnResponse as ProtocolManagedSpawnResponse,
4
5
  ManagedTerminalSnapshot as ProtocolManagedTerminalSnapshot,
5
- WorkflowTier,
6
6
  } from "@signalridge/pi-subagents-protocol";
7
7
  import {
8
8
  PROTOCOL_VERSION,
9
9
  parseManagedSpawnResponse,
10
10
  parseProtocolPing,
11
11
  requiredCapabilitiesMatch,
12
- workflowTierCapabilityMatch,
13
12
  } from "@signalridge/pi-subagents-protocol";
14
13
  import type { WorkflowOwner } from "./journal.js";
15
14
 
@@ -18,11 +17,8 @@ export interface DispatchTask {
18
17
  subagent_type: string;
19
18
  prompt: string;
20
19
  description: string;
21
- tier?: WorkflowTier;
22
- /** Exact provider/model reference; resolution remains inside pi-subagents. */
23
- model?: string;
24
- /** Optional thinking suffix/override resolved by pi-subagents. */
25
- thinking?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "off";
20
+ /** Agent-tier key, from the host's own catalogue. The only model policy here. */
21
+ tier?: string;
26
22
  /** Per-call worktree request owned by pi-subagents. */
27
23
  isolation?: "worktree";
28
24
  /** Forwarded toolset hint (e.g. "web-research") for pi-subagents to augment tools. */
@@ -105,9 +101,11 @@ function callRpc<T>(
105
101
 
106
102
  export const REQUIRED_SUBAGENTS_PROTOCOL = PROTOCOL_VERSION;
107
103
  export const PROTOCOL_DIAGNOSTIC =
108
- "@signalridge/pi-workflows requires @signalridge/pi-subagents protocol v3 with managedSpawn, lifecycleOwner, ownedStop, ownedQuiescence, workflowTiers, and managedPolicy capabilities. " +
104
+ `@signalridge/pi-workflows requires @signalridge/pi-subagents protocol v${PROTOCOL_VERSION} with managedSpawn, lifecycleOwner, ownedStop, childContext, ownedQuiescence, agentTiers, and managedPolicy, plus its Agent-tier routing policy. ` +
109
105
  "Install @signalridge/pi-subagents and @signalridge/pi-workflows exactly once; Pi loads both from their configured pi.extensions manifests.";
110
106
 
107
+ const CAPABILITY_DIAGNOSTIC = `subagents protocol v${PROTOCOL_VERSION} is required, with every capability and an Agent-tier routing policy`;
108
+
111
109
  export const CHILD_CONTEXT_QUERY_TIMEOUT_MS = 250;
112
110
 
113
111
  /**
@@ -169,7 +167,17 @@ export function queryChildSessionContextImmediate(events: WorkflowEventBus): boo
169
167
  }
170
168
 
171
169
  /** Verify the separately configured pi-subagents package before workflow execution. */
172
- export async function checkManagedSpawnProtocol(events: WorkflowEventBus, signal?: AbortSignal): Promise<void> {
170
+ export interface ManagedProtocolCheck {
171
+ /** The host's Agent-tier catalogue, as of this check. */
172
+ routingPolicy: ManagedRoutingPolicy;
173
+ /** Whole-catalogue identity; used only where a per-tier identity cannot be. */
174
+ routingPolicyFingerprint: string;
175
+ }
176
+
177
+ export async function checkManagedSpawnProtocol(
178
+ events: WorkflowEventBus,
179
+ signal?: AbortSignal,
180
+ ): Promise<ManagedProtocolCheck> {
173
181
  const id = requestId();
174
182
  const reply = await new Promise<unknown>((resolve, reject) => {
175
183
  const replyChannel = `subagents:rpc:ping:reply:${id}`;
@@ -228,57 +236,40 @@ export async function checkManagedSpawnProtocol(events: WorkflowEventBus, signal
228
236
  let ping: ReturnType<typeof parseProtocolPing>;
229
237
  try {
230
238
  ping = parseProtocolPing(reply);
231
- } catch {
232
- if (
233
- !isRecord(reply) ||
234
- typeof reply.version !== "number" ||
235
- !isRecord(reply.capabilities) ||
236
- reply.version < REQUIRED_SUBAGENTS_PROTOCOL ||
237
- reply.capabilities.managedSpawn !== true ||
238
- reply.capabilities.lifecycleOwner !== true ||
239
- reply.capabilities.ownedStop !== true ||
240
- reply.capabilities.ownedQuiescence !== true ||
241
- reply.capabilities.managedPolicy !== true
242
- ) {
243
- throw new Error(
244
- `subagents protocol v${REQUIRED_SUBAGENTS_PROTOCOL} with managed spawning, owned stop, owned quiescence, and managed policy capabilities is required`,
245
- );
239
+ } catch (error: unknown) {
240
+ // Anything shaped like a ping is a peer that is present but not v4-complete,
241
+ // and the actionable answer is the same in every such case: install a
242
+ // matching pair. A reply that is not a ping at all gets its own message,
243
+ // because "upgrade the peer" would be the wrong thing to go do.
244
+ if (!isRecord(reply) || typeof reply.version !== "number") {
245
+ throw new Error("subagents protocol ping did not return a valid version/capability envelope");
246
246
  }
247
- throw new Error("subagents protocol ping did not return a valid version/capability envelope");
247
+ throw new Error(`${CAPABILITY_DIAGNOSTIC} (${error instanceof Error ? error.message : String(error)})`);
248
248
  }
249
+ // Every v4 capability is required, so one check covers them all. The parser
250
+ // has already rejected a ping without a well-formed routing policy.
249
251
  if (ping.version < REQUIRED_SUBAGENTS_PROTOCOL || !requiredCapabilitiesMatch(ping.capabilities)) {
250
- throw new Error(
251
- `subagents protocol v${REQUIRED_SUBAGENTS_PROTOCOL} with managed spawning, owned stop, and owned quiescence capability is required`,
252
- );
253
- }
254
- if (!workflowTierCapabilityMatch(ping.capabilities)) {
255
- throw new Error("subagents workflow tier capability is required before tiered managed spawning");
256
- }
257
- if (ping.capabilities.managedPolicy !== true) {
258
- throw new Error("subagents managed policy capability is required before policy-bearing managed spawning");
252
+ throw new Error(CAPABILITY_DIAGNOSTIC);
259
253
  }
254
+ return {
255
+ routingPolicy: ping.routingPolicy.policy,
256
+ routingPolicyFingerprint: ping.routingPolicy.fingerprint,
257
+ };
260
258
  }
261
259
 
262
260
  export type ManagedTerminalSnapshot = ProtocolManagedTerminalSnapshot;
263
261
  export type ManagedSpawnResponse = ProtocolManagedSpawnResponse;
264
262
 
265
263
  export interface ManagedSpawnClient {
266
- /** String remains accepted for deterministic legacy fixtures; protocol v3 returns ManagedSpawnResponse. */
267
- /**
268
- * The fourth argument is the current attempt for protocol-v3 clients. The
269
- * optional fifth argument is retained only for old in-process test/fixture
270
- * clients that used the former context, attemptId shape; it is never a
271
- * runtime context and is not sent over RPC.
272
- */
264
+ /** Managed spawn returns a structured response. */
273
265
  spawn(
274
266
  task: DispatchTask,
275
267
  runId: string,
276
268
  nodeId: string,
277
269
  attemptId?: string,
278
- legacyAttemptId?: string,
279
270
  signal?: AbortSignal,
280
271
  ): Promise<ManagedSpawnResponse | string>;
281
- /** Unscoped v2 stop, retained for existing callers. */
272
+ /** Unscoped stop for non-managed callers. */
282
273
  stop(agentId: string): Promise<void>;
283
274
  /** Owner-scoped v3 stop used by workflow lifecycle operations. */
284
275
  stopOwned?(agentId: string, owner: WorkflowOwner): Promise<void>;
@@ -290,8 +281,8 @@ export interface ManagedSpawnClient {
290
281
  timeoutMs?: number,
291
282
  owners?: WorkflowOwner[],
292
283
  ): Promise<{ settled: boolean; pending: string[] }>;
293
- /** Optional startup capability check; workflow execution remains event-bus-only. */
294
- checkProtocol?: () => Promise<void>;
284
+ /** Fresh capability and routing-policy check; workflow execution remains event-bus-only. */
285
+ checkProtocol?: () => Promise<ManagedProtocolCheck>;
295
286
  }
296
287
 
297
288
  /** Event-bus-only client for the additive pi-subagents managed protocol. */
@@ -320,7 +311,7 @@ export function createManagedSpawnClient(
320
311
  * error for the same key with a different fingerprint. Same key + same
321
312
  * fingerprint still reuses the persisted tombstone (per-call cache).
322
313
  */
323
- async spawn(task, runId, nodeId, attemptId, _legacyAttemptId, signal) {
314
+ async spawn(task, runId, nodeId, attemptId, signal) {
324
315
  const effectiveAttemptId = attemptId ?? `attempt-1`;
325
316
  const spawnKey = `${runId}/${nodeId}/${effectiveAttemptId}`;
326
317
  const requestSignal =
@@ -339,8 +330,6 @@ export function createManagedSpawnClient(
339
330
  ...(task.toolset === undefined ? {} : { toolset: task.toolset }),
340
331
  ...(task.thread === undefined ? {} : { thread: task.thread }),
341
332
  ...(task.excludeTools === undefined ? {} : { excludeTools: task.excludeTools }),
342
- ...(task.model === undefined ? {} : { model: task.model }),
343
- ...(task.thinking === undefined ? {} : { thinking: task.thinking }),
344
333
  ...(task.isolation === undefined ? {} : { isolation: task.isolation }),
345
334
  owner: { extension: "pi-workflows", runId, nodeId, attemptId: effectiveAttemptId },
346
335
  },
package/src/runtime.ts CHANGED
@@ -21,6 +21,8 @@
21
21
  import { AsyncLocalStorage } from "node:async_hooks";
22
22
  import { createHash } from "node:crypto";
23
23
  import vm from "node:vm";
24
+ import type { ManagedRoutingPolicy } from "@signalridge/pi-subagents-protocol";
25
+ import { agentTierPolicyIdentity, isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
24
26
  import { parse } from "acorn";
25
27
  import { checkRuntimeBindings } from "./capabilities.js";
26
28
  import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
@@ -37,24 +39,12 @@ import { SharedStore } from "./shared-store.js";
37
39
  export interface WorkflowMetaPhase {
38
40
  title: string;
39
41
  detail?: string;
40
- /**
41
- * Phase-level model preference — currently validated but not routed.
42
- * Per-call model routing requires a protocol extension (see README
43
- * "Intentional divergences"); use agent({ tier }) instead.
44
- */
45
- model?: string;
46
42
  }
47
43
 
48
44
  export interface WorkflowMeta {
49
45
  name: string;
50
46
  description: string;
51
47
  phases?: WorkflowMetaPhase[];
52
- /**
53
- * Run-level model preference — currently validated but not routed.
54
- * Per-call model routing requires a protocol extension (see README
55
- * "Intentional divergences"); use agent({ tier }) instead.
56
- */
57
- model?: string;
58
48
  }
59
49
 
60
50
  /** A resolved, journaled agent() call. */
@@ -65,6 +55,8 @@ export interface JournalEntry {
65
55
  hash: string;
66
56
  result: unknown;
67
57
  storeDelta?: Record<string, unknown>;
58
+ /** Agent tier this call resolved to, as the host reported it. */
59
+ tier?: string;
68
60
  /** Generation for a nested workflow replay boundary. */
69
61
  generation?: number;
70
62
  /** Logical agents represented by a nested workflow replay boundary. */
@@ -108,7 +100,6 @@ export interface AgentRunOptions {
108
100
  schema?: unknown;
109
101
  signal?: AbortSignal;
110
102
  instructions?: string;
111
- model?: string;
112
103
  tier?: string;
113
104
  cwd?: string;
114
105
  /** Named subagent type; resolved by pi-subagents at dispatch. */
@@ -129,7 +120,14 @@ export interface AgentRunOptions {
129
120
  /** Additional tool names denied for this call. */
130
121
  excludeTools?: string[];
131
122
  onModelResolved?: (id: string) => void;
132
- onModelFallback?: (info: { tier: string; requestedSpec: string }) => void;
123
+ /**
124
+ * The tier the host actually selected, which may be one this call did not
125
+ * name — an agent's own tier, or the host's default. A callback rather than a
126
+ * mutation of `tier`: the dispatcher does not own this object, and reading a
127
+ * reply back out of an input field would only work while every layer happened
128
+ * to preserve its identity.
129
+ */
130
+ onTierResolved?: (tier: string) => void;
133
131
  onUsage?: (usage: AgentUsage) => void;
134
132
  onHistory?: (history: unknown[]) => void;
135
133
  }
@@ -138,7 +136,12 @@ export interface AgentOptions {
138
136
  label?: string;
139
137
  phase?: string;
140
138
  schema?: unknown;
141
- model?: string;
139
+ /**
140
+ * Agent tier: a key from the host's `agentTiers` catalogue. This is the only
141
+ * model control a workflow has — there is no per-call model or thinking, so
142
+ * a script cannot pin a vendor the host did not choose. Omitting it uses the
143
+ * agent's own tier, else the host's configured default.
144
+ */
142
145
  tier?: string;
143
146
  isolation?: "worktree";
144
147
  agentType?: string;
@@ -167,6 +170,27 @@ export interface WorkflowRunOptions {
167
170
  agent?: WorkflowAgentRunner;
168
171
  /** The session's main model (provider/id), shown for default agents. */
169
172
  mainModel?: string;
173
+ /** The host's Agent-tier catalogue, captured fresh for this start/resume. */
174
+ routingPolicy?: ManagedRoutingPolicy;
175
+ /**
176
+ * This script ships with the package rather than being written here.
177
+ *
178
+ * The tier catalogue belongs to the user, names included. A script we ship
179
+ * cannot assert that `low` exists on someone whose tiers are called
180
+ * `cheap`/`deep`, so its tier is a preference: an undefined one falls back to
181
+ * the host's default with a warning. A script the user wrote names tiers from
182
+ * the user's own catalogue, so an undefined one there is a typo and fails
183
+ * closed rather than quietly running a different model.
184
+ */
185
+ shippedScript?: boolean;
186
+ /**
187
+ * Whole-catalogue identity, used wherever a per-tier one would be dishonest:
188
+ * a nested `workflow()` boundary, where the parent caches one value for a
189
+ * child whose per-call tiers it cannot see, and an `agent()` call that names
190
+ * no tier, whose real tier may come from the agent's frontmatter. A call that
191
+ * names its tier keys on that tier's policy instead.
192
+ */
193
+ routingPolicyFingerprint?: string;
170
194
  /** Engine-owned execution nonce used to isolate stale nested managed calls. */
171
195
  executionNonce?: string;
172
196
  /** Runtime frame depth; siblings inherit the same depth and may run concurrently. */
@@ -190,16 +214,29 @@ export interface WorkflowRunOptions {
190
214
  sharedRuntime?: SharedRuntime;
191
215
  initialTokenUsage?: AgentUsage;
192
216
  sharedStore?: SharedStore;
193
- loadSavedWorkflow?: (name: string) => string | undefined;
217
+ /**
218
+ * Resolve a nested `workflow(name)` reference, reporting whether the script
219
+ * it returned ships with this package. Shipped-ness travels with the script,
220
+ * not with the frame that called it — see `shippedScript`.
221
+ */
222
+ loadSavedWorkflow?: (name: string) => { script: string; shippedScript?: boolean } | undefined;
194
223
  confirm?: (promptText: string, options: CheckpointOptions) => Promise<unknown>;
195
224
  onLog?: (message: string) => void;
196
225
  onPhase?: (title: string) => void;
197
226
  onRuntimeEvent?: (event: WorkflowRuntimeEvent) => void;
198
- onAgentStart?: (event: { id: string; label: string; phase?: string; prompt: string; model?: string }) => void;
227
+ onAgentStart?: (event: {
228
+ id: string;
229
+ label: string;
230
+ phase?: string;
231
+ prompt: string;
232
+ model?: string;
233
+ tier?: string;
234
+ }) => void;
199
235
  onAgentEnd?: (event: {
200
236
  id: string;
201
237
  label: string;
202
238
  phase?: string;
239
+ tier?: string;
203
240
  result: unknown;
204
241
  tokens?: number;
205
242
  tokenUsage?: AgentUsage;
@@ -473,26 +510,29 @@ function propertyKey(node: unknown, path: string): string {
473
510
 
474
511
  function validateMeta(meta: unknown): asserts meta is WorkflowMeta {
475
512
  if (!meta || typeof meta !== "object") throw new Error("meta must be an object");
476
- const value = meta as WorkflowMeta;
513
+ const value = meta as WorkflowMeta & Record<string, unknown>;
477
514
  if (typeof value.name !== "string" || !value.name.trim() || value.name.length > 512)
478
515
  throw new Error("meta.name must be a non-empty string of at most 512 characters");
479
516
  if (typeof value.description !== "string" || !value.description.trim() || value.description.length > 100_000)
480
517
  throw new Error("meta.description must be a non-empty string of at most 100000 characters");
481
- if (value.model !== undefined && typeof value.model !== "string") throw new Error("meta.model must be a string");
518
+ if (Object.hasOwn(value, "model") || Object.hasOwn(value, "thinking")) {
519
+ throw new Error("workflow meta model/thinking fields are unsupported; use agent({ tier })");
520
+ }
482
521
  if (value.phases !== undefined) {
483
522
  if (!Array.isArray(value.phases)) throw new Error("meta.phases must be an array");
484
523
  for (const phase of value.phases) {
485
524
  if (
486
525
  !phase ||
487
526
  typeof phase !== "object" ||
488
- typeof phase.title !== "string" ||
489
- !phase.title.trim() ||
490
- phase.title.length > 512
527
+ typeof (phase as { title?: unknown }).title !== "string" ||
528
+ !(phase as { title: string }).title.trim() ||
529
+ (phase as { title: string }).title.length > 512
491
530
  ) {
492
531
  throw new Error("each meta phase must have a title string");
493
532
  }
494
- if ((phase as WorkflowMetaPhase).model !== undefined && typeof (phase as WorkflowMetaPhase).model !== "string") {
495
- throw new Error("each meta phase model must be a string when present");
533
+ const phaseRecord = phase as unknown as Record<string, unknown>;
534
+ if (Object.hasOwn(phaseRecord, "model") || Object.hasOwn(phaseRecord, "thinking")) {
535
+ throw new Error("workflow phase model/thinking fields are unsupported; use agent({ tier })");
496
536
  }
497
537
  }
498
538
  }
@@ -527,24 +567,42 @@ function hashIdentity(value: unknown): string {
527
567
  /**
528
568
  * Stable identity hash for an agent() call. Must cover every input that can
529
569
  * change the outcome so a cache-miss is never served on resume. The agent
530
- * definition is represented by the agentType name (the definition itself lives
531
- * in pi-subagents; a name-only key is the honest observable).
570
+ * definition is represented by the agentType name — the definition itself lives
571
+ * in pi-subagents, so a name-only key is the honest observable, and there is no
572
+ * second agent-definition field to hash beside it.
532
573
  */
533
574
  function hashAgentCall(
534
575
  prompt: string,
535
- model: string | undefined,
536
576
  phase: string | undefined,
537
577
  options: AgentOptions,
538
- agentDefKey: string | null,
578
+ routingPolicy: ManagedRoutingPolicy | undefined,
579
+ routingPolicyFingerprint: string | undefined,
539
580
  ): string {
581
+ // A call that names its tier is keyed on that tier's policy alone, so
582
+ // defining or editing an unrelated tier cannot invalidate it.
583
+ //
584
+ // A call that names none cannot be keyed that way, and keying it on
585
+ // `defaultTier` would be worse than coarse — it would be wrong. The host
586
+ // resolves an unnamed tier against the agent's own frontmatter tier first and
587
+ // only then the default, and frontmatter is invisible here: the agent
588
+ // definition lives in pi-subagents and reaches this side as a name. Keyed on
589
+ // the default, editing the tier the agent actually declares would replay
590
+ // stale work, while editing the default would invalidate work that never
591
+ // touched it. The whole-catalogue fingerprint is the coarse answer that is at
592
+ // least sound, and it is stable across the original run and its resume, which
593
+ // the tier the host selected is not — that one is only known after dispatch,
594
+ // and this hash is computed before it.
595
+ const tierPolicy =
596
+ options.tier === undefined
597
+ ? (routingPolicyFingerprint ?? null)
598
+ : agentTierPolicyIdentity(routingPolicy, options.tier);
540
599
  return hashIdentity({
541
600
  prompt,
542
- model: model ?? null,
543
601
  tier: options.tier ?? null,
602
+ tierPolicy,
544
603
  phase: phase ?? null,
545
604
  agentType: options.agentType ?? null,
546
605
  thread: options.thread ?? null,
547
- agentDef: agentDefKey,
548
606
  schema: options.schema ?? null,
549
607
  isolation: options.isolation ?? null,
550
608
  toolset: options.toolset ?? null,
@@ -930,6 +988,11 @@ export async function runWorkflow<T = unknown>(
930
988
  const agentTimeoutMs = normalizeAgentTimeout(options.agentTimeoutMs);
931
989
  const runId = options.runId ?? `run-${started.toString(36)}`;
932
990
  const baseCwd = options.cwd ?? process.cwd();
991
+ // The host's catalogue, known before the first call. A tier name that is not
992
+ // in it is a typo, and catching it here beats discovering it on dispatch.
993
+ // Undefined when no policy was supplied (in-process tests, nested frames that
994
+ // inherit the parent's checks) — then only the key shape is enforced.
995
+ const knownTiers = options.routingPolicy ? new Set(Object.keys(options.routingPolicy.profiles)) : undefined;
933
996
 
934
997
  const state: RuntimeState = {
935
998
  logs: [],
@@ -1090,16 +1153,52 @@ export async function runWorkflow<T = unknown>(
1090
1153
  log(`agentType "${agentOptions.agentType}" resolves in pi-subagents at dispatch`);
1091
1154
  }
1092
1155
 
1093
- // Model precedence: explicit per-call model > phase/run model. A tier is
1094
- // deliberately left to pi-subagents so its configured profile wins over the
1095
- // phase default. The model string may carry a `:thinking` suffix understood by
1096
- // the managed protocol.
1097
- const phaseModel = meta.phases?.find((candidate) => candidate.title === assignedPhase)?.model ?? meta.model;
1098
- const modelSpec = agentOptions.model ?? (agentOptions.tier === undefined ? phaseModel : undefined);
1156
+ // A tier is a key in the host's catalogue, so the only shape check this
1157
+ // side can make is the key shape. An unknown key is the host's call to
1158
+ // refuse — it is the one that knows what is defined, and its error names
1159
+ // the available tiers.
1160
+ if (agentOptions.tier !== undefined && !isManagedAgentTier(agentOptions.tier)) {
1161
+ throw new WorkflowError(
1162
+ `agent tier must be a non-empty, whitespace-free key (received ${JSON.stringify(agentOptions.tier)})`,
1163
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1164
+ { recoverable: false },
1165
+ );
1166
+ }
1167
+ let effectiveTier = agentOptions.tier;
1168
+ if (effectiveTier !== undefined && knownTiers && !knownTiers.has(effectiveTier)) {
1169
+ if (options.shippedScript) {
1170
+ // Our own script naming a tier this host does not define. Drop it and
1171
+ // let the host's default decide, which is the user's policy either way.
1172
+ log(`agent tier ${JSON.stringify(effectiveTier)} is not defined here; using the host default`);
1173
+ effectiveTier = undefined;
1174
+ } else {
1175
+ // A typo would otherwise surface only when this call reaches dispatch,
1176
+ // possibly deep into a run. The catalogue is known at start; check now.
1177
+ throw new WorkflowError(
1178
+ `unknown agent tier ${JSON.stringify(effectiveTier)}; this host defines: ${
1179
+ [...knownTiers].sort().join(", ") || "(none)"
1180
+ }`,
1181
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1182
+ { recoverable: false },
1183
+ );
1184
+ }
1185
+ }
1186
+ // Everything downstream — the resume hash, the dispatch, the journal —
1187
+ // works from the tier that will actually be requested, not the one the
1188
+ // script wrote, so a dropped name cannot make a cached call look different
1189
+ // from the live one.
1190
+ const tieredOptions: AgentOptions =
1191
+ effectiveTier === agentOptions.tier ? agentOptions : { ...agentOptions, tier: effectiveTier };
1099
1192
 
1100
1193
  // Deterministic resume key: assigned at lexical call time, before the limiter.
1101
1194
  const callIndex = nextCallIndex();
1102
- const callHash = hashAgentCall(promptText, modelSpec, assignedPhase, agentOptions, agentOptions.agentType ?? null);
1195
+ const callHash = hashAgentCall(
1196
+ promptText,
1197
+ assignedPhase,
1198
+ tieredOptions,
1199
+ options.routingPolicy,
1200
+ options.routingPolicyFingerprint,
1201
+ );
1103
1202
  // Store delta key: runId-namespaced so a nested workflow() call's
1104
1203
  // callIndex-0 can never collide with the parent's (see SharedStore doc).
1105
1204
  const deltaKey = `${runId}:${callIndex}`;
@@ -1117,11 +1216,22 @@ export async function runWorkflow<T = unknown>(
1117
1216
  if (hashMatches && !cachedEmptyOutput && callIndex < state.firstMiss) {
1118
1217
  shared.agentCount++;
1119
1218
  frameAgentCount++;
1120
- options.onAgentStart?.({ id: deltaKey, label, phase: assignedPhase, prompt: promptText });
1219
+ // The journal holds the tier the host actually selected, which may be a
1220
+ // default the script never named. Replay reports that durable identity
1221
+ // rather than re-deriving one from the script.
1222
+ const replayTier = cached.tier ?? effectiveTier;
1223
+ options.onAgentStart?.({
1224
+ id: deltaKey,
1225
+ label,
1226
+ phase: assignedPhase,
1227
+ prompt: promptText,
1228
+ ...(replayTier === undefined ? {} : { tier: replayTier }),
1229
+ });
1121
1230
  options.onAgentEnd?.({
1122
1231
  id: deltaKey,
1123
1232
  label,
1124
1233
  phase: assignedPhase,
1234
+ tier: replayTier,
1125
1235
  result: cached.result,
1126
1236
  tokens: 0,
1127
1237
  });
@@ -1166,7 +1276,13 @@ export async function runWorkflow<T = unknown>(
1166
1276
  const retryAttempts = normalizeAgentRetries(agentOptions.retries ?? options.agentRetries ?? 0);
1167
1277
  const maxAttempts = retryAttempts + 1;
1168
1278
 
1169
- options.onAgentStart?.({ id: deltaKey, label, phase: assignedPhase, prompt: promptText });
1279
+ options.onAgentStart?.({
1280
+ id: deltaKey,
1281
+ label,
1282
+ phase: assignedPhase,
1283
+ prompt: promptText,
1284
+ ...(effectiveTier === undefined ? {} : { tier: effectiveTier }),
1285
+ });
1170
1286
 
1171
1287
  // Worktree creation and cleanup remain owned by pi-subagents; the request
1172
1288
  // carries the explicit per-call policy without touching the host filesystem here.
@@ -1206,6 +1322,9 @@ export async function runWorkflow<T = unknown>(
1206
1322
  const externalSignal = options.signal;
1207
1323
  let onExternalAbort: (() => void) | undefined;
1208
1324
  let onRunFatal: (() => void) | undefined;
1325
+ // Filled in from the host's reply, which may name a tier this call
1326
+ // did not: an agent's own tier, or the configured default.
1327
+ let resolvedTier = effectiveTier;
1209
1328
  try {
1210
1329
  throwIfAborted();
1211
1330
  if (batch?.cancelled) throw agentLimitError();
@@ -1238,8 +1357,7 @@ export async function runWorkflow<T = unknown>(
1238
1357
  schema,
1239
1358
  signal: agentController.signal,
1240
1359
  instructions: buildAgentInstructions(assignedPhase, agentOptions, resolvedIsolation),
1241
- model: modelSpec,
1242
- tier: agentOptions.tier,
1360
+ tier: effectiveTier,
1243
1361
  agentType: agentOptions.agentType,
1244
1362
  isolation: resolvedIsolation,
1245
1363
  toolset: options.toolset ?? agentOptions.toolset,
@@ -1252,6 +1370,9 @@ export async function runWorkflow<T = unknown>(
1252
1370
  onModelResolved: (id: string) => {
1253
1371
  void id; // display model tracking is engine-side
1254
1372
  },
1373
+ onTierResolved: (tier: string) => {
1374
+ resolvedTier = tier;
1375
+ },
1255
1376
  onUsage: (u: AgentUsage) => {
1256
1377
  usage = u;
1257
1378
  },
@@ -1313,6 +1434,7 @@ export async function runWorkflow<T = unknown>(
1313
1434
  runId,
1314
1435
  hash: callHash,
1315
1436
  result,
1437
+ ...(resolvedTier === undefined ? {} : { tier: resolvedTier }),
1316
1438
  storeDelta: store.commitDelta(deltaKey),
1317
1439
  });
1318
1440
  options.onAgentEnd?.({
@@ -1320,9 +1442,9 @@ export async function runWorkflow<T = unknown>(
1320
1442
  label,
1321
1443
  phase: assignedPhase,
1322
1444
  result,
1445
+ tier: resolvedTier,
1323
1446
  tokens,
1324
1447
  tokenUsage: usage,
1325
- model: modelSpec,
1326
1448
  });
1327
1449
  return result;
1328
1450
  } catch (error) {
@@ -1350,9 +1472,9 @@ export async function runWorkflow<T = unknown>(
1350
1472
  label,
1351
1473
  phase: assignedPhase,
1352
1474
  result: null,
1475
+ tier: resolvedTier,
1353
1476
  tokens,
1354
1477
  tokenUsage: usage,
1355
- model: modelSpec,
1356
1478
  error: workflowError.message,
1357
1479
  errorCode: workflowError.code,
1358
1480
  recoverable: workflowError.recoverable,
@@ -1512,7 +1634,12 @@ export async function runWorkflow<T = unknown>(
1512
1634
  }
1513
1635
  const callIndex = nextCallIndex();
1514
1636
  const resolved = options.loadSavedWorkflow?.(String(nameOrScript));
1515
- const childScript = resolved ?? String(nameOrScript);
1637
+ const childScript = resolved?.script ?? String(nameOrScript);
1638
+ // A named script carries its own shipped-ness; a raw script literal is the
1639
+ // caller's own text and shares the caller's. Inheriting the frame's flag for
1640
+ // a resolved script would let a user script run a built-in under the strict
1641
+ // tier check — a hard failure on any host that renamed the shipped tiers.
1642
+ const childShippedScript = resolved ? resolved.shippedScript === true : options.shippedScript === true;
1516
1643
  const workflowName = String(nameOrScript);
1517
1644
  const argsPresent = childArgs !== undefined;
1518
1645
  const callHash = hashIdentity({
@@ -1521,6 +1648,10 @@ export async function runWorkflow<T = unknown>(
1521
1648
  script: childScript,
1522
1649
  argsPresent,
1523
1650
  args: argsPresent ? childArgs : null,
1651
+ // Part of the identity because it decides how the child treats a tier
1652
+ // name the host does not define: dropped, or fatal.
1653
+ shippedScript: childShippedScript,
1654
+ routingPolicyFingerprint: options.routingPolicyFingerprint ?? null,
1524
1655
  });
1525
1656
  const cached = options.resumeJournal?.get(`${runId}:${callIndex}`);
1526
1657
  if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
@@ -1540,6 +1671,7 @@ export async function runWorkflow<T = unknown>(
1540
1671
  const child = await runWorkflow(childScript, {
1541
1672
  ...options,
1542
1673
  args: childArgs,
1674
+ shippedScript: childShippedScript,
1543
1675
  sharedRuntime: shared,
1544
1676
  sharedStore: store,
1545
1677
  workflowDepth: workflowDepth + 1,
package/src/ui.ts CHANGED
@@ -172,12 +172,30 @@ async function selectList<T>(
172
172
  }
173
173
 
174
174
  /** Interactive `/workflows` navigator. TUI-only by design. */
175
- export async function showWorkflowNavigator(ctx: ExtensionCommandContext, engine: WorkflowEngine): Promise<void> {
175
+ export async function showWorkflowNavigator(
176
+ ctx: ExtensionCommandContext,
177
+ engineSource: WorkflowEngine | (() => WorkflowEngine),
178
+ ): Promise<void> {
176
179
  if (ctx.mode !== "tui") {
177
180
  ctx.ui.notify("/workflows requires TUI mode; use workflow_control for machine-readable access.", "warning");
178
181
  return;
179
182
  }
183
+ const resolveEngine = typeof engineSource === "function" ? engineSource : () => engineSource;
184
+ let inactiveNotified = false;
185
+ const currentEngine = (): WorkflowEngine | undefined => {
186
+ try {
187
+ return resolveEngine();
188
+ } catch (error: unknown) {
189
+ if (!inactiveNotified) {
190
+ inactiveNotified = true;
191
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
192
+ }
193
+ return undefined;
194
+ }
195
+ };
180
196
  while (true) {
197
+ const engine = currentEngine();
198
+ if (!engine) return;
181
199
  const states = engine
182
200
  .list()
183
201
  .map((summary) => engine.getRun(String(summary.runId)))
@@ -198,7 +216,9 @@ export async function showWorkflowNavigator(ctx: ExtensionCommandContext, engine
198
216
  );
199
217
  if (!selected) return;
200
218
  while (true) {
201
- const run = engine.getRun(selected);
219
+ const latest = currentEngine();
220
+ if (!latest) return;
221
+ const run = latest.getRun(selected);
202
222
  if (!run) break;
203
223
  const name = sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS);
204
224
  const action = await selectList<WorkflowAction>(
@@ -208,7 +228,10 @@ export async function showWorkflowNavigator(ctx: ExtensionCommandContext, engine
208
228
  value: item.value,
209
229
  label: item.label,
210
230
  })),
211
- () => formatWorkflowDetail(engine, selected),
231
+ () => {
232
+ const current = currentEngine();
233
+ return current ? formatWorkflowDetail(current, selected) : "Workflow surface is no longer active.";
234
+ },
212
235
  );
213
236
  if (action === undefined || action === "back") break;
214
237
  if (action === "refresh") continue;
@@ -217,7 +240,9 @@ export async function showWorkflowNavigator(ctx: ExtensionCommandContext, engine
217
240
  if (!confirmed) continue;
218
241
  }
219
242
  try {
220
- await engine.control(action, selected);
243
+ const current = currentEngine();
244
+ if (!current) return;
245
+ await current.control(action, selected);
221
246
  ctx.ui.notify(`Workflow ${action} requested.`, "info");
222
247
  } catch (error: unknown) {
223
248
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");