@signalridge/pi-workflows 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +207 -48
  3. package/package.json +2 -2
  4. package/skills/workflow-authoring/SKILL.md +44 -14
  5. package/skills/workflow-authoring/examples/defensive-json-parsing.js +1 -1
  6. package/skills/workflow-authoring/examples/phased-budgets.js +1 -1
  7. package/skills/workflow-authoring/examples/saved-nested-workflows.js +1 -1
  8. package/skills/workflow-authoring/examples/structured-output.js +1 -1
  9. package/skills/workflow-authoring/references/capabilities.md +7 -7
  10. package/skills/workflow-authoring/references/capability-details.md +24 -10
  11. package/skills/workflow-authoring/references/debugging.md +1 -1
  12. package/skills/workflow-authoring/references/lifecycle.md +5 -1
  13. package/skills/workflow-authoring/references/quality-helpers.md +2 -2
  14. package/skills/workflow-authoring/references/registry-ownership.md +4 -4
  15. package/skills/workflow-authoring/references/review.md +8 -4
  16. package/skills/workflow-authoring/references/runtime.md +2 -2
  17. package/skills/workflow-authoring/references/specialized-helpers.md +1 -1
  18. package/skills/workflow-authoring/references/versions.md +1 -1
  19. package/skills/workflow-patterns/SKILL.md +1 -1
  20. package/skills/workflow-review/SKILL.md +6 -4
  21. package/src/builtins.ts +23 -23
  22. package/src/capabilities.ts +37 -12
  23. package/src/engine.ts +70 -26
  24. package/src/index.ts +243 -70
  25. package/src/journal.ts +0 -14
  26. package/src/rpc-client.ts +14 -1
  27. package/src/runtime.ts +452 -103
  28. package/src/strengths.ts +93 -0
  29. package/src/workflow-settings.ts +95 -1
package/src/index.ts CHANGED
@@ -15,8 +15,20 @@ import {
15
15
  queryChildSessionContextImmediate,
16
16
  } from "./rpc-client.js";
17
17
  import { listSavedWorkflows, loadSavedWorkflow, saveWorkflow } from "./saved-workflows.js";
18
+ import {
19
+ defaultStrengthTable,
20
+ isWorkflowStrength,
21
+ WORKFLOW_STRENGTH_LIST,
22
+ WORKFLOW_STRENGTHS,
23
+ type WorkflowStrengthTable,
24
+ } from "./strengths.js";
18
25
  import { liveWidgetLines, showWorkflowNavigator } from "./ui.js";
19
- import { loadWorkflowSettings, saveWorkflowSettings } from "./workflow-settings.js";
26
+ import {
27
+ loadProjectWorkflowSettings,
28
+ loadWorkflowSettings,
29
+ saveWorkflowSettings,
30
+ type WorkflowSettings,
31
+ } from "./workflow-settings.js";
20
32
 
