@pi-unipi/workflow 2.4.0 → 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.
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 } 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,12 +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;
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;
25
23
  }
26
24
 
27
25
  /** Command definition */
@@ -402,14 +400,17 @@ export function registerWorkflowCommands(
402
400
  return null;
403
401
  },
404
402
  handler: async (args, ctx) => {
405
- // Apply sandbox save current tools, set command's tools
406
- const currentTools = options.getActiveTools();
407
- options.saveTools(currentTools);
408
- const sandboxTools = getToolsForCommand(cmd.name, currentTools);
409
- const sandboxLevel = getSandboxLevel(cmd.name);
410
- options.setActiveTools([...sandboxTools], sandboxLevel);
411
-
412
- // Load skill content from SKILL.md
403
+ const workflowEvent: UnipiWorkflowEvent = {
404
+ command: cmd.name,
405
+ fullCommand: `/${fullCommand}`,
406
+ args: args?.trim() ?? "",
407
+ };
408
+ if (!options.activateSandbox(workflowEvent)) {
409
+ if (ctx.hasUI) ctx.ui.notify("Another UniPi workflow is still active", "warning");
410
+ return;
411
+ }
412
+
413
+ // Load skill content from SKILL.md.
413
414
  let skillContent = "";
414
415
  try {
415
416
  const skillPath = join(
@@ -419,33 +420,34 @@ export function registerWorkflowCommands(
419
420
  );
420
421
  skillContent = readFileSync(skillPath, "utf-8");
421
422
  } catch {
422
- // Skill file not found — continue without it
423
+ // Skill file not found — continue without it.
423
424
  }
424
425
 
425
- // Build skill invocation message
426
426
  let message = `Execute the ${cmd.skillName} workflow.`;
427
427
 
428
- // Add args if provided
429
428
  if (args?.trim()) {
430
429
  message += `\n\nArguments: ${args.trim()}`;
431
430
  }
432
431
 
433
- // Add ralph hint if applicable
434
432
  if (cmd.ralphHint && options.isRalphDetected()) {
435
433
  message += `\n\n💡 ${cmd.ralphHint}`;
436
434
  }
437
435
 
438
- // Inject skill content as context
439
436
  if (skillContent) {
440
437
  message += `\n\n<skill_content>\n${skillContent}\n</skill_content>`;
441
438
  }
442
439
 
443
- // Send as user message to trigger skill processing
444
- 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
+ }
445
448
 
446
449
  if (ctx.hasUI) {
447
450
  ctx.ui.notify(`Running /${fullCommand}`, "info");
448
- // Update extension status with active command name
449
451
  const ralphStatus = options.isRalphDetected() ? "✓ rl" : "○ rl";
450
452
  ctx.ui.setStatus("unipi-workflow", `⚡ wf:${cmd.name} ${ralphStatus}`);
451
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,114 +20,209 @@ 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";
29
+ import { WorkflowLifecycle } from "./lifecycle.js";
24
30
 
25
31
  /** Package version (read from package.json at load time) */
26
32
  const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
27
33
 
28
- /** Whether ralph module is detected */
29
- 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
+ ];
30
52
 
31
- /** Saved tools before sandbox was applied (for restore) */
32
- let savedTools: string[] | null = null;
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
+ }
60
+
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
+ }
69
+
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
+ }
33
100
 
34
- /** Whether sandbox is currently active */
35
- let sandboxActive = false;
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
+ }
36
116
 
37
- /** Current sandbox level (null = no sandbox) */
38
- 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
+ }
39
132
 
40
- /** Current active tools after sandbox filtering */
41
- 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
+ }
42
137
 
