@signalridge/pi-workflows 0.2.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0
4
+ ### Major Changes
5
+
6
+ - Adopt a unified 1.0.0 across every published package.
7
+
8
+ The version numbers no longer track their upstream forks individually; from this release each package
9
+ is versioned on its own merit against the Signalridge line, and 1.0.0 is the shared starting point.
10
+ Packages whose behavior changed in this release document that change in their own changeset entries;
11
+ the remainder are re-released unchanged so the whole set shares one baseline.
12
+
13
+ ### Minor Changes
14
+
15
+ - Chain dependency results into downstream prompts, sanitize child-agent output in the navigator, and make the detail-menu action mapping exhaustive.
16
+
17
+ - `inputs`: a task may declare `inputs`, a duplicate-free subset of its own `depends_on`, so those dependencies' journaled results are appended to its dispatch prompt. `depends_on` on its own stays a pure scheduling barrier, so the DAG can now chain and not only fan out. The field is opt-in: a task without `inputs` dispatches a byte-identical prompt to before. Composition is a pure function of the journaled prefix — the declared `inputs` array is iterated in order and only journaled result fields are read — so a replay recomposes the same prompt and pi-subagents' managed spawn fingerprint stays stable across a branch rewind. Each result is capped at 6,000 characters and the appended section at 24,000, budgeted against the managed protocol's 100,000-character prompt ceiling, which rejects rather than truncates; when the authored prompt leaves too little headroom, nothing is appended.
18
+ - Forward-only journal compatibility: a `run_created` definition carrying `inputs` fails validation on an older `pi-workflows` build, which quarantines and reports that run rather than replaying it. Downgrading after authoring a workflow with `inputs` is not supported.
19
+ - Terminal safety: task results, task errors, and run errors are child-agent output and reached the `/workflows` navigator unsanitized, so a child could emit escape sequences that clear the screen, plant an OSC 8 hyperlink, or reorder the line with a bidi override. Every untrusted string is now neutralized before it is truncated — truncating first could cut through a sequence and leave a live introducer behind.
20
+ - `result_truncated=` in synthesis context now reflects the journaled `truncated` flag, which was previously always reported as `false` because results are already capped when they are journaled.
21
+ - Regression-proofing, not a behavior change: the `/workflows` detail menu built its action list from inline status literals. That mapping is now an exhaustively-typed `workflowActionsFor`, so adding a workflow status is a compile error instead of a silently wrong menu. Every status offers the same actions it did before.
22
+
23
+ ### Patch Changes
24
+
25
+ - Updated dependencies
26
+ - Updated dependencies
27
+ - Updated dependencies
28
+ - Updated dependencies
29
+ - Updated dependencies
30
+ - Updated dependencies
31
+ - @signalridge/pi-subagents@1.0.0
32
+ - @signalridge/pi-subagents-protocol@1.0.0
33
+
3
34
  ## Unreleased
4
35
  - **Semantic workflow tiers:** tasks, synthesis, and an optional run default can request only `small`, `medium`, or `large`; model and thinking policy stays inside pi-subagents. The release requires `@signalridge/pi-subagents >=0.16.0-signalridge.1` and the v3 `workflowTiers` capability.
5
36
 
package/README.md CHANGED
@@ -4,7 +4,7 @@ Declarative DAG orchestration for Pi. The `workflow` tool validates phases, task
4
4
 
5
5
  ## Install
6
6
 
7
- `@signalridge/pi-workflows` requires `@signalridge/pi-subagents` `>=0.16.0-signalridge.1` with protocol v3 managed-spawn support. They are separate Pi packages: the peer dependency documents the requirement but intentionally does not auto-load or duplicate the subagent extension.
7
+ `@signalridge/pi-workflows` requires `@signalridge/pi-subagents` `>=1.0.0` with protocol v3 managed-spawn support. They are separate Pi packages: the peer dependency documents the requirement but intentionally does not auto-load or duplicate the subagent extension.
8
8
 