21
33
  const WorkflowToolSchema = Type.Object(
22
34
  {
@@ -24,11 +36,40 @@ const WorkflowToolSchema = Type.Object(
24
36
  name: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
25
37
  args: Type.Optional(Type.Any()),
26
38
  background: Type.Optional(Type.Boolean()),
27
- maxAgents: Type.Optional(Type.Number({ minimum: 1 })),
28
- concurrency: Type.Optional(Type.Number({ minimum: 1 })),
29
- agentRetries: Type.Optional(Type.Number({ minimum: 0, maximum: 3 })),
30
- tokenBudget: Type.Optional(Type.Number({ minimum: 1 })),
31
- agentTimeoutMs: Type.Optional(Type.Number({ minimum: 1 })),
39
+ maxAgents: Type.Optional(
40
+ Type.Number({
41
+ minimum: 1,
42
+ description:
43
+ "Cap on real agent dispatches for this run. Default 1000, which no ordinary script reaches. Omit it: a value below what the script dispatches stops the run partway with AGENT_LIMIT_EXCEEDED.",
44
+ }),
45
+ ),
46
+ concurrency: Type.Optional(
47
+ Type.Number({
48
+ minimum: 1,
49
+ description:
50
+ "Agents in flight at once. Defaults to the host's own subagent pool size, which is also the ceiling. Omit it: a lower number runs the same work more slowly and buys nothing, and a higher one is clamped.",
51
+ }),
52
+ ),
53
+ agentRetries: Type.Optional(
54
+ Type.Number({
55
+ minimum: 0,
56
+ maximum: 3,
57
+ description: "Retries for a recoverable agent failure. Default 0; a failed call returns null to the script.",
58
+ }),
59
+ ),
60
+ tokenBudget: Type.Optional(
61
+ Type.Number({
62
+ minimum: 1,
63
+ description: "Soft token ceiling for the whole run. Default unlimited; exhausting a set budget aborts the run.",
64
+ }),
65
+ ),
66
+ agentTimeoutMs: Type.Optional(
67
+ Type.Number({
68
+ minimum: 1,
69
+ description:
70
+ "Wall clock per agent, there to bound a hang. Default 1800000 (30min), and it counts from when the agent actually starts, not from dispatch. An agent's real budget is the host's turn, tool and token ceiling. Omit it: a short value fails an entire fan-out as timeouts.",
71
+ }),
72
+ ),
32
73
  resumeFromRunId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
33
74
  },
34
75
  { additionalProperties: false },
@@ -127,23 +168,23 @@ function formatStart(result: ScriptStartResult): string {
127
168
  export function buildAdHocWorkflowScript(prompt: string): string {
128
169
  return `export const meta = { name: "ad-hoc", description: "Plan, execute, and synthesize a bounded multi-agent task graph" };
129
170
  const originalPrompt = ${JSON.stringify(prompt)};
130
- const plan = await agent("Design a bounded execution plan for this user task. Return 1-8 independent or dependency-linked tasks. Use unique short ids, concise descriptions, exact worker prompts, and only dependencies that appear in the task list. Prefer independent tasks when possible. User task:\\n\\n" + originalPrompt, {
171
+ const plan = await agent("Design a bounded execution plan for this user task. Return 1-24 independent or dependency-linked tasks. Use unique short ids, concise descriptions, exact worker prompts, and only dependencies that appear in the task list. Prefer independent tasks when possible, and prefer narrow tasks over few broad ones: a task asked one question is read further into than a task asked four. User task:\\n\\n" + originalPrompt, {
131
172
  label: "planner",
132
- tier: "medium",
173
+ strength: "medium",
133
174
  schema: {
134
175
  type: "object",
135
176
  properties: {
136
177
  tasks: {
137
178
  type: "array",
138
179
  minItems: 1,
139
- maxItems: 8,
180
+ maxItems: 24,
140
181
  items: {
141
182
  type: "object",
142
183
  properties: {
143
184
  id: { type: "string", minLength: 1, maxLength: 64 },
144
185
  description: { type: "string", minLength: 1, maxLength: 500 },
145
186
  prompt: { type: "string", minLength: 1, maxLength: 8000 },
146
- dependsOn: { type: "array", maxItems: 8, items: { type: "string", minLength: 1, maxLength: 64 } },
187
+ dependsOn: { type: "array", maxItems: 24, items: { type: "string", minLength: 1, maxLength: 64 } },
147
188
  },
148
189
  required: ["id", "description", "prompt"],
149
190
  additionalProperties: false,
@@ -158,7 +199,7 @@ const graph = await orchestrate(plan.tasks.map((task) => ({
158
199
  id: task.id,
159
200
  description: task.description,
160
201
  dependsOn: task.dependsOn || [],
161
- run: ({ results, statuses }) => agent(task.prompt + "\\n\\nDependency results (null means unavailable):\\n" + JSON.stringify({ results, statuses }), { label: task.id, tier: "low" }),
202
+ run: ({ results, statuses }) => agent(task.prompt + "\\n\\nDependency results (null means unavailable):\\n" + JSON.stringify({ results, statuses }), { label: task.id, strength: "low" }),
162
203
  })), { onError: "continue" });
163
204
  const SYNTHESIS_CONTEXT_LIMIT = 78000;
164
205
  function renderContextValue(value) {
@@ -232,7 +273,7 @@ while (synthesisJson.length > SYNTHESIS_CONTEXT_LIMIT && (valuePreviewBudget > 1
232
273
  synthesisJson = JSON.stringify(synthesisContext);
233
274
  }
234
275
  if (synthesisJson.length > SYNTHESIS_CONTEXT_LIMIT) throw new Error("Unable to fit the synthesis context within its managed prompt budget");
235
- return await agent("Synthesize the worker results into a direct answer to the original task. Mention incomplete or failed coverage explicitly.\\n\\nOriginal task, execution plan, and graph ledger:\\n" + synthesisJson, { label: "synthesizer", tier: "medium" });`;
276
+ return await agent("Synthesize the worker results into a direct answer to the original task. Mention incomplete or failed coverage explicitly.\\n\\nOriginal task, execution plan, and graph ledger:\\n" + synthesisJson, { label: "synthesizer", strength: "medium" });`;
236
277
  }
237
278
 
238
279
  export default function piWorkflows(pi: ExtensionAPI): void {
@@ -346,37 +387,35 @@ export default function piWorkflows(pi: ExtensionAPI): void {
346
387
  const DEFAULT_EXCLUDED_TOOLS = ["workflow", "workflow_control"] as const;
347
388
 
348
389
  /**
349
- * Resolve a nested `workflow(name)` reference, saying where the script came
350
- * from.
390
+ * Persist one settings change into this project's own file.
351
391
  *
352
- * Shipped-ness has to be reported per script rather than inherited from the
353
- * frame that called it: a user script may call a built-in by name, and a
354
- * built-in's tier names are preferences against whatever catalogue the host
355
- * defines, while a user script's are assertions about a catalogue the user
356
- * owns. Inheriting the caller's flag would apply the wrong rule in both
357
- * directions.
392
+ * `workflowSettings` is the merged view — defaults, then global, then project
393
+ * — while `saveWorkflowSettings` replaces the project file with whatever it
394
+ * is handed. Handing it the merged view would copy every global value and
395
+ * every default into the project on the first edit, and the project would
396
+ * stop tracking the global file from then on. So the patch lands on project
397
+ * scope and the merged view is re-derived from what actually landed, which is
398
+ * also what makes it safe to report the result rather than the intent.
358
399
  */
359
- const resolveNestedWorkflow = (name: string): { script: string; shippedScript?: boolean } | undefined => {
360
- // A saved file shadows a built-in of the same name; it is the user's own
361
- // script and keeps the strict tier check.
362
- const saved = loadSavedWorkflow(name, sessionCwd);
363
- if (saved !== undefined) return { script: saved };
364
- const builtin = BUILTIN_WORKFLOWS[name]?.script;
365
- return builtin === undefined ? undefined : { script: builtin, shippedScript: true };
400
+ const persistWorkflowSetting = (patch: WorkflowSettings): void => {
401
+ saveWorkflowSettings({ ...loadProjectWorkflowSettings(sessionCwd), ...patch }, sessionCwd);
402
+ workflowSettings = loadWorkflowSettings(sessionCwd);
366
403
  };
367
404
 
405
+ /** Resolve a nested `workflow(name)` reference; a saved file shadows a built-in. */
406
+ const resolveNestedWorkflow = (name: string): string | undefined =>
407
+ loadSavedWorkflow(name, sessionCwd) ?? BUILTIN_WORKFLOWS[name]?.script;
408
+
368
409
  const resolveScript = (
369
410
  script: string | undefined,
370
411
  name: string | undefined,
371
412
  args: unknown,
372
- ): { script: string; source: string; toolset?: string; shippedScript?: boolean } => {
413
+ ): { script: string; source: string; toolset?: string } => {
373
414
  if (script !== undefined && name !== undefined) {
374
415
  throw new Error("Provide either `script` or `name`, not both.");
375
416
  }
376
417
  if (script !== undefined) return { script, source: "inline" };
377
418
  if (name !== undefined) {
378
- // A saved workflow is the user's own file even when it shadows a built-in
379
- // name, so it keeps the strict tier check.
380
419
  const saved = loadSavedWorkflow(name, sessionCwd);
381
420
  if (saved) return { script: saved, source: `saved:${name}` };
382
421
  const builtin = BUILTIN_WORKFLOWS[name];
@@ -384,12 +423,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
384
423
  // Validate named invocations before starting a potentially expensive fleet.
385
424
  // Slash commands provide the same shape through their descriptor primaryArg.
386
425
  validateBuiltinArgs(name, args);
387
- return {
388
- script: builtin.script,
389
- source: `builtin:${name}`,
390
- toolset: builtin.toolset,
391
- shippedScript: true,
392
- };
426
+ return { script: builtin.script, source: `builtin:${name}`, toolset: builtin.toolset };
393
427
  }
394
428
  throw new Error(`Unknown workflow name "${name}". It is neither a saved workflow nor a built-in.`);
395
429
  }
@@ -401,6 +435,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
401
435
  active = true;
402
436
  const workflowEngine = currentEngine();
403
437
  workflowEngine.onRunSettled = notifyRunChanged;
438
+ // `control("resume")` and the provider-limit retry start executions with no
439
+ // caller to pass options for them, and the strength table is deliberately
440
+ // never frozen onto a run. Without this they would resume with no table and
441
+ // re-run every finished call untabled.
442
+ workflowEngine.strengths = () => workflowSettings.strengths;
404
443
  // Command/tool registrations persist across session replacement, but the
405
444
  // widget belongs to the newly restored engine and must be refreshed too.
406
445
  refreshWidget();
@@ -417,9 +456,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
417
456
  promptGuidelines: [
418
457
  "Provide either a full `script` (raw JavaScript with the meta contract first) or a `name` of a saved/built-in workflow.",
419
458
  "Use orchestrate([{ id, dependsOn, run }]) for named dependency graphs; use parallel(() => agent(...), ...) for a simple fan-out and pipeline(items, ...stages) for sequential per-item stages.",
459
+ "Size the fan-out to the work: one agent per independent piece of evidence the task actually has. Ten to thirty agents is an ordinary review or audit, and a script that splits a broad task four ways is usually four agents each doing one agent's job. The gain is coverage, not speed — how many run at once is the host's pool, not the script's choice.",
460
+ "Pass script/name/args/background and nothing else unless the user named a specific limit. maxAgents, concurrency, agentRetries, tokenBudget and agentTimeoutMs already default to tuned values, and each one stops or fails the run when it is set too low.",
420
461
  "Use background: true for long runs; retrieve results with workflow_control.",
421
462
  "Use resumeFromRunId with an edited script to replay the unchanged prefix from cache and re-run the rest live.",
422
- "Do not include model, thinking, concurrency, retry, timeout, or turn-limit settings beyond the declared options.",
463
+ "Inside the script, never name a model, a thinking level, or a turn limit: `strength` is the only routing a call may express.",
423
464
  ],
424
465
  parameters: WorkflowToolSchema,
425
466
  renderCall(args, theme) {
@@ -447,12 +488,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
447
488
  ? (() => {
448
489
  const prior = workflowEngine.getRun(params.resumeFromRunId);
449
490
  if (!prior) throw new Error(`Workflow run not found: ${params.resumeFromRunId}`);
450
- // Resume reads the frozen flag off the restored run, so
451
- // this only has to satisfy the shared shape.
452
- return { script: prior.script, source: "resume", toolset: prior.toolset, shippedScript: undefined };
491
+ // Resume replays the stored script; only the shared shape
492
+ // has to be satisfied here.
493
+ return { script: prior.script, source: "resume", toolset: prior.toolset };
453
494
  })()
454
495
  : resolveScript(params.script, params.name, params.args);
455
- const { script, source, toolset, shippedScript } = resolved;
496
+ const { script, source, toolset } = resolved;
456
497
  const options: ScriptStartOptions = {
457
498
  args: params.args,
458
499
  background: params.background ?? true,
@@ -462,7 +503,6 @@ export default function piWorkflows(pi: ExtensionAPI): void {
462
503
  tokenBudget: params.tokenBudget,
463
504
  agentTimeoutMs: params.agentTimeoutMs,
464
505
  toolset,
465
- ...(shippedScript === undefined ? {} : { shippedScript }),
466
506
  excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
467
507
  signal,
468
508
  mainModel: ctx?.model?.id,
@@ -479,6 +519,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
479
519
  }
480
520
  : undefined,
481
521
  loadSavedWorkflow: resolveNestedWorkflow,
522
+ strengths: workflowSettings.strengths,
482
523
  };
483
524
  if (params.resumeFromRunId) {
484
525
  const replacement = params.script !== undefined || params.name !== undefined ? script : undefined;
@@ -546,7 +587,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
546
587
 
547
588
  // /workflows: navigator + run/status/watch/stop/pause/resume/rm/save subcommands
548
589
  pi.registerCommand("workflows", {
549
- description: "Workflow runs: open the navigator, or run/status/watch/stop/pause/resume/rm/save",
590
+ description: "Workflow runs: navigator, or run/status/watch/stop/pause/resume/rm/save/strength",
550
591
  handler: async (args: string, ctx: ExtensionCommandContext) => {
551
592
  const tokens = args.trim().split(/\s+/).filter(Boolean);
552
593
  const [subcommand, ...rest] = tokens;
@@ -656,23 +697,158 @@ export default function piWorkflows(pi: ExtensionAPI): void {
656
697
  }
657
698
  return;
658
699
  }
700
+ if (subcommand === "strength") {
701
+ // The Agent tier catalogue is owned by pi-subagents and still is (A8:
702
+ // no second tier config file). What this edits is narrower and is
703
+ // genuinely ours: which Agent tier each workflow strength runs on.
704
+ //
705
+ // A subcommand rather than a command of its own, because the reason
706
+ // to surface it at all is the three-part view below — two lines of
707
+ // which come from this package and one from the other — and that is
708
+ // a page to read, not a mode to be in.
709
+ //
710
+ // The catalogue lives on the managed peer, so it can legitimately be
711
+ // unreachable here. Status still has its other two parts, and a set
712
+ // made against a table that already exists is stored unvalidated: the
713
+ // run-start check reports a tier this host does not define and drops
714
+ // that entry, which costs the redirect rather than the run. A set made
715
+ // with no table at all is refused instead — see below, where the seed
716
+ // is read.
717
+ const catalogue = await (async (): Promise<string[] | undefined> => {
718
+ try {
719
+ return Object.keys((await awaitProtocol()).routingPolicy.profiles).sort();
720
+ } catch {
721
+ return undefined;
722
+ }
723
+ })();
724
+ const knownTiers = catalogue ? new Set(catalogue) : undefined;
725
+ // What a run would resolve, by the same rule the runtime uses: a
726
+ // configured table if there is one, else the shipped default. Shown
727
+ // rather than the configured table alone, because an unconfigured
728
+ // machine is not an unrouted one and a row reading "(unset)" there
729
+ // would describe something other than what the next run does.
730
+ const effective = (): { table: WorkflowStrengthTable; shipped: boolean } => {
731
+ const configured = workflowSettings.strengths;
732
+ return configured === undefined
733
+ ? { table: defaultStrengthTable(knownTiers), shipped: true }
734
+ : { table: configured, shipped: false };
735
+ };
736
+ const describe = (): string => {
737
+ const { table, shipped } = effective();
738
+ const rows = WORKFLOW_STRENGTHS.map((name) => {
739
+ // The default is derived from the catalogue, so without one there
740
+ // is nothing honest to print: the run will still route these, and
741
+ // "(unset)" would claim it will not.
742
+ if (shipped && knownTiers === undefined) return ` ${name} → (default; needs the tier catalogue)`;
743
+ if (table[name] === undefined) return ` ${name} → (unset; the agent's own default)`;
744
+ return ` ${name} → ${table[name]}${shipped ? " (default)" : ""}`;
745
+ });
746
+ // The shipped default is identity, so on a stock install workflow
747
+ // work rides the same profiles every ordinary spawn does — the
748
+ // coupling this indirection exists to let you break, but does not
749
+ // break on its own. Saying so here is the only place the two steps
750
+ // out of it are visible together.
751
+ const decouple = shipped
752
+ ? "\nThis is the shipped default: each strength on the tier of the same name, so " +
753
+ "workflow work shares the tiers your ordinary spawns use (Explore among them). " +
754
+ "To price workflow work on its own, define a tier in /agents → Model tiers, then " +
755
+ "point a strength at it here."
756
+ : "";
757
+ return (
758
+ `Workflow strengths and the Agent tier each runs on:\n${rows.join("\n")}\n` +
759
+ `Tiers this host defines: ${catalogue?.join(", ") ?? "(unavailable)"}\n` +
760
+ "Edits apply to this project; for a machine-wide table edit " +
761
+ "<agent dir>/workflows/settings.json. The tiers themselves live in " +
762
+ "/agents → Model tiers; this only says which one a strength uses." +
763
+ decouple
764
+ );
765
+ };
766
+ const [strength, target] = rest;
767
+ if (strength === undefined) {
768
+ ctx.ui.notify(describe(), "info");
769
+ return;
770
+ }
771
+ if (!isWorkflowStrength(strength)) {
772
+ ctx.ui.notify(`"${strength}" is not a workflow strength. Use one of ${WORKFLOW_STRENGTH_LIST}.`, "warning");
773
+ return;
774
+ }
775
+ if (target === undefined || rest.length !== 2) {
776
+ ctx.ui.notify(`/workflows strength [<${WORKFLOW_STRENGTHS.join("|")}> <tier>|off]`, "warning");
777
+ return;
778
+ }
779
+ // `off` reads as "unset" unless the host really defines a tier by
780
+ // that name, in which case the user's own catalogue outranks our
781
+ // keyword — a tier you cannot point at is worse than a clumsier way
782
+ // to clear one.
783
+ const unset = target === "off" && catalogue?.includes("off") !== true;
784
+ if (!unset && catalogue && !catalogue.includes(target)) {
785
+ ctx.ui.notify(
786
+ `"${target}" is not a tier this host defines. Available: ${catalogue.join(", ") || "(none)"}. ` +
787
+ "Define it in /agents → Model tiers first.",
788
+ "warning",
789
+ );
790
+ return;
791
+ }
792
+ const { table: current, shipped } = effective();
793
+ if (shipped && knownTiers === undefined) {
794
+ // Seeding from a default we cannot compute would write a one-entry
795
+ // table, and this key replaces rather than merges — so the other two
796
+ // strengths would go from routed to unmapped as a side effect of
797
+ // setting this one. Refusing costs a retry; writing costs a run.
798
+ ctx.ui.notify(
799
+ "The tier catalogue is unavailable, so the current table cannot be read. " +
800
+ "Retry once the managed peer is reachable.",
801
+ "warning",
802
+ );
803
+ return;
804
+ }
805
+ // Seeded from the table a run would use, not from this project's file.
806
+ // Every level of this key replaces the one below rather than merging
807
+ // into it, so a patch built on project scope would silently drop
808
+ // whatever the level below contributed: adding one entry would discard
809
+ // an inherited global table, and `off` against an entry this project
810
+ // never wrote would write nothing and leave the mapping standing. The
811
+ // seed does freeze the current table into the project, which is what a
812
+ // replace-wholesale key costs — the entries a partial file left out
813
+ // would be unmapped, not inherited.
814
+ const { [strength]: _cleared, ...others } = current;
815
+ try {
816
+ persistWorkflowSetting({ strengths: unset ? others : { ...others, [strength]: target } });
817
+ } catch (error: unknown) {
818
+ ctx.ui.notify(
819
+ `Could not save workflow strength routing: ${error instanceof Error ? error.message : String(error)}`,
820
+ "warning",
821
+ );
822
+ return;
823
+ }
824
+ // Show the resolved result rather than echoing the write. The
825
+ // settings layer can drop what it was handed, and `off` against the
826
+ // last remaining entry stores an explicit empty table — "map nothing",
827
+ // which is not the same as the shipped default an absent key would
828
+ // mean — so a message announcing the write would describe something
829
+ // other than what the next run will do. Runs already in flight keep
830
+ // what they started with; the table is read fresh at each start and
831
+ // resume.
832
+ //
833
+ // The one case the table alone cannot explain: the host defines a tier
834
+ // named `off`, so the keyword lost to it above and this mapped rather
835
+ // than cleared. The catalogue outranking our keyword is the right call
836
+ // — a tier you cannot point at is worse than a clumsier way to clear
837
+ // one — but a user who typed the documented keyword and got a mapping
838
+ // has to be told which reading won, and where the other one lives.
839
+ const shadowed =
840
+ target === "off" && !unset
841
+ ? '\n\nNote: this host defines a tier named "off", so `off` was read as that tier rather ' +
842
+ "than as the clear keyword. To leave a strength unmapped here, remove its entry from the " +
843
+ "`strengths` object in this project's settings.json."
844
+ : "";
845
+ ctx.ui.notify(`${describe()}${shadowed}`, "info");
846
+ return;
847
+ }
659
848
  ctx.ui.notify(`Unknown /workflows subcommand: ${subcommand}`, "warning");
660
849
  },
