pi-soly 2.2.7 → 2.3.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.
@@ -40,6 +40,9 @@ export interface CommandsDeps {
40
40
  getConfig: () => SolyConfig;
41
41
  reloadConfig: () => void;
42
42
  getIntentDocs: () => IntentDoc[];
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;
43
46
  }
44
47
 
45
48
  /** Open a focused list modal (overlay) for the given grouped items. */
@@ -6,16 +6,16 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
6
6
  import { getArtifactServer, ensureArtifactServer, artifactDir } from "../artifact/session.ts";
7
7
  import { openListPanel, openExternally, type CommandsDeps } from "./_helpers.ts";
8
8
 
9
- type ArtifactsDeps = Pick<CommandsDeps, "getConfig">;
9
+ type ArtifactsDeps = Pick<CommandsDeps, "getConfig" | "recordEvent">;
10
10
 
11
11
  export function registerArtifactsCommand(pi: ExtensionAPI, deps: ArtifactsDeps): void {
12
- const { getConfig } = deps;
12
+ const { getConfig, recordEvent } = deps;
13
13
 
14
14
  pi.registerCommand("artifacts", {
15
15
  description: "browse this project's html_artifact gallery (list, open, clear)",
16
16
  handler: async (args, ctx) => {
17
17
  if (!getConfig().artifacts.server) {
18
- ctx.ui.notify("soly: artifact server is disabled (artifacts.server=false)", "info");
18
+ recordEvent("artifact server is disabled (artifacts.server=false)");
19
19
  return;
20
20
  }
21
21
  // Always re-resolve: reuse another window's server or start ours, and
@@ -27,7 +27,7 @@ export function registerArtifactsCommand(pi: ExtensionAPI, deps: ArtifactsDeps):
27
27
  // ignore — handled by the count check below
28
28
  }
29
29
  if (!server || server.count === 0) {
30
- ctx.ui.notify("soly: no artifacts for this project yet (use the html_artifact tool)", "info");
30
+ recordEvent("no artifacts for this project yet (use the html_artifact tool)");
31
31
  return;
32
32
  }
33
33
  const gallery = server.galleryUrl();
@@ -35,15 +35,15 @@ export function registerArtifactsCommand(pi: ExtensionAPI, deps: ArtifactsDeps):
35
35
 
36
36
  if (sub === "clear") {
37
37
  const n = server.clear();
38
- ctx.ui.notify(`soly: cleared ${n} artifact(s)`, "info");
38
+ recordEvent(`cleared ${n} artifact(s)`);
39
39
  return;
40
40
  }
41
41
  if (sub === "open" || sub === "gallery") {
42
42
  try {
43
43
  await openExternally(pi, gallery);
44
- ctx.ui.notify(`soly: opened ${gallery}`, "info");
44
+ recordEvent(`opened ${gallery}`);
45
45
  } catch {
46
- ctx.ui.notify(`soly artifacts gallery: ${gallery}`, "info");
46
+ recordEvent(`soly artifacts gallery: ${gallery}`);
47
47
  }
48
48
  return;
49
49
  }
@@ -80,7 +80,7 @@ export function registerArtifactsCommand(pi: ExtensionAPI, deps: ArtifactsDeps):
80
80
 
81
81
  // Non-TUI: print the list + gallery URL.
82
82
  const lines = server.list().map((a) => `🖼 ${a.title} — ${a.url}`);
83
- ctx.ui.notify([`soly artifacts gallery: ${gallery}`, "", ...lines].join("\n"), "info");
83
+ recordEvent([`soly artifacts gallery: ${gallery}`, "", ...lines].join("\n"));
84
84
  },
85
85
  });
86
86
  }
package/commands/docs.ts CHANGED
@@ -7,10 +7,10 @@ import { formatTok, solyDirFor } from "../core.ts";
7
7
  import { buildIntentStats, formatIntentStats, loadInlineIntentBodies, type IntentInlineDoc } from "../intent.ts";
8
8
  import { openListPanel, type CommandUI, type CommandsDeps } from "./_helpers.ts";
9
9
 
10
- type DocsDeps = Pick<CommandsDeps, "getIntentDocs">;
10
+ type DocsDeps = Pick<CommandsDeps, "getIntentDocs" | "recordEvent">;
11
11
 
