pi-soly 2.3.1 → 2.3.3

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.
@@ -40,12 +40,9 @@ export interface CommandsDeps {
40
40
  getConfig: () => SolyConfig;
41
41
  reloadConfig: () => void;
42
42
  getIntentDocs: () => IntentDoc[];
43
- /** Record a non-error soly event. Rendered as a sub-line under the
44
- * Working indicator (└─ prefixed, dim/yellow by level). Use this for
45
- * informational events (e.g. "reloaded 47 rules") they shouldn't be
46
- * popups. Errors must still go through `ui.notify(text, "error")` for
47
- * the popup surface. */
48
- recordEvent: (text: string, level?: "info" | "warning") => void;
43
+ /** Record a soly event (any level). Everything goes through the
44
+ * Working sub-line no popups at all. */
45
+ recordEvent: (text: string, level?: "info" | "warning" | "error") => void;
49
46
  }
50
47
 
51
48
  /** Open a focused list modal (overlay) for the given grouped items. */
package/commands/docs.ts CHANGED
@@ -67,7 +67,7 @@ export function registerDocsCommand(pi: ExtensionAPI, deps: DocsDeps): void {
67
67
  return;
68
68
  }
69
69
 
70
- ui.notify("Usage: /docs <list|stats>", "error");
70
+ recordEvent("Usage: /docs <list|stats>", "error");
71
71
  void solyDirFor; // kept available for future subcommands
72
72
  },
73
73
  });
package/commands/rules.ts CHANGED
@@ -91,12 +91,12 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
91
91
 
