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/commands/_helpers.ts +3 -0
- package/commands/artifacts.ts +8 -8
- package/commands/docs.ts +6 -6
- package/commands/rules.ts +17 -17
- package/commands/rulewizard.ts +5 -2
- package/commands/soly.ts +28 -28
- package/commands/why.ts +3 -3
- package/commands.ts +1 -1
- package/index.ts +19 -11
- package/init.ts +2 -1
- package/mcp/commands.ts +18 -17
- package/mcp/elicitation-handler.ts +2 -1
- package/mcp/index.ts +6 -5
- package/mcp/init.ts +5 -4
- package/mcp/notify.ts +2 -1
- package/notification.ts +2 -1
- package/package.json +1 -1
- package/visual/chrome.ts +17 -1
- package/visual/data.ts +8 -0
- package/visual/event-sink.ts +30 -0
- package/visual/footer.ts +19 -1
- package/workflows/done.ts +1 -1
- package/workflows/index.ts +5 -3
- package/workflows/inspect.ts +14 -13
- package/workflows/new.ts +36 -8
- package/workflows/parser.ts +93 -47
- package/workflows/quick.ts +5 -4
- package/workflows/settings-ui.ts +3 -2
- package/workflows/verify.ts +6 -7
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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
|
|
package/workflows/index.ts
CHANGED
|
@@ -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
|
-
|
|
281
|
+
emit("session compacted — use 'soly resume' to pick up");
|
|
280
282
|
},
|
|
281
283
|
onError: (err) => {
|
|
282
|
-
|
|
284
|
+
emit(`soly: compact failed — ${err.message}`, "error");
|
|
283
285
|
},
|
|
284
286
|
});
|
|
285
287
|
});
|
package/workflows/inspect.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
416
|
+
emit(`soly diff iterations: file not found: ${a}`, "error");
|
|
416
417
|
return;
|
|
417
418
|
}
|
|
418
419
|
if (!fs.existsSync(pathB)) {
|
|
419
|
-
|
|
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
|
-
|
|
458
|
+
emit("soly phase delete: no .agents/ directory in cwd", "error");
|
|
458
459
|
return;
|
|
459
460
|
}
|
|
460
461
|
if (cmd.args.length < 1) {
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
127
|
-
//
|
|
128
|
-
// checkout
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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;
|