pi-soly 1.9.3 → 1.11.0

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.
Files changed (104) hide show
  1. package/ask/index.ts +12 -11
  2. package/ask/picker.ts +356 -76
  3. package/ask/prompt.ts +6 -2
  4. package/ask/tests/picker.test.ts +273 -82
  5. package/codemap.ts +276 -0
  6. package/hotreload.ts +239 -0
  7. package/init.ts +302 -0
  8. package/mcp/CHANGELOG.md +384 -0
  9. package/mcp/LICENSE +21 -0
  10. package/mcp/OAUTH.md +355 -0
  11. package/mcp/README.md +410 -0
  12. package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
  13. package/mcp/__tests__/cli.upstream-test.ts +97 -0
  14. package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
  15. package/mcp/__tests__/config.upstream-test.ts +303 -0
  16. package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
  17. package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
  18. package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
  19. package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
  20. package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
  21. package/mcp/__tests__/errors.upstream-test.ts +218 -0
  22. package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
  23. package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
  24. package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
  25. package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
  26. package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
  27. package/mcp/__tests__/logger.upstream-test.ts +175 -0
  28. package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
  29. package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
  30. package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
  31. package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
  32. package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
  33. package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
  34. package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
  35. package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
  36. package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
  37. package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
  38. package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
  39. package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
  40. package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
  41. package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
  42. package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
  43. package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
  44. package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
  45. package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
  46. package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
  47. package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
  48. package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
  49. package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
  50. package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
  51. package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
  52. package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
  53. package/mcp/agent-dir.ts +20 -0
  54. package/mcp/app-bridge.bundle.js +67 -0
  55. package/mcp/cli.js +184 -0
  56. package/mcp/commands.ts +422 -0
  57. package/mcp/config.ts +666 -0
  58. package/mcp/consent-manager.ts +64 -0
  59. package/mcp/direct-tools.ts +439 -0
  60. package/mcp/elicitation-handler.ts +347 -0
  61. package/mcp/errors.ts +219 -0
  62. package/mcp/glimpse-ui.ts +80 -0
  63. package/mcp/host-html-template.ts +427 -0
  64. package/mcp/index.ts +362 -0
  65. package/mcp/init.ts +362 -0
  66. package/mcp/lifecycle.ts +93 -0
  67. package/mcp/logger.ts +169 -0
  68. package/mcp/mcp-auth-flow.ts +559 -0
  69. package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
  70. package/mcp/mcp-auth.ts +302 -0
  71. package/mcp/mcp-auth.upstream-test.ts +373 -0
  72. package/mcp/mcp-callback-server.ts +372 -0
  73. package/mcp/mcp-callback-server.upstream-test.ts +416 -0
  74. package/mcp/mcp-oauth-provider.ts +369 -0
  75. package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
  76. package/mcp/mcp-panel.ts +829 -0
  77. package/mcp/mcp-setup-panel.ts +580 -0
  78. package/mcp/metadata-cache.ts +201 -0
  79. package/mcp/notify.ts +111 -0
  80. package/mcp/npx-resolver.ts +424 -0
  81. package/mcp/oauth-handler.ts +57 -0
  82. package/mcp/onboarding-state.ts +68 -0
  83. package/mcp/package.json +106 -0
  84. package/mcp/panel-keys.ts +37 -0
  85. package/mcp/proxy-modes.ts +949 -0
  86. package/mcp/resource-tools.ts +17 -0
  87. package/mcp/sampling-handler.ts +268 -0
  88. package/mcp/server-manager.ts +545 -0
  89. package/mcp/state.ts +41 -0
  90. package/mcp/tool-metadata.ts +216 -0
  91. package/mcp/tool-registrar.ts +46 -0
  92. package/mcp/tool-result-renderer.ts +161 -0
  93. package/mcp/types.ts +448 -0
  94. package/mcp/ui-resource-handler.ts +146 -0
  95. package/mcp/ui-server.ts +623 -0
  96. package/mcp/ui-session.ts +386 -0
  97. package/mcp/ui-stream-types.ts +89 -0
  98. package/mcp/utils.ts +129 -0
  99. package/mcp/vitest.config.ts +14 -0
  100. package/migrate.ts +258 -0
  101. package/notification.ts +218 -0
  102. package/notifications-log.ts +83 -0
  103. package/package.json +20 -3
  104. package/status.ts +140 -0