661
850
  });
662
851
 
663
- // /workflows-models: tier configuration is owned by pi-subagents; this
664
- // command documents the delegation (A8: no second tier config file).
665
- pi.registerCommand("workflows-models", {
666
- description: "Workflow tier routing: tiers are configured in pi-subagents /agents → Model tiers",
667
- handler: async (_args: string, ctx: ExtensionCommandContext) => {
668
- ctx.ui.notify(
669
- "Workflow tiers (small/medium/large) route through pi-subagents' workflow.tiers settings. " +
670
- "Use /agents → Model tiers to configure them.",
671
- "info",
672
- );
673
- },
674
- });
675
-
676
852
  // /workflows-progress: persistent compact/detailed live-panel settings.
677
853
  pi.registerCommand("workflows-progress", {
678
854
  description: "Workflow progress panel: compact|detailed|status|max <N>",
@@ -686,21 +862,22 @@ export default function piWorkflows(pi: ExtensionAPI): void {
686
862
  );
687
863
  return;
688
864
  }
865
+ let patch: WorkflowSettings;
689
866
  if (action === "compact" || action === "detailed") {
690
- workflowSettings = { ...workflowSettings, progressMode: action };
867
+ patch = { progressMode: action };
691
868
  } else if (action === "max") {
692
869
  const max = Number(tokens[1]);
693
870
  if (!Number.isInteger(max) || max < 1 || max > 32) {
694
871
  ctx.ui.notify("Usage: /workflows-progress max <1-32>", "warning");
695
872
  return;
696
873
  }
697
- workflowSettings = { ...workflowSettings, maxAgentsShown: max };
874
+ patch = { maxAgentsShown: max };
698
875
  } else {
699
876
  ctx.ui.notify("Usage: /workflows-progress compact|detailed|status|max <N>", "warning");
700
877
  return;
701
878
  }
702
879
  try {
703
- saveWorkflowSettings(workflowSettings, sessionCwd);
880
+ persistWorkflowSetting(patch);
704
881
  refreshWidget();
705
882
  ctx.ui.notify(
706
883
  `Workflow progress set to ${workflowSettings.progressMode}, max ${workflowSettings.maxAgentsShown}.`,
@@ -723,9 +900,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
723
900
  // an ordinary answer.
724
901
  const TRIGGER_WORD_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/u;
725
902
  const persistTriggerSettings = (): void => {
726
- workflowSettings = { ...workflowSettings, keywordTriggerWord: triggerKeyword, keywordTriggerEnabled };
727
903
  try {
728
- saveWorkflowSettings(workflowSettings, sessionCwd);
904
+ persistWorkflowSetting({ keywordTriggerWord: triggerKeyword, keywordTriggerEnabled });
729
905
  } catch {
730
906
  /* setting changes remain active in memory */
731
907
  }
@@ -784,7 +960,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
784
960
  if (event.text.includes(WORKFLOW_ARMED_DIRECTIVE)) return { action: "continue" as const };
785
961
  const effortDirective =
786
962
  effortLevel === "ultra"
787
- ? "Effort: ULTRA. Be exhaustive: use broad parallel review, verification, and completeness checks; choose the large tier where useful."
963
+ ? 'Effort: ULTRA. Be exhaustive: use broad parallel review, verification, and completeness checks. Width is where the exhaustiveness goes: keep steps at strength: "medium" and reserve "high" for a step whose error is expensive to reverse, if the run has one at all.'
788
964
  : effortLevel === "high"
789
965
  ? "Effort: HIGH. Be thorough: use several independent reviewers and an adversarial verification pass."
790
966
  : "";
@@ -806,9 +982,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
806
982
  return;
807
983
  }
808
984
  effortLevel = level;
809
- workflowSettings = { ...workflowSettings, effort: effortLevel };
810
985
  try {
811
- saveWorkflowSettings(workflowSettings, sessionCwd);
986
+ persistWorkflowSetting({ effort: effortLevel });
812
987
  } catch {
813
988
  /* warning is non-fatal */
814
989
  }
@@ -824,9 +999,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
824
999
  description: "Ultracode: toggle exhaustive workflow effort",
825
1000
  handler: async (args: string, ctx: ExtensionCommandContext) => {
826
1001
  effortLevel = args.trim().toLowerCase() === "off" ? "off" : "ultra";
827
- workflowSettings = { ...workflowSettings, effort: effortLevel };
828
1002
  try {
829
- saveWorkflowSettings(workflowSettings, sessionCwd);
1003
+ persistWorkflowSetting({ effort: effortLevel });
830
1004
  } catch {
831
1005
  /* warning is non-fatal */
832
1006
  }
@@ -873,9 +1047,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
873
1047
  args: scriptArgs,
874
1048
  background: true,
875
1049
  toolset: descriptor.toolset,
876
- shippedScript: true,
877
1050
  excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
878
1051
  loadSavedWorkflow: resolveNestedWorkflow,
1052
+ strengths: workflowSettings.strengths,
879
1053
  };
880
1054
  try {
881
1055
  const workflowEngine = currentEngine();
@@ -1019,9 +1193,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1019
1193
  const workflowEngine = currentEngine();
1020
1194
  const result = await workflowEngine.start(script, {
1021
1195
  background: true,
1022
- // We wrote this script, tiers included, so its tier names are a
1023
- // preference against whatever catalogue this host defines.
1024
- shippedScript: true,
1196
+ strengths: workflowSettings.strengths,
1025
1197
  mainModel: ctx.model?.id,
1026
1198
  });
1027
1199
  void deliverBackgroundResult(workflowEngine, result.runId);
@@ -1190,6 +1362,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1190
1362
  background: true,
1191
1363
  mainModel: commandCtx?.model?.id,
1192
1364
  loadSavedWorkflow: resolveNestedWorkflow,
1365
+ strengths: workflowSettings.strengths,
1193
1366
  };
1194
1367
  const result = await workflowEngine.start(script, options);
1195
1368
  void deliverBackgroundResult(workflowEngine, result.runId);
package/src/journal.ts CHANGED
@@ -133,12 +133,6 @@ export interface ScriptRun {
133
133
  frozenArgsPresent: boolean;
134
134
  /** Number of the latest edited-script revision. */
135
135
  revision?: number;
136
- /**
137
- * This run's script ships with the package. Frozen for the same reason
138
- * `toolset` is: a resume must keep treating a shipped script's tier names as
139
- * preferences, not as assertions about the user's catalogue.
140
- */
141
- shippedScript?: boolean;
142
136
  /** Toolset marker persisted across resume (e.g. "web-research" for deep-research). */
143
137
  toolset?: string;
144
138
  /** Frozen run params — persisted so resume after restore keeps original budget/scale. */
@@ -238,7 +232,6 @@ export type JournalEvent =
238
232
  args?: unknown;
239
233
  /** Explicit args presence marker for deterministic resume. */
240
234
  frozenArgsPresent: boolean;
241
- shippedScript?: boolean;
242
235
  toolset?: string;
243
236
  frozenMaxAgents?: number;
244
237
  frozenConcurrency?: number;
@@ -678,11 +671,6 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
678
671
  const frozenArgsPresent = parseFrozenArgsPresence(raw);
679
672
  const args = raw.args === undefined ? undefined : parsePersistedValue(raw.args, "workflow args");
680
673
  const toolset = Object.hasOwn(raw, "toolset") ? boundedString(raw.toolset, "toolset", 64) : undefined;
681
- const shippedScript = (() => {
682
- if (!Object.hasOwn(raw, "shippedScript")) return undefined;
683
- if (typeof raw.shippedScript !== "boolean") throw new Error("shippedScript is invalid");
684
- return raw.shippedScript;
685
- })();
686
674
  const frozenMaxAgents = (() => {
687
675
  if (!Object.hasOwn(raw, "frozenMaxAgents")) return undefined;
688
676
  if (typeof raw.frozenMaxAgents !== "number" || !Number.isFinite(raw.frozenMaxAgents) || raw.frozenMaxAgents < 1) {
@@ -752,7 +740,6 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
752
740
  meta,
753
741
  ...(args === undefined ? {} : { args }),
754
742
  frozenArgsPresent,
755
- ...(shippedScript === undefined ? {} : { shippedScript }),
756
743
  ...(toolset ? { toolset } : {}),
757
744
  ...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
758
745
  ...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
@@ -1192,7 +1179,6 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1192
1179
  ...(event.args === undefined ? {} : { args: structuredClone(event.args) }),
1193
1180
  frozenArgsPresent: event.frozenArgsPresent,
1194
1181
  revision: 0,
1195
- ...(event.shippedScript === undefined ? {} : { shippedScript: event.shippedScript }),
1196
1182
  ...(event.toolset ? { toolset: event.toolset } : {}),
1197
1183
  ...(event.frozenMaxAgents !== undefined ? { frozenMaxAgents: event.frozenMaxAgents } : {}),
1198
1184
  ...(event.frozenConcurrency !== undefined ? { frozenConcurrency: event.frozenConcurrency } : {}),
package/src/rpc-client.ts CHANGED
@@ -172,6 +172,16 @@ export interface ManagedProtocolCheck {
172
172
  routingPolicy: ManagedRoutingPolicy;
173
173
  /** Whole-catalogue identity; used only where a per-tier identity cannot be. */
174
174
  routingPolicyFingerprint: string;
175
+ /**
176
+ * The host's background-agent pool size, when the peer published one.
177
+ *
178
+ * A workflow agent occupies one of those slots like any other background
179
+ * agent, so this is the width the run can actually achieve; dispatching past
180
+ * it only fills the host's queue. Undefined means the peer predates the
181
+ * field or answered through the readiness broadcast, which carries no
182
+ * requested fields — the caller then falls back to its own default.
183
+ */
184
+ maxConcurrent?: number;
175
185
  }
176
186
 
177
187
  export async function checkManagedSpawnProtocol(
@@ -213,7 +223,9 @@ export async function checkManagedSpawnProtocol(
213
223
  });
214
224
  const sendPing = () => {
215
225
  try {
216
- events.emit("subagents:rpc:ping", { requestId: id });
226
+ // Additive reply fields are requested by name: the reply envelope is
227
+ // strictly parsed, so a peer may not volunteer one.
228
+ events.emit("subagents:rpc:ping", { requestId: id, include: ["maxConcurrent"] });
217
229
  } catch (error: unknown) {
218
230
  finish(undefined, error instanceof Error ? error : new Error(String(error)));
219
231
  }
@@ -254,6 +266,7 @@ export async function checkManagedSpawnProtocol(
254
266
  return {
255
267
  routingPolicy: ping.routingPolicy.policy,
256
268
  routingPolicyFingerprint: ping.routingPolicy.fingerprint,
269
+ ...(ping.maxConcurrent === undefined ? {} : { maxConcurrent: ping.maxConcurrent }),
257
270
  };
258
271
  }
259
272