@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,22 +1,33 @@
1
1
  /**
2
2
  * @pi-unipi/milestone — Lifecycle hooks
3
3
  *
4
- * Session start: inject milestone progress as system context.
4
+ * Agent start: append milestone progress as a hidden, persistent context snapshot.
5
5
  * Session end: auto-sync completed items from workflow docs.
6
6
  */
7
7
 
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ buildSessionContext,
12
+ type ExtensionAPI,
13
+ type SessionEntry,
14
+ } from "@earendil-works/pi-coding-agent";
11
15
  import { MILESTONE_DIRS, UNIPI_EVENTS, safeMtimeMs, tryRead } from "@pi-unipi/core";
12
- import { parseMilestones, getProgressSummary, updateItemStatus } from "./milestone.js";
16
+ import { getProgressSummary, updateItemStatus } from "./milestone.js";
13
17
 
14
- /** Track when the session started for diffing modified files */
15
- let sessionStartMs = 0;
18
+ export const MILESTONE_SNAPSHOT_TYPE = "unipi-milestone-snapshot";
16
19
 
17
- /**
18
- * Format a progress summary as a context string for the system prompt.
19
- */
20
+ interface MilestoneSnapshotDetails {
21
+ active: boolean;
22
+ workspace: string;
23
+ }
24
+
25
+ interface EffectiveSnapshot {
26
+ content: unknown;
27
+ details?: MilestoneSnapshotDetails;
28
+ }
29
+
30
+ /** Format the active milestone progress included in a snapshot. */
20
31
  function formatMilestoneContext(filePath: string): string | null {
21
32
  const summary = getProgressSummary(filePath);
22
33
  if (summary.totalItems === 0) return null;
@@ -39,23 +50,84 @@ function formatMilestoneContext(filePath: string): string | null {
39
50
  .join("\n");
40
51
  }
41
52
 
53
+ /** Build an append-only snapshot that explicitly invalidates earlier snapshots. */
54
+ export function formatMilestoneSnapshot(workspace: string, context: string | null): string {
55
+ return [
56
+ "# UniPi Milestone Snapshot",
57
+ "This snapshot supersedes all prior UniPi milestone snapshots; use only this snapshot for milestone status.",
58
+ `Workspace: ${workspace}`,
59
+ `Status: ${context ? "active" : "inactive"}`,
60
+ context ?? "No milestones are active for this workspace.",
61
+ ].join("\n\n");
62
+ }
63
+
64
+ function latestEffectiveSnapshot(branch: SessionEntry[]): EffectiveSnapshot | undefined {
65
+ const messages = buildSessionContext(branch).messages;
66
+ for (let index = messages.length - 1; index >= 0; index--) {
67
+ const message = messages[index];
68
+ if (message.role === "custom" && message.customType === MILESTONE_SNAPSHOT_TYPE) {
69
+ return {
70
+ content: message.content,
71
+ details: message.details as MilestoneSnapshotDetails | undefined,
72
+ };
73
+ }
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ /** Find state that may have been folded into a compaction summary. */
79
+ function latestHistoricalSnapshot(branch: SessionEntry[]): EffectiveSnapshot | undefined {
80
+ for (let index = branch.length - 1; index >= 0; index--) {
81
+ const entry = branch[index];
82
+ if (entry.type === "custom_message" && entry.customType === MILESTONE_SNAPSHOT_TYPE) {
83
+ return {
84
+ content: entry.content,
85
+ details: entry.details as MilestoneSnapshotDetails | undefined,
86
+ };
87
+ }
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ function isActiveSnapshot(snapshot: EffectiveSnapshot): boolean {
93
+ if (typeof snapshot.details?.active === "boolean") return snapshot.details.active;
94
+ return typeof snapshot.content === "string" && snapshot.content.includes("Status: active");
95
+ }
96
+
42
97
  /**
43
- * Register session start hook — injects milestone progress into system context.
98
+ * Register the agent-start hook. Snapshots are hidden from the transcript but
99
+ * persist in the append-only session and therefore keep the system prefix stable.
44
100
  */
45
101
  export function registerSessionStartHook(pi: ExtensionAPI): void {
46
- pi.on("before_agent_start", (event) => {
47
- sessionStartMs = Date.now();
102
+ pi.on("before_agent_start", (_event, ctx) => {
103
+ const workspace = ctx.cwd;
104
+ const milestonesPath = path.join(workspace, MILESTONE_DIRS.MILESTONES);
105
+ const context = formatMilestoneContext(milestonesPath);
106
+ const branch = ctx.sessionManager.getBranch();
107
+ const latest = latestEffectiveSnapshot(branch);
108
+ const historical = latestHistoricalSnapshot(branch);
48
109
 
49
- const cwd = process.cwd();
50
- const milestonesPath = path.join(cwd, MILESTONE_DIRS.MILESTONES);
110
+ // A genuinely clean workspace/session needs no synthetic context. Raw
111
+ // history is checked because compaction may have folded an old active
112
+ // snapshot into summary prose while removing its custom-message identity.
113
+ if (!context && !latest && !historical) return undefined;
51
114
 
52
- const context = formatMilestoneContext(milestonesPath);
53
- if (!context) return undefined;
115
+ const prior = latest ?? historical;
116
+ if (!context && prior && !isActiveSnapshot(prior)) return undefined;
117
+
118
+ const content = formatMilestoneSnapshot(workspace, context);
119
+ if (latest?.content === content) return undefined;
54
120
 
55
- // Append milestone context to the system prompt
56
- const currentPrompt = (event as any).systemPrompt ?? "";
57
121
  return {
58
- systemPrompt: currentPrompt + "\n\n" + context,
122
+ message: {
123
+ customType: MILESTONE_SNAPSHOT_TYPE,
124
+ content,
125
+ display: false,
126
+ details: {
127
+ active: context !== null,
128
+ workspace,
129
+ } satisfies MilestoneSnapshotDetails,
130
+ },
59
131
  };
60
132
  });
61
133
  }
@@ -140,19 +212,21 @@ function scanModifiedDocs(dirs: string[], since: number): string[] {
140
212
  * scans modified docs, and auto-updates MILESTONES.md.
141
213
  */
142
214
  export function registerSessionEndHook(pi: ExtensionAPI): void {
143
- // Store baseline snapshots at session start
215
+ // Capture the session workspace because process.cwd() can change before shutdown.
144
216
  const baselineSnapshots = new Map<string, string>();
217
+ let sessionStartMs = 0;
218
+ let sessionWorkspace: string | null = null;
145
219
 
146
220
  // Capture baselines on session start
147
- pi.on("session_start", () => {
221
+ pi.on("session_start", (_event, ctx) => {
148
222
  sessionStartMs = Date.now();
223
+ sessionWorkspace = ctx.cwd;
149
224
  baselineSnapshots.clear();
150
225
 
151
- const cwd = process.cwd();
152
226
  const scanDirs = [
153
- path.join(cwd, ".unipi/docs/specs"),
154
- path.join(cwd, ".unipi/docs/plans"),
155
- path.join(cwd, ".unipi/docs/quick-work"),
227
+ path.join(sessionWorkspace, ".unipi/docs/specs"),
228
+ path.join(sessionWorkspace, ".unipi/docs/plans"),
229
+ path.join(sessionWorkspace, ".unipi/docs/quick-work"),
156
230
  ];
157
231
 
158
232
  for (const dir of scanDirs) {
@@ -168,15 +242,15 @@ export function registerSessionEndHook(pi: ExtensionAPI): void {
168
242
  });
169
243
 
170
244
  const syncModifiedDocs = () => {
171
- const cwd = process.cwd();
172
- const milestonesPath = path.join(cwd, MILESTONE_DIRS.MILESTONES);
245
+ if (!sessionWorkspace) return;
173
246
 
247
+ const milestonesPath = path.join(sessionWorkspace, MILESTONE_DIRS.MILESTONES);
174
248
  if (!fs.existsSync(milestonesPath)) return;
175
249
 
176
250
  const scanDirs = [
177
- path.join(cwd, ".unipi/docs/specs"),
178
- path.join(cwd, ".unipi/docs/plans"),
179
- path.join(cwd, ".unipi/docs/quick-work"),
251
+ path.join(sessionWorkspace, ".unipi/docs/specs"),
252
+ path.join(sessionWorkspace, ".unipi/docs/plans"),
253
+ path.join(sessionWorkspace, ".unipi/docs/quick-work"),
180
254
  ];
181
255
 
182
256
  const modifiedFiles = scanModifiedDocs(scanDirs, sessionStartMs);
@@ -2,7 +2,7 @@
2
2
  * @pi-unipi/milestone — Extension entry point
3
3
  *
4
4
  * Lifecycle layer for project-level goals. Tracks progress via MILESTONES.md,
5
- * injects context on session start, auto-syncs on session end.
5
+ * appends hidden context snapshots before agent turns, and auto-syncs on session end.
6
6
  */
7
7
 
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@pi-unipi/milestone",
3
- "version": "2.4.1",
3
+ "version": "2.6.0",
4
4
  "description": "Lifecycle layer for project-level goals — MILESTONES.md tracking, session hooks, auto-sync",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
+ "scripts": {
8
+ "test": "npx tsx --test tests/hooks.test.ts"
9
+ },
7
10
  "license": "MIT",
8
11
  "author": "Neuron Mr White",
9
12
  "repository": {
@@ -29,7 +32,7 @@
29
32
  "access": "public"
30
33
  },
31
34
  "dependencies": {
32
- "@pi-unipi/core": "2.4.1"
35
+ "@pi-unipi/core": "2.6.0"
33
36
  },
34
37
  "peerDependencies": {
35
38
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "2.4.1",
3
+ "version": "2.6.0",
4
4
  "description": "Cross-platform notification extension for Pi — native OS, Gotify, and Telegram notifications for agent lifecycle events",
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
  "node-notifier": "^10.0.1"
39
39
  },
40
40
  "peerDependencies": {
@@ -30,6 +30,7 @@ const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
30
30
  /** Current loop manager instance (recreated on session reload) */
31
31
  let manager: RalphLoopManager | null = null;
32
32
 
33
+
33
34
  /**
34
35
  * Get or create the loop manager for the current context.
35
36
  */
@@ -42,9 +43,14 @@ function getManager(ctx: ExtensionContext, pi: ExtensionAPI): RalphLoopManager {
42
43
  return manager;
43
44
  }
44
45
 
46
+ export { buildRalphLoopReminder } from "./reminder.js";
47
+ import { buildRalphLoopReminder, latestRalphReminder, RALPH_REMINDER_TYPE } from "./reminder.js";
48
+
45
49
  export default function (pi: ExtensionAPI) {
46
- // Register tools
47
- // (Manager will be created lazily on first use)
50
+ // Register static tool definitions at extension load. Their executors resolve
51
+ // the session-scoped manager lazily, so schemas never arrive late or reorder
52
+ // the provider tool array during session_start.
53
+ registerRalphTools(pi, (ctx) => getManager(ctx, pi));
48
54
 
49
55
  // Register commands
50
56
  registerCommands(pi);
@@ -157,20 +163,13 @@ export default function (pi: ExtensionAPI) {
157
163
  const state = mgr.loadState(currentLoop);
158
164
  if (!state || state.status !== "active") return;
159
165
 
160
- const iterStr = `${state.iteration}${state.maxIterations > 0 ? `/${state.maxIterations}` : ""}`;
161
-
162
- let instructions = `You are in a Ralph loop working on: ${state.taskFile}\n`;
163
- if (state.itemsPerIteration > 0) {
164
- instructions += `- Work on ~${state.itemsPerIteration} items this iteration\n`;
165
- }
166
- instructions += `- Update the task file as you progress\n`;
167
- instructions += `- When FULLY COMPLETE: ${RALPH_COMPLETE_MARKER}\n`;
168
- instructions += `- Otherwise, call ralph_done tool to proceed to next iteration`;
166
+ const content = buildRalphLoopReminder(state);
167
+ if (latestRalphReminder(ctx) === content) return;
169
168
 
170
169
  return {
171
170
  message: {
172
- customType: "unipi-ralph-loop-reminder",
173
- content: `[RALPH LOOP - ${state.name} - Iteration ${iterStr}]\n\n${instructions}`,
171
+ customType: RALPH_REMINDER_TYPE,
172
+ content,
174
173
  display: false,
175
174
  },
176
175
  };
@@ -187,11 +186,6 @@ export default function (pi: ExtensionAPI) {
187
186
  manager = null;
188
187
  });
189
188
 
190
- // Register tools after manager setup
191
- pi.on("session_start", async (_event, ctx) => {
192
- const mgr = getManager(ctx, pi);
193
- registerRalphTools(pi, mgr);
194
- });
195
189
  }
196
190
 
197
191
  /**
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ralph",
3
- "version": "2.4.2",
3
+ "version": "2.6.0",
4
4
  "description": "Long-running iterative development loops for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -26,9 +26,12 @@
26
26
  "publishConfig": {
27
27
  "access": "public"
28
28
  },
29
+ "scripts": {
30
+ "test": "npx tsx --test reminder.test.ts"
31
+ },
29
32
  "dependencies": {
30
- "@pi-unipi/core": "2.4.1",
31
- "@pi-unipi/info-screen": "2.4.1"
33
+ "@pi-unipi/core": "2.6.0",
34
+ "@pi-unipi/info-screen": "2.6.0"
32
35
  },
33
36
  "peerDependencies": {
34
37
  "@earendil-works/pi-ai": "^0.80.0",
@@ -0,0 +1,40 @@
1
+ import type { ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import { RALPH_COMPLETE_MARKER } from "@pi-unipi/core";
3
+
4
+ export const RALPH_REMINDER_TYPE = "unipi-ralph-loop-reminder";
5
+
6
+ export interface RalphReminderInput {
7
+ name: string;
8
+ iteration: number;
9
+ maxIterations: number;
10
+ taskFile: string;
11
+ itemsPerIteration: number;
12
+ }
13
+
14
+ /** Build the exact deterministic hidden reminder used by the live hook. */
15
+ export function buildRalphLoopReminder(state: RalphReminderInput): string {
16
+ const iterStr = `${state.iteration}${state.maxIterations > 0 ? `/${state.maxIterations}` : ""}`;
17
+ let instructions = "This snapshot supersedes all earlier Ralph loop reminders.\n";
18
+ instructions += `You are in a Ralph loop working on: ${state.taskFile}\n`;
19
+ if (state.itemsPerIteration > 0) {
20
+ instructions += `- Work on ~${state.itemsPerIteration} items this iteration\n`;
21
+ }
22
+ instructions += "- Update the task file as you progress\n";
23
+ instructions += `- When FULLY COMPLETE: ${RALPH_COMPLETE_MARKER}\n`;
24
+ instructions += "- Otherwise, call ralph_done tool to proceed to next iteration";
25
+ return `[RALPH LOOP - ${state.name} - Iteration ${iterStr}]\n\n${instructions}`;
26
+ }
27
+
28
+ export function latestRalphReminder(ctx: Pick<ExtensionContext, "sessionManager">): string | null {
29
+ const branch = ctx.sessionManager.getBranch() as SessionEntry[];
30
+ for (let index = branch.length - 1; index >= 0; index--) {
31
+ const entry = branch[index];
32
+ if (entry.type === "custom_message" && entry.customType === RALPH_REMINDER_TYPE) {
33
+ return typeof entry.content === "string" ? entry.content : null;
34
+ }
35
+ // A compacted summary may contain the old reminder but no longer retains a
36
+ // dedicated custom entry. Inject the current snapshot once in the new epoch.
37
+ if (entry.type === "compaction") break;
38
+ }
39
+ return null;
40
+ }
@@ -9,10 +9,12 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
9
9
  import { RALPH_COMPLETE_MARKER, RALPH_DEFAULTS, RALPH_TOOLS } from "@pi-unipi/core";
10
10
  import { RalphLoopManager, DEFAULT_REFLECT_INSTRUCTIONS } from "./ralph-loop.js";
11
11
 
12
+ type ManagerProvider = (ctx: ExtensionContext) => RalphLoopManager;
13
+
12
14
  /**
13
15
  * Register ralph_start and ralph_done tools.
14
16
  */
15
- export function registerRalphTools(pi: ExtensionAPI, manager: RalphLoopManager): void {
17
+ export function registerRalphTools(pi: ExtensionAPI, getManager: ManagerProvider): void {
16
18
  // --- ralph_start tool ---
17
19
  pi.registerTool({
18
20
  name: RALPH_TOOLS.START,
@@ -44,6 +46,7 @@ export function registerRalphTools(pi: ExtensionAPI, manager: RalphLoopManager):
44
46
  ),
45
47
  }),
46
48
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
49
+ const manager = getManager(ctx);
47
50
  const taskFile = `.unipi/ralph/${params.name.replace(/[^a-zA-Z0-9_-]/g, "_")}.md`;
48
51
 
49
52
  if (manager.loadState(params.name)?.status === "active") {
@@ -92,6 +95,7 @@ export function registerRalphTools(pi: ExtensionAPI, manager: RalphLoopManager):
92
95
  ],
93
96
  parameters: Type.Object({}),
94
97
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
98
+ const manager = getManager(ctx);
95
99
  if (!manager.getCurrentLoop()) {
96
100
  return {
97
101
  content: [{ type: "text", text: "No active Ralph loop." }],
@@ -69,6 +69,8 @@ spawn_helper(
69
69
  get_helper_result(agent_id: "helper_abc123")
70
70
  ```
71
71
 
72
+ Foreground and retrieved background results have a hard 64 KiB model-visible ceiling. For raw results up to 16 MiB, larger output includes a bounded head/tail preview and a path to the complete private mode-0600 artifact under a mode-0700 `~/.unipi/tool-results/` directory. Use `read` with offset/limit to inspect only the needed region. Repeated retrieval reuses the same artifact. Results above the safety cap or artifact-write failures still return a preview with an explicit non-retention warning.
73
+
72
74
  ## Custom Agent Types
73
75
 
74
76
  Create markdown files defining agent behavior:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/subagents",
3
- "version": "2.4.1",
3
+ "version": "2.6.0",
4
4
  "description": "Subagents for UniPi — parallel execution, file locking, workflow integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -13,6 +13,10 @@ import { resolveModel, type ModelRegistry } from "./model-resolver.js";
13
13
  import type { AgentRecord, AgentConfig, AgentType, ThinkingLevel, SubagentsConfig } from "./types.js";
14
14
  import { BUILTIN_CONFIGS } from "./types.js";
15
15
  import { loadCustomAgents } from "./custom-agents.js";
16
+
17
+ function compareCodeUnits(a: string, b: string): number {
18
+ return a < b ? -1 : a > b ? 1 : 0;
19
+ }
16
20
  import { FileLock } from "./file-lock.js";
17
21
 
18
22
  export type OnAgentComplete = (record: AgentRecord) => void;
@@ -88,7 +92,7 @@ export class AgentManager {
88
92
  ...Object.keys(BUILTIN_CONFIGS).filter((type) => type !== "name-gen"),
89
93
  ...this.customAgents.keys(),
90
94
  ...Object.keys(this.typeSettings),
91
- ])];
95
+ ])].sort(compareCodeUnits);
92
96
  }
93
97
 
94
98
  /** A type is enabled only when both JSON config and agent frontmatter allow it. */
@@ -19,8 +19,8 @@ import {
19
19
  } from "@earendil-works/pi-coding-agent";
20
20
  import { BUILTIN_CONFIGS, type AgentConfig, type AgentType, type ThinkingLevel } from "./types.js";
21
21
 
22
- /** Tools excluded from subagents to prevent nesting. */
23
- const EXCLUDED_TOOL_NAMES = ["Agent", "get_result"];
22
+ /** Tools excluded from subagents to prevent nesting. Legacy names remain for older hosts. */
23
+ const EXCLUDED_TOOL_NAMES = ["spawn_helper", "get_helper_result", "Agent", "get_result"];
24
24
 
25
25
  /** All known built-in tool names. */
26
26
  const BUILTIN_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"];
@@ -1,4 +1,8 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { chmodSync, lstatSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { randomUUID } from "node:crypto";
2
6
 
3
7
  export const MODULES = { SUBAGENTS: "subagents" } as const;
4
8
 
@@ -24,6 +28,75 @@ export function emitEvent(
24
28
  }
25
29
  }
26
30
 
31
+ export interface BoundedHelperOutput {
32
+ text: string;
33
+ truncated: boolean;
34
+ originalBytes: number;
35
+ artifactPath?: string;
36
+ }
37
+
38
+ /** Standalone-package twin of core's bounded-output policy. */
39
+ const MAX_RAW_HELPER_ARTIFACT_BYTES = 16 * 1024 * 1024;
40
+
41
+ export function boundHelperOutput(
42
+ text: string,
43
+ maxBytes = 64 * 1024,
44
+ existingArtifactPath?: string,
45
+ ): BoundedHelperOutput {
46
+ const originalBytes = Buffer.byteLength(text, "utf8");
47
+ if (originalBytes <= maxBytes) return { text, truncated: false, originalBytes };
48
+
49
+ let artifactPath = existingArtifactPath;
50
+ let artifactWarning: string | undefined;
51
+ if (!artifactPath && originalBytes <= MAX_RAW_HELPER_ARTIFACT_BYTES) {
52
+ try {
53
+ const dir = join(homedir(), ".unipi", "tool-results");
54
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
55
+ let stat = lstatSync(dir);
56
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
57
+ throw new Error(`Refusing unsafe tool-result directory: ${dir}`);
58
+ }
59
+ if ((stat.mode & 0o077) !== 0) {
60
+ chmodSync(dir, 0o700);
61
+ stat = lstatSync(dir);
62
+ if ((stat.mode & 0o077) !== 0) throw new Error(`Refusing non-private tool-result directory: ${dir}`);
63
+ }
64
+ artifactPath = join(dir, `helper-${randomUUID()}.txt`);
65
+ writeFileSync(artifactPath, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
66
+ } catch (error) {
67
+ artifactWarning = `Full-output artifact unavailable: ${error instanceof Error ? error.message : String(error)}`;
68
+ artifactPath = undefined;
69
+ }
70
+ } else if (!artifactPath) {
71
+ artifactWarning = `Full output exceeded the ${MAX_RAW_HELPER_ARTIFACT_BYTES}-byte local artifact safety cap and was not retained.`;
72
+ }
73
+
74
+ const marker = [
75
+ "",
76
+ "--- output bounded by UniPi ---",
77
+ artifactPath ? `Full output: ${artifactPath}` : artifactWarning!,
78
+ `Original size: ${originalBytes} bytes; model-visible ceiling: ${maxBytes} bytes.`,
79
+ ...(artifactPath ? ["Use the read tool with offset/limit to inspect only the needed region."] : []),
80
+ ].join("\n");
81
+ const markerBytes = Buffer.byteLength(marker, "utf8");
82
+ const contentBudget = Math.max(1, maxBytes - markerBytes - 80);
83
+ const bytes = Buffer.from(text, "utf8");
84
+ const headBytes = Math.ceil(contentBudget * 0.75);
85
+ const tailBytes = Math.max(0, contentBudget - headBytes);
86
+ const head = bytes.subarray(0, headBytes).toString("utf8").replace(/\uFFFD+$/u, "");
87
+ const tail = bytes.subarray(originalBytes - tailBytes).toString("utf8").replace(/^\uFFFD+/u, "");
88
+ let bounded = `${head}\n… ${Math.max(0, originalBytes - contentBudget)} bytes omitted …\n${tail}${marker}`;
89
+ if (Buffer.byteLength(bounded, "utf8") > maxBytes) {
90
+ bounded = Buffer.from(bounded, "utf8").subarray(0, maxBytes).toString("utf8").replace(/\uFFFD+$/u, "");
91
+ }
92
+ return {
93
+ text: bounded,
94
+ truncated: true,
95
+ originalBytes,
96
+ artifactPath,
97
+ };
98
+ }
99
+
27
100
  export async function withHerdrBlocked<T>(
28
101
  pi: Pick<ExtensionAPI, "events">,
29
102
  label: string,
@@ -12,6 +12,10 @@ import { homedir } from "node:os";
12
12
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
13
13
  import type { AgentConfig } from "./types.js";
14
14
 
15
+ function compareCodeUnits(a: string, b: string): number {
16
+ return a < b ? -1 : a > b ? 1 : 0;
17
+ }
18
+
15
19
  /** Backup a corrupted file by renaming to .bak */
16
20
  function backupCorrupted(filePath: string): void {
17
21
  const backupPath = filePath + ".bak";
@@ -93,7 +97,9 @@ export function loadCustomAgents(cwd: string): Map<string, AgentConfig> {
93
97
  // Load global agents first
94
98
  const globalDir = getGlobalAgentsDir();
95
99
  if (existsSync(globalDir)) {
96
- const files = readdirSync(globalDir).filter((f) => f.endsWith(".md"));
100
+ const files = readdirSync(globalDir)
101
+ .filter((f) => f.endsWith(".md"))
102
+ .sort(compareCodeUnits);
97
103
  for (const file of files) {
98
104
  const agent = loadAgentFromFile(join(globalDir, file), "global");
99
105
  if (agent) {
@@ -105,7 +111,9 @@ export function loadCustomAgents(cwd: string): Map<string, AgentConfig> {
105
111
  // Load project agents (overrides global)
106
112
  const projectDir = getProjectAgentsDir(cwd);
107
113
  if (existsSync(projectDir)) {
108
- const files = readdirSync(projectDir).filter((f) => f.endsWith(".md"));
114
+ const files = readdirSync(projectDir)
115
+ .filter((f) => f.endsWith(".md"))
116
+ .sort(compareCodeUnits);
109
117
  for (const file of files) {
110
118
  const agent = loadAgentFromFile(join(projectDir, file), "project");
111
119
  if (agent) {
@@ -12,7 +12,7 @@ import { Type } from "typebox";
12
12
  import { existsSync, readdirSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { homedir } from "node:os";
15
- import { emitEvent, MODULES, UNIPI_EVENTS, withHerdrBlocked, type UnipiBadgeGenerateRequestEvent } from "./core-compat.js";
15
+ import { boundHelperOutput, emitEvent, MODULES, UNIPI_EVENTS, withHerdrBlocked, type UnipiBadgeGenerateRequestEvent } from "./core-compat.js";
16
16
  import { AgentManager } from "./agent-manager.js";
17
17
  import { initConfig } from "./config.js";
18
18
  import { type AgentActivity, type NotificationDetails, BUILTIN_TYPES } from "./types.js";
@@ -756,9 +756,11 @@ Guidelines:
756
756
  });
757
757
  }
758
758
 
759
+ const output = boundHelperOutput(record.result?.trim() || "No output.");
760
+ record.resultArtifactPath = output.artifactPath;
759
761
  return textResult(
760
762
  `Agent completed in ${(durationMs / 1000).toFixed(1)}s (${record.toolUses} tool uses${tokenText ? `, ${tokenText} tokens` : ""}).\n\n` +
761
- (record.result?.trim() || "No output."),
763
+ output.text,
762
764
  {
763
765
  status: "completed",
764
766
  toolUses: record.toolUses,
@@ -766,6 +768,9 @@ Guidelines:
766
768
  durationMs,
767
769
  turnCount: fgState.turnCount,
768
770
  maxTurns: fgState.maxTurns,
771
+ truncated: output.truncated,
772
+ originalBytes: output.originalBytes,
773
+ artifactPath: output.artifactPath,
769
774
  },
770
775
  );
771
776
  },
@@ -851,7 +856,13 @@ Guidelines:
851
856
  } else if (record.status === "error") {
852
857
  output += `Error: ${record.error}`;
853
858
  } else {
854
- output += record.result?.trim() || "No output.";
859
+ const bounded = boundHelperOutput(
860
+ record.result?.trim() || "No output.",
861
+ 64 * 1024,
862
+ record.resultArtifactPath,
863
+ );
864
+ record.resultArtifactPath = bounded.artifactPath;
865
+ output += bounded.text;
855
866
  }
856
867
 
857
868
  if (record.status !== "running" && record.status !== "queued") {
@@ -90,6 +90,8 @@ export interface AgentRecord {
90
90
  description: string;
91
91
  status: "queued" | "running" | "completed" | "aborted" | "stopped" | "error";
92
92
  result?: string;
93
+ /** Private artifact holding the complete result when model-visible output is bounded. */
94
+ resultArtifactPath?: string;
93
95
  error?: string;
94
96
  toolUses: number;
95
97
  startedAt: number;