@pi-unipi/unipi 0.1.13 → 0.1.14

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
@@ -41,7 +41,7 @@ pi install npm:@pi-unipi/kanboard
41
41
  | `@pi-unipi/btw` | Parallel side conversations with `/btw` |
42
42
  | `@pi-unipi/web-api` | Web search, read, and summarize with provider selection |
43
43
  | `@pi-unipi/compactor` | Session compaction, context management, batch execution |
44
- | `@pi-unipi/notify` | Cross-platform notifications (native, Gotify, Telegram) |
44
+ | `@pi-unipi/notify` | Cross-platform notifications (native, Gotify, Telegram, ntfy) |
45
45
  | `@pi-unipi/utility` | Environment info, diagnostics, settings inspector, cleanup |
46
46
  | `@pi-unipi/mcp` | MCP server discovery, connection, and tool integration |
47
47
  | `@pi-unipi/ask-user` | Structured user input with options and freeform text |
@@ -145,6 +145,8 @@ pi install npm:@pi-unipi/kanboard
145
145
  | `/unipi:notify-settings` | Configure notification platforms |
146
146
  | `/unipi:notify-set-gotify` | Set Gotify server config |
147
147
  | `/unipi:notify-set-tg` | Set Telegram bot config |
148
+ | `/unipi:notify-set-ntfy` | Set ntfy topic and server |
149
+ | `/unipi:notify-recap-model` | Set model for notification recaps |
148
150
  | `/unipi:notify-test` | Test notification delivery |
149
151
 
150
152
  ### Utility (`/unipi:*`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -132,11 +132,13 @@ export const COMMAND_REGISTRY: Record<string, string> = {
132
132
  "unipi:milestone-onboard": "milestone",
133
133
  "unipi:milestone-update": "milestone",
134
134
 
135
- // notify (4 commands)
135
+ // notify (6 commands)
136
136
  "unipi:notify-settings": "notify",
137
137
  "unipi:notify-set-gotify": "notify",
138
138
  "unipi:notify-set-tg": "notify",
139
+ "unipi:notify-set-ntfy": "notify",
139
140
  "unipi:notify-test": "notify",
141
+ "unipi:notify-recap-model": "notify",
140
142
 
141
143
  // kanboard (3 commands)
142
144
  "unipi:kanboard": "kanboard",