92
92
  if (sub === "show") {
93
93
  if (!target) {
94
- ui.notify("Usage: /rules show <relPath>", "error");
94
+ recordEvent("Usage: /rules show <relPath>", "error");
95
95
  return;
96
96
  }
97
97
  const r = getRules().find((x) => x.relPath === target);
98
98
  if (!r) {
99
- ui.notify(`Rule not found: ${target}`, "error");
99
+ recordEvent(`Rule not found: ${target}`, "error");
100
100
  return;
101
101
  }
102
102
  const text = `---\n${r.meta.description ? `description: ${r.meta.description}\n` : ""}source: ${r.sourceLabel}\nenabled: ${r.enabled}\n---\n\n${r.body}`;
@@ -119,12 +119,12 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
119
119
 
120
120
  if (sub === "enable" || sub === "disable") {
121
121
  if (!target) {
122
- ui.notify(`Usage: /rules ${sub} <relPath>`, "error");
122
+ recordEvent(`Usage: /rules ${sub} <relPath>`, "error");
123
123
  return;
124
124
  }
125
125
  const r = getRules().find((x) => x.relPath === target);
126
126
  if (!r) {
127
- ui.notify(`Rule not found: ${target}`, "error");
127
+ recordEvent(`Rule not found: ${target}`, "error");
128
128
  return;
129
129
  }
130
130
  r.enabled = sub === "enable";
@@ -141,7 +141,7 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
141
141
  }
142
142
 
143
143
  if (sub === "add") {
144
- ui.notify(
144
+ recordEvent(
145
145
  "Use /rulewizard add <url> (or /rules add for the rule-creation guide).",
146
146
  "info",
147
147
  );
@@ -149,7 +149,7 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
149
149
  }
150
150
 
151
151
  if (sub === "new") {
152
- ui.notify(
152
+ recordEvent(
153
153
  "Use /rulewizard to scaffold a new rule (it guides the rule-vs-editorconfig-vs-linter decision).",
154
154
  "info",
155
155
  );
@@ -165,7 +165,7 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
165
165
  path.join(require("node:os").homedir(), ".agents", "rules"),
166
166
  ];
167
167
  const found = dirs.filter((d) => fs.existsSync(d));
168
- ui.notify(
168
+ recordEvent(
169
169
  "Rules sources (existing first):\n" +
170
170
  (found.length ? found.map((d) => ` ✓ ${d}`).join("\n") : " (none)") +
171
171
  "\n" +
@@ -178,7 +178,7 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
178
178
  return;
179
179
  }
180
180
 
181
- ui.notify(
181
+ recordEvent(
182
182
  `Usage: /rules <list|show|stats|analytics|enable|disable|reload|add|new|path> [target]`,
183
183
  "error",
184
184
  );
@@ -1,3 +1,4 @@
1
+ import { emit } from "../visual/event-sink.ts";
1
2
  // =============================================================================
2
3
  // commands/rulewizard.ts — /rulewizard command (interactive guide)
3
4
  // =============================================================================
@@ -11,7 +12,7 @@ export function registerRulewizardCommand(pi: ExtensionAPI, deps: CommandsDeps):
11
12
  description:
12
13
  "interactive guide: decide whether a constraint should be a soly rule, an .editorconfig entry, or a linter config (eslint/biome/prettier). Use this BEFORE writing a new rule to avoid duplicating what linters already enforce.",
13
14
  handler: async (_args, ctx) => {
14
- ctx.ui.notify(
15
+ emit(
15
16
  [
16
17
  "soly-rule-wizard:",
17
18
  "",
package/commands/soly.ts CHANGED
@@ -68,7 +68,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
68
68
 
69
69
  const showFile = (label: string, content: string | null) => {
70
70
  if (!content) {
71
- ui.notify(`${label}: not found`, "error");
71
+ recordEvent(`${label}: not found`, "error");
72
72
  return;
73
73
  }
74
74
  const MAX = 4000;
@@ -176,7 +176,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
176
176
  const s = getState();
177
177
  if (s.position) {
178
178
  // Brief status; full inspector lives in `/soly inspect`.
179
- ui.notify(
179
+ recordEvent(
180
180
  `pos: ${s.position.phase} · ${s.position.plan} (${s.position.status}) · ${s.progress.percent}%`,
181
181
  "info",
182
182
  );
@@ -199,7 +199,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
199
199
  if (parts.length <= 1) {
200
200
  const s = getState();
201
201
  if (!s.currentPlanPath) {
202
- ui.notify("soly: no current plan", "error");
202
+ recordEvent("soly: no current plan", "error");
203
203
  return;
204
204
  }
205
205
  showFile(
@@ -216,7 +216,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
216
216
  run: () => {
217
217
  const s = getState();
218
218
  if (!s.currentPhase) {
219
- ui.notify("soly: no current phase", "error");
219
+ recordEvent("soly: no current phase", "error");
220
220
  return;
221
221
  }
222
222
  const p = path.join(s.currentPhase.dir, `${s.currentPhase.slug}-CONTEXT.md`);
@@ -228,7 +228,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
228
228
  run: () => {
229
229
  const s = getState();
230
230
  if (!s.currentPhase) {
231
- ui.notify("soly: no current phase", "error");
231
+ recordEvent("soly: no current phase", "error");
232
232
  return;
233
233
  }
234
234
  const p = path.join(s.currentPhase.dir, `${s.currentPhase.slug}-RESEARCH.md`);
@@ -296,13 +296,13 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
296
296
  run: (parts: string[]) => {
297
297
  const id = (parts[1] ?? "").trim();
298
298
  if (!id) {
299
- ui.notify("Usage: /soly task <task-id>", "error");
299
+ recordEvent("Usage: /soly task <task-id>", "error");
300
300
  return;
301
301
  }
302
302
  const s = getState();
303
303
  const task = s.tasks.find((t) => t.id === id);
304
304
  if (!task) {
305
- ui.notify(
305
+ recordEvent(
306
306
  `soly: task ${id} not found.\nKnown: ${s.tasks.map((t) => t.id).join(", ") || "(none)"}`,
307
307
  "error",
308
308
  );
@@ -357,7 +357,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
357
357
  return;
358
358
  }
359
359
  }
360
- ui.notify(
360
+ recordEvent(
361
361
  `soly: no milestone file found. tried:\n ${candidates.map((c) => path.relative(process.cwd(), c)).join("\n ")}`,
362
362
  "error",
363
363
  );
@@ -536,7 +536,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
536
536
  }
537
537
 
538
538
  if (!(subcommands as Record<string, unknown>)[sub]) {
539
- ui.notify(`soly: unknown subcommand '${sub}'`, "error");
539
+ recordEvent(`soly: unknown subcommand '${sub}'`, "error");
540
540
  return openMenu();
541
541
  }
542
542
 
package/commands/why.ts CHANGED
@@ -97,7 +97,7 @@ export function registerWhyCommand(pi: ExtensionAPI, deps: WhyDeps): void {
97
97
  "If a behavior surprises you, look here first for the basis.",
98
98
  );
99
99
 
100
- ctx.ui.notify(lines.join("\n"), "info");
100
+ recordEvent(lines.join("\n"), "info");
101
101
 
102
102
  // Suppress unused arg
103
103
  void args;
package/index.ts CHANGED
@@ -77,6 +77,7 @@ import piAskExtension from "./ask/index.ts";
77
77
  import piDeckExtension from "./deck/index.ts";
78
78
  import piArtifactExtension from "./artifact/index.ts";
79
79
  import { getArtifactServer } from "./artifact/session.ts";
80
+ import { emit } from "./visual/event-sink.ts";
80
81
 
81
82
  /** Compact phase label for the chrome top bar, e.g. "plan 2/5". Null when idle. */
82
83
  function phaseLabelFromState(s: SolyState): string | null {
@@ -387,7 +388,7 @@ export default function solyExtension(pi: ExtensionAPI) {
387
388
  // Non-error soly events land in the Working sub-line (└─ prefixed).
388
389
  // The route goes through the chrome so the sub-line auto-clears on
389
390
  // the next agent_start.
390
- recordEvent: (text, level) => chrome.recordEvent(text, level),
391
+ recordEvent: (text, level) => chrome.emit(text, level),
391
392
  });
392
393
 
393
394
  registerTools(pi, {
@@ -397,7 +398,7 @@ export default function solyExtension(pi: ExtensionAPI) {
397
398
  });
398
399
 
399
400
  registerWorkflows(pi, {
400
- recordEvent: (text, level) => chrome.recordEvent(text, level),
401
+ recordEvent: (text, level) => chrome.emit(text, level),
401
402
  getState: () => state,
402
403
  getInteractiveRules: () =>
403
404
  combinedRules()
@@ -463,20 +464,20 @@ export default function solyExtension(pi: ExtensionAPI) {
463
464
  const cfgResult = loadConfig(ctx.cwd);
464
465
  activeConfig = cfgResult.config;
465
466
  for (const w of cfgResult.warnings) {
466
- ctx.ui.notify(`soly config: ${w}`, "warning");
467
+ emit(`soly config: ${w}`, "warning");
467
468
  }
468
469
  if (cfgResult.sources.global || cfgResult.sources.project) {
469
470
  const sources = [
470
471
  cfgResult.sources.global ? `global: ${cfgResult.sources.global}` : null,
471
472
  cfgResult.sources.project ? `project: ${cfgResult.sources.project}` : null,
472
473
  ].filter(Boolean).join(", ");
473
- ctx.ui.notify(`soly config loaded (${sources})`, "info");
474
+ emit(`soly config loaded (${sources})`, "info");
474
475
  }
475
476
  // Auto-prune old iteration files per retention config
476
477
  if (state.exists) {
477
478
  const r = pruneOldIterations(state.solyDir, activeConfig.iteration.retentionDays);
478
479
  if (r.pruned > 0) {
479
- ctx.ui.notify(
480
+ emit(
480
481
  `soly: pruned ${r.pruned} old iteration file(s) (retention ${activeConfig.iteration.retentionDays}d)`,
481
482
  "info",
482
483
  );
@@ -540,7 +541,7 @@ export default function solyExtension(pi: ExtensionAPI) {
540
541
  // Working indicator (└─ reloaded 47 rules). Errors here are real
541
542
  // (disk I/O failed) and stay as popups.
542
543
  hotReload.setNotifyHandler((reason) => {
543
- chrome.recordEvent(`reloaded rules (${reason})`);
544
+ chrome.emit(`reloaded rules (${reason})`);
544
545
  });
545
546
 
546
547
  // Notifications (one-shot at startup)
@@ -556,10 +557,10 @@ export default function solyExtension(pi: ExtensionAPI) {
556
557
  if (phaseRules.length > 0) {
557
558
  summary += ` + ${phaseRules.length} phase-${state.currentPhase?.number}`;
558
559
  }
559
- ctx.ui.notify(summary, "info");
560
+ emit(summary, "info");
560
561
 
561
562
  if (overriddenRulePaths.length > 0) {
562
- ctx.ui.notify(
563
+ emit(
563
564
  `soly: ${overriddenRulePaths.length} rule(s) overridden by project (${overriddenRulePaths.join(", ")})`,
564
565
  "info",
565
566
  );
@@ -583,23 +584,23 @@ export default function solyExtension(pi: ExtensionAPI) {
583
584
  if (added.length) parts.push(`+${added.length}`);
584
585
  if (removed.length) parts.push(`-${removed.length}`);
585
586
  if (changed.length) parts.push(`~${changed.length}`);
586
- ctx.ui.notify(`soly: rules changed since last session (${parts.join(" ")})`, "info");
587
+ emit(`soly: rules changed since last session (${parts.join(" ")})`, "info");
587
588
  }
588
589
 
589
590
  // Rule budget analytics
590
591
  const analytics = analyzeRules(alwaysOnRules, CONTEXT_WINDOW_TOKENS);
591
592
  if (analytics.contextBudgetPct > 5) {
592
- ctx.ui.notify(
593
+ emit(
593
594
  `soly: rules use ${analytics.contextBudgetPct.toFixed(1)}% of context window (${formatTok(analytics.totalTokens)} across ${analytics.fileCount} files)`,
594
595
  "info",
595
596
  );
596
597
  }
597
598
  } else {
598
- ctx.ui.notify("soly rules: none found in .agents/rules.local, .agents/rules, or ~/.agents/rules", "info");
599
+ emit("soly rules: none found in .agents/rules.local, .agents/rules, or ~/.agents/rules", "info");
599
600
  }
600
601
 
601
602
  if (state.exists) {
602
- ctx.ui.notify(`soly state: ${state.milestone} (${state.phases.length} phases)`, "info");
603
+ emit(`soly state: ${state.milestone} (${state.phases.length} phases)`, "info");
603
604
  }
604
605
 
605
606
  updateStatus(ctx);
package/init.ts CHANGED
@@ -35,6 +35,7 @@
35
35
  import * as fs from "node:fs";
36
36
  import * as path from "node:path";
37
37
  import { SOLY_DIRNAME } from "./core.js";
38
+ import { emit } from "./visual/event-sink.ts";
38
39
 
39
40
  export type InitTemplate = "minimal" | "web-app" | "library" | "cli";
40
41
 
@@ -207,7 +208,7 @@ export async function initSolyProject(
207
208
 
208
209
  // Preconditions
209
210
  if (fs.existsSync(agentsDir)) {
210
- ui.notify(
211
+ emit(
211
212
  `soly init: project already initialized (found ${SOLY_DIRNAME}/). ` +
212
213
  `Aborting to avoid overwriting.`,
213
214
  "error",
package/mcp/commands.ts CHANGED
@@ -20,6 +20,7 @@ import { getAuthForUrl } from "./mcp-auth.ts";
20
20
  import { notifyReconnectFailed } from "./notify.ts";
21
21
  import { loadOnboardingState, markSetupCompleted as persistSetupCompleted, markSharedConfigHintShown } from "./onboarding-state.ts";
22
22
  import { openPath } from "./utils.ts";
23
+ import { emit } from "../visual/event-sink.ts";
23
24
 
24
25
  export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
25
26
  if (!ctx.hasUI) return;
@@ -70,7 +71,7 @@ export async function showStatus(state: McpExtensionState, ctx: ExtensionContext
70
71
  );
71
72
  }
72
73
 
73
- ctx.ui.notify(lines.join("\n"), "info");
74
+ emit(lines.join("\n"), "info");
74
75
  }
75
76
 
76
77
  export async function showTools(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
@@ -79,7 +80,7 @@ export async function showTools(state: McpExtensionState, ctx: ExtensionContext)
79
80
  const allTools = [...state.toolMetadata.values()].flat().map(m => m.name);
80
81
 
81
82
  if (allTools.length === 0) {
82
- ctx.ui.notify("No MCP tools available", "info");
83
+ emit("No MCP tools available", "info");
83
84
  return;
84
85
  }
85
86
 
@@ -91,7 +92,7 @@ export async function showTools(state: McpExtensionState, ctx: ExtensionContext)
91
92
  `Total: ${allTools.length} tools`,
92
93
  ];
93
94
 
94
- ctx.ui.notify(lines.join("\n"), "info");
95
+ emit(lines.join("\n"), "info");
95
96
  }
96
97
 
97
98
  export async function reconnectServers(
@@ -101,7 +102,7 @@ export async function reconnectServers(
101
102
  ): Promise<void> {
102
103
  if (targetServer && !state.config.mcpServers[targetServer]) {
103
104
  if (ctx.hasUI) {
104
- ctx.ui.notify(`Server "${targetServer}" not found in config`, "error");
105
+ emit(`Server "${targetServer}" not found in config`, "error");
105
106
  }
106
107
  return;
107
108
  }
@@ -117,7 +118,7 @@ export async function reconnectServers(
117
118
  const connection = await state.manager.connect(name, definition);
118
119
  if (connection.status === "needs-auth") {
119
120
  if (ctx.hasUI) {
120
- ctx.ui.notify(`MCP: ${name} requires OAuth. Run /mcp-auth ${name} first.`, "warning");
121
+ emit(`MCP: ${name} requires OAuth. Run /mcp-auth ${name} first.`, "warning");
121
122
  }
122
123
  continue;
123
124
  }
@@ -129,12 +130,12 @@ export async function reconnectServers(
129
130
  state.failureTracker.delete(name);
130
131
 
131
132
  if (ctx.hasUI) {
132
- ctx.ui.notify(
133
+ emit(
133
134
  `MCP: Reconnected to ${name} (${connection.tools.length} tools, ${connection.resources.length} resources)`,
134
135
  "info"
135
136
  );
136
137
  if (failedTools.length > 0) {
137
- ctx.ui.notify(`MCP: ${name} - ${failedTools.length} tools skipped`, "warning");
138
+ emit(`MCP: ${name} - ${failedTools.length} tools skipped`, "warning");
138
139
  }
139
140
  }
140
141
  } catch (error) {
@@ -159,13 +160,13 @@ export async function authenticateServer(
159
160
  const definition = config.mcpServers[serverName];
160
161
  if (!definition) {
161
162
  const message = `Server "${serverName}" not found in config`;
162
- ctx.ui.notify(message, "error");
163
+ emit(message, "error");
163
164
  return { ok: false, message };
164
165
  }
165
166
 
166
167
  if (!supportsOAuth(definition)) {
167
168
  const message = `Server "${serverName}" does not use OAuth authentication. Set "auth": "oauth" or omit auth for auto-detection.`;
168
- ctx.ui.notify(
169
+ emit(
169
170
  `Server "${serverName}" does not use OAuth authentication.\n` +
170
171
  `Set "auth": "oauth" or omit auth for auto-detection.`,
171
172
  "error"
@@ -175,7 +176,7 @@ export async function authenticateServer(
175
176
 
176
177
  if (!definition.url) {
177
178
  const message = `Server "${serverName}" has no URL configured (OAuth requires HTTP transport)`;
178
- ctx.ui.notify(message, "error");
179
+ emit(message, "error");
179
180
  return { ok: false, message };
180
181
  }
181
182
 
@@ -185,7 +186,7 @@ export async function authenticateServer(
185
186
 
186
187
  if (status === "authenticated") {
187
188
  const message = `OAuth authentication successful for "${serverName}"! Run /mcp reconnect ${serverName} to connect with the new token.`;
188
- ctx.ui.notify(
189
+ emit(
189
190
  `OAuth authentication successful for "${serverName}"!\n` +
190
191
  `Run /mcp reconnect ${serverName} to connect with the new token.`,
191
192
  "info"
@@ -194,11 +195,11 @@ export async function authenticateServer(
194
195
  }
195
196
 
196
197
  const message = `OAuth authentication failed for "${serverName}".`;
197
- ctx.ui.notify(message, "error");
198
+ emit(message, "error");
198
199
  return { ok: false, message };
199
200
  } catch (error) {
200
201
  const message = error instanceof Error ? error.message : String(error);
201
- ctx.ui.notify(`Failed to authenticate "${serverName}": ${message}`, "error");
202
+ emit(`Failed to authenticate "${serverName}": ${message}`, "error");
202
203
  return { ok: false, message };
203
204
  } finally {
204
205
  ctx.ui.setStatus("mcp-auth", undefined);
@@ -213,7 +214,7 @@ export async function logoutServer(
213
214
  const definition = state.config.mcpServers[serverName];
214
215
  if (!definition) {
215
216
  const message = `Server "${serverName}" not found in config`;
216
- if (ctx.hasUI) ctx.ui.notify(message, "error");
217
+ if (ctx.hasUI) emit(message, "error");
217
218
  return { ok: false, message };
218
219
  }
219
220
 
@@ -222,7 +223,7 @@ export async function logoutServer(
222
223
  updateStatusBar(state);
223
224
 
224
225
  const message = `OAuth credentials cleared for "${serverName}". Run /mcp-auth ${serverName} to authenticate again.`;
225
- if (ctx.hasUI) ctx.ui.notify(message, "info");
226
+ if (ctx.hasUI) emit(message, "info");
226
227
  return { ok: true, message };
227
228
  }
228
229
 
@@ -376,7 +377,7 @@ export async function openMcpPanel(
376
377
  if (!result.cancelled && result.changes.size > 0) {
377
378
  writeDirectToolsConfig(result.changes, provenanceMap, config);
378
379
  configChanged = true;
379
- ctx.ui.notify("Direct tools updated. Pi will reload after this panel closes.", "info");
380
+ emit("Direct tools updated. Pi will reload after this panel closes.", "info");
380
381
  }
381
382
  done(undefined);
382
383
  resolve();
@@ -404,7 +405,7 @@ export async function openMcpAuthPanel(
404
405
  const config = state.config;
405
406
  const oauthServers = Object.entries(config.mcpServers).filter(([, definition]) => supportsOAuth(definition));
406
407
  if (oauthServers.length === 0) {
407
- ctx.ui.notify("No OAuth-capable MCP servers are configured.", "warning");
408
+ emit("No OAuth-capable MCP servers are configured.", "warning");
408
409
  return { configChanged: false };
409
410
  }
410
411
 
@@ -12,6 +12,7 @@ import {
12
12
  import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
13
13
  import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types.js";
14
14
  import open from "open";
15
+ import { emit } from "../visual/event-sink.ts";
15
16
 
16
17
  export type ElicitationValue = string | number | boolean | string[] | undefined;
17
18
  type FormProperty = ElicitRequestFormParams["requestedSchema"]["properties"][string];
@@ -104,7 +105,7 @@ async function collectValidField(
104
105
  }, { [name]: result.value });
105
106
  return result;
106
107
  } catch (error) {
107
- ui.notify(error instanceof Error ? error.message : String(error), "error");
108
+ emit(error instanceof Error ? error.message : String(error), "error");
108
109
  current = result.value;
109
110
  }
110
111
  }
package/mcp/index.ts CHANGED
@@ -14,6 +14,7 @@ import { initializeOAuth, shutdownOAuth } from "./mcp-auth-flow.ts";
14
14
  import { createMcpDirectToolCallRenderer, renderMcpProxyToolCall, renderMcpToolResult } from "./tool-result-renderer.ts";
15
15
  import { ToolCache, cacheKey as makeCacheKey } from "./tool-cache.ts";
16
16
  import { preloadAppBridge } from "./ext-apps-bridge.ts";
17
+ import { emit } from "../visual/event-sink.ts";
17
18
 
18
19
  /** Default TTL for cached MCP tool results (60s). Tools that hit a stable
19
20
  * server benefit; volatile ones are penalized for 60s — call sites can
@@ -190,12 +191,12 @@ export default function mcpAdapter(pi: ExtensionAPI) {
190
191
  state = await initPromise;
191
192
  } catch (error) {
192
193
  const message = error instanceof Error ? error.message : String(error);
193
- if (ctx.hasUI) ctx.ui.notify(`MCP initialization failed: ${message}`, "error");
194
+ if (ctx.hasUI) emit(`MCP initialization failed: ${message}`, "error");
194
195
  return;
195
196
  }
196
197
  }
197
198
  if (!state) {
198
- if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error");
199
+ if (ctx.hasUI) emit("MCP not initialized", "error");
199
200
  return;
200
201
  }
201
202
 
@@ -222,7 +223,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
222
223
  case "logout": {
223
224
  const serverName = rest;
224
225
  if (!serverName) {
225
- if (ctx.hasUI) ctx.ui.notify("Usage: /mcp logout <server>", "error");
226
+ if (ctx.hasUI) emit("Usage: /mcp logout <server>", "error");
226
227
  return;
227
228
  }
228
229
  await logoutServer(serverName, state, ctx);
@@ -258,12 +259,12 @@ export default function mcpAdapter(pi: ExtensionAPI) {
258
259
  state = await initPromise;
259
260
  } catch (error) {
260
261
  const message = error instanceof Error ? error.message : String(error);
261
- if (ctx.hasUI) ctx.ui.notify(`MCP initialization failed: ${message}`, "error");
262
+ if (ctx.hasUI) emit(`MCP initialization failed: ${message}`, "error");
262
263
  return;
263
264
  }
264
265
  }
265
266
  if (!state) {
266
- if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error");
267
+ if (ctx.hasUI) emit("MCP not initialized", "error");
267
268
  return;
268
269
  }
269
270
 
package/mcp/init.ts CHANGED
@@ -22,6 +22,7 @@ import { UiResourceHandler } from "./ui-resource-handler.ts";
22
22
  import { openUrl, parallelLimit } from "./utils.ts";
23
23
  import { logger } from "./logger.ts";
24
24
  import { getMissingConfiguredDirectToolServers } from "./direct-tools.ts";
25
+ import { emit } from "../visual/event-sink.ts";
25
26
  import {
26
27
  notifySessionRecovered,
27
28
  notifySessionRecoveryFailed,
@@ -158,7 +159,7 @@ export async function initializeMcp(
158
159
  for (const { name, definition, connection, error } of results) {
159
160
  if (error || !connection) {
160
161
  if (ctx.hasUI) {
161
- ctx.ui.notify(`MCP: Failed to connect to ${name}: ${error}`, "error");
162
+ emit(`MCP: Failed to connect to ${name}: ${error}`, "error");
162
163
  }
163
164
  console.error(`MCP: Failed to connect to ${name}: ${error}`);
164
165
  continue;
@@ -169,7 +170,7 @@ export async function initializeMcp(
169
170
  updateMetadataCache(state, name);
170
171
 
171
172
  if (failedTools.length > 0 && ctx.hasUI) {
172
- ctx.ui.notify(
173
+ emit(
173
174
  `MCP: ${name} - ${failedTools.length} tools skipped`,
174
175
  "warning"
175
176
  );
@@ -183,7 +184,7 @@ export async function initializeMcp(
183
184
  const msg = failedCount > 0
184
185
  ? `MCP: ${connectedCount}/${startupServers.length} servers connected (${totalTools} tools)`
185
186
  : `MCP: ${connectedCount} servers connected (${totalTools} tools)`;
186
- ctx.ui.notify(msg, "info");
187
+ emit(msg, "info");
187
188
  }
188
189
 
189
190
  const envDirect = process.env.MCP_DIRECT_TOOLS;
@@ -215,7 +216,7 @@ export async function initializeMcp(
215
216
  );
216
217
  const bootstrapped = bootstrapResults.filter(r => r.ok).map(r => r.name);
217
218
  if (bootstrapped.length > 0 && ctx.hasUI) {
218
- ctx.ui.notify(`MCP: direct tools for ${bootstrapped.join(", ")} will be available after restart`, "info");
219
+ emit(`MCP: direct tools for ${bootstrapped.join(", ")} will be available after restart`, "info");
219
220
  }
220
221
  }
221
222
  }
package/mcp/notify.ts CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { notifyFramed, type NotifBg } from "../notification.ts";
16
16
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
17
+ import { emit } from "../visual/event-sink.ts";
17
18
 
18
19
  const MCP_KEY_PREFIX = "mcp-";
19
20
 
@@ -36,7 +37,7 @@ function notifyBox(
36
37
  // with a short title-only message. Don't crash the MCP handler.
37
38
  const plain = lines.length > 0 ? `${title} — ${lines.join(" ")}` : title;
38
39
  try {
39
- ui.notify(plain, bg === "toolErrorBg" ? "error" : bg === "toolPendingBg" ? "warning" : "info");
40
+ emit(plain, bg === "toolErrorBg" ? "error" : bg === "toolPendingBg" ? "warning" : "info");
40
41
  } catch {
41
42
  // no UI at all — silent
42
43
  }
package/notification.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // soft background — the same pattern pi itself uses for branch summaries
7
7
  // and compaction messages (see pi-coding-agent's BranchSummaryMessage).
8
8
  //
9
- // Why a widget, not `ui.notify()`?
9
+ // Why a widget, not `emit()`?
10
10
  // - `notify()` is plain text, no background, no styled box
11
11
  // - `setWidget(key, factory, opts)` accepts a factory returning a
12
12
  // `Box` / `Text` component, which gives full theme support
@@ -29,6 +29,7 @@
29
29
  import { Box, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
30
30
  import type { ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
31
31
  import { appendNotification } from "./notifications-log.ts";
32
+ import { emit } from "./visual/event-sink.ts";
32
33
 
33
34
  /** Theme background color names (subset of ThemeBg). */
34
35
  export type NotifBg =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "2.3.1",
3
+ "version": "2.3.3",
4
4
  "description": "Workflow + project management for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker. One npm install, zero config. LLM drives the workflow inline via the soly_workflow tool — no external subagent plugin.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/visual/chrome.ts CHANGED
@@ -20,6 +20,7 @@ import { SolyFooter } from "./footer.ts";
20
20
  import { SolyTopBar } from "./topbar.ts";
21
21
  import { SolyHeader, type WelcomeInput } from "./welcome.ts";
22
22
  import { buildWorkingMessage } from "./working.ts";
23
+ import { emit } from "./event-sink.ts";
23
24
 
24
25
  /** Subset of soly config that controls the chrome (see config.ts `chrome`). */
25
26
  export type ChromeConfig = {
@@ -61,11 +62,11 @@ export type Chrome = {
61
62
  updateWorking(ui: ExtensionUIContext, outputTokens: number): void;
62
63
  /** Stop the telemetry line and restore the default message (agent_end). */
63
64
  stopWorking(ui: ExtensionUIContext): void;
64
- /** Record a non-error soly event. Rendered as a sub-line under the
65
- * Working indicator (└─ prefixed, level-marker glyph). Auto-cleared on
66
- * the next `startWorking`. Errors are still surfaced via
67
- * `ui.notify(text, "error")` — call that directly for the popup. */
68
- recordEvent(text: string, level?: "info" | "warning"): void;
65
+ /** Record a soly event (any level). Rendered as a sub-line under the
66
+ * Working indicator. No popups everything goes through the sub-line.
67
+ * └─ for info, └─ for warning, └─ ✗ for error. Auto-cleared on
68
+ * the next `startWorking`. */
69
+ emit(text: string, level?: "info" | "warning" | "error"): void;
69
70
  /** Restore pi's native footer/widgets/indicator (session_shutdown / disable). */
70
71
  dispose(ui?: ExtensionUIContext): void;
71
72
  };
@@ -94,7 +95,7 @@ export function createChrome(getConfig: () => ChromeConfig): Chrome {
94
95
  },
95
96
  workingWidth(),
96
97
  );
97
- try { working.ui.setWorkingMessage(message); } catch { /* session may have ended */ }
98
+ try { working.ui.setWorkingMessage(message + (data.recentEvent ? `\n ${data.recentEventLevel === "error" ? "└─ ✗" : data.recentEventLevel === "warning" ? "└─ ⚠" : "└─"} ${data.recentEvent}` : "")); } catch { /* session may have ended */ }
98
99
  };
99
100
 
100
101
  const clearWorking = (): void => {
@@ -103,9 +104,12 @@ export function createChrome(getConfig: () => ChromeConfig): Chrome {
103
104
  };
104
105
 
105
106
  return {
106
- recordEvent(text: string, level: "info" | "warning" = "info"): void {
107
+ emit(text: string, level: "info" | "warning" | "error" = "info"): void {
107
108
  data.recentEvent = text;
108
109
  data.recentEventLevel = level;
110
+ // Force the working message to re-render so the sub-line appears
111
+ // immediately, even mid-turn.
112
+ if (working) renderWorking();
109
113
  try { tui?.requestRender(); } catch { /* not mounted yet */ }
110
114
  },
111
115
 
@@ -0,0 +1,30 @@
1
+ // visual/event-sink.ts — global event sink for soly notifications
2
+ //
3
+ // All soly modules (commands, workflows, mcp, init) route their events
4
+ // through this sink instead of ctx.ui.notify. The sink is set once at
5
+ // session_start by index.ts (wired to chrome.recordEvent). Before it's
6
+ // set, events are silently dropped (no chrome = no UI).
7
+ //
8
+ // Why a module-level global instead of threading `recordEvent` through
9
+ // every function signature? The mcp/ module has 30+ call sites in
10
+ // deeply-nested handlers; threading a new parameter through all of them
11
+ // is pure churn with no architectural benefit — every call would pass
12
+ // the same value. A global sink is the pragmatic choice.
13
+
14
+ type EventLevel = "info" | "warning" | "error";
15
+ type EventSink = (text: string, level?: EventLevel) => void;
16
+
17
+ let sink: EventSink | null = null;
18
+
19
+ /** Wire the event sink to the chrome's recordEvent. Called once at
20
+ * session_start. Safe to call multiple times (idempotent). */
21
+ export function setEventSink(fn: EventSink): void {
22
+ sink = fn;
23
+ }
24
+
25
+ /** Record a soly event. Routes to chrome.recordEvent if the sink is
26
+ * wired (TUI mode); silently drops if not (RPC/print mode or before
27
+ * session_start). */
28
+ export function emit(text: string, level: EventLevel = "info"): void {
29
+ sink?.(text, level);
30
+ }
package/visual/footer.ts CHANGED
@@ -124,15 +124,6 @@ export class SolyFooter implements Component {
124
124
  }
125
125
 
126
126
  render(width: number): string[] {
127
- const out: string[] = [buildFooterLine(this.data, this.fd, width, { ascii: this.getAscii(), styler: themeStyler(this.theme) })];
128
- const event = this.data.recentEvent;
129
- if (event) {
130
- const glyph = this.data.recentEventLevel === "warning" ? "└─ ⚠" : "└─";
131
- const styled = this.data.recentEventLevel === "warning"
132
- ? themeStyler(this.theme).fg("warning", `${glyph} ${event}`)
133
- : themeStyler(this.theme).dim(`${glyph} ${event}`);
134
- out.push(" " + styled);
135
- }
136
- return out;
127
+ return [buildFooterLine(this.data, this.fd, width, { ascii: this.getAscii(), styler: themeStyler(this.theme) })];
137
128
  }
138
129
  }
@@ -30,6 +30,7 @@ import { createVerifyLoop, type VerifyState } from "./verify.ts";
30
30
  import type { ContextManager } from "../context-manager.ts";
31
31
  import type { SolyState } from "../core.js";
32
32
  import type { SolyConfig } from "../config.js";
33
+ import { emit } from "../visual/event-sink.ts";
33
34
 
34
35
  export interface WorkflowsDeps {
35
36
  recordEvent: (text: string, level?: "info" | "warning") => void;
@@ -277,10 +278,10 @@ State inspection lives on the slash form — \`/soly <sub>\`:
277
278
  "and .agents/.continue-here.md. Preserve milestone/phase/plan position and key " +
278
279
  "decisions in the summary. Drop implementation-detail noise.",
279
280
  onComplete: () => {
280
- recordEvent("session compacted — use 'soly resume' to pick up");
281
+ emit("session compacted — use 'soly resume' to pick up");
281
282
  },
282
283
  onError: (err) => {
283
- ctx.ui.notify(`soly: compact failed — ${err.message}`, "error");
284
+ emit(`soly: compact failed — ${err.message}`, "error");
284
285
  },
285
286
  });
286
287
  });
@@ -9,6 +9,7 @@ import * as path from "node:path";
9
9
  import { solyDirFor } from "../core.js";
10
10
  import type { SolyState } from "../core.js";
11
11
  import type { SolyConfig } from "../config.js";
12
+ import { emit } from "../visual/event-sink.ts";
12
13
 
13
14
  interface InspectUI {
14
15
  notify: (text: string, kind?: "info" | "warning" | "error") => void;
@@ -282,7 +283,7 @@ export function showTodos(
282
283
  ui: InspectUI,
283
284
  ): void {
284
285
  if (!state.exists) {
285
- ui.notify("soly todos: no .agents/ directory in cwd", "error");
286
+ emit("soly todos: no .agents/ directory in cwd", "error");
286
287
  return;
287
288
  }
288
289
  const file = findTodosFile(state.solyDir);
@@ -294,7 +295,7 @@ export function showTodos(
294
295
  try {
295
296
  parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
296
297
  } catch {
297
- ui.notify(`soly todos: failed to parse ${path.basename(file)} (corrupt JSON?)`, "error");
298
+ emit(`soly todos: failed to parse ${path.basename(file)} (corrupt JSON?)`, "error");
298
299
  return;
299
300
  }
300
301
  if (!parsed || !Array.isArray(parsed.todos) || parsed.todos.length === 0) {
@@ -327,7 +328,7 @@ export function showIterations(
327
328
  limitDefault: number = 10,
328
329
  ): void {
329
330
  if (!state.exists) {
330
- ui.notify("soly iterations: no .agents/ directory in cwd", "error");
331
+ emit("soly iterations: no .agents/ directory in cwd", "error");
331
332
  return;
332
333
  }
333
334
  const iterDir = path.join(state.solyDir, "iterations");
@@ -342,7 +343,7 @@ export function showIterations(
342
343
  if (nArg) {
343
344
  const parsed = parseInt(nArg, 10);
344
345
  if (!Number.isFinite(parsed) || parsed <= 0) {
345
- ui.notify(`soly iterations: invalid count "${nArg}"`, "error");
346
+ emit(`soly iterations: invalid count "${nArg}"`, "error");
346
347
  return;
347
348
  }
348
349
  limit = parsed;
@@ -396,12 +397,12 @@ export function showDiffIterations(
396
397
  ui: InspectUI,
397
398
  ): void {
398
399
  if (!state.exists) {
399
- ui.notify("soly diff iterations: no .agents/ directory in cwd", "error");
400
+ emit("soly diff iterations: no .agents/ directory in cwd", "error");
400
401
  return;
401
402
  }
402
403
  const iterDir = path.join(state.solyDir, "iterations");
403
404
  if (cmd.args.length < 2) {
404
- ui.notify(
405
+ emit(
405
406
  `soly diff iterations: need two file arguments (e.g. "soly diff iterations 05-02-exec-T1.md 05-02-exec-T2.md")`,
406
407
  "error",
407
408
  );
@@ -412,11 +413,11 @@ export function showDiffIterations(
412
413
  const pathB = path.isAbsolute(b) ? b : path.join(iterDir, b);
413
414
 
414
415
  if (!fs.existsSync(pathA)) {
415
- ui.notify(`soly diff iterations: file not found: ${a}`, "error");
416
+ emit(`soly diff iterations: file not found: ${a}`, "error");
416
417
  return;
417
418
  }
418
419
  if (!fs.existsSync(pathB)) {
419
- ui.notify(`soly diff iterations: file not found: ${b}`, "error");
420
+ emit(`soly diff iterations: file not found: ${b}`, "error");
420
421
  return;
421
422
  }
422
423
 
@@ -454,23 +455,23 @@ export function showPhaseDelete(
454
455
  ui: InspectUI,
455
456
  ): void {
456
457
  if (!state.exists) {
457
- ui.notify("soly phase delete: no .agents/ directory in cwd", "error");
458
+ emit("soly phase delete: no .agents/ directory in cwd", "error");
458
459
  return;
459
460
  }
460
461
  if (cmd.args.length < 1) {
461
- ui.notify("soly phase delete: need a phase number (e.g. `soly phase delete 5`)", "error");
462
+ emit("soly phase delete: need a phase number (e.g. `soly phase delete 5`)", "error");
462
463
  return;
463
464
  }
464
465
  const phaseNum = parseInt(cmd.args[0]!, 10);
465
466
  if (!Number.isFinite(phaseNum)) {
466
- ui.notify(`soly phase delete: invalid phase number "${cmd.args[0]}"`, "error");
467
+ emit(`soly phase delete: invalid phase number "${cmd.args[0]}"`, "error");
467
468
  return;
468
469
  }
469
470
 
470
471
  const phase = state.phases.find((p) => p.number === phaseNum);
471
472
  if (!phase) {
472
473
  const known = state.phases.map((p) => p.number).join(", ") || "(none)";
473
- ui.notify(`soly phase delete: phase ${phaseNum} not found. Known: ${known}`, "error");
474
+ emit(`soly phase delete: phase ${phaseNum} not found. Known: ${known}`, "error");
474
475
  return;
475
476
  }
476
477
 
@@ -482,7 +483,7 @@ export function showPhaseDelete(
482
483
  fs.mkdirSync(trashDir, { recursive: true });
483
484
  fs.renameSync(phase.dir, dest);
484
485
  } catch (e) {
485
- ui.notify(`soly phase delete: failed to move phase (${(e as Error).message})`, "error");
486
+ emit(`soly phase delete: failed to move phase (${(e as Error).message})`, "error");
486
487
  return;
487
488
  }
488
489
 
package/workflows/new.ts CHANGED
@@ -18,6 +18,7 @@ import * as fs from "node:fs";
18
18
  import { execFileSync } from "node:child_process";
19
19
  import { parsePlanName, type SolyCommand } from "./parser.ts";
20
20
  import type { SolyState } from "../core.js";
21
+ import { emit } from "../visual/event-sink.ts";
21
22
 
22
23
  export interface NewResult {
23
24
  handled: boolean;
@@ -152,7 +153,7 @@ export function buildNewTransform(
152
153
  }
153
154
  try {
154
155
  git(["checkout", baseBranch], { cwd: projectRoot });
155
- ui.notify(
156
+ emit(
156
157
  `auto-checkout ${baseBranch} (was on ${currentBranch})`,
157
158
  "info",
158
159
  );
@@ -17,6 +17,7 @@ import * as path from "node:path";
17
17
  import { readIfExists, buildProgressBar, type SolyState } from "../core.js";
18
18
  import type { SolyConfig } from "../config.js";
19
19
  import type { SolyCommand } from "./parser.ts";
20
+ import { emit } from "../visual/event-sink.ts";
20
21
 
21
22
  const execFileAsync = promisify(execFile);
22
23
 
@@ -36,7 +37,7 @@ export function showStatus(
36
37
  config?: SolyConfig,
37
38
  ): void {
38
39
  if (!state.exists) {
39
- ui.notify("soly: no .agents/ directory in cwd", "error");
40
+ emit("soly: no .agents/ directory in cwd", "error");
40
41
  return;
41
42
  }
42
43
  const maxPhases = config?.display.maxPhasesInStatus ?? 20;
@@ -122,14 +123,14 @@ const DECISIONS_TABLE_ROW = /^\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|\s*
122
123
 
123
124
  export function showLog(cmd: SolyCommand, state: SolyState, ui: QuickUI): void {
124
125
  if (!state.exists) {
125
- ui.notify("soly log: no .agents/ directory in cwd", "error");
126
+ emit("soly log: no .agents/ directory in cwd", "error");
126
127
  return;
127
128
  }
128
129
 
129
130
  const statePath = path.join(state.solyDir, "STATE.md");
130
131
  const raw = readIfExists(statePath);
131
132
  if (!raw) {
132
- ui.notify("soly log: STATE.md not found", "error");
133
+ emit("soly log: STATE.md not found", "error");
133
134
  return;
134
135
  }
135
136
 
@@ -160,7 +161,7 @@ export function showLog(cmd: SolyCommand, state: SolyState, ui: QuickUI): void {
160
161
  if (limitArg) {
161
162
  const parsed = parseInt(limitArg, 10);
162
163
  if (!Number.isFinite(parsed) || parsed <= 0) {
163
- ui.notify(
164
+ emit(
164
165
  `soly log: invalid limit "${limitArg}" (must be a positive integer)`,
165
166
  "error",
166
167
  );
@@ -27,6 +27,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
27
27
  import { ListPanel, type ListItem, type ListAction, type ListGroup, type PanelKeybindings } from "../visual/list-panel.ts";
28
28
 
29
29
  import { DEFAULT_CONFIG, type SolyConfig } from "../config.ts";
30
+ import { emit } from "../visual/event-sink.ts";
30
31
 
31
32
  // ---------------------------------------------------------------------------
32
33
  // Setting descriptors
@@ -442,12 +443,12 @@ export function openSettingsUI(deps: SettingsUIDeps): void {
442
443
  try {
443
444
  const diff = diffVsDefaults(working);
444
445
  saveConfigFile(solyDir, diff);
445
- ui.notify(`settings saved → ${path.basename(solyDir)}/soly.json`, "info");
446
+ emit(`settings saved → ${path.basename(solyDir)}/soly.json`, "info");
446
447
  // Re-read the config from disk so the active config matches
447
448
  // what we just wrote.
448
449
  reloadConfig();
449
450
  } catch (e) {
450
- ui.notify(`save failed: ${(e as Error).message}`, "error");
451
+ emit(`save failed: ${(e as Error).message}`, "error");
451
452
  }
452
453
  }
453
454
  done();
@@ -18,6 +18,7 @@
18
18
 
19
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
20
  import type { AgentMessageLike, ContextManager, ContextRewriter } from "../context-manager.ts";
21
+ import { emit } from "../visual/event-sink.ts";
21
22
 
22
23
  /** Resolved verify settings (from soly config `verify`). */
23
24
  export type VerifyConfig = {
@@ -121,7 +122,7 @@ export function createVerifyLoop(pi: ExtensionAPI, deps: VerifyDeps): VerifyLoop
121
122
  let exitRes: RegExp[] = [];
122
123
  let fixedRes: RegExp[] = [];
123
124
 
124
- const emit = () => deps.onState({ active, iteration, max, fresh });
125
+ const emitState = () => deps.onState({ active, iteration, max, fresh });
125
126
 
126
127
  // Captures the boundary on the first call, then strips prior iterations.
127
128
  const rewriter: ContextRewriter = (messages) => {
@@ -137,8 +138,7 @@ export function createVerifyLoop(pi: ExtensionAPI, deps: VerifyDeps): VerifyLoop
137
138
  active = false;
138
139
  boundary = -1;
139
140
  deps.contextManager.setRewriter(null);
140
- emit();
141
- ctx?.ui.notify(`soly verify: ${reason}`, "info");
141
+ emit(`soly verify: ${reason}`);
142
142
  };
143
143
 
144
144
  pi.on("agent_end", async (event, ctx) => {
@@ -158,7 +158,7 @@ export function createVerifyLoop(pi: ExtensionAPI, deps: VerifyDeps): VerifyLoop
158
158
  stop(ctx, `stopped after ${max} iterations`);
159
159
  return;
160
160
  }
161
- emit();
161
+ emit(`soly verify: loop iteration ${iteration}`);
162
162
  pi.sendUserMessage(deps.getConfig().prompt, { deliverAs: "followUp" });
163
163
  });
164
164
 
@@ -173,7 +173,7 @@ export function createVerifyLoop(pi: ExtensionAPI, deps: VerifyDeps): VerifyLoop
173
173
  isActive: () => active,
174
174
  start(ctx, opts = {}): void {
175
175
  if (active) {
176
- ctx.ui.notify("soly verify: already running", "info");
176
+ emit("soly verify: already running");
177
177
  return;
178
178
  }
179
179
  const cfg = deps.getConfig();
@@ -185,8 +185,7 @@ export function createVerifyLoop(pi: ExtensionAPI, deps: VerifyDeps): VerifyLoop
185
185
  exitRes = compilePatterns(cfg.exitPatterns);
186
186
  fixedRes = compilePatterns(cfg.issuesFixedPatterns);
187
187
  if (fresh) deps.contextManager.setRewriter(rewriter);
188
- emit();
189
- ctx.ui.notify(`soly verify: review mode on (max ${max}${fresh ? ", fresh context" : ""})`, "info");
188
+ emit("verify started");
190
189
  pi.sendUserMessage(cfg.prompt);
191
190
  },
192
191
  stop,