9
9
  ```bash
10
10
  pi install npm:@signalridge/pi-subagents
@@ -26,10 +26,12 @@ At `session_start`, this extension sends `subagents:rpc:ping` and verifies proto
26
26
 
27
27
  ## Tools and command
28
28
 
29
- - `workflow`: run a foreground or background workflow.
29
+ - `workflow`: run a foreground or background workflow. `depends_on` is a scheduling barrier only. A task may additionally declare `inputs`, a duplicate-free subset of its own `depends_on`, to have those dependencies' journaled results appended to its dispatch prompt: each result is capped at 6,000 characters, the appended section at 24,000, and the whole prompt at the protocol's 100,000-character ceiling. When the task prompt leaves less than 512 characters of headroom, nothing is appended and the prompt dispatches unchanged. Omitting `inputs` dispatches exactly the authored prompt.
30
30
  - `workflow_control`: `list`, `get`, `pause`, `resume`, or `stop` a run. `get` returns bounded task and synthesis details including status, agent ID, compactions, and result/error previews.
31
31
  - `/workflows`: TUI run list/detail navigator. Live agent conversation navigation remains in pi-subagents FleetView.
32
32
 
33
+ Composed input sections are a pure function of the journaled prefix — declared `inputs` order, journaled result fields only — so a replay reproduces the same prompt and the managed spawn fingerprint stays stable across a branch rewind.
34
+
33
35
  State is persisted as `pi.appendEntry("pi-workflows:journal", ...)` custom entries, which do not enter model context. Results and synthesis instructions are capped before journaling or dispatch. Interrupted runs reset unreconciled dispatches to a recoverable state and reuse their managed spawn keys; completed task facts are not dispatched again. A task failure stops remaining owned agents, journals their terminal callbacks, blocks dependents, and settles the workflow as failed.
34
36
  Restore rotation is journaled atomically per run (`run_recovery`) so a failed append leaves no partial generation; older multi-entry recovery prefixes remain replayable and are migrated with independent `attempt_recovery` entries.
35
37
  Each atomic recovery also carries a deterministic bounded `recoveryId` derived from its branch/run and attempt rotations. Replay treats an exact normalized duplicate as a no-op, quarantines same-ID conflicts, and accepts older schema-v2 atomic entries without an ID only through deterministic legacy normalization.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-workflows",
3
- "version": "0.2.0",
3
+ "version": "1.2.0",
4
4
  "description": "Declarative DAG workflows backed by pi-subagents managed spawning.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,11 +17,11 @@
17
17
  "peerDependencies": {
18
18
  "@earendil-works/pi-coding-agent": "*",
19
19
  "@earendil-works/pi-tui": "*",
20
- "@signalridge/pi-subagents": ">=0.16.0-signalridge.1"
20
+ "@signalridge/pi-subagents": ">=1.0.0"
21
21
  },
22
22
  "dependencies": {
23
23
  "@sinclair/typebox": "^0.34.49",
24
- "@signalridge/pi-subagents-protocol": "^0.1.0"
24
+ "@signalridge/pi-subagents-protocol": "^1.0.0"
25
25
  },
26
26
  "pi": {
27
27
  "extensions": [
package/src/engine.ts CHANGED
@@ -36,7 +36,18 @@ import {
36
36
  transitionWorkflow,
37
37
  type WorkflowStatus,
38
38
  } from "./state-machine.js";
39
- import { buildSynthesisPrompt, resultFromLifecycle } from "./synthesis.js";
39
+ import {
40
+ buildBoundedResultSection,
41
+ buildSynthesisPrompt,
42
+ cap,
43
+ MAX_DISPATCH_PROMPT_CHARS,
44
+ MAX_TASK_INPUT_CHARS,
45
+ MAX_TASK_RESULT_CHARS,
46
+ MIN_TASK_INPUT_CHARS,
47
+ type ResultEntry,
48
+ resultFromLifecycle,
49
+ TASK_INPUT_HEADER,
50
+ } from "./synthesis.js";
40
51
 
41
52
  const SYNTHESIS_NODE_ID = "__synthesis__";
42
53
  const MAX_WAITERS = 256;
@@ -1767,6 +1778,41 @@ export class WorkflowEngine {
1767
1778
  }
1768
1779
  }
1769
1780
 
1781
+ /**
1782
+ * Append the journaled results of `task.inputs` to its prompt.
1783
+ *
1784
+ * Must be a pure function of the journaled prefix: the composed prompt feeds pi-subagents'
1785
+ * managed fingerprint, and a replay that recomposes a different prompt is reported as a spawn
1786
+ * key conflict and fails the run non-retryably. So: iterate the declared `inputs` array in
1787
+ * order (never `Object.keys(run.taskResults)`, whose order differs between a live run and a
1788
+ * replay), read only journaled result fields (never `run.taskStatus`, live agent state, or the
1789
+ * clock), and never throw — `dispatchTask` has no error path around composition.
1790
+ */
1791
+ private composeTaskPrompt(run: WorkflowRun, task: WorkflowTask): string {
1792
+ const inputs = task.inputs ?? [];
1793
+ if (inputs.length === 0) return task.prompt;
1794
+ const separator = "\n\n";
1795
+ // pi-subagents-protocol rejects an oversized prompt by throwing, so budget against the
1796
+ // remaining headroom rather than adding a fixed section and hoping it fits.
1797
+ const headroom = MAX_DISPATCH_PROMPT_CHARS - task.prompt.length - separator.length;
1798
+ const budget = Math.min(MAX_TASK_INPUT_CHARS, headroom);
1799
+ if (budget < MIN_TASK_INPUT_CHARS) return task.prompt;
1800
+ const entries: ResultEntry[] = inputs.map((id) => {
1801
+ const dependency = run.definition.tasks.find((candidate) => candidate.id === id);
1802
+ return {
1803
+ task: dependency ?? { id, description: id },
1804
+ result: run.taskResults[id],
1805
+ fallbackStatus: "missing",
1806
+ };
1807
+ });
1808
+ const section = buildBoundedResultSection([TASK_INPUT_HEADER], entries, {
1809
+ maxPerResult: MAX_TASK_RESULT_CHARS,
1810
+ maxTotal: budget,
1811
+ overflowMarker: "\n\n[dependency input truncated: additional results omitted]",
1812
+ });
1813
+ return cap(`${task.prompt}${separator}${section}`, MAX_DISPATCH_PROMPT_CHARS).text;
1814
+ }
1815
+
1770
1816
  private async dispatchTask(run: WorkflowRun, nodeId: string, branchEpoch: number): Promise<void> {
1771
1817
  const task = run.definition.tasks.find((candidate) => candidate.id === nodeId);
1772
1818
  if (
@@ -1778,8 +1824,14 @@ export class WorkflowEngine {
1778
1824
  )
1779
1825
  return;
1780
1826
  const attemptId = run.attemptIds[nodeId];
1781
- const dispatchTaskDefinition =
1782
- task.tier === undefined && run.definition.tier !== undefined ? { ...task, tier: run.definition.tier } : task;
1827
+ const prompt = this.composeTaskPrompt(run, task);
1828
+ const tierOverride = task.tier === undefined ? run.definition.tier : undefined;
1829
+ // Keeping the `task` identity when nothing changes guarantees a byte-identical dispatch
1830
+ // for every workflow that does not declare `inputs`.
1831
+ const dispatchTaskDefinition: WorkflowTask =
1832
+ tierOverride === undefined && prompt === task.prompt
1833
+ ? task
1834
+ : { ...task, prompt, ...(tierOverride === undefined ? {} : { tier: tierOverride }) };
1783
1835
  let response: ManagedSpawnResponse;
1784
1836
  try {
1785
1837
  response = normalizeManagedSpawnResponse(
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ const TaskSchema = Type.Object(
27
27
  description: Type.String({ minLength: 1, maxLength: 512 }),
28
28
  prompt: Type.String({ minLength: 1, maxLength: 100_000 }),
29
29
  depends_on: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 128 }), { maxItems: 128 })),
30
+ inputs: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 128 }), { maxItems: 128 })),
30
31
  },
31
32
  { additionalProperties: false },
32
33
  );
@@ -117,6 +118,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
117
118
  promptSnippet: "Run independent tasks as a dependency-aware workflow",
118
119
  promptGuidelines: [
119
120
  "Use workflow for explicit multi-task DAG orchestration; an optional tier must be small, medium, or large. Do not include model, thinking, concurrency, retry, timeout, or turn-limit settings.",
121
+ "depends_on controls ordering only. To pass a dependency's result into a task, also list it in inputs, which must be a subset of that task's depends_on; those results are appended to the task prompt under a bounded budget.",
120
122
  "Use workflow_control to inspect, pause, resume, or stop a background workflow.",
121
123
  ],
122
124
  parameters: WorkflowSchema,
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Terminal-safety for untrusted text rendered by the `/workflows` navigator.
3
+ *
4
+ * Task results, task errors, and run errors originate in child agent sessions, so they are
5
+ * attacker-influenced from this package's point of view. Duplicated rather than imported:
6
+ * extensions must not depend on another extension's source (see docs/package-boundaries.md).
7
+ */
8
+
9
+ /** Control, escape-introducing, and bidi-override code points a terminal would act on. */
10
+ function isUnsafeTerminalCodePoint(codePoint: number): boolean {
11
+ return (
12
+ codePoint <= 0x1f ||
13
+ (codePoint >= 0x7f && codePoint <= 0x9f) ||
14
+ codePoint === 0x061c ||
15
+ codePoint === 0x200e ||
16
+ codePoint === 0x200f ||
17
+ (codePoint >= 0x202a && codePoint <= 0x202e) ||
18
+ (codePoint >= 0x2066 && codePoint <= 0x2069)
19
+ );
20
+ }
21
+
22
+ /**
23
+ * Replace every code point a terminal could interpret as control input with a space.
24
+ * Neutralizing the introducer (ESC, and the 8-bit C1 forms such as `0x9b` CSI) is what makes
25
+ * the remainder of a sequence inert payload text rather than a command.
26
+ */
27
+ export function sanitizeTerminalText(value: string): string {
28
+ return [...value]
29
+ .map((character) => (isUnsafeTerminalCodePoint(character.codePointAt(0) ?? 0) ? " " : character))
30
+ .join("");
31
+ }
32
+
33
+ /**
34
+ * Single-line, bounded rendering of untrusted text.
35
+ *
36
+ * Sanitizing strictly before truncating is load-bearing: slicing first can cut through an
37
+ * escape sequence and leave a dangling introducer that swallows whatever the terminal draws
38
+ * next. Slicing by code point additionally avoids splitting a surrogate pair.
39
+ */
40
+ export function sanitizeDisplayText(value: string, maximumLength: number): string {
41
+ if (maximumLength < 1) return "";
42
+ const safe = sanitizeTerminalText(value).replace(/\s+/gu, " ").trim();
43
+ return [...safe].slice(0, maximumLength).join("");
44
+ }
package/src/schema.ts CHANGED
@@ -19,6 +19,11 @@ export interface WorkflowTask {
19
19
  prompt: string;
20
20
  tier?: WorkflowTier;
21
21
  depends_on: string[];
22
+ /**
23
+ * Subset of `depends_on` whose journaled results are appended to this task's dispatch prompt.
24
+ * Opt-in: absent means `depends_on` stays a pure ordering barrier, as it always has been.
25
+ */
26
+ inputs?: string[];
22
27
  }
23
28
 
24
29
  export interface WorkflowSynthesis {
@@ -77,6 +82,25 @@ function stringArray(value: unknown, label: string, maxItems: number, maxText: n
77
82
  return value.map((item, index) => stringField(item, `${label}[${index}]`, maxText));
78
83
  }
79
84
 
85
+ /**
86
+ * `inputs` must be a duplicate-free subset of the same task's `depends_on`, so it can never
87
+ * introduce an edge the cycle checker has not already seen.
88
+ */
89
+ function validateTaskInputs(tasks: WorkflowTask[]): void {
90
+ for (const task of tasks) {
91
+ if (!task.inputs) continue;
92
+ const dependencies = new Set(task.depends_on);
93
+ const seen = new Set<string>();
94
+ for (const input of task.inputs) {
95
+ if (!dependencies.has(input)) {
96
+ throw new Error(`task "${task.id}" input "${input}" is not one of its depends_on`);
97
+ }
98
+ if (seen.has(input)) throw new Error(`task "${task.id}" lists duplicate input "${input}"`);
99
+ seen.add(input);
100
+ }
101
+ }
102
+ }
103
+
80
104
  function validateDag(tasks: WorkflowTask[]): void {
81
105
  const ids = new Set(tasks.map((task) => task.id));
82
106
  const visiting = new Set<string>();
@@ -132,7 +156,7 @@ export function validateWorkflow(input: unknown): WorkflowDefinition {
132
156
  if (!isRecord(raw)) throw new Error(`tasks[${index}] must be an object`);
133
157
  assertObjectKeys(
134
158
  raw,
135
- ["id", "phase", "tier", "subagent_type", "description", "prompt", "depends_on"],
159
+ ["id", "phase", "tier", "subagent_type", "description", "prompt", "depends_on", "inputs"],
136
160
  `tasks[${index}]`,
137
161
  );
138
162
  const task: WorkflowTask = {
@@ -143,6 +167,10 @@ export function validateWorkflow(input: unknown): WorkflowDefinition {
143
167
  description: stringField(raw.description, `tasks[${index}].description`, MAX_SHORT_TEXT),
144
168
  prompt: stringField(raw.prompt, `tasks[${index}].prompt`, MAX_TEXT),
145
169
  depends_on: stringArray(raw.depends_on, `tasks[${index}].depends_on`, MAX_TASKS, 128),
170
+ // Spread so an absent field stays absent and journaled definitions remain byte-stable.
171
+ ...(raw.inputs === undefined
172
+ ? {}
173
+ : { inputs: stringArray(raw.inputs, `tasks[${index}].inputs`, MAX_TASKS, 128) }),
146
174
  };
147
175
  if (taskIds.has(task.id)) throw new Error(`duplicate task id "${task.id}"`);
148
176
  taskIds.add(task.id);
@@ -151,6 +179,7 @@ export function validateWorkflow(input: unknown): WorkflowDefinition {
151
179
  }
152
180
  return task;
153
181
  });
182
+ validateTaskInputs(tasks);
154
183
  validateDag(tasks);
155
184
 
156
185
  let synthesis: WorkflowSynthesis | undefined;
@@ -34,6 +34,12 @@ const workflowTransitions: Record<WorkflowStatus, readonly WorkflowStatus[]> = {
34
34
  interrupted: ["running", "stopping", "stopped"],
35
35
  };
36
36
 
37
+ /**
38
+ * Every `WorkflowStatus`, derived from the exhaustive transition table rather than re-listed,
39
+ * so a new union member cannot silently be omitted from status-driven enumerations.
40
+ */
41
+ export const WORKFLOW_STATUSES: readonly WorkflowStatus[] = Object.keys(workflowTransitions) as WorkflowStatus[];
42
+
37
43
  const taskTransitions: Record<TaskStatus, readonly TaskStatus[]> = {
38
44
  pending: ["ready", "blocked", "stopped"],
39
45
  ready: ["dispatching", "blocked", "stopped"],
package/src/synthesis.ts CHANGED
@@ -4,45 +4,69 @@ import type { WorkflowDefinition, WorkflowTask } from "./schema.js";
4
4
  export const MAX_TASK_RESULT_CHARS = 6_000;
5
5
  export const MAX_SYNTHESIS_INSTRUCTION_CHARS = 8_000;
6
6
  export const MAX_SYNTHESIS_INPUT_CHARS = 48_000;
7
+ /** Aggregate budget for a task's `inputs` section; deliberately smaller than synthesis's. */
8
+ export const MAX_TASK_INPUT_CHARS = 24_000;
9
+ /** Must equal `boundedString`'s prompt cap in pi-subagents-protocol, which throws rather than truncating. */
10
+ export const MAX_DISPATCH_PROMPT_CHARS = 100_000;
11
+ /** Below this much headroom an input section carries no useful content, so none is injected. */
12
+ export const MIN_TASK_INPUT_CHARS = 512;
13
+ export const TASK_INPUT_HEADER = "Dependency results (pi-workflows inputs v1):";
7
14
 
8
- function cap(value: string, limit: number): { text: string; truncated: boolean } {
15
+ export function cap(value: string, limit: number): { text: string; truncated: boolean } {
9
16
  if (value.length <= limit) return { text: value, truncated: false };
10
17
  const marker = "\n…[truncated]";
11
18
  const contentLimit = Math.max(0, limit - marker.length);
12
19
  return { text: `${value.slice(0, contentLimit)}${marker}`, truncated: true };
13
20
  }
14
21
 
15
- /** Build bounded, labeled synthesis context without concatenating full transcripts. */
16
- export function buildSynthesisPrompt(
17
- definition: WorkflowDefinition,
18
- run: WorkflowRun,
19
- tasks: readonly WorkflowTask[] = definition.tasks,
20
- ): string {
21
- if (!definition.synthesis) throw new Error("workflow has no synthesis definition");
22
+ export interface ResultEntry {
23
+ task: Pick<WorkflowTask, "id" | "description">;
24
+ result: WorkflowTaskResult | undefined;
25
+ /** Reported as `status=` only when no journaled result exists. */
26
+ fallbackStatus: string;
27
+ }
22
28
 
23
- const instruction = cap(definition.synthesis.prompt, MAX_SYNTHESIS_INSTRUCTION_CHARS);
24
- const chunks: string[] = [instruction.text, "", "Workflow task results:"];
29
+ export interface BoundedSectionOptions {
30
+ maxPerResult: number;
31
+ maxTotal: number;
32
+ overflowMarker: string;
33
+ }
34
+
35
+ /** One labeled, bounded result block. Reads only journaled fields so replays reproduce it exactly. */
36
+ export function buildResultBlock(entry: ResultEntry, maxResultChars = MAX_TASK_RESULT_CHARS): string {
37
+ const { task, result } = entry;
38
+ const bounded = cap(result?.text ?? "(no result)", maxResultChars);
39
+ const pointers = [
40
+ result?.agentId ? `agent_id=${result.agentId}` : "agent_id=unknown",
41
+ result?.outputFile ? `transcript=${result.outputFile}` : undefined,
42
+ result?.tokenCount !== undefined ? `token_count=${result.tokenCount}` : undefined,
43
+ ]
44
+ .filter((value): value is string => value !== undefined)
45
+ .join(" ");
46
+ // `result.truncated` matters because `resultFromLifecycle` already caps text to
47
+ // MAX_TASK_RESULT_CHARS, so a re-cap here can never observe the original overflow.
48
+ const truncated = bounded.truncated || result?.truncated === true;
49
+ return [
50
+ `### ${task.id} — ${task.description}`,
51
+ `status=${result?.status ?? entry.fallbackStatus} ${pointers}`,
52
+ truncated ? "result_truncated=true" : "result_truncated=false",
53
+ bounded.text,
54
+ ].join("\n");
55
+ }
56
+
57
+ /** Append result blocks after `preamble` until the aggregate budget is reached. */
58
+ export function buildBoundedResultSection(
59
+ preamble: readonly string[],
60
+ entries: readonly ResultEntry[],
61
+ options: BoundedSectionOptions,
62
+ ): string {
63
+ const chunks: string[] = [...preamble];
25
64
  let total = chunks.join("\n").length;
26
65
  let aggregateTruncated = false;
27
66
 
28
- for (const task of tasks) {
29
- const result = run.taskResults[task.id];
30
- const text = result?.text ?? "(no result)";
31
- const bounded = cap(text, MAX_TASK_RESULT_CHARS);
32
- const pointers = [
33
- result?.agentId ? `agent_id=${result.agentId}` : "agent_id=unknown",
34
- result?.outputFile ? `transcript=${result.outputFile}` : undefined,
35
- result?.tokenCount !== undefined ? `token_count=${result.tokenCount}` : undefined,
36
- ]
37
- .filter((value): value is string => value !== undefined)
38
- .join(" ");
39
- const block = [
40
- `### ${task.id} — ${task.description}`,
41
- `status=${result?.status ?? run.taskStatus[task.id]} ${pointers}`,
42
- bounded.truncated ? "result_truncated=true" : "result_truncated=false",
43
- bounded.text,
44
- ].join("\n");
45
- if (total + block.length + 2 > MAX_SYNTHESIS_INPUT_CHARS) {
67
+ for (const entry of entries) {
68
+ const block = buildResultBlock(entry, options.maxPerResult);
69
+ if (total + block.length + 2 > options.maxTotal) {
46
70
  aggregateTruncated = true;
47
71
  break;
48
72
  }
@@ -52,13 +76,34 @@ export function buildSynthesisPrompt(
52
76
 
53
77
  let output = chunks.join("\n");
54
78
  if (aggregateTruncated) {
55
- const marker = "\n\n[aggregate synthesis input truncated: additional task results omitted]";
56
- if (output.length + marker.length <= MAX_SYNTHESIS_INPUT_CHARS) {
57
- output += marker;
79
+ if (output.length + options.overflowMarker.length <= options.maxTotal) {
80
+ output += options.overflowMarker;
58
81
  } else {
59
- output = `${output.slice(0, MAX_SYNTHESIS_INPUT_CHARS - marker.length)}${marker}`;
82
+ output = `${output.slice(0, options.maxTotal - options.overflowMarker.length)}${options.overflowMarker}`;
60
83
  }
61
84
  }
85
+ return output;
86
+ }
87
+
88
+ /** Build bounded, labeled synthesis context without concatenating full transcripts. */
89
+ export function buildSynthesisPrompt(
90
+ definition: WorkflowDefinition,
91
+ run: WorkflowRun,
92
+ tasks: readonly WorkflowTask[] = definition.tasks,
93
+ ): string {
94
+ if (!definition.synthesis) throw new Error("workflow has no synthesis definition");
95
+
96
+ const instruction = cap(definition.synthesis.prompt, MAX_SYNTHESIS_INSTRUCTION_CHARS);
97
+ const entries = tasks.map((task) => ({
98
+ task,
99
+ result: run.taskResults[task.id],
100
+ fallbackStatus: String(run.taskStatus[task.id]),
101
+ }));
102
+ const output = buildBoundedResultSection([instruction.text, "", "Workflow task results:"], entries, {
103
+ maxPerResult: MAX_TASK_RESULT_CHARS,
104
+ maxTotal: MAX_SYNTHESIS_INPUT_CHARS,
105
+ overflowMarker: "\n\n[aggregate synthesis input truncated: additional task results omitted]",
106
+ });
62
107
  return cap(output, MAX_SYNTHESIS_INPUT_CHARS).text;
63
108
  }
64
109
 
package/src/ui.ts CHANGED
@@ -2,6 +2,15 @@ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-a
2
2
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
3
3
  import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
4
4
  import type { WorkflowEngine, WorkflowSummary } from "./engine.js";
5
+ import { sanitizeDisplayText } from "./safe-text.js";
6
+ import { isTerminalWorkflow, type WorkflowStatus } from "./state-machine.js";
7
+
8
+ /** Bounds, in code points, for untrusted strings rendered into the navigator. */
9
+ const MAX_NAME_CHARS = 200;
10
+ const MAX_ID_CHARS = 128;
11
+ const MAX_RESULT_CHARS = 300;
12
+ const MAX_RUN_ERROR_CHARS = 500;
13
+ const MAX_DESCRIPTION_CHARS = 120;
5
14
 
6
15
  function elapsed(ms: number): string {
7
16
  const seconds = Math.floor(ms / 1_000);
@@ -13,7 +22,8 @@ function elapsed(ms: number): string {
13
22
  export function formatWorkflowRunLabel(run: WorkflowSummary): string {
14
23
  const phases = run.phaseCount > 0 ? ` · phases ${run.completedPhases}/${run.phaseCount}` : "";
15
24
  const tokens = run.tokenCount === undefined ? "" : ` · ${run.tokenCount} tokens`;
16
- return `${run.name} · ${run.status}${phases} · ${run.completedTasks}/${run.taskCount} tasks · ${elapsed(run.elapsedMs)}${tokens}`;
25
+ const name = sanitizeDisplayText(run.name, MAX_NAME_CHARS);
26
+ return `${name} · ${run.status}${phases} · ${run.completedTasks}/${run.taskCount} tasks · ${elapsed(run.elapsedMs)}${tokens}`;
17
27
  }
18
28
 
19
29
  export function formatWorkflowDetail(engine: WorkflowEngine, runId: string): string {
@@ -24,22 +34,22 @@ export function formatWorkflowDetail(engine: WorkflowEngine, runId: string): str
24
34
  .filter((value): value is number => value !== undefined);
25
35
  const tokenCount = tokenValues.length > 0 ? tokenValues.reduce((total, value) => total + value, 0) : undefined;
26
36
  const lines = [
27
- `${run.definition.name} [${run.status}]`,
28
- `run: ${run.runId}`,
29
- `elapsed: ${elapsed(Math.max(0, (run.status === "completed" || run.status === "failed" || run.status === "stopped" ? run.updatedAt : Date.now()) - run.startedAt))}`,
37
+ `${sanitizeDisplayText(run.definition.name, MAX_NAME_CHARS)} [${run.status}]`,
38
+ `run: ${sanitizeDisplayText(run.runId, MAX_ID_CHARS)}`,
39
+ `elapsed: ${elapsed(Math.max(0, (isTerminalWorkflow(run.status) ? run.updatedAt : Date.now()) - run.startedAt))}`,
30
40
  "",
31
41
  ...(tokenCount === undefined ? [] : [`tokens: ${tokenCount}`]),
32
42
  ];
33
43
  for (const phase of run.definition.phases) {
34
- lines.push(`Phase: ${phase.title}`);
44
+ lines.push(`Phase: ${sanitizeDisplayText(phase.title, MAX_NAME_CHARS)}`);
35
45
  for (const task of run.definition.tasks.filter((candidate) => candidate.phase === phase.id)) {
36
46
  const status = run.taskStatus[task.id] ?? "pending";
37
- const agent = run.agentIds[task.id] ? ` agent=${run.agentIds[task.id]}` : "";
47
+ const agent = run.agentIds[task.id] ? ` agent=${sanitizeDisplayText(run.agentIds[task.id], MAX_ID_CHARS)}` : "";
38
48
  const compact = run.compactions[task.id] ? ` compactions=${run.compactions[task.id]}` : "";
39
- lines.push(` ${status.padEnd(11)} ${task.id}${agent}${compact}`);
49
+ lines.push(` ${status.padEnd(11)} ${sanitizeDisplayText(task.id, MAX_ID_CHARS)}${agent}${compact}`);
40
50
  const result = run.taskResults[task.id];
41
- if (result?.error) lines.push(` error: ${result.error.slice(0, 300)}`);
42
- if (result?.text) lines.push(` result: ${result.text.replaceAll("\n", " ").slice(0, 300)}`);
51
+ if (result?.error) lines.push(` error: ${sanitizeDisplayText(result.error, MAX_RESULT_CHARS)}`);
52
+ if (result?.text) lines.push(` result: ${sanitizeDisplayText(result.text, MAX_RESULT_CHARS)}`);
43
53
  }
44
54
  }
45
55
  const unphased = run.definition.tasks.filter((task) => task.phase === undefined);
@@ -47,15 +57,55 @@ export function formatWorkflowDetail(engine: WorkflowEngine, runId: string): str
47
57
  lines.push("Tasks:");
48
58
  for (const task of unphased) {
49
59
  const status = run.taskStatus[task.id] ?? "pending";
50
- const agent = run.agentIds[task.id] ? ` agent=${run.agentIds[task.id]}` : "";
51
- lines.push(` ${status.padEnd(11)} ${task.id}${agent}`);
60
+ const agent = run.agentIds[task.id] ? ` agent=${sanitizeDisplayText(run.agentIds[task.id], MAX_ID_CHARS)}` : "";
61
+ lines.push(` ${status.padEnd(11)} ${sanitizeDisplayText(task.id, MAX_ID_CHARS)}${agent}`);
52
62
  }
53
63
  }
54
64
  lines.push("", "Live agent conversations remain available through pi-subagents FleetView.");
55
- if (run.error) lines.push(`Error: ${run.error.slice(0, 500)}`);
65
+ if (run.error) lines.push(`Error: ${sanitizeDisplayText(run.error, MAX_RUN_ERROR_CHARS)}`);
56
66
  return lines.join("\n");
57
67
  }
58
68
 
69
+ /** Actions the `/workflows` detail menu can offer, in display order. */
70
+ export type WorkflowAction = "refresh" | "pause" | "resume" | "stop" | "back";
71
+
72
+ export interface WorkflowActionItem {
73
+ value: WorkflowAction;
74
+ label: string;
75
+ }
76
+
77
+ const REFRESH: WorkflowActionItem = { value: "refresh", label: "Refresh" };
78
+ const PAUSE: WorkflowActionItem = { value: "pause", label: "Pause" };
79
+ const RESUME: WorkflowActionItem = { value: "resume", label: "Resume" };
80
+ const STOP: WorkflowActionItem = { value: "stop", label: "Stop" };
81
+ const BACK: WorkflowActionItem = { value: "back", label: "Back" };
82
+
83
+ /**
84
+ * Menu actions legal for a run in `status`, mirroring what `WorkflowEngine.control` accepts.
85
+ * Exhaustive by construction: there is no `default` arm, so adding a `WorkflowStatus` member
86
+ * is a compile error here instead of a silently wrong menu.
87
+ */
88
+ export function workflowActionsFor(status: WorkflowStatus): readonly WorkflowActionItem[] {
89
+ switch (status) {
90
+ case "pending":
91
+ case "pausing":
92
+ case "synthesizing":
93
+ case "stopping":
94
+ return [REFRESH, STOP, BACK];
95
+ case "running":
96
+ return [REFRESH, PAUSE, STOP, BACK];
97
+ case "paused":
98
+ case "interrupted":
99
+ return [REFRESH, RESUME, STOP, BACK];
100
+ case "completed":
101
+ case "failed":
102
+ case "stopped":
103
+ return [REFRESH, BACK];
104
+ }
105
+ const unreachable: never = status;
106
+ return unreachable;
107
+ }
108
+
59
109
  async function selectList<T>(
60
110
  ctx: ExtensionCommandContext,
61
111
  title: string,
@@ -102,7 +152,7 @@ export async function showWorkflowNavigator(ctx: ExtensionCommandContext, engine
102
152
  runs.map((run) => ({
103
153
  value: run.runId,
104
154
  label: formatWorkflowRunLabel(run),
105
- description: run.error?.slice(0, 120),
155
+ description: run.error === undefined ? undefined : sanitizeDisplayText(run.error, MAX_DESCRIPTION_CHARS),
106
156
  })),
107
157
  (theme) =>
108
158
  runs.length > 0
@@ -113,26 +163,24 @@ export async function showWorkflowNavigator(ctx: ExtensionCommandContext, engine
113
163
  while (true) {
114
164
  const run = engine.get(selected);
115
165
  if (!run) break;
116
- const action = await selectList<string>(
166
+ const name = sanitizeDisplayText(run.name, MAX_NAME_CHARS);
167
+ const action = await selectList<WorkflowAction>(
117
168
  ctx,
118
- `${run.name} · ${run.status}`,
119
- [
120
- { value: "refresh", label: "Refresh" },
121
- ...(run.status === "running" ? [{ value: "pause", label: "Pause" }] : []),
122
- ...(run.status === "paused" || run.status === "interrupted" ? [{ value: "resume", label: "Resume" }] : []),
123
- ...(!["completed", "failed", "stopped"].includes(run.status) ? [{ value: "stop", label: "Stop" }] : []),
124
- { value: "back", label: "Back" },
125
- ],
169
+ `${name} · ${run.status}`,
170
+ workflowActionsFor(run.status).map((item) => ({
171
+ value: item.value,
172
+ label: item.label,
173
+ })),
126
174
  () => formatWorkflowDetail(engine, selected),
127
175
  );
128
- if (!action || action === "back") break;
176
+ if (action === undefined || action === "back") break;
129
177
  if (action === "refresh") continue;
130
178
  if (action === "stop") {
131
- const confirmed = await ctx.ui.confirm("Stop workflow", `Stop ${run.name}? The run will be non-resumable.`);
179
+ const confirmed = await ctx.ui.confirm("Stop workflow", `Stop ${name}? The run will be non-resumable.`);
132
180
  if (!confirmed) continue;
133
181
  }
134
182
  try {
135
- await engine.control(action as "pause" | "resume" | "stop", selected);
183
+ await engine.control(action, selected);
136
184
  ctx.ui.notify(`Workflow ${action} requested.`, "info");
137
185
  } catch (error: unknown) {
138
186
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");