12
12
  export function registerDocsCommand(pi: ExtensionAPI, deps: DocsDeps): void {
13
- const { getIntentDocs } = deps;
13
+ const { getIntentDocs, recordEvent } = deps;
14
14
 
15
15
  pi.registerCommand("docs", {
16
16
  description: "manage soly intent docs (stats — show context breakdown)",
@@ -30,7 +30,7 @@ export function registerDocsCommand(pi: ExtensionAPI, deps: DocsDeps): void {
30
30
  if (sub === "list") {
31
31
  const docs = getIntentDocs();
32
32
  if (docs.length === 0) {
33
- ui.notify("no intent docs found in .agents/docs/ — drop your vision/domain docs there", "info");
33
+ recordEvent("no intent docs found in .agents/docs/ — drop your vision/domain docs there");
34
34
  return;
35
35
  }
36
36
  if (ctx.mode === "tui") {
@@ -55,7 +55,7 @@ export function registerDocsCommand(pi: ExtensionAPI, deps: DocsDeps): void {
55
55
  });
56
56
  return;
57
57
  }
58
- ui.notify(docs.map((d) => `○ ${d.title || d.relPath} (${formatTok(d.tokens)} tok)`).join("\n"), "info");
58
+ recordEvent(docs.map((d) => `○ ${d.title || d.relPath} (${formatTok(d.tokens)} tok)`).join("\n"));
59
59
  return;
60
60
  }
61
61
 
@@ -63,11 +63,11 @@ export function registerDocsCommand(pi: ExtensionAPI, deps: DocsDeps): void {
63
63
  const docs = getIntentDocs();
64
64
  const inlineBodies: IntentInlineDoc[] = loadInlineIntentBodies(docs);
65
65
  const stats = buildIntentStats(docs, inlineBodies);
66
- ui.notify(formatIntentStats(stats), "info");
66
+ recordEvent(formatIntentStats(stats));
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
@@ -30,10 +30,10 @@ function ruleItem(r: RuleFile): ListItem {
30
30
  };
31
31
  }
32
32
 
33
- type RulesDeps = Pick<CommandsDeps, "getRules" | "getOverridden" | "refreshRules" | "updateStatus">;
33
+ type RulesDeps = Pick<CommandsDeps, "getRules" | "getOverridden" | "refreshRules" | "updateStatus" | "recordEvent">;
34
34
 
35
35
  export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
36
- const { getRules, getOverridden, refreshRules, updateStatus } = deps;
36
+ const { getRules, getOverridden, refreshRules, updateStatus, recordEvent } = deps;
37
37
 
38
38
  pi.registerCommand("rules", {
39
39
  description: "manage soly rules (list, show, stats, analytics, reload, enable, disable)",
@@ -55,7 +55,7 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
55
55
  const rules = getRules();
56
56
  const overridden = getOverridden();
57
57
  if (rules.length === 0 && overridden.length === 0) {
58
- ui.notify("soly: no rules loaded — check `.agents/rules/` or `~/.agents/rules/`", "warning");
58
+ recordEvent("no rules loaded — check `.agents/rules/` or `~/.agents/rules/`", "warning");
59
59
  return;
60
60
  }
61
61
  // Rich modal in the TUI; plain select elsewhere (RPC/print).
@@ -85,63 +85,63 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
85
85
  for (const p of overridden) {
86
86
  lines.push(`⊘ [overridden] ${p}`);
87
87
  }
88
- ui.notify(lines.join("\n"), "info");
88
+ recordEvent(lines.join("\n"));
89
89
  return;
90
90
  }
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}`;
103
- ui.notify(text, "info");
103
+ recordEvent(text);
104
104
  return;
105
105
  }
106
106
 
107
107
  if (sub === "stats") {
108
108
  const analytics = analyzeRules(getRules(), CONTEXT_WINDOW_TOKENS);
109
- ui.notify(formatAnalyticsFull(analytics), "info");
109
+ recordEvent(formatAnalyticsFull(analytics));
110
110
  return;
111
111
  }
112
112
 
113
113
  if (sub === "analytics") {
114
114
  const rules = getRules();
115
115
  const stats = buildRulesContextStats(rules, CONTEXT_WINDOW_TOKENS);
116
- ui.notify(formatRulesContextStats(stats), "info");
116
+ recordEvent(formatRulesContextStats(stats));
117
117
  return;
118
118
  }
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";
131
131
  updateStatus(ui);
132
- ui.notify(`Rule ${target}: ${sub}d`, "info");
132
+ recordEvent(`Rule ${target}: ${sub}d`);
133
133
  return;
134
134
  }
135
135
 
136
136
  if (sub === "reload") {
137
137
  refreshRules();
138
138
  updateStatus(ui);
139
- ui.notify(`Reloaded ${getRules().length} rules`, "info");
139
+ recordEvent(`Reloaded ${getRules().length} rules`);
140
140
  return;
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,15 +1,18 @@
1
+ import { emit } from "../visual/event-sink.ts";
1
2
  // =============================================================================
2
3
  // commands/rulewizard.ts — /rulewizard command (interactive guide)
3
4
  // =============================================================================
4
5
 
5
6
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
7
+ import type { CommandsDeps } from "./_helpers.ts";
6
8
 
7
- export function registerRulewizardCommand(pi: ExtensionAPI): void {
9
+ export function registerRulewizardCommand(pi: ExtensionAPI, deps: CommandsDeps): void {
10
+ void deps; // rulewizard doesn't currently emit non-error events
8
11
  pi.registerCommand("rulewizard", {
9
12
  description:
10
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.",
11
14
  handler: async (_args, ctx) => {
12
- ctx.ui.notify(
15
+ emit(
13
16
  [
14
17
  "soly-rule-wizard:",
15
18
  "",
package/commands/soly.ts CHANGED
@@ -25,10 +25,10 @@ import { initSolyProject } from "../init.js";
25
25
  import { parseSolyCommand, type SolyCommand, type WorkflowVerb } from "../workflows/parser.ts";
26
26
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
27
27
 
28
- type SolyDeps = Pick<CommandsDeps, "getState" | "getConfig" | "reloadConfig" | "updateStatus" | "refreshState">;
28
+ type SolyDeps = Pick<CommandsDeps, "getState" | "getConfig" | "reloadConfig" | "updateStatus" | "refreshState" | "recordEvent">;
29
29
 
30
30
  export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
31
- const { getState, getConfig, reloadConfig, updateStatus, refreshState } = deps;
31
+ const { getState, getConfig, reloadConfig, updateStatus, refreshState, recordEvent } = deps;
32
32
 
33
33
  const solyBody = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
34
34
  const ui: CommandUI = {
@@ -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;
@@ -105,22 +105,22 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
105
105
  switch (verb) {
106
106
  case "new": {
107
107
  const r = buildNewTransform(cmd, state, ui, ctx.cwd, getConfig().plan.defaultBranchPrefix);
108
- if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
108
+ if (r.handled && r.transformedText) recordEvent(r.transformedText);
109
109
  return;
110
110
  }
111
111
  case "done": {
112
112
  const r = buildDoneTransform(cmd, state, ui, ctx.cwd, { defaultBranchPrefix: getConfig().plan.defaultBranchPrefix });
113
- if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
113
+ if (r.handled && r.transformedText) recordEvent(r.transformedText);
114
114
  return;
115
115
  }
116
116
  case "migrate": {
117
117
  const r = buildMigrateTransform(state, ui, ctx.cwd);
118
- if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
118
+ if (r.handled && r.transformedText) recordEvent(r.transformedText);
119
119
  return;
120
120
  }
121
121
  case "plan": {
122
122
  const r = buildPlanTransform(cmd, state);
123
- if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
123
+ if (r.handled && r.transformedText) recordEvent(r.transformedText);
124
124
  return;
125
125
  }
126
126
  case "discuss": {
@@ -129,13 +129,13 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
129
129
  // plain-text discussion format; the LLM-driven path still
130
130
  // gets the richer ask_pro flow.
131
131
  const r = buildDiscussTransform(cmd, state, { hasAskPro: false });
132
- if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
132
+ if (r.handled && r.transformedText) recordEvent(r.transformedText);
133
133
  return;
134
134
  }
135
135
  case "execute": {
136
136
  // Slash-command path doesn't have live interactive rules.
137
137
  const r = buildExecuteTransform(cmd, state, []);
138
- if (r.handled && r.transformedText) ui.notify(r.transformedText, "info");
138
+ if (r.handled && r.transformedText) recordEvent(r.transformedText);
139
139
  return;
140
140
  }
141
141
  default:
@@ -176,12 +176,12 @@ 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
  );
183
183
  } else {
184
- ui.notify(`${s.milestone} — no position set`, "info");
184
+ recordEvent(`${s.milestone} — no position set`);
185
185
  }
186
186
  },
187
187
  },
@@ -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`);
@@ -244,7 +244,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
244
244
  run: () => {
245
245
  const s = getState();
246
246
  const line = `${s.progress.percent}% · ${s.progress.completedPhases}/${s.progress.totalPhases} phases · ${s.progress.completedPlans}/${s.progress.totalPlans} plans`;
247
- ui.notify(line, "info");
247
+ recordEvent(line);
248
248
  },
249
249
  },
250
250
  phases: {
@@ -252,7 +252,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
252
252
  run: () => {
253
253
  const phases = getState().phases;
254
254
  if (phases.length === 0) {
255
- ui.notify("no phases", "info");
255
+ recordEvent("no phases");
256
256
  return;
257
257
  }
258
258
  const current = getState().currentPhase?.number;
@@ -261,7 +261,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
261
261
  const cr = (p.contextExists ? "C" : "·") + (p.researchExists ? "R" : "·");
262
262
  return `${marker} ${String(p.number).padStart(2, "0")}. ${p.name} [${cr}] plans=${p.planCount}`;
263
263
  });
264
- ui.notify(lines.join("\n"), "info");
264
+ recordEvent(lines.join("\n"));
265
265
  },
