@pi-unipi/unipi 2.4.2 → 2.6.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 (69) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +2 -0
  3. package/docs/prefix-cache-architecture.md +89 -0
  4. package/package.json +24 -22
  5. package/packages/ask-user/package.json +2 -2
  6. package/packages/autocomplete/package.json +1 -1
  7. package/packages/autocomplete/src/constants.ts +2 -0
  8. package/packages/btw/package.json +2 -2
  9. package/packages/cocoindex/README.md +2 -1
  10. package/packages/cocoindex/index.ts +6 -13
  11. package/packages/cocoindex/package.json +4 -3
  12. package/packages/cocoindex/tools.ts +45 -11
  13. package/packages/compactor/README.md +4 -2
  14. package/packages/compactor/package.json +3 -3
  15. package/packages/compactor/src/session/snapshot.ts +3 -2
  16. package/packages/compactor/src/tools/register.ts +6 -2
  17. package/packages/compactor/src/tools/vcc-recall.ts +18 -3
  18. package/packages/core/bounded-output.ts +106 -0
  19. package/packages/core/constants.ts +2 -0
  20. package/packages/core/index.ts +1 -0
  21. package/packages/core/package.json +1 -1
  22. package/packages/core/sandbox.ts +7 -6
  23. package/packages/footer/package.json +2 -2
  24. package/packages/image/package.json +2 -2
  25. package/packages/info-screen/package.json +2 -2
  26. package/packages/input-shortcuts/package.json +2 -2
  27. package/packages/kanboard/package.json +2 -2
  28. package/packages/mcp/README.md +13 -0
  29. package/packages/mcp/package.json +5 -2
  30. package/packages/mcp/src/bridge/registry.ts +244 -137
  31. package/packages/mcp/src/bridge/translator.ts +96 -21
  32. package/packages/mcp/src/index.ts +43 -67
  33. package/packages/mcp/src/tui/settings-overlay.ts +3 -9
  34. package/packages/memory/README.md +15 -11
  35. package/packages/memory/bridge/mempalace_bridge.py +636 -0
  36. package/packages/memory/index.ts +56 -34
  37. package/packages/memory/mempalace.ts +169 -18
  38. package/packages/memory/package.json +3 -3
  39. package/packages/memory/storage.ts +27 -10
  40. package/packages/milestone/README.md +11 -3
  41. package/packages/milestone/hooks.ts +103 -29
  42. package/packages/milestone/index.ts +1 -1
  43. package/packages/milestone/package.json +5 -2
  44. package/packages/notify/package.json +2 -2
  45. package/packages/ralph/index.ts +12 -18
  46. package/packages/ralph/package.json +6 -3
  47. package/packages/ralph/reminder.ts +40 -0
  48. package/packages/ralph/tools.ts +5 -1
  49. package/packages/subagents/README.md +2 -0
  50. package/packages/subagents/package.json +1 -1
  51. package/packages/subagents/src/agent-manager.ts +5 -1
  52. package/packages/subagents/src/agent-runner.ts +2 -2
  53. package/packages/subagents/src/core-compat.ts +73 -0
  54. package/packages/subagents/src/custom-agents.ts +10 -2
  55. package/packages/subagents/src/index.ts +14 -3
  56. package/packages/subagents/src/types.ts +2 -0
  57. package/packages/unipi/bundled.js +1445 -686
  58. package/packages/updater/package.json +2 -2
  59. package/packages/utility/README.md +9 -0
  60. package/packages/utility/package.json +2 -2
  61. package/packages/utility/src/index.ts +48 -0
  62. package/packages/utility/src/lifecycle/cleanup.ts +29 -0
  63. package/packages/utility/src/prefix-cache.ts +263 -0
  64. package/packages/utility/src/types.ts +1 -1
  65. package/packages/web-api/package.json +2 -2
  66. package/packages/workflow/README.md +8 -0
  67. package/packages/workflow/commands.ts +16 -26
  68. package/packages/workflow/index.ts +165 -85
  69. package/packages/workflow/package.json +5 -2
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/updater",
3
- "version": "2.4.1",
3
+ "version": "2.6.0",
4
4
  "description": "Auto-updater, changelog browser, and readme browser for Unipi — checks npm registry, renders CHANGELOG.md and README.md files in TUI overlays",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "2.4.1"
