@pi-unipi/workflow 2.4.1 → 2.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.
Files changed (4) hide show
  1. package/README.md +8 -0
  2. package/commands.ts +16 -26
  3. package/index.ts +165 -85
  4. package/package.json +5 -2
package/README.md CHANGED
@@ -128,6 +128,14 @@ Skills define:
128
128
 
129
129
  The agent reads the skill, executes the steps, and produces artifacts in the `.unipi/` directory.
130
130
 
131
+ ## Workflow Sandbox
132
+
133
+ Each workflow activates a command-specific sandbox for the duration of its agent run. The sandbox blocks disallowed tool calls by name (for example, `write`, `edit`, or `bash` in read-only workflows) without changing Pi's active tool list. Keeping the provider tool schemas and their order unchanged makes the workflow prefix cache-stable.
134
+
135
+ Sandbox instructions are stored as hidden, append-only `unipi-workflow-sandbox-snapshot` messages rather than being injected into the system prompt. An active snapshot explicitly supersedes older snapshots. After the workflow completes at `agent_end`, the next agent start appends an inactive snapshot when needed so stale restrictions no longer apply. Sessions that have never activated a workflow do not receive a marker.
136
+
137
+ The sandbox preserves the existing command-level semantics; skill instructions still define narrower write locations and setup-only shell usage where applicable.
138
+
131
139
  ## Directory Structure
132
140
 