266
266
  },
267
267
  tasks: {
@@ -269,7 +269,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
269
269
  run: () => {
270
270
  const s = getState();
271
271
  if (s.tasks.length === 0) {
272
- ui.notify("no tasks", "info");
272
+ recordEvent("no tasks");
273
273
  return;
274
274
  }
275
275
  const byFeature = new Map<string, typeof s.tasks>();
@@ -288,7 +288,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
288
288
  }
289
289
  out.push("");
290
290
  }
291
- ui.notify(out.join("\n"), "info");
291
+ recordEvent(out.join("\n"));
292
292
  },
293
293
  },
294
294
  task: {
@@ -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
  );
@@ -319,7 +319,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
319
319
  if (summaryBody) {
320
320
  showFile("SUMMARY.md", summaryBody);
321
321
  } else {
322
- ui.notify("no SUMMARY.md yet", "info");
322
+ recordEvent("no SUMMARY.md yet");
323
323
  }
324
324
  },
325
325
  },
@@ -328,14 +328,14 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
328
328
  run: () => {
329
329
  const features = getState().features;
330
330
  if (features.length === 0) {
331
- ui.notify("no features", "info");
331
+ recordEvent("no features");
332
332
  return;
333
333
  }
334
334
  const lines = features.map((f) => {
335
335
  const rm = f.readmeExists ? "R" : "·";
336
336
  return ` ${f.name.padEnd(28)} tasks=${f.taskCount} [${rm}]`;
337
337
  });
338
- ui.notify(lines.join("\n"), "info");
338
+ recordEvent(lines.join("\n"));
339
339
  },