37
+ "@pi-unipi/core": "2.6.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -16,6 +16,7 @@ The diff rendering is the standout feature — Shiki-powered syntax-highlighted
16
16
  | `/unipi:name-badge` | Toggle name badge overlay |
17
17
  | `/unipi:badge-gen` | Generate session name via LLM and enable badge |
18
18
  | `/unipi:util-settings` | Unified settings for badge and diff rendering |
19
+ | `/unipi:prefix-cache` | Show privacy-safe request-prefix transitions and provider cache token counters |
19
20
 
20
21
  ### Examples
21
22
 
@@ -32,6 +33,14 @@ The diff rendering is the standout feature — Shiki-powered syntax-highlighted
32
33
 
33
34
  Utility registers with the info-screen dashboard, showing module status and diagnostic results. The footer subscribes to utility events for its extension status segment.
34
35
 
36
+ ## Provider prefix-cache diagnostics
37
+
38
+ `/unipi:prefix-cache` observes Pi's provider-native request payloads without modifying them. It reports session-local cache epochs, exact structural prefix extensions, retries, request-envelope/history boundaries, and provider-reported `cacheRead`/`cacheWrite` token totals.
39
+
40
+ The fingerprints are keyed HMAC-SHA-256 values using a random in-memory key that is discarded on reload. Raw prompts, messages, tool arguments, tool schemas, and provider payloads are never retained or logged by this diagnostic. Fingerprints therefore cannot be compared across process lifetimes. A reported prefix extension means the request is structurally eligible for reuse; provider TTL, routing, and cache policy still decide whether a hit occurs.
41
+
42
+ `/unipi:cleanup` includes private `~/.unipi/tool-results/` artifacts in the normal temporary-file retention policy (7 days by default). Use dry-run mode to review candidates before deletion.
43
+
35
44
  The diff rendering feature wraps Pi's built-in `write` and `edit` tools. When enabled, these tools show syntax-highlighted diffs instead of plain output. This is a transparent replacement — the agent doesn't need to know about it.
36
45
 
37
46
  ## Agent Tools
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/utility",
3
- "version": "2.4.1",
3
+ "version": "2.6.0",
4
4
  "description": "Utility commands and tools for Pi coding agent — lifecycle, diagnostics, cache, analytics, display, batch execution",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -36,7 +36,7 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@pi-unipi/core": "2.4.1",
39
+ "@pi-unipi/core": "2.6.0",
40
40
  "diff": "^7.0.0",
41
41
  "shiki": "^4.0.2"
42
42
  },
@@ -32,6 +32,7 @@ import { registerEnhancedWriteTool, registerEnhancedEditTool } from "./diff/wrap
32
32
  import { getLifecycle } from "./lifecycle/process.js";
33
33
  import { getAnalyticsCollector } from "./analytics/collector.js";
34
34
  import { registerInfoScreen } from "./info-screen.js";
35
+ import { PrefixCacheTracker, formatPrefixCacheStats } from "./prefix-cache.js";
35
36
 
36
37
  /** Re-export readBadgeSettings for cross-package use */
37
38
  export { readBadgeSettings } from "./tui/badge-settings.js";
@@ -61,6 +62,7 @@ const ALL_COMMANDS = [
61
62
  UTILITY_COMMANDS.BADGE_TOGGLE,
62
63
  UTILITY_COMMANDS.BADGE_SETTINGS,
63
64
  UTILITY_COMMANDS.UTIL_SETTINGS,
65
+ UTILITY_COMMANDS.PREFIX_CACHE,
64
66
  ].map((cmd) => `unipi:${cmd}`);
65
67
 
66
68
  /** All tools registered by this module */
