@pi-unipi/unipi 2.4.0 → 2.4.2

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 (99) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +26 -22
  3. package/packages/ask-user/package.json +2 -2
  4. package/packages/autocomplete/package.json +4 -2
  5. package/packages/btw/package.json +2 -2
  6. package/packages/cocoindex/package.json +2 -2
  7. package/packages/cocoindex/tools.ts +0 -8
  8. package/packages/compactor/README.md +18 -22
  9. package/packages/compactor/package.json +3 -3
  10. package/packages/compactor/src/compaction/auto-trigger.ts +0 -0
  11. package/packages/compactor/src/compaction/build-sections.ts +2 -2
  12. package/packages/compactor/src/compaction/format.ts +1 -1
  13. package/packages/compactor/src/config/presets.ts +19 -30
  14. package/packages/compactor/src/config/schema.ts +0 -0
  15. package/packages/compactor/src/index.ts +39 -56
  16. package/packages/compactor/src/session/resume-inject.ts +27 -0
  17. package/packages/compactor/src/tools/ctx-batch-execute.ts +4 -2
  18. package/packages/compactor/src/tools/ctx-execute-file.ts +4 -2
  19. package/packages/compactor/src/tools/ctx-execute.ts +4 -2
  20. package/packages/compactor/src/tools/register.ts +22 -4
  21. package/packages/compactor/src/tui/settings-overlay.ts +9 -22
  22. package/packages/compactor/src/types.ts +24 -2
  23. package/packages/core/events.ts +13 -13
  24. package/packages/core/package.json +3 -2
  25. package/packages/footer/README.md +2 -2
  26. package/packages/footer/package.json +3 -3
  27. package/packages/footer/src/events.ts +0 -10
  28. package/packages/footer/src/segments/core.ts +0 -9
  29. package/packages/image/package.json +2 -2
  30. package/packages/info-screen/core-groups.ts +0 -14
  31. package/packages/info-screen/package.json +2 -2
  32. package/packages/input-shortcuts/package.json +2 -2
  33. package/packages/kanboard/commands.ts +0 -1
  34. package/packages/kanboard/package.json +2 -2
  35. package/packages/mcp/package.json +2 -2
  36. package/packages/memory/package.json +3 -3
  37. package/packages/memory/storage.ts +0 -18
  38. package/packages/memory/tools.ts +0 -1
  39. package/packages/milestone/hooks.ts +9 -15
  40. package/packages/milestone/package.json +2 -2
  41. package/packages/notify/README.md +2 -0
  42. package/packages/notify/events.ts +23 -13
  43. package/packages/notify/package.json +2 -2
  44. package/packages/notify/tools.ts +17 -8
  45. package/packages/notify/types.ts +5 -1
  46. package/packages/ralph/index.ts +10 -5
  47. package/packages/ralph/package.json +3 -3
  48. package/packages/ralph/ralph-loop.ts +10 -0
  49. package/packages/subagents/README.md +3 -1
  50. package/packages/subagents/package.json +10 -6
  51. package/packages/subagents/src/agent-manager.ts +46 -11
  52. package/packages/subagents/src/core-compat.ts +38 -0
  53. package/packages/subagents/src/global.d.ts +7 -0
  54. package/packages/subagents/src/index.ts +6 -3
  55. package/packages/subagents/src/widget.ts +0 -7
  56. package/packages/unipi/bundled.js +371 -438
  57. package/packages/updater/package.json +4 -3
  58. package/packages/utility/README.md +1 -1
  59. package/packages/utility/package.json +7 -4
  60. package/packages/utility/src/commands.ts +2 -10
  61. package/packages/utility/src/diff/theme.ts +0 -3
  62. package/packages/utility/src/tui/name-badge.ts +0 -9
  63. package/packages/web-api/package.json +2 -2
  64. package/packages/web-api/src/engine/extract.ts +0 -26
  65. package/packages/web-api/src/tools.ts +0 -13
  66. package/packages/workflow/commands.ts +13 -1
  67. package/packages/workflow/index.ts +12 -1
  68. package/packages/workflow/lifecycle.ts +43 -0
  69. package/packages/workflow/package.json +3 -2
  70. package/packages/autocomplete/index.ts +0 -7
  71. package/packages/autocomplete/src/__tests__/command-registry.audit.test.ts +0 -120
  72. package/packages/autocomplete/src/__tests__/provider.sorting.test.ts +0 -423
  73. package/packages/compactor/src/compaction/recall-scope.ts +0 -32
  74. package/packages/compactor/src/compaction/sections.ts +0 -15
  75. package/packages/compactor/src/display/bash-display.ts +0 -28
  76. package/packages/compactor/src/display/diff-presentation.ts +0 -25
  77. package/packages/compactor/src/display/pending-diff-preview.ts +0 -51
  78. package/packages/compactor/src/display/user-message-box.ts +0 -16
  79. package/packages/compactor/src/session/analytics.ts +0 -205
  80. package/packages/image/index.ts +0 -1
  81. package/packages/input-shortcuts/index.ts +0 -5
  82. package/packages/input-shortcuts/src/status.ts +0 -35
  83. package/packages/kanboard/tui/kanboard-overlay.ts +0 -300
  84. package/packages/mcp/index.ts +0 -1
  85. package/packages/memory/search.ts +0 -134
  86. package/packages/milestone/coexist.ts +0 -114
  87. package/packages/notify/src/__tests__/ask-user-prompt-message.test.ts +0 -159
  88. package/packages/notify/src/__tests__/event-bus.test.ts +0 -119
  89. package/packages/notify/src/__tests__/permission-prompt-message.test.ts +0 -172
  90. package/packages/subagents/src/__tests__/badge-generation.test.ts +0 -315
  91. package/packages/subagents/src/__tests__/config.test.ts +0 -240
  92. package/packages/subagents/src/__tests__/esc-propagation.test.ts +0 -162
  93. package/packages/subagents/src/__tests__/file-lock.test.ts +0 -244
  94. package/packages/subagents/src/__tests__/shutdown-stale-ctx.test.ts +0 -185
  95. package/packages/subagents/src/__tests__/workflow-integration.test.ts +0 -334
  96. package/packages/subagents/src/prompts.ts +0 -39
  97. package/packages/utility/index.ts +0 -1
  98. package/packages/web-api/src/tui/progress.ts +0 -168
  99. package/packages/web-api/src/tui/result.ts +0 -173