340
340
  },
341
341
  milestone: {
@@ -343,7 +343,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
343
343
  run: () => {
344
344
  const s = getState();
345
345
  if (!s.milestone || s.milestone === "—") {
346
- ui.notify("soly: no milestone set", "info");
346
+ recordEvent("no milestone set");
347
347
  return;
348
348
  }
349
349
  const candidates = [
@@ -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
  );
@@ -369,7 +369,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
369
369
  refreshState();
370
370
  updateStatus(ui);
371
371
  const s = getState();
372
- ui.notify(`re-read state: ${s.phases.length} phases · ${s.tasks.length} tasks`, "info");
372
+ recordEvent(`re-read state: ${s.phases.length} phases · ${s.tasks.length} tasks`);
373
373
  },
374
374
  },
375
375
  // ------------------------------------------------------------------
@@ -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
@@ -5,10 +5,10 @@
5
5
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
6
  import type { CommandsDeps } from "./_helpers.ts";
7
7
 
8
- type WhyDeps = Pick<CommandsDeps, "getState" | "getRules">;
8
+ type WhyDeps = Pick<CommandsDeps, "getState" | "getRules" | "recordEvent">;
9
9
 
10
10
  export function registerWhyCommand(pi: ExtensionAPI, deps: WhyDeps): void {
11
- const { getState, getRules } = deps;
11
+ const { getState, getRules, recordEvent } = deps;
12
12
 
13
13
  pi.registerCommand("why", {
14
14
  description:
@@ -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/commands.ts CHANGED
@@ -39,6 +39,6 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
39
39
  registerDocsCommand(pi, deps);
40
40
  registerSolyCommand(pi, deps); // also registers /sly and /s
41
41
  registerArtifactsCommand(pi, deps);
42
- registerRulewizardCommand(pi);
42
+ registerRulewizardCommand(pi, deps);
43
43
  registerWhyCommand(pi, deps);
44
44
  }
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 {
@@ -384,6 +385,10 @@ export default function solyExtension(pi: ExtensionAPI) {
384
385
  }
385
386
  },
386
387
  getIntentDocs: () => intentDocs,
388
+ // Non-error soly events land in the Working sub-line (└─ prefixed).
389
+ // The route goes through the chrome so the sub-line auto-clears on
390
+ // the next agent_start.
391
+ recordEvent: (text, level) => chrome.emit(text, level),
387
392
  });
388
393
 
