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.
- package/commands/_helpers.ts +3 -6
- package/commands/docs.ts +1 -1
- package/commands/rules.ts +8 -8
- package/commands/rulewizard.ts +2 -1
- package/commands/soly.ts +9 -9
- package/commands/why.ts +1 -1
- package/index.ts +13 -12
- 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 +11 -7
- package/visual/event-sink.ts +30 -0
- package/visual/footer.ts +1 -10
- package/workflows/index.ts +3 -2
- package/workflows/inspect.ts +14 -13
- package/workflows/new.ts +2 -1
- package/workflows/quick.ts +5 -4
- package/workflows/settings-ui.ts +3 -2
- package/workflows/verify.ts +6 -7
package/commands/_helpers.ts
CHANGED
|
@@ -40,12 +40,9 @@ export interface CommandsDeps {
|
|
|
40
40
|
getConfig: () => SolyConfig;
|
|
41
41
|
reloadConfig: () => void;
|
|
42
42
|
getIntentDocs: () => IntentDoc[];
|
|
43
|
-
/** Record a
|
|
44
|
-
* Working
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
+
recordEvent(
|
|
182
182
|
`Usage: /rules <list|show|stats|analytics|enable|disable|reload|add|new|path> [target]`,
|
|
183
183
|
"error",
|
|
184
184
|
);
|
package/commands/rulewizard.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
560
|
+
emit(summary, "info");
|
|
560
561
|
|
|
561
562
|
if (overriddenRulePaths.length > 0) {
|
|
562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.3.
|
|
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
|
|
65
|
-
* Working indicator
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/workflows/index.ts
CHANGED
|
@@ -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
|
-
|
|
281
|
+
emit("session compacted — use 'soly resume' to pick up");
|
|
281
282
|
},
|
|
282
283
|
onError: (err) => {
|
|
283
|
-
|
|
284
|
+
emit(`soly: compact failed — ${err.message}`, "error");
|
|
284
285
|
},
|
|
285
286
|
});
|
|
286
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;
|
|
@@ -152,7 +153,7 @@ export function buildNewTransform(
|
|
|
152
153
|
}
|
|
153
154
|
try {
|
|
154
155
|
git(["checkout", baseBranch], { cwd: projectRoot });
|
|
155
|
-
|
|
156
|
+
emit(
|
|
156
157
|
`auto-checkout ${baseBranch} (was on ${currentBranch})`,
|
|
157
158
|
"info",
|
|
158
159
|
);
|
package/workflows/quick.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
+
emit(
|
|
164
165
|
`soly log: invalid limit "${limitArg}" (must be a positive integer)`,
|
|
165
166
|
"error",
|
|
166
167
|
);
|
package/workflows/settings-ui.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
451
|
+
emit(`save failed: ${(e as Error).message}`, "error");
|
|
451
452
|
}
|
|
452
453
|
}
|
|
453
454
|
done();
|
package/workflows/verify.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|