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.
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.2.7",
3
+ "version": "2.3.2",
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,6 +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;
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;
64
70
  /** Restore pi's native footer/widgets/indicator (session_shutdown / disable). */
65
71
  dispose(ui?: ExtensionUIContext): void;
66
72
  };
@@ -98,9 +104,15 @@ export function createChrome(getConfig: () => ChromeConfig): Chrome {
98
104
  };
99
105
 
100
106
  return {
107
+ emit(text: string, level: "info" | "warning" | "error" = "info"): void {
108
+ data.recentEvent = text;
109
+ data.recentEventLevel = level;
110
+ try { tui?.requestRender(); } catch { /* not mounted yet */ }
111
+ },
112
+
101
113
  data,
102
114
 
103
- install(ui): void {
115
+ install(ui: ExtensionUIContext): void {
104
116
  if (!getConfig().enabled) return;
105
117
  ui.setWorkingIndicator({ frames: getConfig().spinnerFrames, intervalMs: getConfig().spinnerIntervalMs });
106
118
  ui.setFooter((t, theme, footerData) => {
@@ -133,6 +145,10 @@ export function createChrome(getConfig: () => ChromeConfig): Chrome {
133
145
  startWorking(ui): void {
134
146
  if (!getConfig().enabled || !getConfig().telemetry) return;
135
147
  clearWorking();
148
+ // Each new turn starts with a clean sub-line — old events were
149
+ // from the previous turn, not relevant to the current one.
150
+ data.recentEvent = null;
151
+ data.recentEventLevel = null;
136
152
  working = { ui, startMs: Date.now(), inputTokens: data.ctxTokens ?? 0, outputTokens: 0, timer: null };
137
153
  renderWorking();
138
154
  working.timer = setInterval(renderWorking, 1000);
package/visual/data.ts CHANGED
@@ -39,6 +39,12 @@ export type ChromeData = {
39
39
  verbLabel: string | null;
40
40
  /** html_artifacts created this session (0 = no segment). */
41
41
  artifactCount: number;
42
+ /** Most recent non-error soly event (e.g. "reloaded 47 rules"). Rendered
43
+ * as a sub-line under the Working indicator; auto-cleared by the next
44
+ * agent_start. null = no recent event. */
45
+ recentEvent: string | null;
46
+ /** Level of the recent event (used for glyph/color). */
47
+ recentEventLevel: "info" | "warning" | "error" | null;
42
48
  };
43
49
 
44
50
  /** A fresh ChromeData with everything empty/idle. */
@@ -58,5 +64,7 @@ export function emptyChromeData(): ChromeData {
58
64
  phaseLabel: null,
59
65
  verbLabel: null,
60
66
  artifactCount: 0,
67
+ recentEvent: null,
68
+ recentEventLevel: null,
61
69
  };
62
70
  }
@@ -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,6 +124,24 @@ export class SolyFooter implements Component {
124
124
  }
125
125
 
126
126
  render(width: number): string[] {
127
- return [buildFooterLine(this.data, this.fd, width, { ascii: this.getAscii(), styler: themeStyler(this.theme) })];
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 styler = themeStyler(this.theme);
131
+ const level = this.data.recentEventLevel;
132
+ let glyph = "└─";
133
+ let styled: string;
134
+ if (level === "error") {
135
+ glyph = "└─ ✗";
136
+ styled = styler.fg("error", `${glyph} ${event}`);
137
+ } else if (level === "warning") {
138
+ glyph = "└─ ⚠";
139
+ styled = styler.fg("warning", `${glyph} ${event}`);
140
+ } else {
141
+ styled = styler.dim(`${glyph} ${event}`);
142
+ }
143
+ out.push(" " + styled);
144
+ }
145
+ return out;
128
146
  }
129
147
  }
package/workflows/done.ts CHANGED
@@ -95,7 +95,7 @@ export function buildDoneTransform(
95
95
  if ("error" in parsed) {
96
96
  return reply(`soly done: ${parsed.error}\n\nUsage: soly done <slug> OR soly done <prefix>/<slug>`);
97
97
  }
98
- const { name, prefix } = parsed;
98
+ const { name, prefix, autoSlugified, originalInput } = parsed;
99
99
  const effectivePrefix = prefix ?? opts.defaultBranchPrefix ?? "";
100
100
  const branchName = effectivePrefix ? `${effectivePrefix}/${name}` : name;
101
101
 
@@ -30,8 +30,10 @@ 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 {
36
+ recordEvent: (text: string, level?: "info" | "warning") => void;
35
37
  getState: () => SolyState;
36
38
  /** List of rule relPaths marked `interactive: true` — inlined into the
37
39
  * execute instruction so the model knows which rules are explicitly out of
@@ -57,7 +59,7 @@ export interface WorkflowsDeps {
57
59
  const MODE_VERBS: readonly string[] = ["execute", "plan", "discuss", "resume"];
58
60
 
59
61
  export function registerWorkflows(pi: ExtensionAPI, deps: WorkflowsDeps): void {
60
- const { getState, getInteractiveRules, getActiveTools, getConfig, onWorkflowUsed } = deps;
62
+ const { getState, getInteractiveRules, getActiveTools, getConfig, onWorkflowUsed, recordEvent } = deps;
61
63
 
62
64
  // Self-review loop ("soly verify"). Owns its own agent_end + input hooks;
63
65
  // fresh-context mode rewrites the next LLM call through the context manager.
@@ -276,10 +278,10 @@ State inspection lives on the slash form — \`/soly <sub>\`:
276
278
  "and .agents/.continue-here.md. Preserve milestone/phase/plan position and key " +
277
279
  "decisions in the summary. Drop implementation-detail noise.",
278
280
  onComplete: () => {
279
- ctx.ui.notify("soly: session compacted. Use `soly resume` to pick up.", "info");
281
+ emit("session compacted use 'soly resume' to pick up");
280
282
  },
281
283
  onError: (err) => {
282
- ctx.ui.notify(`soly: compact failed — ${err.message}`, "error");
284
+ emit(`soly: compact failed — ${err.message}`, "error");
283
285
  },
284
286
  });
285
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;
@@ -89,7 +90,7 @@ export function buildNewTransform(
89
90
  const parsed = parsePlanName(cmd.args.join(" "));
90
91
  if ("error" in parsed) return reply(`soly new: ${parsed.error}`);
91
92
 
92
- const { name, prefix } = parsed;
93
+ const { name, prefix, autoSlugified, originalInput } = parsed;
93
94
  // Branch can be one of three shapes:
94
95
  // - "feature/statistic-preparation" (user typed <prefix>/<slug>)
95
96
  // - "feature/statistic-preparation" (config has defaultBranchPrefix "feature", user typed "statistic-preparation")
@@ -123,18 +124,45 @@ export function buildNewTransform(
123
124
 
124
125
  const currentBranch = git(["branch", "--show-current"], { cwd: projectRoot }) || "HEAD (detached)";
125
126
  // A plan branch is a kebab-case slug (no `<type>/` prefix after 1.15.x).
126
- // The branch can also be the long-lived integration branches `master`
127
- // or `main`. Anything else (release tags, weird suffixes) user must
128
- // checkout first.
127
+ // The base can also be the long-lived integration branches `master`/`main`.
128
+ // Anything else (release tags, weird suffixes, feature branches from
129
+ // previous work) — we auto-checkout the base branch instead of
130
+ // blocking the user. The LLM driving soly shouldn't have to manage
131
+ // git state.
129
132
  if (
130
133
  currentBranch !== "master" &&
131
134
  currentBranch !== "main" &&
132
135
  !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(currentBranch)
133
136
  ) {
134
- return reply(
135
- `soly new: currently on "${currentBranch}" (not master/main, not a soly plan branch). ` +
136
- `Switch back to master first with \`git checkout master\`.`,
137
- );
137
+ // Detect which base branch the repo uses: try `main` first, fall
138
+ // back to `master`. If neither exists, give up the repo is in
139
+ // a state the user needs to resolve by hand.
140
+ const baseBranch = ["main", "master"].find((b) => {
141
+ try {
142
+ git(["rev-parse", "--verify", b], { cwd: projectRoot });
143
+ return true;
144
+ } catch {
145
+ return false;
146
+ }
147
+ });
148
+ if (!baseBranch) {
149
+ return reply(
150
+ `soly new: cannot find a base branch to base off — repo has neither "main" nor "master". ` +
151
+ `Run \`git checkout <base>\` first, or create a default branch.`,
152
+ );
153
+ }
154
+ try {
155
+ git(["checkout", baseBranch], { cwd: projectRoot });
156
+ emit(
157
+ `auto-checkout ${baseBranch} (was on ${currentBranch})`,
158
+ "info",
159
+ );
160
+ } catch (err) {
161
+ const msg = err instanceof Error ? err.message : String(err);
162
+ return reply(
163
+ `soly new: failed to checkout ${baseBranch} (was on ${currentBranch}): ${msg}`,
164
+ );
165
+ }
138
166
  }
139
167
 
140
168
  let branchExisted = false;