389
394
  registerTools(pi, {
@@ -393,6 +398,7 @@ export default function solyExtension(pi: ExtensionAPI) {
393
398
  });
394
399
 
395
400
  registerWorkflows(pi, {
401
+ recordEvent: (text, level) => chrome.emit(text, level),
396
402
  getState: () => state,
397
403
  getInteractiveRules: () =>
398
404
  combinedRules()
@@ -458,20 +464,20 @@ export default function solyExtension(pi: ExtensionAPI) {
458
464
  const cfgResult = loadConfig(ctx.cwd);
459
465
  activeConfig = cfgResult.config;
460
466
  for (const w of cfgResult.warnings) {
461
- ctx.ui.notify(`soly config: ${w}`, "warning");
467
+ emit(`soly config: ${w}`, "warning");
462
468
  }
463
469
  if (cfgResult.sources.global || cfgResult.sources.project) {
464
470
  const sources = [
465
471
  cfgResult.sources.global ? `global: ${cfgResult.sources.global}` : null,
466
472
  cfgResult.sources.project ? `project: ${cfgResult.sources.project}` : null,
467
473
  ].filter(Boolean).join(", ");
468
- ctx.ui.notify(`soly config loaded (${sources})`, "info");
474
+ emit(`soly config loaded (${sources})`, "info");
469
475
  }
470
476
  // Auto-prune old iteration files per retention config
471
477
  if (state.exists) {
472
478
  const r = pruneOldIterations(state.solyDir, activeConfig.iteration.retentionDays);
473
479
  if (r.pruned > 0) {
474
- ctx.ui.notify(
480
+ emit(
475
481
  `soly: pruned ${r.pruned} old iteration file(s) (retention ${activeConfig.iteration.retentionDays}d)`,
476
482
  "info",
477
483
  );
@@ -531,9 +537,11 @@ export default function solyExtension(pi: ExtensionAPI) {
531
537
  },
532
538
  });
533
539
  // Editors save in bursts (write to .tmp, rename, touch). Coalesce
534
- // those rapid reload events into a single user-visible notify.
540
+ // those rapid reload events into a single sub-line event under the
541
+ // Working indicator (└─ reloaded 47 rules). Errors here are real
542
+ // (disk I/O failed) and stay as popups.
535
543
  hotReload.setNotifyHandler((reason) => {
536
- ctx.ui.notify(`soly: rules reloaded (${reason})`, "info");
544
+ chrome.emit(`reloaded rules (${reason})`);
537
545
  });
538
546
 
539
547
  // Notifications (one-shot at startup)
@@ -549,10 +557,10 @@ export default function solyExtension(pi: ExtensionAPI) {
549
557
  if (phaseRules.length > 0) {
550
558
  summary += ` + ${phaseRules.length} phase-${state.currentPhase?.number}`;
551
559
  }
552
- ctx.ui.notify(summary, "info");
560
+ emit(summary, "info");
553
561
 
554
562
  if (overriddenRulePaths.length > 0) {
555
- ctx.ui.notify(
563
+ emit(
556
564
  `soly: ${overriddenRulePaths.length} rule(s) overridden by project (${overriddenRulePaths.join(", ")})`,
557
565
  "info",
558
566
  );
@@ -576,23 +584,23 @@ export default function solyExtension(pi: ExtensionAPI) {
576
584
  if (added.length) parts.push(`+${added.length}`);
577
585
  if (removed.length) parts.push(`-${removed.length}`);
578
586
  if (changed.length) parts.push(`~${changed.length}`);
579
- ctx.ui.notify(`soly: rules changed since last session (${parts.join(" ")})`, "info");
587
+ emit(`soly: rules changed since last session (${parts.join(" ")})`, "info");
580
588
  }
581
589
 
582
590
  // Rule budget analytics
583
591
  const analytics = analyzeRules(alwaysOnRules, CONTEXT_WINDOW_TOKENS);
584
592
  if (analytics.contextBudgetPct > 5) {
585
- ctx.ui.notify(
593
+ emit(
586
594
  `soly: rules use ${analytics.contextBudgetPct.toFixed(1)}% of context window (${formatTok(analytics.totalTokens)} across ${analytics.fileCount} files)`,
587
595
  "info",
588
596
  );
589
597
  }
590
598
  } else {
591
- 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");
592
600
  }
593
601
 
594
602
  if (state.exists) {
595
- ctx.ui.notify(`soly state: ${state.milestone} (${state.phases.length} phases)`, "info");
603
+ emit(`soly state: ${state.milestone} (${state.phases.length} phases)`, "info");
596
604
  }
597
605
 
598
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",