43
138
  export default function (pi: ExtensionAPI) {
139
+ const workflowLifecycle = new WorkflowLifecycle();
140
+ let ralphDetected = false;
141
+ let sandboxCommand: string | null = null;
44
142
 
45
- // Register all workflow commands
46
143
  registerWorkflowCommands(pi, {
47
144
  isRalphDetected: () => ralphDetected,
48
- getActiveTools: () => pi.getActiveTools(),
49
- setActiveTools: (tools: string[], level: SandboxLevel) => {
50
- pi.setActiveTools(tools);
51
- sandboxActive = true;
52
- currentSandboxLevel = level;
53
- currentSandboxTools = tools;
145
+ activateSandbox: (event) => {
146
+ if (!workflowLifecycle.start(event)) return false;
147
+ sandboxCommand = event.command;
148
+ emitEvent(pi, UNIPI_EVENTS.WORKFLOW_START, event);
149
+ return true;
54
150
  },
55
- saveTools: (tools: string[]) => {
56
- savedTools = tools;
151
+ abortWorkflow: () => {
152
+ sandboxCommand = null;
153
+ workflowLifecycle.reset();
57
154
  },
58
155
  });
59
156
 
60
- // Block tool calls that violate sandbox
157
+ // Keep tool schemas/order stable and enforce only the existing blocked names.
61
158
  pi.on("tool_call", async (event, _ctx) => {
62
- if (!sandboxActive || !currentSandboxLevel) return;
159
+ if (!sandboxCommand) return;
63
160
 
64
- const allowed = currentSandboxTools ?? [];
65
- if (!allowed.includes(event.toolName)) {
161
+ const level = getSandboxLevel(sandboxCommand);
162
+ if (!isToolAllowed(level, event.toolName)) {
163
+ const blocked = getBlockedToolsForLevel(level);
66
164
  return {
67
165
  block: true,
68
- 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(", ")}`,
69
167
  };
70
168
  }
71
169
  });
72
170
 
73
- // Inject sandbox constraints into system prompt so LLM knows its limits
74
- pi.on("before_agent_start", async (event, _ctx) => {
75
- if (!sandboxActive || !currentSandboxLevel) return;
76
-
77
- const allowed = currentSandboxTools ?? pi.getActiveTools();
78
- 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);
79
176
 
80
- const blockedLine = blocked.length > 0
81
- ? `\nBlocked tools: ${blocked.join(", ")} — removed from your tool list.`
82
- : "\nNo tools were blocked by this sandbox.";
83
- 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;
84
181
 
85
- if (currentSandboxLevel === "brainstorm") {
86
182
  return {
87
- systemPrompt:
88
- event.systemPrompt +
89
- base +
90
- `\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
+ },
91
193
  };
92
194
  }
93
195
 
94
- if (currentSandboxLevel === "write_unipi") {
95
- return {
96
- systemPrompt:
97
- event.systemPrompt +
98
- base +
99
- `\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>`,
100
- };
101
- }
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;
102
200
 
103
201
  return {
104
- systemPrompt:
105
- event.systemPrompt +
106
- base +
107
- `\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
+ },
108
210
  };
109
211
  });
110
212
 
111
- // Restore tools when agent finishes
112
- pi.on("agent_end", async (_event, _ctx) => {
113
- if (sandboxActive && savedTools) {
114
- pi.setActiveTools(savedTools);
115
- savedTools = null;
116
- sandboxActive = false;
117
- currentSandboxLevel = null;
118
- currentSandboxTools = null;
119
- }
213
+ // Pi 0.80.2 compatibility: agent_end remains the workflow completion boundary.
214
+ pi.on("agent_end", async (event, _ctx) => {
215
+ const completedWorkflow = workflowLifecycle.complete(event.messages);
216
+ if (!completedWorkflow) return;
217
+
218
+ sandboxCommand = null;
219
+ emitEvent(pi, UNIPI_EVENTS.WORKFLOW_END, completedWorkflow);
120
220
  });
121
221
 
122
- // Announce module presence on session start
222
+ // Announce module presence on session start.
123
223
  pi.on("session_start", async (_event, ctx) => {
124
- // Initialize .unipi directory structure
125
224
  initUnipiDirs();
126
225
 
127
- // Emit MODULE_READY
128
226
  emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
129
227
  name: MODULES.WORKFLOW,
130
228
  version: VERSION,
@@ -132,36 +230,29 @@ export default function (pi: ExtensionAPI) {
132
230
  tools: [],
133
231
  });
134
232
 
135
- // Listen for ralph module
136
233
  if (!ralphDetected) {
137
234
  try {
138
- // Check if ralph tools exist (indicates @unipi/ralph is loaded)
139
235
  const allTools = pi.getAllTools();
140
- ralphDetected = allTools.some((t) => t.name === "ralph_start");
236
+ ralphDetected = allTools.some((tool) => tool.name === "ralph_start");
141
237
  } catch {
142
- // Ignore — ralph not present
238
+ // Ignore — ralph not present.
143
239
  }
144
240
  }
145
241
 
146
- // Show workflow status in UI
147
242
  if (ctx.hasUI) {
148
243
  const ralphStatus = ralphDetected ? "✓ rl" : "○ rl";
149
244
  ctx.ui.setStatus("unipi-workflow", `⚡ wf ${ralphStatus}`);
150
245
  }
151
246
  });
152
247
 
153
- // Listen for ralph module ready event
154
248
  pi.events.on(UNIPI_EVENTS.MODULE_READY, (data) => {
155
249
  const event = data as { name?: string };
156
- if (event?.name === MODULES.RALPH) {
157
- ralphDetected = true;
158
- }
250
+ if (event?.name === MODULES.RALPH) ralphDetected = true;
159
251
  });
160
252
 
161
- // Clean up on shutdown
162
253
  pi.on("session_shutdown", async () => {
163
254
  ralphDetected = false;
164
- savedTools = null;
165
- sandboxActive = false;
255
+ sandboxCommand = null;
256
+ workflowLifecycle.reset();
166
257
  });
167
258
  }
package/lifecycle.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { UnipiWorkflowEvent } from "@pi-unipi/core";
2
+
3
+ interface WorkflowMessage {
4
+ role: string;
5
+ stopReason?: string;
6
+ }
7
+
8
+ export interface CompletedWorkflowEvent extends UnipiWorkflowEvent {
9
+ success: boolean;
10
+ durationMs: number;
11
+ }
12
+
13
+ /** Single-active workflow lifecycle state shared by slash handlers and agent_end. */
14
+ export class WorkflowLifecycle {
15
+ private active: (UnipiWorkflowEvent & { startedAt: number }) | null = null;
16
+
17
+ constructor(private readonly now: () => number = Date.now) {}
18
+
19
+ start(event: UnipiWorkflowEvent): boolean {
20
+ if (this.active) return false;
21
+ this.active = { ...event, startedAt: this.now() };
22
+ return true;
23
+ }
24
+
25
+ complete(messages: WorkflowMessage[]): CompletedWorkflowEvent | undefined {
26
+ if (!this.active) return undefined;
27
+ const workflow = this.active;
28
+ this.active = null;
29
+ const finalAssistant = [...messages].reverse().find((message) => message.role === "assistant");
30
+ const success = finalAssistant?.stopReason !== "error" && finalAssistant?.stopReason !== "aborted";
31
+ return {
32
+ command: workflow.command,
33
+ fullCommand: workflow.fullCommand,
34
+ args: workflow.args,
35
+ success,
36
+ durationMs: this.now() - workflow.startedAt,
37
+ };
38
+ }
39
+
40
+ reset(): void {
41
+ this.active = null;
42
+ }
43
+ }
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@pi-unipi/workflow",
3
- "version": "2.4.0",
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",
@@ -19,6 +22,7 @@
19
22
  ],
20
23
  "files": [
21
24
  "*.ts",
25
+ "!*.test.ts",
22
26
  "skills/**/*",
23
27
  "README.md"
24
28
  ],
@@ -26,7 +30,7 @@
26
30
  "access": "public"
27
31
  },
28
32
  "dependencies": {
29
- "@pi-unipi/core": "2.4.0"
33
+ "@pi-unipi/core": "2.5.0"
30
34
  },
31
35
  "peerDependencies": {
32
36
  "@earendil-works/pi-ai": "^0.80.0",