@@ -8,7 +8,7 @@
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
- import { MILESTONE_DIRS, safeMtimeMs, tryRead } from "@pi-unipi/core";
11
+ import { MILESTONE_DIRS, UNIPI_EVENTS, safeMtimeMs, tryRead } from "@pi-unipi/core";
12
12
  import { parseMilestones, getProgressSummary, updateItemStatus } from "./milestone.js";
13
13
 
14
14
  /** Track when the session started for diffing modified files */
@@ -167,19 +167,7 @@ export function registerSessionEndHook(pi: ExtensionAPI): void {
167
167
  }
168
168
  });
169
169
 
170
- // Listen for WORKFLOW_END events
171
- pi.on("input", (event) => {
172
- // Check if this is a unipi event emission for WORKFLOW_END
173
- // The input event fires for tool calls; we need to detect when
174
- // the workflow ends. We'll use the events system instead.
175
- return undefined;
176
- });
177
-
178
- // Use tool_result to detect workflow end
179
- // Actually, we should listen for the UNIPI_EVENTS.WORKFLOW_END via pi.events
180
- // But the ExtensionAPI doesn't expose pi.events.on() directly.
181
- // Instead, we'll hook into session_shutdown to do a final sync.
182
- pi.on("session_shutdown", () => {
170
+ const syncModifiedDocs = () => {
183
171
  const cwd = process.cwd();
184
172
  const milestonesPath = path.join(cwd, MILESTONE_DIRS.MILESTONES);
185
173
 
@@ -204,5 +192,11 @@ export function registerSessionEndHook(pi: ExtensionAPI): void {
204
192
  updateItemStatus(milestonesPath, phase, text, true);
205
193
  }
206
194
  }
207
- });
195
+ };
196
+
197
+ // Workflow emits this once its follow-up agent loop drains.
198
+ pi.events.on(UNIPI_EVENTS.WORKFLOW_END, syncModifiedDocs);
199
+
200
+ // Final fallback for changes made outside a workflow or before event wiring.
201
+ pi.on("session_shutdown", syncModifiedDocs);
208
202
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/milestone",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
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",
@@ -29,7 +29,7 @@
29
29
  "access": "public"