133
141
  ```
package/commands.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
9
  import { readFileSync, readdirSync, existsSync, statSync } from "fs";
10
10
  import { join, basename } from "path";
11
- import { UNIPI_PREFIX, WORKFLOW_COMMANDS, getToolsForCommand, getSandboxLevel, type SandboxLevel, type UnipiWorkflowEvent } from "@pi-unipi/core";
11
+ import { UNIPI_PREFIX, WORKFLOW_COMMANDS, type UnipiWorkflowEvent } from "@pi-unipi/core";
12
12
 
13
13
  type CompletionItem = { value: string; label: string; description: string };
14
14
 
@@ -16,14 +16,10 @@ type CompletionItem = { value: string; label: string; description: string };
16
16
  export interface WorkflowCommandOptions {
17
17
  /** Check if ralph module is detected */
18
18
  isRalphDetected: () => boolean;
19
- /** Get current active tool names */
20
- getActiveTools: () => string[];
21
- /** Set active tools with sandbox level */
22
- setActiveTools: (tools: string[], level: SandboxLevel) => void;
23
- /** Save tools for later restore */
24
- saveTools: (tools: string[]) => void;
25
- /** Begin one workflow lifecycle; false means another workflow is active. */
26
- startWorkflow: (event: UnipiWorkflowEvent) => boolean;
19
+ /** Begin one workflow lifecycle and activate its sandbox. */
20
+ activateSandbox: (event: UnipiWorkflowEvent) => boolean;
21
+ /** Roll back lifecycle and sandbox state after dispatch failure. */
22
+ abortWorkflow: () => void;
27
23
  }
28
24
 
29
25
  /** Command definition */
@@ -409,19 +405,12 @@ export function registerWorkflowCommands(
409
405
  fullCommand: `/${fullCommand}`,
410
406
  args: args?.trim() ?? "",
411
407
  };
412
- if (!options.startWorkflow(workflowEvent)) {
408
+ if (!options.activateSandbox(workflowEvent)) {
413
409
  if (ctx.hasUI) ctx.ui.notify("Another UniPi workflow is still active", "warning");
414
410
  return;
415
411
  }
416
412
 
417
- // Apply sandbox save current tools, set command's tools
418
- const currentTools = options.getActiveTools();
419
- options.saveTools(currentTools);
420
- const sandboxTools = getToolsForCommand(cmd.name, currentTools);
421
- const sandboxLevel = getSandboxLevel(cmd.name);
422
- options.setActiveTools([...sandboxTools], sandboxLevel);
423
-
424
- // Load skill content from SKILL.md
413
+ // Load skill content from SKILL.md.
425
414
  let skillContent = "";
426
415
  try {
427
416
  const skillPath = join(
@@ -431,33 +420,34 @@ export function registerWorkflowCommands(
431
420
  );
432
421
  skillContent = readFileSync(skillPath, "utf-8");
433
422
  } catch {
434
- // Skill file not found — continue without it
423
+ // Skill file not found — continue without it.
435
424
  }
436
425
 
437
- // Build skill invocation message
438
426
  let message = `Execute the ${cmd.skillName} workflow.`;
439
427
 
440
- // Add args if provided
441
428
  if (args?.trim()) {
442
429
  message += `\n\nArguments: ${args.trim()}`;
443
430
  }
444
431
 
445
- // Add ralph hint if applicable
446
432
  if (cmd.ralphHint && options.isRalphDetected()) {
447
433
  message += `\n\n💡 ${cmd.ralphHint}`;
448
434
  }
449
435
 
450
- // Inject skill content as context
451
436
  if (skillContent) {
452
437
  message += `\n\n<skill_content>\n${skillContent}\n</skill_content>`;
453
438
  }
454
439
 
455
- // Send as user message to trigger skill processing
456
- pi.sendUserMessage(message, { deliverAs: "followUp" });
440
+ // Any synchronous dispatch failure must not leave a phantom workflow
441
+ // lifecycle or sandbox behind.
442
+ try {
443
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
444
+ } catch (error) {
445
+ options.abortWorkflow();
446
+ throw error;
447
+ }
457
448
 
458
449
  if (ctx.hasUI) {
459
450
  ctx.ui.notify(`Running /${fullCommand}`, "info");
460
- // Update extension status with active command name
461
451
  const ralphStatus = options.isRalphDetected() ? "✓ rl" : "○ rl";
462
452
  ctx.ui.setStatus("unipi-workflow", `⚡ wf:${cmd.name} ${ralphStatus}`);
463
453
  }
package/index.ts CHANGED
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * @unipi/workflow — Structured development workflow commands
3
3
  *
4
- * Registers 13 commands that dispatch to skills for LLM instruction.
5
- * Emits MODULE_READY event for inter-module discovery.
6
- * Detects @unipi/ralph presence for loop integration.
7
- * Applies sandbox (tool filtering) per command.
4
+ * Registers workflow commands that dispatch to skills for LLM instruction.
5
+ * Emits MODULE_READY for inter-module discovery and detects @unipi/ralph.
6
+ * Enforces workflow sandboxes without changing Pi's active tool schemas.
8
7
  */
9
8
 
10
9
  import { dirname } from "node:path";
11
10
  import { fileURLToPath } from "node:url";
12
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ buildSessionContext,
13
+ type ExtensionAPI,
14
+ type SessionEntry,
15
+ } from "@earendil-works/pi-coding-agent";
13
16
  import {
14
17
  UNIPI_EVENTS,
15
18
  MODULES,
@@ -17,8 +20,10 @@ import {
17
20
  emitEvent,
18
21
  getPackageVersion,
19
22
  initUnipiDirs,
20
- type SandboxLevel,
21
23
  getBlockedToolsForLevel,
24
+ getSandboxLevel,
25
+ isToolAllowed,
26
+ type SandboxLevel,
22
27
  } from "@pi-unipi/core";
23
28
  import { registerWorkflowCommands } from "./commands.js";
24
29
  import { WorkflowLifecycle } from "./lifecycle.js";
@@ -26,115 +31,198 @@ import { WorkflowLifecycle } from "./lifecycle.js";
26
31
  /** Package version (read from package.json at load time) */
27
32
  const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
28
33
 
29
- /** Whether ralph module is detected */
30
- let ralphDetected = false;
34
+ export const WORKFLOW_SANDBOX_SNAPSHOT_TYPE = "unipi-workflow-sandbox-snapshot";
35
+
36
+ interface WorkflowSandboxSnapshotDetails {
37
+ active: boolean;
38
+ command?: string;
39
+ level?: SandboxLevel;
40
+ }
41
+
42
+ interface EffectiveSandboxSnapshot {
43
+ content: unknown;
44
+ details?: WorkflowSandboxSnapshotDetails;
45
+ }
46
+
47
+ function sandboxRestrictions(level: SandboxLevel): string[] {
48
+ const common = [
49
+ "Do not attempt to call tools blocked by name.",
50
+ "If the user requests an action that requires a blocked tool, explain that the workflow sandbox does not allow it.",
51
+ ];
52
+
53
+ if (level === "brainstorm") {
54
+ return [
55
+ "The write tool is restricted to .unipi/docs/specs/ only.",
56
+ "Use bash only for specific setup operations such as git init or mkdir; use grep, find, and ls for discovery instead of bash.",
57
+ ...common,
58
+ ];
59
+ }
31
60
 
32
- /** Saved tools before sandbox was applied (for restore) */
33
- let savedTools: string[] | null = null;
61
+ if (level === "write_unipi") {
62
+ return [
63
+ "The write tool is restricted to .unipi/docs/ only (specs and plans).",
64
+ "Use grep, find, and ls for file discovery instead of guessing filenames.",
65
+ "Bash is blocked; use read, write, edit, grep, find, and ls only.",
66
+ ...common,
67
+ ];
68
+ }
34
69
 
35
- /** Whether sandbox is currently active */
36
- let sandboxActive = false;
70
+ return common;
71
+ }
72
+
73
+ /** Build a persistent snapshot that explicitly invalidates earlier sandbox state. */
74
+ function formatActiveSandboxSnapshot(command: string, level: SandboxLevel): string {
75
+ const blocked = getBlockedToolsForLevel(level);
76
+ const blockedLine = blocked.length > 0
77
+ ? blocked.join(", ")
78
+ : "none";
79
+
80
+ return [
81
+ "# UniPi Workflow Sandbox Snapshot",
82
+ "This snapshot supersedes all prior UniPi workflow sandbox snapshots; use only this snapshot for workflow sandbox status and restrictions.",
83
+ "Status: active",
84
+ `Workflow: /unipi:${command}`,
85
+ `Sandbox level: ${level}`,
86
+ `Blocked tool names: ${blockedLine}`,
87
+ "Pi's provider tool schemas and tool order remain unchanged. Calls to blocked tool names are rejected by the workflow sandbox.",
88
+ ["Restrictions:", ...sandboxRestrictions(level).map((restriction) => `- ${restriction}`)].join("\n"),
89
+ ].join("\n\n");
90
+ }
91
+
92
+ function formatInactiveSandboxSnapshot(): string {
93
+ return [
94
+ "# UniPi Workflow Sandbox Snapshot",
95
+ "This snapshot supersedes all prior UniPi workflow sandbox snapshots; use only this snapshot for workflow sandbox status and restrictions.",
96
+ "Status: inactive",
97
+ "No UniPi workflow sandbox is active. Prior workflow sandbox restrictions no longer apply.",
98
+ ].join("\n\n");
99
+ }
100
+
101
+ function latestEffectiveSandboxSnapshot(
102
+ branch: SessionEntry[],
103
+ ): EffectiveSandboxSnapshot | undefined {
104
+ const messages = buildSessionContext(branch).messages;
105
+ for (let index = messages.length - 1; index >= 0; index--) {
106
+ const message = messages[index];
107
+ if (message.role === "custom" && message.customType === WORKFLOW_SANDBOX_SNAPSHOT_TYPE) {
108
+ return {
109
+ content: message.content,
110
+ details: message.details as WorkflowSandboxSnapshotDetails | undefined,
111
+ };
112
+ }
113
+ }
114
+ return undefined;
115
+ }
37
116
 
38
- /** Current sandbox level (null = no sandbox) */
39
- let currentSandboxLevel: SandboxLevel | null = null;
117
+ /** Find state that may survive only as prose inside a compaction summary. */
118
+ function latestHistoricalSandboxSnapshot(
119
+ branch: SessionEntry[],
120
+ ): EffectiveSandboxSnapshot | undefined {
121
+ for (let index = branch.length - 1; index >= 0; index--) {
122
+ const entry = branch[index];
123
+ if (entry.type === "custom_message" && entry.customType === WORKFLOW_SANDBOX_SNAPSHOT_TYPE) {
124
+ return {
125
+ content: entry.content,
126
+ details: entry.details as WorkflowSandboxSnapshotDetails | undefined,
127
+ };
128
+ }
129
+ }
130
+ return undefined;
131
+ }
40
132
 
41
- /** Current active tools after sandbox filtering */
42
- let currentSandboxTools: string[] | null = null;
133
+ function isActiveSnapshot(snapshot: EffectiveSandboxSnapshot): boolean {
134
+ if (typeof snapshot.details?.active === "boolean") return snapshot.details.active;
135
+ return typeof snapshot.content === "string" && snapshot.content.includes("Status: active");
136
+ }
43
137
 
44
138
  export default function (pi: ExtensionAPI) {
45
139
  const workflowLifecycle = new WorkflowLifecycle();
140
+ let ralphDetected = false;
141
+ let sandboxCommand: string | null = null;
46
142
 
47
- // Register all workflow commands
48
143
  registerWorkflowCommands(pi, {
49
144
  isRalphDetected: () => ralphDetected,
50
- getActiveTools: () => pi.getActiveTools(),
51
- setActiveTools: (tools: string[], level: SandboxLevel) => {
52
- pi.setActiveTools(tools);
53
- sandboxActive = true;
54
- currentSandboxLevel = level;
55
- currentSandboxTools = tools;
56
- },
57
- saveTools: (tools: string[]) => {
58
- savedTools = tools;
59
- },
60
- startWorkflow: (event) => {
145
+ activateSandbox: (event) => {
61
146
  if (!workflowLifecycle.start(event)) return false;
147
+ sandboxCommand = event.command;
62
148
  emitEvent(pi, UNIPI_EVENTS.WORKFLOW_START, event);
63
149
  return true;
64
150
  },
151
+ abortWorkflow: () => {
152
+ sandboxCommand = null;
153
+ workflowLifecycle.reset();
154
+ },
65
155
  });
66
156
 
67
- // Block tool calls that violate sandbox
157
+ // Keep tool schemas/order stable and enforce only the existing blocked names.
68
158
  pi.on("tool_call", async (event, _ctx) => {
69
- if (!sandboxActive || !currentSandboxLevel) return;
159
+ if (!sandboxCommand) return;
70
160
 
71
- const allowed = currentSandboxTools ?? [];
72
- if (!allowed.includes(event.toolName)) {
161
+ const level = getSandboxLevel(sandboxCommand);
162
+ if (!isToolAllowed(level, event.toolName)) {
163
+ const blocked = getBlockedToolsForLevel(level);
73
164
  return {
74
165
  block: true,
75
- reason: `Tool "${event.toolName}" is not allowed in ${currentSandboxLevel} sandbox. Allowed: ${allowed.join(", ")}`,
166
+ reason: `Tool "${event.toolName}" is not allowed in ${level} sandbox. Blocked: ${blocked.join(", ")}`,
76
167
  };
77
168
  }
78
169
  });
79
170
 
80
- // Inject sandbox constraints into system prompt so LLM knows its limits
81
- pi.on("before_agent_start", async (event, _ctx) => {
82
- if (!sandboxActive || !currentSandboxLevel) return;
83
-
84
- const allowed = currentSandboxTools ?? pi.getActiveTools();
85
- const blocked = getBlockedToolsForLevel(currentSandboxLevel);
171
+ // Persist hidden append-only sandbox state without mutating the system prompt.
172
+ pi.on("before_agent_start", async (_event, ctx) => {
173
+ const branch = ctx.sessionManager.getBranch();
174
+ const latest = latestEffectiveSandboxSnapshot(branch);
175
+ const historical = latestHistoricalSandboxSnapshot(branch);
86
176
 
87
- const blockedLine = blocked.length > 0
88
- ? `\nBlocked tools: ${blocked.join(", ")} — removed from your tool list.`
89
- : "\nNo tools were blocked by this sandbox.";
90
- const base = `\n\n<sandbox>\nSandbox mode: ${currentSandboxLevel}.\nAvailable tools: ${allowed.join(", ")}.${blockedLine}`;
177
+ if (sandboxCommand) {
178
+ const level = getSandboxLevel(sandboxCommand);
179
+ const content = formatActiveSandboxSnapshot(sandboxCommand, level);
180
+ if (latest?.content === content) return undefined;
91
181
 
92
- if (currentSandboxLevel === "brainstorm") {
93
182
  return {
94
- systemPrompt:
95
- event.systemPrompt +
96
- base +
97
- `\nThe write tool is available but restricted to .unipi/docs/specs/ only.\nbash is available ONLY for specific setup use case (e.g., git init, mkdir). Do NOT use bash for reading files or listing directories — use grep, find, ls instead.\nDo NOT attempt to call blocked tools. Do NOT output tool call XML for them.\nIf the user requests an action that requires a blocked tool, respond that you do not have access.\n</sandbox>`,
183
+ message: {
184
+ customType: WORKFLOW_SANDBOX_SNAPSHOT_TYPE,
185
+ content,
186
+ display: false,
187
+ details: {
188
+ active: true,
189
+ command: sandboxCommand,
190
+ level,
191
+ } satisfies WorkflowSandboxSnapshotDetails,
192
+ },
98
193
  };
99
194
  }
100
195
 
101
- if (currentSandboxLevel === "write_unipi") {
102
- return {
103
- systemPrompt:
104
- event.systemPrompt +
105
- base +
106
- `\nWrite tool is restricted to .unipi/docs/ only (specs and plans).\nUse grep, find, ls for file discovery — do NOT guess filenames.\nbash is blocked — use read, write, edit, grep, find, ls only.\nDo NOT attempt to call blocked tools. Do NOT output tool call XML for them.\nIf the user requests an action that requires a blocked tool, respond that you do not have access.\n</sandbox>`,
107
- };
108
- }
196
+ // A clean session gets no marker. Only an effective active snapshot needs
197
+ // an append-only inactive successor after agent_end completes the workflow.
198
+ const prior = latest ?? historical;
199
+ if (!prior || !isActiveSnapshot(prior)) return undefined;
109
200
 
110
201
  return {
111
- systemPrompt:
112
- event.systemPrompt +
113
- base +
114
- `\nDo NOT attempt to call blocked tools. Do NOT output tool call XML for them.\nIf the user requires an action that requires a blocked tool, respond that you do not have access.\n</sandbox>`,
202
+ message: {
203
+ customType: WORKFLOW_SANDBOX_SNAPSHOT_TYPE,
204
+ content: formatInactiveSandboxSnapshot(),
205
+ display: false,
206
+ details: {
207
+ active: false,
208
+ } satisfies WorkflowSandboxSnapshotDetails,
209
+ },
115
210
  };
116
211
  });
117
212
 
118
- // Restore tools when agent finishes
213
+ // Pi 0.80.2 compatibility: agent_end remains the workflow completion boundary.
119
214
  pi.on("agent_end", async (event, _ctx) => {
120
- if (sandboxActive && savedTools) {
121
- pi.setActiveTools(savedTools);
122
- savedTools = null;
123
- sandboxActive = false;
124
- currentSandboxLevel = null;
125
- currentSandboxTools = null;
126
- }
127
-
128
215
  const completedWorkflow = workflowLifecycle.complete(event.messages);
129
- if (completedWorkflow) emitEvent(pi, UNIPI_EVENTS.WORKFLOW_END, completedWorkflow);
216
+ if (!completedWorkflow) return;
217
+
218
+ sandboxCommand = null;
219
+ emitEvent(pi, UNIPI_EVENTS.WORKFLOW_END, completedWorkflow);
130
220
  });
131
221
 
132
- // Announce module presence on session start
222
+ // Announce module presence on session start.
133
223
  pi.on("session_start", async (_event, ctx) => {
134
- // Initialize .unipi directory structure
135
224
  initUnipiDirs();
136
225
 
137
- // Emit MODULE_READY
138
226
  emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
139
227
  name: MODULES.WORKFLOW,
140
228
  version: VERSION,
@@ -142,37 +230,29 @@ export default function (pi: ExtensionAPI) {
142
230
  tools: [],
143
231
  });
144
232
 
145
- // Listen for ralph module
146
233
  if (!ralphDetected) {
147
234
  try {
148
- // Check if ralph tools exist (indicates @unipi/ralph is loaded)
149
235
  const allTools = pi.getAllTools();
150
- ralphDetected = allTools.some((t) => t.name === "ralph_start");
236
+ ralphDetected = allTools.some((tool) => tool.name === "ralph_start");
151
237
  } catch {
152
- // Ignore — ralph not present
238
+ // Ignore — ralph not present.
153
239
  }
154
240
  }
155
241
 
156
- // Show workflow status in UI
157
242
  if (ctx.hasUI) {
158
243
  const ralphStatus = ralphDetected ? "✓ rl" : "○ rl";
159
244
  ctx.ui.setStatus("unipi-workflow", `⚡ wf ${ralphStatus}`);
160
245
  }
161
246
  });
162
247
 
163
- // Listen for ralph module ready event
164
248
  pi.events.on(UNIPI_EVENTS.MODULE_READY, (data) => {
165
249
  const event = data as { name?: string };
166
- if (event?.name === MODULES.RALPH) {
167
- ralphDetected = true;
168
- }
250
+ if (event?.name === MODULES.RALPH) ralphDetected = true;
169
251
  });
170
252
 
171
- // Clean up on shutdown
172
253
  pi.on("session_shutdown", async () => {
173
254
  ralphDetected = false;
174
- savedTools = null;
175
- sandboxActive = false;
255
+ sandboxCommand = null;
176
256
  workflowLifecycle.reset();
177
257
  });
178
258
  }
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@pi-unipi/workflow",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "Structured development workflow commands for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "license": "MIT",
8
8
  "author": "Neuron Mr White",
9
+ "scripts": {
10
+ "test": "npx bun test lifecycle.test.ts tests"
11
+ },
9
12
  "repository": {
10
13
  "type": "git",
11
14
  "url": "git+https://github.com/Neuron-Mr-White/unipi.git",
@@ -27,7 +30,7 @@
27
30
  "access": "public"
28
31
  },
29
32
  "dependencies": {
30
- "@pi-unipi/core": "2.4.1"
33
+ "@pi-unipi/core": "2.5.0"
31
34
  },
32
35
  "peerDependencies": {
33
36
  "@earendil-works/pi-ai": "^0.80.0",