package/migrate.ts ADDED
@@ -0,0 +1,258 @@
1
+ // =============================================================================
2
+ // migrate.ts — Atomic .soly/ → .agents/ migration
3
+ // =============================================================================
4
+ //
5
+ // Moves the legacy `.soly/` project state dir to the new vendor-neutral
6
+ // `.agents/` location. The move tries fs.rename first (atomic on the same
7
+ // filesystem). On Windows, rename often fails with EPERM because soly's
8
+ // hot-reload watcher (or an editor) holds open handles on .soly/ files.
9
+ // We retry the rename a few times, then fall back to recursive copy +
10
+ // delete if the handle won't release. Validates after move that key files
11
+ // made it.
12
+ //
13
+ // What gets moved (everything in `.soly/`):
14
+ // - ROADMAP.md
15
+ // - STATE.md
16
+ // - docs/ (intent docs)
17
+ // - rules/ (project rules)
18
+ // - phases/ (PLAN.md / SUMMARY.md / CONTEXT.md / RESEARCH.md)
19
+ // - iterations/ (per-execution context)
20
+ // - HANDOFF.json (pause snapshot)
21
+ // - .continue-here.md
22
+ // - config.json (per-project config — note: kept for backward compat
23
+ // reading; new writes go to .agents/config.json too)
24
+ //
25
+ // What does NOT get moved:
26
+ // - rotor persistence (no longer used in 1.4.0)
27
+ // - git history — `git mv` would preserve; fs.rename loses history. We
28
+ // suggest the user commit beforehand.
29
+ //
30
+ // Usage:
31
+ // /soly-migrate # confirm before doing
32
+ // /soly-migrate --yes # skip confirmation
33
+ // /soly-migrate --dry-run # show what would happen
34
+ // =============================================================================
35
+
36
+ import * as fs from "node:fs";
37
+ import * as path from "node:path";
38
+ import { promisify } from "node:util";
39
+ import { LEGACY_SOLY_DIRNAME, SOLY_DIRNAME } from "./core.js";
40
+
41
+ const renameAsync = promisify(fs.rename);
42
+ const rmAsync = promisify(fs.rm);
43
+
44
+ /**
45
+ * Cross-platform directory move.
46
+ *
47
+ * `fs.rename` is atomic on POSIX but on Windows it fails with EPERM if any
48
+ * file inside the source dir has an open handle (soly hot-reload watcher,
49
+ * editor, antivirus, the cwd itself). We:
50
+ *
51
+ * 1. Retry rename up to 5 times with 200ms backoff — most EPERM are
52
+ * transient (OS releasing a handle).
53
+ * 2. Fall back to recursive copy-then-delete if rename keeps failing.
54
+ * Slower but works when a handle is genuinely held for the duration.
55
+ */
56
+ async function moveDir(from: string, to: string): Promise<void> {
57
+ let lastErr: unknown;
58
+ for (let attempt = 0; attempt < 5; attempt++) {
59
+ try {
60
+ await renameAsync(from, to);
61
+ return;
62
+ } catch (err) {
63
+ lastErr = err;
64
+ const code = (err as NodeJS.ErrnoException).code;
65
+ if (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") throw err;
66
+ await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
67
+ }
68
+ }
69
+ // Rename exhausted — fall back to copy + delete.
70
+ // This works even when handles are held because we open files read-only
71
+ // for the copy and the source is removed last.
72
+ await copyDirRecursive(from, to);
73
+ try {
74
+ await rmAsync(from, { recursive: true, force: true });
75
+ } catch (err) {
76
+ // Source dir couldn't be removed (handle still held). The migration
77
+ // itself succeeded — .agents/ has everything. Throw a soft warning so
78
+ // the caller can tell the user to delete .soly/ manually.
79
+ throw new Error(
80
+ `migrated to ${to} but could not remove ${from}: ${(err as Error).message}. ` +
81
+ `Delete ${from} manually after closing editors/pi.`,
82
+ );
83
+ }
84
+ }
85
+
86
+ function copyDirRecursive(src: string, dest: string): Promise<void> {
87
+ return new Promise((resolve, reject) => {
88
+ fs.mkdirSync(dest, { recursive: true });
89
+ const entries = fs.readdirSync(src, { withFileTypes: true });
90
+ let pending = entries.length;
91
+ if (pending === 0) return resolve();
92
+ let rejected = false;
93
+ const done = (err?: Error) => {
94
+ if (rejected) return;
95
+ if (err) {
96
+ rejected = true;
97
+ reject(err);
98
+ return;
99
+ }
100
+ pending -= 1;
101
+ if (pending === 0) resolve();
102
+ };
103
+ for (const entry of entries) {
104
+ const s = path.join(src, entry.name);
105
+ const d = path.join(dest, entry.name);
106
+ if (entry.isDirectory()) {
107
+ copyDirRecursive(s, d).then(() => done(), done);
108
+ } else if (entry.isSymbolicLink()) {
109
+ try {
110
+ fs.symlinkSync(fs.readlinkSync(s), d);
111
+ done();
112
+ } catch (e) {
113
+ done(e as Error);
114
+ }
115
+ } else {
116
+ fs.copyFile(s, d, (e) => done(e ? new Error(e.message) : undefined));
117
+ }
118
+ }
119
+ });
120
+ }
121
+
122
+ /** UI primitives needed by the migration. */
123
+ export interface MigrateUI {
124
+ notify: (text: string, level?: "info" | "warning" | "error") => void;
125
+ confirm: (title: string, message: string) => Promise<boolean>;
126
+ }
127
+
128
+ export interface MigrateOptions {
129
+ /** Skip the confirmation dialog. */
130
+ autoYes?: boolean;
131
+ /** Don't actually move; just report. */
132
+ dryRun?: boolean;
133
+ }
134
+
135
+ export interface MigrateResult {
136
+ /** Whether the move happened (false if cancelled or skipped). */
137
+ moved: boolean;
138
+ /** What was moved (file/dir names relative to cwd). */
139
+ relocated: string[];
140
+ /** Warnings during validation. */
141
+ warnings: string[];
142
+ }
143
+
144
+ /** What we expect to find in `.soly/`. Listed for dry-run reporting. */
145
+ const EXPECTED_ENTRIES = [
146
+ "ROADMAP.md",
147
+ "STATE.md",
148
+ "docs",
149
+ "rules",
150
+ "phases",
151
+ "iterations",
152
+ "HANDOFF.json",
153
+ ".continue-here.md",
154
+ "config.json",
155
+ ] as const;
156
+
157
+ /** Top-level move function. Returns a result so tests can assert. */
158
+ export async function migrateSolyDir(
159
+ cwd: string,
160
+ ui: MigrateUI,
161
+ options: MigrateOptions = {},
162
+ ): Promise<MigrateResult> {
163
+ const from = path.join(cwd, LEGACY_SOLY_DIRNAME);
164
+ const to = path.join(cwd, SOLY_DIRNAME);
165
+ const result: MigrateResult = { moved: false, relocated: [], warnings: [] };
166
+
167
+ // Preconditions
168
+ if (!fs.existsSync(from)) {
169
+ ui.notify(`soly-migrate: no ${LEGACY_SOLY_DIRNAME}/ to migrate (already on ${SOLY_DIRNAME}/)`, "info");
170
+ return result;
171
+ }
172
+ if (fs.existsSync(to)) {
173
+ ui.notify(
174
+ `soly-migrate: both ${LEGACY_SOLY_DIRNAME}/ and ${SOLY_DIRNAME}/ exist. ` +
175
+ `Resolve manually before migrating.`,
176
+ "error",
177
+ );
178
+ return result;
179
+ }
180
+
181
+ // Inventory what's in .soly/
182
+ let entries: string[];
183
+ try {
184
+ entries = fs.readdirSync(from);
185
+ } catch (err) {
186
+ ui.notify(`soly-migrate: cannot read ${from}: ${(err as Error).message}`, "error");
187
+ return result;
188
+ }
189
+ const inventory = entries.filter((e) => !e.startsWith("."));
190
+ result.relocated = inventory;
191
+
192
+ // Dry-run: report and exit
193
+ if (options.dryRun) {
194
+ ui.notify(
195
+ `soly-migrate (dry run): would move ${inventory.length} entries from ` +
196
+ `${LEGACY_SOLY_DIRNAME}/ to ${SOLY_DIRNAME}/:\n - ${inventory.join("\n - ")}`,
197
+ "info",
198
+ );
199
+ return { ...result, relocated: inventory };
200
+ }
201
+
202
+ // Confirm with the user
203
+ if (!options.autoYes) {
204
+ const ok = await ui.confirm(
205
+ `soly migrate ${LEGACY_SOLY_DIRNAME}/ → ${SOLY_DIRNAME}/`,
206
+ `Move ${inventory.length} entries (${inventory.slice(0, 5).join(", ")}${inventory.length > 5 ? ", …" : ""})? ` +
207
+ `This is atomic on the same filesystem but does NOT preserve git history. ` +
208
+ `Recommend committing ${LEGACY_SOLY_DIRNAME}/ separately first if you care about history.`,
209
+ );
210
+ if (!ok) {
211
+ ui.notify("soly-migrate: cancelled", "info");
212
+ return result;
213
+ }
214
+ }
215
+
216
+ // Do the move (with Windows EPERM retry + copy fallback)
217
+ try {
218
+ await moveDir(from, to);
219
+ } catch (err) {
220
+ const msg = (err as Error).message;
221
+ const isPartial = msg.includes("Delete ") || msg.includes("could not remove");
222
+ ui.notify(
223
+ isPartial
224
+ ? `soly-migrate: ${msg}`
225
+ : `soly-migrate: rename failed: ${msg}. Original .soly/ untouched.`,
226
+ isPartial ? "warning" : "error",
227
+ );
228
+ return result;
229
+ }
230
+
231
+ // Validate the result
232
+ if (!fs.existsSync(to)) {
233
+ ui.notify(`soly-migrate: post-rename check failed — ${SOLY_DIRNAME}/ missing`, "error");
234
+ return result;
235
+ }
236
+ for (const expected of EXPECTED_ENTRIES) {
237
+ const src = path.join(from, expected);
238
+ const dst = path.join(to, expected);
239
+ // Only check items that existed before; some are optional
240
+ if (fs.existsSync(src) && !fs.existsSync(dst)) {
241
+ result.warnings.push(`missing after move: ${expected}`);
242
+ }
243
+ }
244
+
245
+ result.moved = true;
246
+
247
+ // Success message + git hint
248
+ const warnPart = result.warnings.length > 0
249
+ ? `\n\nWarnings:\n - ${result.warnings.join("\n - ")}`
250
+ : "";
251
+ ui.notify(
252
+ `soly-migrate: done. ${inventory.length} entries moved to ${SOLY_DIRNAME}/.${warnPart}\n\n` +
253
+ `Recommended next:\n` +
254
+ ` git add -A && git commit -m "migrate soly state to .agents/"`,
255
+ "info",
256
+ );
257
+ return result;
258
+ }
@@ -0,0 +1,218 @@
1
+ // =============================================================================
2
+ // notification.ts — Styled, framed notifications for soly (pi-themed)
3
+ // =============================================================================
4
+ //
5
+ // Uses pi's `ui.setWidget()` with a Box container and `theme.bg()` for a
6
+ // soft background — the same pattern pi itself uses for branch summaries
7
+ // and compaction messages (see pi-coding-agent's BranchSummaryMessage).
8
+ //
9
+ // Why a widget, not `ui.notify()`?
10
+ // - `notify()` is plain text, no background, no styled box
11
+ // - `setWidget(key, factory, opts)` accepts a factory returning a
12
+ // `Box` / `Text` component, which gives full theme support
13
+ // - The widget lives in the editor area (above/below the prompt),
14
+ // so the user actually sees it before sending the next message
15
+ //
16
+ // Lifecycle:
17
+ // - Each notification uses a unique key (or shared "soly-nudge" for nudges)
18
+ // - New notification with the same key REPLACES the previous
19
+ // - `autoClearMs` (default 5s) removes the widget via setWidget(undefined)
20
+ //
21
+ // API:
22
+ // - formatFramed(title, lines) — pure text → framed text (no styling)
23
+ // - notifyFramed(ui, title, lines, opts) — boxed widget with theme bg
24
+ // - notifyNudge(ui, variant, angle) — specific for prompt nudges
25
+ // - notifyDeprecation(ui, old, new, hint?) — for migration warnings
26
+ // - clearNotification(ui, key) — manual clear
27
+ // =============================================================================
28
+
29
+ import { Box, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
30
+ import type { ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
31
+ import { appendNotification } from "./notifications-log.ts";
32
+
33
+ /** Theme background color names (subset of ThemeBg). */
34
+ export type NotifBg =
35
+ | "customMessageBg" // soft, neutral (default — matches branch summary)
36
+ | "selectedBg" // for emphasis
37
+ | "toolPendingBg" // warning yellow
38
+ | "toolSuccessBg" // green
39
+ | "toolErrorBg"; // red
40
+
41
+ export interface NotifOptions {
42
+ /** Auto-clear after N ms. Default 5000. 0 = no auto-clear. */
43
+ autoClearMs?: number;
44
+ /** "aboveEditor" (default) or "belowEditor". */
45
+ placement?: "aboveEditor" | "belowEditor";
46
+ /** Widget key for replacement/clearing. Default: "soly-notif". */
47
+ key?: string;
48
+ /** Background color name. Default "customMessageBg". */
49
+ bg?: NotifBg;
50
+ /** Bold the title. Default true. */
51
+ boldTitle?: boolean;
52
+ }
53
+
54
+ const DEFAULT_KEY = "soly-notif";
55
+
56
+ /** Build a Box with the given title + body lines, themed with bg. */
57
+ function buildNotifBox(
58
+ theme: Theme,
59
+ title: string,
60
+ lines: readonly string[],
61
+ bg: NotifBg,
62
+ boldTitle: boolean,
63
+ ): Box {
64
+ const box = new Box(1, 0, (t) => theme.bg(bg, t));
65
+ const titleStyled = boldTitle
66
+ ? theme.bold(theme.fg("accent", title))
67
+ : theme.fg("accent", title);
68
+ box.addChild(new Text(titleStyled, 1, 0));
69
+ if (lines.length > 0) {
70
+ box.addChild(new Spacer(1));
71
+ for (const line of lines) {
72
+ box.addChild(new Text(line, 1, 0));
73
+ }
74
+ }
75
+ return box;
76
+ }
77
+
78
+ /**
79
+ * Show a framed, themed notification as a widget above/below the editor.
80
+ * Replaces any prior notification with the same key. Auto-clears after
81
+ * `autoClearMs` (default 5s) unless 0.
82
+ */
83
+ export function notifyFramed(
84
+ ui: ExtensionUIContext,
85
+ title: string,
86
+ lines: readonly string[],
87
+ options: NotifOptions = {},
88
+ ): void {
89
+ const key = options.key ?? DEFAULT_KEY;
90
+ const bg: NotifBg = options.bg ?? "customMessageBg";
91
+ const placement = options.placement ?? "aboveEditor";
92
+ const autoClearMs = options.autoClearMs ?? 5000;
93
+ const boldTitle = options.boldTitle ?? true;
94
+ ui.setWidget(
95
+ key,
96
+ (_tui: TUI, theme: Theme) => buildNotifBox(theme, title, lines, bg, boldTitle),
97
+ { placement },
98
+ );
99
+ if (autoClearMs > 0) {
100
+ setTimeout(() => {
101
+ try { ui.setWidget(key, undefined); } catch { /* session may have ended */ }
102
+ }, autoClearMs);
103
+ }
104
+ }
105
+
106
+ /** Manually clear a notification by key. */
107
+ export function clearNotification(ui: ExtensionUIContext, key: string = DEFAULT_KEY): void {
108
+ try { ui.setWidget(key, undefined); } catch { /* ignore */ }
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // High-level helpers
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /** Nudge: "I should clarify before acting on this non-trivial prompt". */
116
+ export function notifyNudge(
117
+ ui: ExtensionUIContext,
118
+ variant: "nonTrivial" | "research",
119
+ angle: string,
120
+ cwd?: string,
121
+ ): void {
122
+ const title = variant === "research" ? "soly · research-heavy" : "soly · non-trivial";
123
+ const body = variant === "research"
124
+ ? [
125
+ "Prompt looks like a research / look-up task.",
126
+ `Consider clarifying: ${angle}`,
127
+ ]
128
+ : [
129
+ "Prompt looks like a non-trivial change.",
130
+ `Consider asking for: ${angle}`,
131
+ ];
132
+ notifyFramed(ui, title, body, {
133
+ key: "soly-nudge",
134
+ bg: "customMessageBg",
135
+ autoClearMs: 4000, // nudge is transient — clears before next user input
136
+ });
137
+ if (cwd) {
138
+ appendNotification(cwd, { kind: "nudge", title, body, meta: { variant } });
139
+ }
140
+ }
141
+
142
+ /** Deprecation: warn when an old path/convention is used. */
143
+ export function notifyDeprecation(
144
+ ui: ExtensionUIContext,
145
+ oldThing: string,
146
+ newThing: string,
147
+ hint?: string,
148
+ cwd?: string,
149
+ ): void {
150
+ const body: string[] = [
151
+ `Using: ${oldThing}`,
152
+ `Preferred: ${newThing}`,
153
+ ];
154
+ if (hint) body.push(hint);
155
+ notifyFramed(ui, "soly · deprecation", body, {
156
+ key: "soly-deprecation",
157
+ bg: "toolPendingBg", // warning yellow tint
158
+ autoClearMs: 12000, // stays longer — user needs to see it
159
+ });
160
+ if (cwd) {
161
+ appendNotification(cwd, {
162
+ kind: "deprecation",
163
+ title: "soly · deprecation",
164
+ body,
165
+ });
166
+ }
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Plain-text fallback (no widget, just framed Unicode text via notify).
171
+ // Used for the .soly/ migration banner and other places that don't have
172
+ // access to a UI context yet (CLI startup, print mode, etc.)
173
+ // ---------------------------------------------------------------------------
174
+
175
+ const CHARS = {
176
+ topLeft: "╭",
177
+ topRight: "╮",
178
+ bottomLeft: "╰",
179
+ bottomRight: "╯",
180
+ horizontal: "─",
181
+ vertical: "│",
182
+ } as const;
183
+
184
+ /** Format lines as a rounded Unicode frame. Pure text, no ANSI. */
185
+ export function formatFramed(
186
+ title: string,
187
+ lines: readonly string[],
188
+ options: { minWidth?: number } = {},
189
+ ): string {
190
+ const minWidth = options.minWidth ?? 30;
191
+ const titleWithSep = ` ${title} `;
192
+ const bodyWidths = lines.map((l) => stripAnsi(l).length);
193
+ const naturalWidth = Math.max(
194
+ titleWithSep.length + 2,
195
+ ...bodyWidths.map((w) => w + 4),
196
+ minWidth,
197
+ );
198
+ const titlePadTotal = naturalWidth - titleWithSep.length - 2;
199
+ const titlePadLeft = Math.floor(titlePadTotal / 2);
200
+ const titlePadRight = titlePadTotal - titlePadLeft;
201
+ const horizontalRule =
202
+ CHARS.topLeft +
203
+ CHARS.horizontal.repeat(titlePadLeft) +
204
+ titleWithSep +
205
+ CHARS.horizontal.repeat(titlePadRight) +
206
+ CHARS.topRight;
207
+ const bottomRule =
208
+ CHARS.bottomLeft + CHARS.horizontal.repeat(naturalWidth - 2) + CHARS.bottomRight;
209
+ const bodyLines = lines.map((line) => {
210
+ const pad = naturalWidth - 4 - stripAnsi(line).length;
211
+ return CHARS.vertical + " " + line + " ".repeat(Math.max(0, pad)) + " " + CHARS.vertical;
212
+ });
213
+ return [horizontalRule, ...bodyLines, bottomRule].join("\n");
214
+ }
215
+
216
+ function stripAnsi(s: string): string {
217
+ return s.replace(/\u001b\[[0-9;]*m/g, "");
218
+ }
@@ -0,0 +1,83 @@
1
+ // =============================================================================
2
+ // notifications-log.ts — Persistent record of soly notifications
3
+ // =============================================================================
4
+ //
5
+ // Appends every nudge/deprecation to a JSONL file at .agents/.soly/notifications.log
6
+ // (inside the project dir; safe to commit if the user wants a public audit
7
+ // trail, or .gitignore if not). One line per notification, JSON-encoded.
8
+ //
9
+ // Reading: `/soly-log notifications [N]` shows the last N entries (default 20).
10
+ //
11
+ // Why: transient Box widgets auto-clear. If the user missed a nudge or wants
12
+ // to see what nudges fired in past turns/sessions, the log is the source of
13
+ // truth.
14
+ // =============================================================================
15
+
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+ import { SOLY_DIRNAME } from "./core.js";
20
+
21
+ /** Notification kinds we log. */
22
+ export type NotificationKind = "nudge" | "deprecation" | "info" | "warning" | "error";
23
+
24
+ export interface NotificationEntry {
25
+ ts: string; // ISO timestamp
26
+ kind: NotificationKind;
27
+ title: string; // e.g. "soly · non-trivial"
28
+ body: string[]; // message lines
29
+ /** Free-form metadata (e.g. "variant": "research") */
30
+ meta?: Record<string, unknown>;
31
+ }
32
+
33
+ /** Where the log file lives. Respects HOME for tests. */
34
+ export function logFilePath(cwd: string, home?: string): string {
35
+ const h = home ?? process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
36
+ return path.join(cwd, SOLY_DIRNAME, ".soly", "notifications.log");
37
+ }
38
+
39
+ /** Append a single entry to the log. Creates parent dirs as needed. */
40
+ export function appendNotification(cwd: string, entry: Omit<NotificationEntry, "ts">): void {
41
+ const file = logFilePath(cwd);
42
+ try {
43
+ fs.mkdirSync(path.dirname(file), { recursive: true });
44
+ } catch { /* ignore */ }
45
+ const full: NotificationEntry = { ts: new Date().toISOString(), ...entry };
46
+ try {
47
+ fs.appendFileSync(file, JSON.stringify(full) + "\n", "utf-8");
48
+ } catch { /* best-effort — don't fail the notification itself */ }
49
+ }
50
+
51
+ /** Read the last N entries (newest first). */
52
+ export function readNotifications(cwd: string, limit: number = 20, home?: string): NotificationEntry[] {
53
+ const file = logFilePath(cwd, home);
54
+ if (!fs.existsSync(file)) return [];
55
+ let raw: string;
56
+ try {
57
+ raw = fs.readFileSync(file, "utf-8");
58
+ } catch {
59
+ return [];
60
+ }
61
+ const lines = raw.split("\n").filter((l) => l.length > 0);
62
+ const entries: NotificationEntry[] = [];
63
+ for (const line of lines) {
64
+ try {
65
+ entries.push(JSON.parse(line) as NotificationEntry);
66
+ } catch { /* skip malformed */ }
67
+ }
68
+ // Newest first
69
+ return entries.reverse().slice(0, limit);
70
+ }
71
+
72
+ /** Format entries for display in the TUI. */
73
+ export function formatNotifications(entries: readonly NotificationEntry[]): string {
74
+ if (entries.length === 0) return "(no notifications recorded)";
75
+ const lines: string[] = [];
76
+ for (const e of entries) {
77
+ const date = e.ts.slice(0, 16).replace("T", " ");
78
+ lines.push(`[${date}] ${e.title}`);
79
+ for (const b of e.body) lines.push(` ${b}`);
80
+ lines.push("");
81
+ }
82
+ return lines.join("\n");
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "1.9.3",
3
+ "version": "1.11.0",
4
4
  "description": "Project management + workflow engine for pi-coding-agent. Plans, state, MANDATORY rules, multi-question picker — one npm install, zero config. LLM is the executor (no subagent layer).",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -16,10 +16,15 @@
16
16
  "devDependencies": {
17
17
  "@earendil-works/pi-ai": "0.78.1",
18
18
  "@earendil-works/pi-coding-agent": "0.78.1",
19
+ "@modelcontextprotocol/ext-apps": "^1.7.4",
20
+ "@modelcontextprotocol/sdk": "1.27.1",
19
21
  "@types/node": "^25.9.1",
20
22
  "bun-types": "^1.3.14",
23
+ "open": "^11.0.0",
24
+ "recheck": "^4.5.0",
21
25
  "typebox": "1.1.38",
22
- "typescript": "^6.0.3"
26
+ "typescript": "^6.0.3",
27
+ "zod": "^4.4.3"
23
28
  },
24
29
  "files": [
25
30
  "README.md",
@@ -41,7 +46,16 @@
41
46
  "ask",
42
47
  "skills",
43
48
  "workflows",
44
- "workflows-data"
49
+ "workflows-data",
50
+ "mcp",
51
+ "notification.ts",
52
+ "notifications-log.ts",
53
+ "status.ts",
54
+ "init.ts",
55
+ "migrate.ts",
56
+ "codemap.ts",
57
+ "state.ts",
58
+ "hotreload.ts"
45
59
  ],
46
60
  "keywords": [
47
61
  "pi",
@@ -53,6 +67,9 @@
53
67
  "workflow-engine",
54
68
  "rules-engine",
55
69
  "intent-docs",
70
+ "mcp",
71
+ "model-context-protocol",
72
+ "session-recovery",
56
73
  "ai-coding"
57
74
  ],
58
75
  "license": "MIT",