@@ -219,7 +221,9 @@ export const COMMAND_DESCRIPTIONS: Record<string, string> = {
219
221
  "unipi:notify-settings": "Configure notification platforms and events",
220
222
  "unipi:notify-set-gotify": "Set up Gotify push notifications",
221
223
  "unipi:notify-set-tg": "Set up Telegram bot notifications",
224
+ "unipi:notify-set-ntfy": "Set up ntfy push notifications",
222
225
  "unipi:notify-test": "Test all enabled notification platforms",
226
+ "unipi:notify-recap-model": "Select model for notification recaps",
223
227
 
224
228
  "unipi:milestone-onboard": "Create MILESTONES.md from existing workflow docs",
225
229
  "unipi:milestone-update": "Sync MILESTONES.md with completed work",
@@ -274,16 +274,54 @@ export function createEnchantedProvider(
274
274
  descriptionOverrides,
275
275
  );
276
276
 
277
- // If no unipi items match, just return non-unipi (or null if empty)
277
+ // If no unipi items match, handle skill vs system items
278
278
  if (enhancedUnipiItems.length === 0) {
279
- return nonUnipiItems.length > 0
280
- ? { items: nonUnipiItems, prefix: effectivePrefix }
279
+ if (nonUnipiItems.length === 0) return null;
280
+
281
+ // Check if user explicitly typed /skill: prefix
282
+ const isSkillQuery = effectivePrefix.replace(/^\//, "").toLowerCase().startsWith("skill:");
283
+
284
+ if (isSkillQuery) {
285
+ // User wants skill commands — return them
286
+ return { items: nonUnipiItems, prefix: effectivePrefix };
287
+ }
288
+
289
+ // Otherwise, filter out skill commands from suggestions
290
+ const systemOnly = nonUnipiItems.filter(item => !item.value.startsWith("skill:"));
291
+ return systemOnly.length > 0
292
+ ? { items: systemOnly, prefix: effectivePrefix }
281
293
  : null;
282
294
  }
283
295
 
284
- // Merge: system commands first, then enhanced unipi (sorted by package)
296
+ // Separate non-unipi items into system commands and skill commands
297
+ const systemItems: AutocompleteItem[] = [];
298
+ const skillItems: AutocompleteItem[] = [];
299
+
300
+ for (const item of nonUnipiItems) {
301
+ if (item.value.startsWith("skill:")) {
302
+ skillItems.push(item);
303
+ } else {
304
+ systemItems.push(item);
305
+ }
306
+ }
307
+
308
+ // Check if user explicitly typed /skill: prefix
309
+ const isExplicitSkillQuery = effectivePrefix.replace(/^\//, "").toLowerCase().startsWith("skill:");
310
+
311
+ // Build final list based on query context
312
+ let finalItems: AutocompleteItem[];
313
+
314
+ if (isExplicitSkillQuery) {
315
+ // User explicitly wants skill commands — show them first
316
+ finalItems = [...skillItems, ...enhancedUnipiItems, ...systemItems];
317
+ } else {
318
+ // Default: unipi commands first, then system commands, hide skill commands
319
+ // (skill commands are redundant when unipi equivalents exist)
320
+ finalItems = [...enhancedUnipiItems, ...systemItems];
321
+ }
322
+
285
323
  return {
286
- items: [...nonUnipiItems, ...enhancedUnipiItems],
324
+ items: finalItems,
287
325
  prefix: effectivePrefix,
288
326
  };
289
327
  },
@@ -4,8 +4,9 @@
4
4
 
5
5
  import { createHash } from "node:crypto";
6
6
  import { execFileSync } from "node:child_process";
7
+ import { existsSync, mkdirSync } from "node:fs";
7
8
  import { homedir } from "node:os";
8
- import { join } from "node:path";
9
+ import { dirname, join } from "node:path";
9
10
  import type { SessionEvent, StoredEvent, SessionMeta, ResumeRow } from "../types.js";
10
11
 
11
12
  export function getWorktreeSuffix(): string {
@@ -76,6 +77,9 @@ export class SessionDB {
76
77
  }
77
78
 
78
79
  async init(): Promise<void> {
80
+ const dir = dirname(this.dbPath);
81
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
82
+
79
83
  const sqlite: any = await getSQLite();
80
84
  // Handle different SQLite API shapes:
81
85
  // - bun:sqlite exports Database as a named export
@@ -32,27 +32,56 @@ export default function (pi: ExtensionAPI) {
32
32
  // Start load tracking
33
33
  startLoadTracking();
34
34
 
35
- // Listen for module announcements track and trigger reactive updates
36
- pi.events.on(UNIPI_EVENTS.MODULE_READY, (event: any) => {
37
- if (event.name && event.name !== MODULES.INFO_SCREEN) {
35
+ // Debounced MODULE_READY handlingbatch module announcements
36
+ // to prevent layout shift from rapid per-module cache invalidation.
37
+ let moduleReadyBatch: Array<{ name: string; version: string; tools?: string[]; loadTimeMs?: number }> = [];
38
+ let moduleReadyTimer: ReturnType<typeof setTimeout> | null = null;
39
+ const MODULE_READY_DEBOUNCE_MS = 150;
40
+
41
+ function flushModuleReadyBatch(): void {
42
+ const batch = moduleReadyBatch;
43
+ moduleReadyBatch = [];
44
+ moduleReadyTimer = null;
45
+
46
+ if (batch.length === 0) return;
47
+
48
+ // Track all modules and tools
49
+ let hasTools = false;
50
+ for (const event of batch) {
38
51
  trackModule(event.name, event.version || "unknown");
39
52
  recordLoadTime(event.name, "module", event.loadTimeMs);
40
-
41
- // Invalidate overview so next fetch picks up new module list
42
- infoRegistry.invalidateCache("overview");
43
-
44
- // Trigger background refresh of overview — subscribers will re-render
45
- infoRegistry.getGroupData("overview");
46
-
47
53
  if (event.tools && Array.isArray(event.tools)) {
48
54
  for (const tool of event.tools) {
49
55
  trackTool(tool, event.name);
50
56
  }
51
- // Refresh tools group too
52
- infoRegistry.invalidateCache("tools");
53
- infoRegistry.getGroupData("tools");
57
+ hasTools = true;
54
58
  }
55
59
  }
60
+
61
+ // Single cache invalidation for all modules
62
+ infoRegistry.invalidateCache("overview");
63
+ infoRegistry.getGroupData("overview");
64
+
65
+ if (hasTools) {
66
+ infoRegistry.invalidateCache("tools");
67
+ infoRegistry.getGroupData("tools");
68
+ }
69
+ }
70
+
71
+ // Listen for module announcements — track and trigger reactive updates
72
+ pi.events.on(UNIPI_EVENTS.MODULE_READY, (event: any) => {
73
+ if (event.name && event.name !== MODULES.INFO_SCREEN) {
74
+ moduleReadyBatch.push({
75
+ name: event.name,
76
+ version: event.version,
77
+ tools: event.tools,
78
+ loadTimeMs: event.loadTimeMs,
79
+ });
80
+
81
+ // Debounce: wait for more modules to arrive, then flush once
82
+ if (moduleReadyTimer) clearTimeout(moduleReadyTimer);
83
+ moduleReadyTimer = setTimeout(flushModuleReadyBatch, MODULE_READY_DEBOUNCE_MS);
84
+ }
56
85
  });
57
86
 
58
87
  pi.events.on(UNIPI_EVENTS.INFO_GROUP_REGISTERED, (_event: any) => {
@@ -107,9 +107,7 @@ export class ServerRegistry {
107
107
  safeEnv[k] = typeof v === "string" ? v : String(v);
108
108
  }
109
109
  } else {
110
- console.error(
111
- `[MCP] Server '${name}': env is not an object (${typeof def.env}), skipping env vars`,
112
- );
110
+ // Env config invalid — silently skip env vars.
113
111
  }
114
112
  }
115
113
 
@@ -75,11 +75,8 @@ export default function (pi: ExtensionAPI) {
75
75
  try {
76
76
  const result = loadAndResolve(cwd);
77
77
  servers = result.servers;
78
- } catch (err) {
79
- console.error(
80
- "[MCP] Failed to load config:",
81
- err instanceof Error ? err.message : err,
82
- );
78
+ } catch (_err) {
79
+ // Config load failure — servers will be empty, visible via /unipi:mcp-status.
83
80
  }
84
81
 
85
82
  // Start enabled servers (parallel, non-blocking errors)
@@ -88,14 +85,11 @@ export default function (pi: ExtensionAPI) {
88
85
  .map(async (server) => {
89
86
  try {
90
87
  await registry!.startServer(server);
91
- console.log(
92
- `[MCP] Started server '${server.name}' (${registry!.getServerState(server.name)?.toolCount ?? 0} tools)`,
93
- );
88
+ // Removed console.log — startup logs cause layout shift in TUI.
89
+ // Server status visible via /unipi:mcp-status or info screen.
94
90
  } catch (err) {
95
- console.error(
96
- `[MCP] Failed to start server '${server.name}':`,
97
- err instanceof Error ? err.message : err,
98
- );
91
+ // Removed console.error — errors surfaced via info-screen MCP group.
92
+ // Server failure tracked in registry state.
99
93
  }
100
94
  });
101
95
 
@@ -138,7 +132,7 @@ export default function (pi: ExtensionAPI) {
138
132
  },
139
133
  };
140
134
  } catch (err) {
141
- console.error("[MCP] Info dataProvider error:", err);
135
+ // Removed console.error info-screen shows "?" on error.
142
136
  return {
143
137
  total: { value: "?" },
144
138
  active: { value: "?" },
@@ -78,11 +78,10 @@ export default function (pi: ExtensionAPI) {
78
78
 
79
79
  // Sync any orphaned markdown files into the database
80
80
  const synced = projectStorage.syncOrphanedFiles();
81
- if (synced > 0) {
82
- console.warn(`[unipi/memory] Synced ${synced} orphaned memory files into database`);
83
- }
84
- } catch (err) {
85
- console.warn("[unipi/memory] Failed to initialize storage, running without memory:", (err as any)?.message ?? err);
81
+ // Removed console.warn orphaned file sync is informational only.
82
+ // Visible via memory tool list or info-screen memory group.
83
+ } catch (_err) {
84
+ // Memory init failure — running without memory. Silent startup.
86
85
  projectStorage = null;
87
86
  }
88
87
 
@@ -113,7 +112,6 @@ export default function (pi: ExtensionAPI) {
113
112
  // Register info group
114
113
  const registry = getInfoRegistry();
115
114
  if (registry) {
116
- console.debug("[memory] Registering info group");
117
115
  registry.registerGroup({
118
116
  id: "memory",
119
117
  name: "Memory",
@@ -143,8 +141,8 @@ export default function (pi: ExtensionAPI) {
143
141
  try {
144
142
  projectMemories = projectStorage.listAll();
145
143
  allMemories = listAllProjects();
146
- } catch (err) {
147
- console.warn("[unipi/memory] Failed to list memories for info panel:", err);
144
+ } catch (_err) {
145
+ // Info panel data unavailable shows empty values.
148
146
  }
149
147
  const uniqueProjects = [...new Set(allMemories.map((m) => m.project))];
150
148
 
@@ -175,8 +173,8 @@ export default function (pi: ExtensionAPI) {
175
173
  try {
176
174
  projectCount = projectStorage?.listAll()?.length ?? 0;
177
175
  projectCountAll = listAllProjects().length;
178
- } catch (err) {
179
- console.warn("[unipi/memory] Failed to count memories for status:", err);
176
+ } catch (_err) {
177
+ // Count unavailable status bar shows 0.
180
178
  }
181
179
  const vecReady = isEmbeddingReady();
182
180
  const vecIcon = vecReady ? "⚡" : "📝";
@@ -196,8 +194,7 @@ export default function (pi: ExtensionAPI) {
196
194
  let projectMemories: Array<{ id: string; title: string; type: string }> = [];
197
195
  try {
198
196
  projectMemories = projectStorage.listAll();
199
- } catch (err) {
200
- console.warn("[unipi/memory] Failed to list memories for recall:", err);
197
+ } catch (_err) {
201
198
  recallDone = true; // Skip recall on error
202
199
  return;
203
200
  }
@@ -1,6 +1,6 @@
1
1
  # @pi-unipi/notify
2
2
 
3
- Cross-platform notification extension for Pi. Sends push notifications to native OS, Gotify, and Telegram when agent lifecycle events occur.
3
+ Cross-platform notification extension for Pi. Sends push notifications to native OS, Gotify, Telegram, and ntfy when agent lifecycle events occur.
4
4
 
5
5
  ## What it does
6
6
 
@@ -52,12 +52,35 @@ This guides you through:
52
52
  2. Pasting the bot token
53
53
  3. Auto-detecting your chat ID
54
54
 
55
+ ### ntfy
56
+
57
+ HTTP-based pub-sub notifications via [ntfy.sh](https://ntfy.sh) or self-hosted. Run setup command:
58
+
59
+ ```
60
+ /unipi:notify-set-ntfy
61
+ ```
62
+
63
+ Or configure manually:
64
+
65
+ ```json
66
+ {
67
+ "ntfy": {
68
+ "enabled": true,
69
+ "serverUrl": "https://ntfy.sh",
70
+ "topic": "your-topic-name",
71
+ "priority": 3
72
+ }
73
+ }
74
+ ```
75
+
55
76
  ## Commands
56
77
 
57
78
  | Command | Description |
58
79
  |---------|-------------|
59
80
  | `/unipi:notify-settings` | Open settings overlay to configure platforms and events |
81
+ | `/unipi:notify-set-gotify` | Configure Gotify server connection |
60
82
  | `/unipi:notify-set-tg` | Interactive Telegram bot setup |
83
+ | `/unipi:notify-set-ntfy` | Configure ntfy topic and server |
61
84
  | `/unipi:notify-test` | Send test notification to all enabled platforms |
62
85
 
63
86
  ## Agent Tool
@@ -19,6 +19,8 @@ import { loadConfig } from "./settings.js";
19
19
  import {
20
20
  registerEventListeners,
21
21
  unregisterEventListeners,
22
+ setSessionContext,
23
+ clearSessionContext,
22
24
  } from "./events.js";
23
25
 
24
26
  /** Package version */
@@ -38,20 +40,22 @@ export default function (pi: ExtensionAPI) {
38
40
  registerNotifyCommands(pi);
39
41
 
40
42
  // Session lifecycle — register events and announce module
41
- pi.on("session_start", async () => {
43
+ pi.on("session_start", async (_event, ctx) => {
44
+ setSessionContext(ctx);
42
45
  const config = loadConfig();
43
46
  registerEventListeners(pi, config);
44
47
 
45
48
  emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
46
49
  name: MODULES.NOTIFY,
47
50
  version: VERSION,
48
- commands: ["unipi:notify-settings", "unipi:notify-set-gotify", "unipi:notify-set-tg", "unipi:notify-test"],
51
+ commands: ["unipi:notify-settings", "unipi:notify-set-gotify", "unipi:notify-set-tg", "unipi:notify-set-ntfy", "unipi:notify-test", "unipi:notify-recap-model"],
49
52
  tools: [NOTIFY_TOOLS.NOTIFY_USER],
50
53
  });
51
54
  });
52
55
 
53
56
  // Cleanup on session shutdown
54
57
  pi.on("session_shutdown", async () => {
58
+ clearSessionContext();
55
59
  unregisterEventListeners();
56
60
  });
57
61
  }
@@ -47,6 +47,13 @@ Help users configure the `@pi-unipi/notify` notification system.
47
47
  "enabled": false,
48
48
  "botToken": null,
49
49
  "chatId": null
50
+ },
51
+ "ntfy": {
52
+ "enabled": false,
53
+ "serverUrl": "https://ntfy.sh",
54
+ "topic": null,
55
+ "token": null,
56
+ "priority": 3
50
57
  }
51
58
  }
52
59
  ```
@@ -75,6 +82,20 @@ Bot API notifications. Requires:
75
82
  - `botToken` — From @BotFather
76
83
  - `chatId` — Auto-detected by `/unipi:notify-set-tg`
77
84
 
85
+ ### ntfy (default: disabled)
86
+
87
+ Simple HTTP-based pub-sub notification service. Supports public [ntfy.sh](https://ntfy.sh) and self-hosted instances.
88
+ Requires:
89
+ - `serverUrl` — ntfy server URL (default: `https://ntfy.sh`)
90
+ - `topic` — Topic name to publish to (acts as a channel)
91
+ - `token` — Optional access token for authenticated servers
92
+ - `priority` — 1-5 (default: 3)
93
+
94
+ **Setup options:**
95
+ 1. **Interactive overlay:** Run `/unipi:notify-set-ntfy` for guided setup with connection test
96
+ 2. **Manual config:** Edit `config.json` directly with the fields above
97
+ 3. **Agent can write config:** Read the current config, merge changes, write back
98
+
78
99
  ## Commands
79
100
 
80
101
  | Command | Description |
@@ -82,6 +103,7 @@ Bot API notifications. Requires:
82
103
  | `/unipi:notify-settings` | TUI overlay to toggle platforms and events |
83
104
  | `/unipi:notify-set-gotify` | Interactive Gotify setup wizard |
84
105
  | `/unipi:notify-set-tg` | Interactive Telegram setup wizard |
106
+ | `/unipi:notify-set-ntfy` | Interactive ntfy setup wizard |
85
107
  | `/unipi:notify-test` | Send test notification to all enabled platforms |
86
108
 
87
109
  ## Events
@@ -125,6 +147,7 @@ Read the JSON, make changes, write it back. Example:
125
147
 
126
148
  For Gotify: suggest running `/unipi:notify-set-gotify`
127
149
  For Telegram: suggest running `/unipi:notify-set-tg`
150
+ For ntfy: suggest running `/unipi:notify-set-ntfy`
128
151
  For general settings: suggest `/unipi:notify-settings`
129
152
 
130
153
  ## Validation rules
@@ -132,3 +155,5 @@ For general settings: suggest `/unipi:notify-settings`
132
155
  - Gotify: `serverUrl` and `appToken` required when enabled
133
156
  - Gotify: `priority` must be 1-10
134
157
  - Telegram: `botToken` and `chatId` required when enabled
158
+ - ntfy: `serverUrl` and `topic` required when enabled
159
+ - ntfy: `priority` must be 1-5
@@ -205,6 +205,7 @@ describe("Badge generation — event bus (CRITICAL FIX)", () => {
205
205
  const validLifecycleEvents = [
206
206
  "session_start", "session_shutdown", "input",
207
207
  "tool_call", "tool_execution_start",
208
+ "agent_end", "before_agent_start",
208
209
  ];
209
210
 
210
211
  // Check that pi.on() is only used with lifecycle events
@@ -228,11 +229,26 @@ describe("Badge generation — event bus (CRITICAL FIX)", () => {
228
229
  // ─── Test: Event flow ──────────────────────────────────────────────
229
230
 
230
231
  describe("Badge generation — event flow", () => {
231
- it("utility emits BADGE_GENERATE_REQUEST on first input", () => {
232
+ it("utility emits BADGE_GENERATE_REQUEST after agent responds (deferred from input)", () => {
232
233
  const src = readSource("packages/utility/src/index.ts");
233
234
 
235
+ // BADGE_GENERATE_REQUEST should be emitted in agent_end handler, not input
234
236
  assert.ok(src.includes("BADGE_GENERATE_REQUEST"));
235
237
  assert.ok(src.includes('source: "input-hook"'));
238
+
239
+ // input handler should NOT emit BADGE_GENERATE_REQUEST directly
240
+ const inputBlock = src.match(/pi\.on\("input"[\s\S]*?(?=pi\.on\(|$)/)?.[0] ?? "";
241
+ assert.ok(
242
+ !inputBlock.includes("BADGE_GENERATE_REQUEST"),
243
+ "input handler should NOT emit BADGE_GENERATE_REQUEST — deferred to agent_end",
244
+ );
245
+
246
+ // agent_end handler should emit BADGE_GENERATE_REQUEST
247
+ const agentEndBlock = src.match(/pi\.on\("agent_end"[\s\S]*?(?=pi\.on\(|$)/)?.[0] ?? "";
248
+ assert.ok(
249
+ agentEndBlock.includes("BADGE_GENERATE_REQUEST"),
250
+ "agent_end handler should emit BADGE_GENERATE_REQUEST with full conversation context",
251
+ );
236
252
  });
237
253
 
238
254
  it("BADGE_GENERATE_REQUEST event is defined in core", () => {
@@ -172,12 +172,16 @@ export async function runAgent(
172
172
  let toolNames = getToolNamesForType(type, agentConfig);
173
173
 
174
174
  // Create resource loader
175
+ // Respect agentConfig.extensions/skills flags: if explicitly false, skip loading.
176
+ // This prevents explore/work agents from loading all parent extensions.
175
177
  const agentDir = getAgentDir();
178
+ const skipExtensions = options.isolated || agentConfig?.extensions === false;
179
+ const skipSkills = options.isolated || agentConfig?.skills === false;
176
180
  const loader = new DefaultResourceLoader({
177
181
  cwd: effectiveCwd,
178
182
  agentDir,
179
- noExtensions: options.isolated,
180
- noSkills: options.isolated,
183
+ noExtensions: skipExtensions,
184
+ noSkills: skipSkills,
181
185
  noPromptTemplates: true,
182
186
  noThemes: true,
183
187
  noContextFiles: true,
@@ -213,15 +217,19 @@ export async function runAgent(
213
217
  });
214
218
  session.setActiveToolsByName(activeTools);
215
219
 
216
- // Bind extensions
217
- await session.bindExtensions({
218
- onError: (err) => {
219
- options.onToolActivity?.({
220
- type: "end",
221
- toolName: `extension-error:${err.extensionPath}`,
222
- });
223
- },
224
- });
220
+ // Bind extensions — only if extensions were loaded.
221
+ // Skipping for agents with extensions: false avoids firing session_start
222
+ // on an empty extension set, preventing unnecessary MODULE_READY cascade.
223
+ if (!skipExtensions) {
224
+ await session.bindExtensions({
225
+ onError: (err) => {
226
+ options.onToolActivity?.({
227
+ type: "end",
228
+ toolName: `extension-error:${err.extensionPath}`,
229
+ });
230
+ },
231
+ });
232
+ }
225
233
 
226
234
  options.onSessionCreated?.(session);
227
235
 
@@ -38,6 +38,12 @@ const VERSION = getPackageVersion(new URL(".", import.meta.url).pathname);
38
38
  /** Whether we've seen the first user message (for auto badge generation) */
39
39
  let firstMessageSeen = false;
40
40
 
41
+ /** Stored user text from first input, used to build conversation summary after agent responds */
42
+ let firstUserText = "";
43
+
44
+ /** Stored UI context from first input, used to show badge overlay after agent responds */
45
+ let firstInputCtx: any = null;
46
+
41
47
  /** All commands registered by this module */
42
48
  const ALL_COMMANDS = [
43
49
  UTILITY_COMMANDS.CONTINUE,
@@ -106,7 +112,7 @@ export default function (pi: ExtensionAPI) {
106
112
  }
107
113
  });
108
114
 
109
- // First-message hook: auto-generate session name on first user message
115
+ // First-message hook: capture user text for deferred badge generation
110
116
  pi.on("input", async (_event: any, ctx: any) => {
111
117
  // Only trigger on first user message
112
118
  if (firstMessageSeen) return;
@@ -120,8 +126,8 @@ export default function (pi: ExtensionAPI) {
120
126
  const sessionName = pi.getSessionName?.();
121
127
  if (sessionName) return;
122
128
 
123
- // Get first message text for context
124
- const messageText = typeof _event?.content === "string"
129
+ // Store first message text for later use in agent_end
130
+ firstUserText = typeof _event?.content === "string"
125
131
  ? _event.content
126
132
  : Array.isArray(_event?.content)
127
133
  ? _event.content
@@ -130,16 +136,57 @@ export default function (pi: ExtensionAPI) {
130
136
  .join(" ")
131
137
  : "";
132
138
 
133
- // Emit event for subagents to spawn background agent
134
- emitEvent(pi, UNIPI_EVENTS.BADGE_GENERATE_REQUEST, {
135
- source: "input-hook",
136
- conversationSummary: messageText.slice(0, 500),
137
- });
139
+ // Store ctx for badge overlay show after agent responds
140
+ firstInputCtx = ctx;
141
+ });
142
+
143
+ // After agent completes first response, generate badge name with full conversation context
144
+ pi.on("agent_end", async (event: any, _ctx: any) => {
145
+ // Only act if we captured a first input and are waiting for badge generation
146
+ if (!firstInputCtx) return;
147
+ const ctx = firstInputCtx;
148
+ firstInputCtx = null; // consume — only trigger once
149
+
150
+ // Check if a name was already set (e.g. manually) in the meantime
151
+ const sessionName = pi.getSessionName?.();
152
+ if (sessionName) return;
138
153
 
139
154
  // Show badge overlay if UI available
140
155
  if (ctx?.hasUI && !nameBadgeState.isVisible()) {
141
156
  await nameBadgeState.show(pi, ctx);
142
157
  }
158
+
159
+ // Build conversation summary from full message history (user + assistant)
160
+ const messages: any[] = event?.messages ?? [];
161
+ const summaryParts: string[] = [];
162
+
163
+ // Include the user's first message
164
+ if (firstUserText) {
165
+ summaryParts.push(`User: ${firstUserText}`);
166
+ }
167
+
168
+ // Include assistant's response text
169
+ const assistantMsgs = messages.filter((m: any) => m.role === "assistant");
170
+ for (const msg of assistantMsgs) {
171
+ if (Array.isArray(msg.content)) {
172
+ const textParts = msg.content
173
+ .filter((c: any) => c.type === "text")
174
+ .map((c: any) => c.text)
175
+ .join(" ");
176
+ if (textParts) summaryParts.push(`Assistant: ${textParts}`);
177
+ } else if (typeof msg.content === "string" && msg.content) {
178
+ summaryParts.push(`Assistant: ${msg.content}`);
179
+ }
180
+ }
181
+
182
+ // Truncate to reasonable size
183
+ const conversationSummary = summaryParts.join("\n").slice(0, 800);
184
+
185
+ // Emit event for subagents to spawn background agent
186
+ emitEvent(pi, UNIPI_EVENTS.BADGE_GENERATE_REQUEST, {
187
+ source: "input-hook",
188
+ conversationSummary,
189
+ });
143
190
  });
144
191
 
145
192
  // Track command usage
@@ -153,6 +200,8 @@ export default function (pi: ExtensionAPI) {
153
200
  pi.on("session_shutdown", async () => {
154
201
  nameBadgeState.hide();
155
202
  firstMessageSeen = false;
203
+ firstUserText = "";
204
+ firstInputCtx = null;
156
205
  await lifecycle.shutdown("session_shutdown");
157
206
  });
158
207
  }