@@ -73,6 +75,52 @@ export default function (pi: ExtensionAPI) {
73
75
  // Initialize analytics collector
74
76
  const analytics = getAnalyticsCollector();
75
77
 
78
+ // Session-local provider prefix observability. The random HMAC key and all
79
+ // payload-derived fingerprints remain in memory and are discarded on reload.
80
+ const prefixCache = new PrefixCacheTracker();
81
+ pi.registerCommand(`unipi:${UTILITY_COMMANDS.PREFIX_CACHE}`, {
82
+ description: "Show privacy-safe provider prefix-cache diagnostics",
83
+ handler: async (_args, ctx) => {
84
+ const report = formatPrefixCacheStats(prefixCache.getSnapshot());
85
+ if (ctx.hasUI) {
86
+ ctx.ui.notify(report, "info");
87
+ } else {
88
+ pi.sendMessage({ customType: "unipi-response", content: report, display: true }, { deliverAs: "followUp" });
89
+ }
90
+ },
91
+ });
92
+
93
+ pi.on("session_start", () => {
94
+ // A replacement/reloaded session has a distinct provider prefix lineage.
95
+ // The extension factory is normally recreated, but reset explicitly so
96
+ // custom hosts that reuse one factory cannot leak counts across sessions.
97
+ prefixCache.reset();
98
+ });
99
+
100
+ pi.on("before_provider_request", (event, ctx) => {
101
+ prefixCache.observeRequest(event.payload, ctx.model);
102
+ // Observe only: never replace or mutate provider payloads.
103
+ });
104
+
105
+ pi.on("agent_end", (event) => {
106
+ prefixCache.observeMessages(event.messages);
107
+ });
108
+
109
+ pi.on("model_select", (event) => {
110
+ if (event.previousModel && (
111
+ event.previousModel.provider !== event.model.provider ||
112
+ event.previousModel.id !== event.model.id ||
113
+ event.previousModel.api !== event.model.api
114
+ )) {
115
+ prefixCache.markBoundary("envelope_changed");
116
+ }
117
+ });
118
+ pi.on("thinking_level_select", (event) => {
119
+ if (event.previousLevel !== event.level) prefixCache.markBoundary("envelope_changed");
120
+ });
121
+ pi.on("session_compact", () => prefixCache.markBoundary("history_rewritten"));
122
+ pi.on("session_tree", () => prefixCache.markBoundary("history_rewritten"));
123
+
76
124
  // Register cleanup on shutdown
77
125
  lifecycle.registerCleanup(async () => {
78
126
  analytics.disable();
@@ -121,6 +121,34 @@ function cleanDbs(options: Required<CleanupOptions>): CleanupResult {
121
121
  return result;
122
122
  }
123
123
 
124
+ /** Clean private tool-result artifacts after the configured temp retention. */
125
+ function cleanToolResults(options: Required<CleanupOptions>): CleanupResult {
126
+ const result: CleanupResult = {
127
+ category: "tool-results",
128
+ removed: 0,
129
+ bytesFreed: 0,
130
+ paths: [],
131
+ };
132
+ const dir = expandHome("~/.unipi/tool-results");
133
+ if (!existsSync(dir)) return result;
134
+
135
+ let entries: string[];
136
+ try { entries = readdirSync(dir); } catch { return result; }
137
+ for (const entry of entries) {
138
+ if (!/^(?:tool-result|mcp-[a-zA-Z0-9_-]+|helper)-[a-f0-9-]+\.txt$/.test(entry)) continue;
139
+ const path = join(dir, entry);
140
+ try {
141
+ const stats = statSync(path);
142
+ if (!stats.isFile() || !isStale(path, options.tempMaxAgeDays)) continue;
143
+ result.paths.push(path);
144
+ result.bytesFreed += stats.size;
145
+ if (!options.dryRun) unlinkSync(path);
146
+ result.removed++;
147
+ } catch { /* best effort */ }
148
+ }
149
+ return result;
150
+ }
151
+
124
152
  /** Clean temp files matching unipi patterns */
125
153
  function cleanTemps(options: Required<CleanupOptions>): CleanupResult {
126
154
  const result: CleanupResult = {
@@ -288,6 +316,7 @@ export function cleanupStale(options: CleanupOptions = {}): CleanupReport {
288
316
  const results: CleanupResult[] = [
289
317
  cleanDbs(opts),
290
318
  cleanTemps(opts),
319
+ cleanToolResults(opts),
291
320
  cleanSessions(opts),
292
321
  cleanCache(opts),
293
322
  ];
@@ -0,0 +1,263 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+
3
+ export type PrefixTransition =
4
+ | "first_request"
5
+ | "prefix_extended"
6
+ | "identical_retry"
7
+ | "envelope_changed"
8
+ | "history_rewritten"
9
+ | "payload_shape_changed";
10
+
11
+ export interface PrefixCacheSnapshot {
12
+ epoch: number;
13
+ requests: number;
14
+ prefixExtensions: number;
15
+ identicalRetries: number;
16
+ boundaries: number;
17
+ lastTransition: PrefixTransition | "none";
18
+ requestFingerprint: string;
19
+ envelopeFingerprint: string;
20
+ sequenceField: string;
21
+ sequenceItems: number;
22
+ route: string;
23
+ usage: {
24
+ input: number;
25
+ output: number;
26
+ cacheRead: number;
27
+ cacheWrite: number;
28
+ responses: number;
29
+ };
30
+ }
31
+
32
+ interface ObservedRequest {
33
+ envelopeFingerprint: string;
34
+ requestFingerprint: string;
35
+ sequenceField: string;
36
+ sequenceItemFingerprints: string[];
37
+ }
38
+
39
+ const EMPTY_SNAPSHOT: PrefixCacheSnapshot = {
40
+ epoch: 0,
41
+ requests: 0,
42
+ prefixExtensions: 0,
43
+ identicalRetries: 0,
44
+ boundaries: 0,
45
+ lastTransition: "none",
46
+ requestFingerprint: "none",
47
+ envelopeFingerprint: "none",
48
+ sequenceField: "none",
49
+ sequenceItems: 0,
50
+ route: "unknown",
51
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, responses: 0 },
52
+ };
53
+
54
+ function canonicalize(value: unknown, seen = new WeakSet<object>()): unknown {
55
+ if (value === undefined) return { $type: "undefined" };
56
+ if (typeof value === "bigint") return { $type: "bigint", value: value.toString() };
57
+ if (typeof value === "number" && !Number.isFinite(value)) {
58
+ return { $type: "number", value: String(value) };
59
+ }
60
+ if (typeof value !== "object" || value === null) return value;
61
+ if (seen.has(value)) return { $type: "cycle" };
62
+ seen.add(value);
63
+
64
+ if (Array.isArray(value)) {
65
+ const result = value.map((item) => canonicalize(item, seen));
66
+ seen.delete(value);
67
+ return result;
68
+ }
69
+
70
+ if (value instanceof Uint8Array) {
71
+ seen.delete(value);
72
+ return { $type: "bytes", length: value.byteLength };
73
+ }
74
+
75
+ const record = value as Record<string, unknown>;
76
+ const result: Record<string, unknown> = {};
77
+ for (const key of Object.keys(record).sort((a, b) => a < b ? -1 : a > b ? 1 : 0)) {
78
+ result[key] = canonicalize(record[key], seen);
79
+ }
80
+ seen.delete(value);
81
+ return result;
82
+ }
83
+
84
+ function canonicalJson(value: unknown): string {
85
+ return JSON.stringify(canonicalize(value));
86
+ }
87
+
88
+ function routeLabel(model: unknown): string {
89
+ if (!model || typeof model !== "object") return "unknown";
90
+ const value = model as Record<string, unknown>;
91
+ return [value.provider, value.id, value.api]
92
+ .filter((part): part is string => typeof part === "string" && part.length > 0)
93
+ .join("/") || "unknown";
94
+ }
95
+
96
+ function findSequence(payload: Record<string, unknown>): { field: string; items: unknown[] } {
97
+ for (const field of ["messages", "input", "contents"] as const) {
98
+ if (Array.isArray(payload[field])) return { field, items: payload[field] as unknown[] };
99
+ }
100
+ return { field: "none", items: [] };
101
+ }
102
+
103
+ function isPrefix(previous: string[], current: string[]): boolean {
104
+ return previous.length <= current.length && previous.every((item, index) => current[index] === item);
105
+ }
106
+
107
+ function usageNumber(value: unknown): number {
108
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
109
+ }
110
+
111
+ /**
112
+ * Session-local, privacy-safe prefix observability.
113
+ *
114
+ * Prompt/tool bytes are only canonicalized transiently. The tracker retains
115
+ * keyed HMACs and counters, never raw provider payloads. Its random key is not
116
+ * persisted, so fingerprints cannot be correlated across process lifetimes or
117
+ * used as a precomputed dictionary oracle.
118
+ */
119
+ export class PrefixCacheTracker {
120
+ private readonly key: Uint8Array;
121
+ private previous: ObservedRequest | null = null;
122
+ private seenResponses = new WeakSet<object>();
123
+ private snapshot: PrefixCacheSnapshot = structuredClone(EMPTY_SNAPSHOT);
124
+
125
+ constructor(key: Uint8Array = randomBytes(32)) {
126
+ this.key = key;
127
+ }
128
+
129
+ reset(): void {
130
+ this.previous = null;
131
+ this.seenResponses = new WeakSet<object>();
132
+ this.snapshot = structuredClone(EMPTY_SNAPSHOT);
133
+ }
134
+
135
+ private fingerprint(value: unknown): string {
136
+ return createHmac("sha256", this.key).update(canonicalJson(value)).digest("hex").slice(0, 16);
137
+ }
138
+
139
+ observeRequest(payload: unknown, model?: unknown): PrefixCacheSnapshot {
140
+ const object = payload && typeof payload === "object" && !Array.isArray(payload)
141
+ ? payload as Record<string, unknown>
142
+ : { payload };
143
+ const { field, items } = findSequence(object);
144
+ const route = routeLabel(model);
145
+ const envelope: Record<string, unknown> = { route };
146
+ for (const [key, value] of Object.entries(object)) {
147
+ if (key !== field) envelope[key] = value;
148
+ }
149
+
150
+ const current: ObservedRequest = {
151
+ envelopeFingerprint: this.fingerprint(envelope),
152
+ requestFingerprint: this.fingerprint(object),
153
+ sequenceField: field,
154
+ sequenceItemFingerprints: items.map((item) => this.fingerprint(item)),
155
+ };
156
+
157
+ let transition: PrefixTransition;
158
+ let epoch = this.snapshot.epoch;
159
+ if (!this.previous) {
160
+ transition = "first_request";
161
+ epoch = Math.max(1, epoch);
162
+ } else if (
163
+ current.envelopeFingerprint !== this.previous.envelopeFingerprint
164
+ ) {
165
+ transition = "envelope_changed";
166
+ epoch++;
167
+ } else if (current.sequenceField !== this.previous.sequenceField) {
168
+ transition = "payload_shape_changed";
169
+ epoch++;
170
+ } else if (current.requestFingerprint === this.previous.requestFingerprint) {
171
+ transition = "identical_retry";
172
+ } else if (isPrefix(this.previous.sequenceItemFingerprints, current.sequenceItemFingerprints)) {
173
+ transition = "prefix_extended";
174
+ } else {
175
+ transition = "history_rewritten";
176
+ epoch++;
177
+ }
178
+
179
+ this.snapshot = {
180
+ ...this.snapshot,
181
+ epoch,
182
+ requests: this.snapshot.requests + 1,
183
+ prefixExtensions: this.snapshot.prefixExtensions + (transition === "prefix_extended" ? 1 : 0),
184
+ identicalRetries: this.snapshot.identicalRetries + (transition === "identical_retry" ? 1 : 0),
185
+ boundaries: this.snapshot.boundaries + (
186
+ transition === "envelope_changed" || transition === "history_rewritten" || transition === "payload_shape_changed"
187
+ ? 1
188
+ : 0
189
+ ),
190
+ lastTransition: transition,
191
+ requestFingerprint: current.requestFingerprint,
192
+ envelopeFingerprint: current.envelopeFingerprint,
193
+ sequenceField: current.sequenceField,
194
+ sequenceItems: current.sequenceItemFingerprints.length,
195
+ route,
196
+ };
197
+ this.previous = current;
198
+ return this.getSnapshot();
199
+ }
200
+
201
+ observeMessages(messages: unknown[]): PrefixCacheSnapshot {
202
+ for (const message of messages) {
203
+ if (!message || typeof message !== "object") continue;
204
+ const value = message as Record<string, unknown>;
205
+ if (value.role !== "assistant" || !value.usage || typeof value.usage !== "object") continue;
206
+ if (value.stopReason === "error" || value.stopReason === "aborted") continue;
207
+
208
+ // agent_end can expose the whole active context repeatedly. Deduplicate
209
+ // exact message objects by identity; two genuinely distinct provider
210
+ // responses with identical content/usage must still both count.
211
+ if (this.seenResponses.has(message as object)) continue;
212
+ this.seenResponses.add(message as object);
213
+
214
+ const usage = value.usage as Record<string, unknown>;
215
+ this.snapshot.usage.input += usageNumber(usage.input);
216
+ this.snapshot.usage.output += usageNumber(usage.output);
217
+ this.snapshot.usage.cacheRead += usageNumber(usage.cacheRead);
218
+ this.snapshot.usage.cacheWrite += usageNumber(usage.cacheWrite);
219
+ this.snapshot.usage.responses++;
220
+ }
221
+ return this.getSnapshot();
222
+ }
223
+
224
+ markBoundary(transition: Extract<PrefixTransition, "history_rewritten" | "envelope_changed">): void {
225
+ // Lifecycle events can identify a boundary before the next payload arrives.
226
+ // Drop the comparison baseline so the next request starts a fresh observed
227
+ // epoch rather than being misclassified against pre-boundary history.
228
+ if (this.previous) {
229
+ this.snapshot.epoch++;
230
+ this.snapshot.boundaries++;
231
+ }
232
+ this.snapshot.lastTransition = transition;
233
+ this.previous = null;
234
+ }
235
+
236
+ getSnapshot(): PrefixCacheSnapshot {
237
+ return structuredClone(this.snapshot);
238
+ }
239
+ }
240
+
241
+ export function formatPrefixCacheStats(snapshot: PrefixCacheSnapshot): string {
242
+ const usage = snapshot.usage;
243
+ const totalPrompt = usage.input + usage.cacheRead + usage.cacheWrite;
244
+ const readRate = totalPrompt > 0 ? `${((usage.cacheRead / totalPrompt) * 100).toFixed(1)}%` : "n/a";
245
+ return [
246
+ "## Provider Prefix Cache",
247
+ "",
248
+ `- Epoch: ${snapshot.epoch || "not observed"}`,
249
+ `- Requests observed: ${snapshot.requests}`,
250
+ `- Prefix extensions: ${snapshot.prefixExtensions}`,
251
+ `- Explicit boundaries: ${snapshot.boundaries}`,
252
+ `- Last transition: ${snapshot.lastTransition}`,
253
+ `- Route: ${snapshot.route}`,
254
+ `- Request fingerprint: ${snapshot.requestFingerprint}`,
255
+ `- Envelope fingerprint: ${snapshot.envelopeFingerprint}`,
256
+ `- Provider cache read: ${usage.cacheRead} tokens`,
257
+ `- Provider cache write: ${usage.cacheWrite} tokens`,
258
+ `- Observed cache-read share: ${readRate}`,
259
+ "",
260
+ "Fingerprints are session-local keyed HMACs. Raw prompts, messages, tool arguments, and payloads are not retained or logged.",
261
+ "A prefix extension indicates structural eligibility only; provider cache retention and routing still determine actual hits.",
262
+ ].join("\n");
263
+ }
@@ -227,7 +227,7 @@ export interface EnvironmentInfo {
227
227
  /** Result of a cleanup operation */
228
228
  export interface CleanupResult {
229
229
  /** What was cleaned */
230
- category: "db" | "temp" | "session" | "cache" | "log";
230
+ category: "db" | "temp" | "tool-results" | "session" | "cache" | "log";
231
231
  /** Items removed */
232
232
  removed: number;
233
233
  /** Bytes freed (approximate) */
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/web-api",
3
- "version": "2.4.1",
3
+ "version": "2.6.0",
4
4
  "description": "Web search, read, and summarize tools with provider-based backend selection for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -31,7 +31,7 @@
31
31
  "README.md"
32
32
  ],
33
33
  "dependencies": {
34
- "@pi-unipi/core": "2.4.1",
34
+ "@pi-unipi/core": "2.6.0",
35
35
  "defuddle": "^0.18.1",
36
36
  "linkedom": "^0.18.12",
37
37
  "lodash": "^4.17.21",
@@ -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
  ```
@@ -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
  }