30
30
  },
31
31
  "dependencies": {
32
- "@pi-unipi/core": "2.4.0"
32
+ "@pi-unipi/core": "2.4.1"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -47,6 +47,8 @@ notify_user({
47
47
  })
48
48
  ```
49
49
 
50
+ An explicit semantic priority overrides configured urgency for that dispatch on platforms that support it: `low`/`normal`/`high` map to Gotify `2`/`5`/`8` and ntfy `2`/`3`/`5`. Native and Telegram have no priority input and ignore it. When omitted, Gotify and ntfy retain their configured numeric priorities.
51
+
50
52
  ## Platforms
51
53
 
52
54
  ### Native OS
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
  import { UNIPI_EVENTS, emitEvent } from "@pi-unipi/core";
10
- import type { NotifyConfig, NotifyPlatform, NotifyDispatchResult } from "./types.js";
10
+ import type { NotifyConfig, NotifyPlatform, NotifyDispatchResult, NotifyPriority } from "./types.js";
11
11
  import { loadNtfyConfig } from "./ntfy-config.js";
12
12
  import { sendNativeNotification, SuppressedError } from "./platforms/native.js";
13
13
  import { sendGotifyNotification } from "./platforms/gotify.js";
@@ -170,7 +170,8 @@ export async function dispatchNotification(
170
170
  eventPlatforms: NotifyPlatform[],
171
171
  eventType: string,
172
172
  config: NotifyConfig,
173
- cwd: string
173
+ cwd: string,
174
+ priority?: NotifyPriority,
174
175
  ): Promise<NotifyDispatchResult> {
175
176
  // Resolve ntfy config from project/global ntfy.json
176
177
  const ntfyConfig = loadNtfyConfig(cwd);
@@ -194,8 +195,8 @@ export async function dispatchNotification(
194
195
  const results = await Promise.all(
195
196
  enabledPlatforms.map(async (platform) => {
196
197
  try {
197
- await sendToPlatform(platform, title, message, config, cwd);
198
- return { platform, success: true };
198
+ const effectivePriority = await sendToPlatform(platform, title, message, config, cwd, priority);
199
+ return { platform, success: true, ...(effectivePriority === undefined ? {} : { priority: effectivePriority }) };
199
200
  } catch (err) {
200
201
  // SuppressedError is intentional, not a failure
201
202
  if (err instanceof SuppressedError) {
@@ -230,20 +231,29 @@ export async function dispatchNotification(
230
231
  }
231
232
 
232
233
  /** Send to a single platform */
234
+ export function mapNotifyPriority(platform: "gotify" | "ntfy", priority: NotifyPriority): number {
235
+ const mappings = {
236
+ gotify: { low: 2, normal: 5, high: 8 },
237
+ ntfy: { low: 2, normal: 3, high: 5 },
238
+ } as const;
239
+ return mappings[platform][priority];
240
+ }
241
+
233
242
  async function sendToPlatform(
234
243
  platform: NotifyPlatform,
235
244
  title: string,
236
245
  message: string,
237
246
  config: NotifyConfig,
238
- cwd: string
239
- ): Promise<void> {
247
+ cwd: string,
248
+ priority?: NotifyPriority,
249
+ ): Promise<number | undefined> {
240
250
  switch (platform) {
241
251
  case "native":
242
252
  await sendNativeNotification(title, message, {
243
253
  windowsAppId: config.native.windowsAppId,
244
254
  suppressWhenFocused: config.native.suppressWhenFocused,
245
255
  });
246
- break;
256
+ return undefined;
247
257
  case "gotify":
248
258
  if (!config.gotify.serverUrl || !config.gotify.appToken) {
249
259
  throw new Error("Gotify: serverUrl and appToken are required");
@@ -253,9 +263,9 @@ async function sendToPlatform(
253
263
  config.gotify.appToken,
254
264
  title,
255
265
  message,
256
- config.gotify.priority
266
+ priority ? mapNotifyPriority("gotify", priority) : config.gotify.priority
257
267
  );
258
- break;
268
+ return priority ? mapNotifyPriority("gotify", priority) : config.gotify.priority;
259
269
  case "telegram":
260
270
  if (!config.telegram.botToken || !config.telegram.chatId) {
261
271
  throw new Error("Telegram: botToken and chatId are required");
@@ -266,10 +276,10 @@ async function sendToPlatform(
266
276
  title,
267
277
  message
268
278
  );
269
- break;
279
+ return undefined;
270
280
  case "ntfy": {
271
281
  const ntfyConfig = loadNtfyConfig(cwd);
272
- if (!ntfyConfig.enabled) return;
282
+ if (!ntfyConfig.enabled) return undefined;
273
283
  if (!ntfyConfig.serverUrl || !ntfyConfig.topic) {
274
284
  throw new Error("ntfy: serverUrl and topic are required");
275
285
  }
@@ -278,10 +288,10 @@ async function sendToPlatform(
278
288
  ntfyConfig.topic,
279
289
  title,
280
290
  message,
281
- ntfyConfig.priority,
291
+ priority ? mapNotifyPriority("ntfy", priority) : ntfyConfig.priority,
282
292
  ntfyConfig.token
283
293
  );
284
- break;
294
+ return priority ? mapNotifyPriority("ntfy", priority) : ntfyConfig.priority;
285
295
  }
286
296
  }
287
297
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
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.0",
37
+ "@pi-unipi/core": "2.4.1",
38
38
  "node-notifier": "^10.0.1"
39
39
  },
40
40
  "peerDependencies": {
@@ -8,7 +8,8 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
8
8
  import { Type } from "typebox";
9
9
  import { NOTIFY_TOOLS } from "@pi-unipi/core";
10
10
  import { loadConfig } from "./settings.js";
11
- import { dispatchNotification } from "./events.js";
11
+ import { dispatchNotification, mapNotifyPriority } from "./events.js";
12
+ import type { NotifyPriority } from "./types.js";
12
13
 
13
14
  /** Schema for notify_user tool parameters */
14
15
  const NotifyUserSchema = Type.Object({
@@ -19,7 +20,6 @@ const NotifyUserSchema = Type.Object({
19
20
  priority: Type.Optional(
20
21
  Type.String({
21
22
  enum: ["low", "normal", "high"],
22
- default: "normal",
23
23
  description: "Priority level",
24
24
  })
25
25
  ),
@@ -46,12 +46,12 @@ export function registerNotifyTools(pi: ExtensionAPI): void {
46
46
  const {
47
47
  message,
48
48
  title,
49
- priority: _priority,
49
+ priority,
50
50
  platforms,
51
51
  } = params as {
52
52
  message: string;
53
53
  title?: string;
54
- priority?: "low" | "normal" | "high";
54
+ priority?: NotifyPriority;
55
55
  platforms?: Array<"native" | "gotify" | "telegram" | "ntfy">;
56
56
  };
57
57
 
@@ -65,18 +65,27 @@ export function registerNotifyTools(pi: ExtensionAPI): void {
65
65
 
66
66
  // Fire-and-forget: dispatch in background so the tool doesn't block the agent
67
67
  const cwd = process.cwd();
68
- dispatchNotification(
68
+ const dispatchPromise = dispatchNotification(
69
69
  pi,
70
70
  notifTitle,
71
71
  message,
72
72
  notifPlatforms,
73
73
  "agent_tool",
74
74
  config,
75
- cwd
76
- ).catch(() => {
75
+ cwd,
76
+ priority,
77
+ );
78
+ void dispatchPromise.catch(() => {
77
79
  // Silently ignore — background dispatch failure is non-blocking.
78
80
  });
79
81
 
82
+ const priorityByPlatform = priority ? Object.fromEntries(
83
+ notifPlatforms.flatMap((platform) => {
84
+ if (platform === "gotify" || platform === "ntfy") return [[platform, mapNotifyPriority(platform, priority)]];
85
+ return [];
86
+ }),
87
+ ) : undefined;
88
+
80
89
  return {
81
90
  content: [
82
91
  {
@@ -84,7 +93,7 @@ export function registerNotifyTools(pi: ExtensionAPI): void {
84
93
  text: `Notification sending to ${notifPlatforms.length} platform(s): ${notifPlatforms.join(", ")}`,
85
94
  },
86
95
  ],
87
- details: { platforms: notifPlatforms },
96
+ details: { platforms: notifPlatforms, priority, priorityByPlatform },
88
97
  };
89
98
  },
90
99
  });
@@ -90,13 +90,15 @@ export interface NotifyConfig {
90
90
  }
91
91
 
92
92
  /** Parameters for the notify_user agent tool */
93
+ export type NotifyPriority = "low" | "normal" | "high";
94
+
93
95
  export interface NotifyUserParams {
94
96
  /** Notification message body */
95
97
  message: string;
96
98
  /** Notification title (default: "Pi Notification") */
97
99
  title?: string;
98
100
  /** Priority level */
99
- priority?: "low" | "normal" | "high";
101
+ priority?: NotifyPriority;
100
102
  /** Override platforms for this notification */
101
103
  platforms?: NotifyPlatform[];
102
104
  }
@@ -109,6 +111,8 @@ export interface NotifyResult {
109
111
  success: boolean;
110
112
  /** True when the notification was intentionally suppressed (e.g. window focused) */
111
113
  suppressed?: boolean;
114
+ /** Effective numeric priority for platforms that support it. */
115
+ priority?: number;
112
116
  /** Error message if failed */
113
117
  error?: string;
114
118
  }
@@ -145,8 +145,11 @@ export default function (pi: ExtensionAPI) {
145
145
  }
146
146
  });
147
147
 
148
- // Inject ralph instructions when loop is active
149
- pi.on("before_agent_start", async (event, ctx) => {
148
+ // Inject ralph instructions when loop is active.
149
+ // NOTE: injected as a hidden tail message (not the system prompt) so the
150
+ // cacheable prefix (system prompt + prior history) stays byte-stable across
151
+ // turns. The iteration number changes every turn and must never touch the prefix.
152
+ pi.on("before_agent_start", async (_event, ctx) => {
150
153
  const mgr = getManager(ctx, pi);
151
154
  const currentLoop = mgr.getCurrentLoop();
152
155
  if (!currentLoop) return;
@@ -165,9 +168,11 @@ export default function (pi: ExtensionAPI) {
165
168
  instructions += `- Otherwise, call ralph_done tool to proceed to next iteration`;
166
169
 
167
170
  return {
168
- systemPrompt:
169
- event.systemPrompt +
170
- `\n[RALPH LOOP - ${state.name} - Iteration ${iterStr}]\n\n${instructions}`,
171
+ message: {
172
+ customType: "unipi-ralph-loop-reminder",
173
+ content: `[RALPH LOOP - ${state.name} - Iteration ${iterStr}]\n\n${instructions}`,
174
+ display: false,
175
+ },
171
176
  };
172
177
  });
173
178
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ralph",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "Long-running iterative development loops for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -27,8 +27,8 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@pi-unipi/core": "2.4.0",
31
- "@pi-unipi/info-screen": "2.4.0"
30
+ "@pi-unipi/core": "2.4.1",
31
+ "@pi-unipi/info-screen": "2.4.1"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@earendil-works/pi-ai": "^0.80.0",
@@ -374,6 +374,8 @@ export class RalphLoopManager {
374
374
  return null;
375
375
  }
376
376
 
377
+ this.emitIterationDone(state);
378
+
377
379
  const needsReflection =
378
380
  state.reflectEvery > 0 && (state.iteration - 1) % state.reflectEvery === 0;
379
381
  if (needsReflection) state.lastReflectionAt = state.iteration;
@@ -384,6 +386,14 @@ export class RalphLoopManager {
384
386
  return { state, needsReflection };
385
387
  }
386
388
 
389
+ private emitIterationDone(state: LoopState): void {
390
+ this.emitFn(UNIPI_EVENTS.RALPH_ITERATION_DONE, {
391
+ name: state.name,
392
+ iteration: state.iteration - 1,
393
+ nextIteration: state.iteration,
394
+ });
395
+ }
396
+
387
397
  archiveLoop(name: string): boolean {
388
398
  const state = this.loadState(name);
389
399
  if (!state) return false;
@@ -99,7 +99,9 @@ Create markdown files defining agent behavior:
99
99
  |---------|---------|--------------|
100
100
  | `enabled` | true | Enable/disable subagents |
101
101
  | `maxConcurrent` | 3 | Max agents running at once |
102
- | `types.{name}.enabled` | true | Toggle agent types |
102
+ | `types.{name}.enabled` | true | Disable built-in or custom agent types. Disabled types are omitted from tool guidance and rejected before queueing. |
103
+
104
+ For custom agents, JSON configuration and Markdown frontmatter are both enforced: the type can run only when neither source sets `enabled: false`. Configuration is loaded when the extension starts, so reload the session after changing enablement.
103
105
 
104
106
  ## License
105
107
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/subagents",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "Subagents for UniPi — parallel execution, file locking, workflow integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -12,14 +12,12 @@
12
12
  }
13
13
  },
14
14
  "scripts": {
15
- "build": "tsc",
15
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/verify-build.mjs",
16
+ "prepack": "npm run build",
16
17
  "dev": "tsc --watch",
17
18
  "test": "npx tsx --test src/__tests__/*.test.ts"
18
19
  },
19
20
  "dependencies": {
20
- "@pi-unipi/core": "2.4.0",
21
- "@pi-unipi/workflow": "2.4.0",
22
- "@pi-unipi/info-screen": "2.4.0",
23
21
  "@earendil-works/pi-agent-core": "^0.80.0"
24
22
  },
25
23
  "devDependencies": {
@@ -30,8 +28,14 @@
30
28
  "@earendil-works/pi-tui": "^0.80.0",
31
29
  "typebox": "^1.1.38"
32
30
  },
31
+ "files": [
32
+ "dist/**",
33
+ "README.md"
34
+ ],
33
35
  "pi": {
34
- "extensions": [],
36
+ "extensions": [
37
+ "./dist/index.js"
38
+ ],
35
39
  "skills": [],
36
40
  "prompts": [],
37
41
  "themes": []
@@ -10,7 +10,7 @@ import type { Model } from "@earendil-works/pi-ai";
10
10
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { runAgent, type ToolActivity } from "./agent-runner.js";
12
12
  import { resolveModel, type ModelRegistry } from "./model-resolver.js";
13
- import type { AgentRecord, AgentConfig, AgentType, ThinkingLevel } from "./types.js";
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
16
  import { FileLock } from "./file-lock.js";
@@ -52,6 +52,7 @@ export class AgentManager {
52
52
  private onStart?: OnAgentStart;
53
53
  private maxConcurrent: number;
54
54
  private customAgents: Map<string, AgentConfig>;
55
+ private typeSettings: SubagentsConfig["types"];
55
56
 
56
57
  /** Per-file transparent locking for write agents. */
57
58
  readonly fileLock = new FileLock();
@@ -61,11 +62,18 @@ export class AgentManager {
61
62
  /** Number of currently running background agents. */
62
63
  private runningBackground = 0;
63
64
 
64
- constructor(onComplete?: OnAgentComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart?: OnAgentStart) {
65
+ constructor(
66
+ onComplete?: OnAgentComplete,
67
+ maxConcurrent = DEFAULT_MAX_CONCURRENT,
68
+ onStart?: OnAgentStart,
69
+ typeSettings: SubagentsConfig["types"] = {},
70
+ agentsCwd = process.cwd(),
71
+ ) {
65
72
  this.onComplete = onComplete;
66
73
  this.onStart = onStart;
67
74
  this.maxConcurrent = maxConcurrent;
68
- this.customAgents = loadCustomAgents(process.cwd());
75
+ this.typeSettings = typeSettings;
76
+ this.customAgents = loadCustomAgents(agentsCwd);
69
77
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
70
78
  }
71
79
 
@@ -74,6 +82,27 @@ export class AgentManager {
74
82
  return this.customAgents.get(type) ?? BUILTIN_CONFIGS[type];
75
83
  }
76
84
 
85
+ /** Public agent types known from built-ins, custom files, or JSON config. */
86
+ getKnownTypes(): string[] {
87
+ return [...new Set([
88
+ ...Object.keys(BUILTIN_CONFIGS).filter((type) => type !== "name-gen"),
89
+ ...this.customAgents.keys(),
90
+ ...Object.keys(this.typeSettings),
91
+ ])];
92
+ }
93
+
94
+ /** A type is enabled only when both JSON config and agent frontmatter allow it. */
95
+ isTypeEnabled(type: AgentType): boolean {
96
+ const agentConfig = this.getAgentConfig(type);
97
+ return this.typeSettings[type]?.enabled !== false && agentConfig?.enabled !== false;
98
+ }
99
+
100
+ private assertTypeEnabled(type: AgentType): void {
101
+ if (!this.isTypeEnabled(type)) {
102
+ throw new Error(`Agent type "${type}" is disabled by configuration.`);
103
+ }
104
+ }
105
+
77
106
  setMaxConcurrent(n: number) {
78
107
  this.maxConcurrent = Math.max(1, n);
79
108
  this.drainQueue();
@@ -93,6 +122,10 @@ export class AgentManager {
93
122
  prompt: string,
94
123
  options: SpawnOptions,
95
124
  ): string {
125
+ // Reject before allocating a record or queue entry. This applies equally to
126
+ // foreground and background spawns and avoids ghost disabled agents.
127
+ this.assertTypeEnabled(type);
128
+
96
129
  const id = randomUUID().slice(0, 17);
97
130
  const abortController = new AbortController();
98
131
  const record: AgentRecord = {
@@ -125,10 +158,13 @@ export class AgentManager {
125
158
  if (options.isBackground) this.runningBackground++;
126
159
  this.onStart?.(record);
127
160
 
128
- // Resolve model: explicit input > config model > parent model
161
+ const agentConfig = this.getAgentConfig(type);
162
+
163
+ // Resolve model: explicit input > per-agent config > parent model.
129
164
  let model = options.model;
130
- if (options.modelInput && options.modelRegistry) {
131
- const resolved = resolveModel(options.modelInput, options.modelRegistry);
165
+ const modelInput = options.modelInput ?? agentConfig?.model;
166
+ if (!model && modelInput && options.modelRegistry) {
167
+ const resolved = resolveModel(modelInput, options.modelRegistry);
132
168
  if (typeof resolved === "string") {
133
169
  // Error message — return early with error
134
170
  record.status = "error";
@@ -143,15 +179,14 @@ export class AgentManager {
143
179
  model = resolved;
144
180
  }
145
181
 
146
- const agentConfig = this.getAgentConfig(type);
147
182
  const promise = runAgent(ctx, type, prompt, {
148
183
  pi,
149
184
  model,
150
185
  agentConfig,
151
- maxTurns: options.maxTurns,
152
- isolated: options.isolated,
153
- inheritContext: options.inheritContext,
154
- thinkingLevel: options.thinkingLevel,
186
+ maxTurns: options.maxTurns ?? agentConfig?.maxTurns,
187
+ isolated: options.isolated ?? agentConfig?.isolated,
188
+ inheritContext: options.inheritContext ?? agentConfig?.inheritContext,
189
+ thinkingLevel: options.thinkingLevel ?? agentConfig?.thinking,
155
190
  signal: record.abortController!.signal,
156
191
  onToolActivity: (activity) => {
157
192
  if (activity.type === "end") record.toolUses++;
@@ -0,0 +1,38 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const MODULES = { SUBAGENTS: "subagents" } as const;
4
+
5
+ export const UNIPI_EVENTS = {
6
+ MODULE_READY: "unipi:module:ready",
7
+ BADGE_GENERATE_REQUEST: "unipi:badge:generate:request",
8
+ } as const;
9
+
10
+ export interface UnipiBadgeGenerateRequestEvent {
11
+ conversationSummary?: string;
12
+ }
13
+
14
+ export function emitEvent(
15
+ pi: Pick<ExtensionAPI, "events">,
16
+ eventName: string,
17
+ payload: unknown,
18
+ ): boolean {
19
+ try {
20
+ pi.events.emit(eventName, payload);
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ export async function withHerdrBlocked<T>(
28
+ pi: Pick<ExtensionAPI, "events">,
29
+ label: string,
30
+ fn: () => Promise<T>,
31
+ ): Promise<T> {
32
+ emitEvent(pi, "herdr:blocked", { active: true, label });
33
+ try {
34
+ return await fn();
35
+ } finally {
36
+ emitEvent(pi, "herdr:blocked", { active: false, label });
37
+ }
38
+ }
@@ -0,0 +1,7 @@
1
+ import type { InfoRegistryLike } from "@pi-unipi/core/global-types";
2
+
3
+ declare global {
4
+ var __unipi_info_registry: InfoRegistryLike | undefined;
5
+ }
6
+
7
+ export {};
@@ -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 "@pi-unipi/core";
15
+ import { 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";
@@ -246,6 +246,7 @@ export default function (pi: ExtensionAPI) {
246
246
  description: record.description,
247
247
  });
248
248
  },
249
+ config.types,
249
250
  );
250
251
 
251
252
  // Build notification details for the message renderer
@@ -494,6 +495,8 @@ export default function (pi: ExtensionAPI) {
494
495
  // ---- Agent tool ----
495
496
 
496
497
  const builtinTypes = BUILTIN_TYPES.join(", ");
498
+ const enabledTypes = manager.getKnownTypes().filter((type) => manager.isTypeEnabled(type));
499
+ const availableTypes = enabledTypes.join(", ") || "none";
497
500
 
498
501
  pi.registerTool(
499
502
  defineTool({
@@ -501,7 +504,7 @@ export default function (pi: ExtensionAPI) {
501
504
  label: "Spawn Helper",
502
505
  description: `Launch a sub-agent for parallel work.
503
506
 
504
- Available agent types: ${builtinTypes}
507
+ Available agent types: ${availableTypes}
505
508
  Custom types can be defined in:
506
509
  - ~/.unipi/config/agents/<name>.md (global)
507
510
  - <workspace>/.unipi/config/agents/<name>.md (project)
@@ -514,7 +517,7 @@ Guidelines:
514
517
  - Agents inherit the parent model by default`,
515
518
  parameters: Type.Object({
516
519
  type: Type.String({
517
- description: `Agent type: ${builtinTypes}, or custom type from ~/.unipi/config/agents/*.md`,
520
+ description: `Enabled agent type: ${availableTypes}`,
518
521
  }),
519
522
  prompt: Type.String({
520
523
  description: "The task for the agent to perform.",
@@ -51,13 +51,6 @@ function formatTurns(turn: number, max?: number): string {
51
51
  return max != null ? `⟳${turn}≤${max}` : `⟳${turn}`;
52
52
  }
53
53
 
54
- /** Format token count compactly: "33.8k token", "1.2M token". */
55
- function formatTokens(count: number): string {
56
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
57
- if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
58
- return `${count} token`;
59
- }
60
-
61
54
  /**
62
55
  * Build a human-readable activity string from currently-running tools.
63
56
  * Groups by tool type with counts: "reading 3 files, searching 2 patterns".