pi-archimedes 2.7.3 → 2.9.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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  An extra pair of eyes on your code. Agents working in parallel. A terminal that keeps you in the loop—and looks good doing it.
5
5
 
6
- **Archimedes brings subagents, shared task lists, MCP tools, and a polished interface to [Pi](https://github.com/earendil-works/pi). Install them together, use what you like, and make the setup yours.**
6
+ **Archimedes brings subagents, shared task lists, and a polished interface to [Pi](https://github.com/earendil-works/pi). Install them together, use what you like, and make the setup yours.**
7
7
 
8
8
  [![npm version](https://img.shields.io/npm/v/pi-archimedes?style=flat-square)](https://www.npmjs.com/package/pi-archimedes)
9
9
  [![Node.js Version](https://img.shields.io/badge/node-%3E%3D22.19.0-brightgreen?style=flat-square)](https://nodejs.org)
@@ -23,7 +23,7 @@ One command:
23
23
  pi install npm:pi-archimedes
24
24
  ```
25
25
 
26
- Your `~/.pi/agent/` stays as it is — Archimedes only adds its namespaces under `settings.json`. Pi's own `auth.json`, `keybindings.json`, agents, and sessions are untouched (and `/mcp setup` only writes the project's `.mcp.json`, when you run it).
26
+ Your `~/.pi/agent/` stays as it is — Archimedes only adds its namespaces under `settings.json`. Pi's own `auth.json`, `keybindings.json`, agents, and sessions are untouched.
27
27
 
28
28
  Then run `/reload` in your session (or start a new one) to pick it up — that reloads the extensions *and* your keybindings, so any shortcuts you've customized in `~/.pi/agent/keybindings.json` keep working.
29
29
 
@@ -104,14 +104,6 @@ You don't have to copy messages between terminals to stay involved.
104
104
 
105
105
  ---
106
106
 
107
- ## Bring the tools you already use.
108
-
109
- [Connect MCP servers](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/mcp/README.md), browse their tools, and handle authentication inside Pi. Import server definitions from Cursor, Claude Code, Claude Desktop, or VS Code rather than rebuilding your setup.
110
-
111
- Start with `/mcp setup`. Manage it with `/mcp`.
112
-
113
- ---
114
-
115
107
  ## See what changed. Not just that something changed.
116
108
 
117
109
  [Syntax-highlighted diffs](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/diff/README.md), side by side when there's room and unified when there isn't. Word-level highlights draw your eye to the changes inside each line.
@@ -150,7 +142,7 @@ You can leave the terminal to do its thing.
150
142
 
151
143
  One install brings everything together. Switch optional extensions on or off with `/plugins`, then `/reload` to apply. Use `/archimedes` to adjust the available settings.
152
144
 
153
- Only want the diffs, footer, or MCP tools? Each component is available separately — see [Components](#components).
145
+ Only want the diffs or footer? Each component is available separately — see [Components](#components).
154
146
 
155
147
  ---
156
148
 
@@ -158,11 +150,10 @@ Only want the diffs, footer, or MCP tools? Each component is available separatel
158
150
 
159
151
  | Command | Scope | Notes |
160
152
  |---------|-------|-------|
161
- | `/plugins` | Suite | Toggle the ten optional extensions (core is always on and not toggleable). Toggles persist immediately; `/reload` (or a fresh session) applies them. |
153
+ | `/plugins` | Suite | Toggle the eleven optional extensions (core is always on and not toggleable). Toggles persist immediately; `/reload` (or a fresh session) applies them. |
162
154
  | `/archimedes` | Suite | Interactive settings panel — up/down moves, left/right changes values, Enter edits supported fields, `s` saves, Esc discards the current edits. Settings captured at startup need `/reload`. Not every setting has a panel control. |
163
155
  | `/agents` | Suite, subagent enabled | Browse, create, and edit custom subagent definitions in `.pi/agents/*.md`. |
164
156
  | `/todos` | Todo component | Refreshes the todo widget and reports its status. `/todos clear` clears the list. (The board's visibility is not a `/todos` toggle — see the [todo docs](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/todo/README.md).) |
165
- | `/mcp`, `/mcp setup` | MCP component | Manage servers and run logins; the setup wizard scaffolds `.mcp.json` or imports configs from Cursor, Claude Code, Claude Desktop, or VS Code. |
166
157
  | `/sudo`, `/sudo forget` | Sudo component | Inspect cached credential state; `forget` clears it. |
167
158
  | `/reload` | Pi | Applies plugin changes and settings read at startup. |
168
159
 
@@ -170,7 +161,7 @@ Only want the diffs, footer, or MCP tools? Each component is available separatel
170
161
 
171
162
  ## Settings
172
163
 
173
- Every component keeps its own namespace under `~/.pi/agent/settings.json`, which Pi parses as **strict JSON** (no comments — unlike MCP server configs, which accept JSONC). Each component's README documents its namespace, fields, and defaults — including [core](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/core/README.md) (chrome, spinner, thinking), [footer](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/footer/README.md), [diff](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/diff/README.md), [notify](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/notify/README.md), [mcp](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/mcp/README.md), [image-paste](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/image-paste/README.md), and [sudo](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/sudo/README.md) (also strict JSON). The `/archimedes` panel covers the settings that have a control; not everything does.
164
+ Every component keeps its own namespace under `~/.pi/agent/settings.json`, which Pi parses as **strict JSON**. Each component's README documents its namespace, fields, and defaults — including [core](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/core/README.md) (chrome, spinner, thinking), [footer](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/footer/README.md), [diff](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/diff/README.md), [notify](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/notify/README.md), [image-paste](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/image-paste/README.md), and [sudo](https://github.com/danielcherubini/pi-archimedes/blob/main/packages/sudo/README.md) (also strict JSON). The `/archimedes` panel covers the settings that have a control; not everything does.
174
165
 
175
166
  ---
176
167
 
@@ -182,7 +173,6 @@ Every component keeps its own namespace under `~/.pi/agent/settings.json`, which
182
173
  | **Subagent** | [`@pi-archimedes/subagent`](https://www.npmjs.com/package/@pi-archimedes/subagent) | Live subagent dispatch, custom agent definitions; `/agents` editor with the suite |
183
174
  | **Todo** | [`@pi-archimedes/todo`](https://www.npmjs.com/package/@pi-archimedes/todo) | Multi-column todo board with subagent columns and auto-clear |
184
175
  | **Ask** | [`@pi-archimedes/ask`](https://www.npmjs.com/package/@pi-archimedes/ask) | Structured questions — including subagent questions relayed into your terminal |
185
- | **MCP** | [`@pi-archimedes/mcp`](https://www.npmjs.com/package/@pi-archimedes/mcp) | `/mcp` management, setup wizard, OAuth, config imports |
186
176
  | **Sudo** | [`@pi-archimedes/sudo`](https://www.npmjs.com/package/@pi-archimedes/sudo) | `sudo_exec` with masked password prompt and interactive-sudo guard |
187
177
  | **Diff** | [`@pi-archimedes/diff`](https://www.npmjs.com/package/@pi-archimedes/diff) | Syntax-highlighted side-by-side and unified diffs with word-level highlights |
188
178
  | **Footer** | [`@pi-archimedes/footer`](https://www.npmjs.com/package/@pi-archimedes/footer) | Branch, model, context usage, and token/cost status bar |
@@ -199,7 +189,6 @@ pi install npm:@pi-archimedes/core
199
189
  pi install npm:@pi-archimedes/subagent
200
190
  pi install npm:@pi-archimedes/todo
201
191
  pi install npm:@pi-archimedes/ask
202
- pi install npm:@pi-archimedes/mcp
203
192
  pi install npm:@pi-archimedes/sudo
204
193
  pi install npm:@pi-archimedes/diff
205
194
  pi install npm:@pi-archimedes/footer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-archimedes",
3
- "version": "2.7.3",
3
+ "version": "2.9.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -9,32 +9,33 @@
9
9
  "keywords": [
10
10
  "pi-package"
11
11
  ],
12
- "description": "Parallel agents, shared task lists, MCP tools, and a polished terminal for the Pi coding agent.",
12
+ "description": "Parallel agents, shared task lists, and a polished terminal for the Pi coding agent.",
13
13
  "files": [
14
14
  "src",
15
15
  "README.md"
16
16
  ],
17
17
  "main": "./src/index.ts",
18
18
  "dependencies": {
19
- "@pi-archimedes/core": "2.7.3",
20
- "@pi-archimedes/footer": "2.7.3",
21
- "@pi-archimedes/ask": "2.7.3",
22
- "@pi-archimedes/image-paste": "2.7.3",
23
- "@pi-archimedes/todo": "2.7.3",
24
- "@pi-archimedes/subagent": "2.7.3",
25
- "@pi-archimedes/diff": "2.7.3",
26
- "@pi-archimedes/session-name": "2.7.3",
27
- "@pi-archimedes/mcp": "2.7.3",
28
- "@pi-archimedes/notify": "2.7.3",
29
- "@pi-archimedes/sudo": "2.7.3"
19
+ "@pi-archimedes/core": "2.9.0",
20
+ "@pi-archimedes/footer": "2.9.0",
21
+ "@pi-archimedes/diff": "2.9.0",
22
+ "@pi-archimedes/ask": "2.9.0",
23
+ "@pi-archimedes/image-paste": "2.9.0",
24
+ "@pi-archimedes/subagent": "2.9.0",
25
+ "@pi-archimedes/todo": "2.9.0",
26
+ "@pi-archimedes/notify": "2.9.0",
27
+ "@pi-archimedes/session-name": "2.9.0",
28
+ "@pi-archimedes/sudo": "2.9.0",
29
+ "@pi-archimedes/ui": "2.9.0",
30
+ "@pi-archimedes/web": "2.9.0"
30
31
  },
31
32
  "peerDependencies": {
32
33
  "@earendil-works/pi-coding-agent": ">=0.1.0",
33
34
  "@earendil-works/pi-tui": ">=0.1.0"
34
35
  },
35
36
  "devDependencies": {
36
- "@earendil-works/pi-coding-agent": "^0.85.1",
37
- "@earendil-works/pi-tui": "^0.85.1",
37
+ "@earendil-works/pi-coding-agent": "^0.87.0",
38
+ "@earendil-works/pi-tui": "^0.87.0",
38
39
  "typescript": "^6.0.0"
39
40
  },
40
41
  "pi": {
package/src/config.ts CHANGED
@@ -1,20 +1,31 @@
1
- // ── Re-export core config ──────────────────────────────────────────────
1
+ // ── Re-export UI config ──────────────────────────────────────────────
2
2
 
3
3
  import {
4
4
  loadCoreConfig,
5
- saveCoreConfig,
6
- DEFAULT_CORE_CONFIG,
7
- ANIMATION_STYLES,
8
5
  type CoreConfig,
9
6
  } from "@pi-archimedes/core/config";
10
7
  export {
11
8
  loadCoreConfig,
12
- saveCoreConfig,
13
- DEFAULT_CORE_CONFIG,
14
- ANIMATION_STYLES,
15
9
  type CoreConfig,
16
10
  } from "@pi-archimedes/core/config";
17
11
 
12
+ // ── Re-export UI config ──────────────────────────────────────────────
13
+
14
+ import {
15
+ loadUIConfig,
16
+ saveUIConfig,
17
+ DEFAULT_UI_CONFIG,
18
+ type UIConfig,
19
+ ANIMATION_STYLES,
20
+ } from "@pi-archimedes/ui/config";
21
+ export {
22
+ loadUIConfig,
23
+ saveUIConfig,
24
+ DEFAULT_UI_CONFIG,
25
+ type UIConfig,
26
+ ANIMATION_STYLES,
27
+ } from "@pi-archimedes/ui/config";
28
+
18
29
  // ── Re-export footer config ────────────────────────────────────────────
19
30
 
20
31
  import {
@@ -40,6 +51,7 @@ export const DEFAULT_DIFF_CONFIG: DiffConfig = {
40
51
  diffTheme: "github-dark",
41
52
  diffSplitMinWidth: 150,
42
53
  diffSplitMinCodeWidth: 60,
54
+ diffSplitWrapCheck: true,
43
55
  };
44
56
 
45
57
  const NAMESPACE = "archimedes.diff";
@@ -71,6 +83,7 @@ export {
71
83
 
72
84
  export function loadAllConfig(): {
73
85
  core: CoreConfig;
86
+ ui: UIConfig;
74
87
  footer: FooterConfig;
75
88
  diff: DiffConfig;
76
89
  notify: NotifyConfig;
@@ -78,6 +91,7 @@ export function loadAllConfig(): {
78
91
  } {
79
92
  return {
80
93
  core: loadCoreConfig(),
94
+ ui: loadUIConfig(),
81
95
  footer: loadFooterConfig(),
82
96
  diff: loadDiffConfig(),
83
97
  notify: loadNotifyConfig(),
@@ -53,6 +53,9 @@ vi.mock("@pi-archimedes/core/profiler", () => ({
53
53
  }));
54
54
  vi.mock("@pi-archimedes/core", () => ({
55
55
  registerCore: vi.fn(),
56
+ }));
57
+ vi.mock("@pi-archimedes/ui", () => ({
58
+ registerUI: vi.fn(),
56
59
  unpatchConsoleLog: vi.fn(),
57
60
  }));
58
61
  vi.mock("@pi-archimedes/footer", () => ({ registerFooter: vi.fn() }));
@@ -63,8 +66,8 @@ vi.mock("@pi-archimedes/session-name", () => ({ registerSessionName: vi.fn() }))
63
66
  vi.mock("@pi-archimedes/sudo", () => ({ registerSudo: vi.fn() }));
64
67
 
65
68
  // Dynamic imports done in the session_start handler — mock EXACTLY the
66
- // properties index.ts uses via destructured `ipMod.*` / `diffMod` / `saMod` /
67
- // `mcpMod` access (import result objects, not named destructure).
69
+ // properties index.ts uses via destructured `ipMod.*` / `diffMod` / `saMod`
70
+ // access (import result objects, not named destructure).
68
71
  vi.mock("@pi-archimedes/diff", () => ({
69
72
  registerDiffTools: vi.fn(),
70
73
  }));
@@ -80,14 +83,12 @@ vi.mock("@pi-archimedes/subagent", () => ({
80
83
  registerSubagent: vi.fn(),
81
84
  registerAgentsCommand: vi.fn(),
82
85
  }));
83
- vi.mock("@pi-archimedes/mcp", () => ({
84
- registerMcp: vi.fn(),
85
- }));
86
86
 
87
87
  // Meta-local modules the factory imports — not under test here
88
88
  vi.mock("./config.js", () => ({ loadDiffConfig: vi.fn(() => ({})) }));
89
89
  vi.mock("./settings.js", () => ({ openSettings: vi.fn() }));
90
90
  vi.mock("./plugin-manager.js", () => ({ registerPluginsCommand: vi.fn() }));
91
+ vi.mock("./onboarding/index.js", () => ({ runOnboarding: vi.fn(async () => {}) }));
91
92
 
92
93
  // The real plugins.ts gate semantics are what these tests exercise
93
94
  // (read via the mocked settings-io on each call → mutable mid-session).
@@ -95,9 +96,11 @@ const { default: metaFactory } = await import("./index.js");
95
96
 
96
97
  const { registerImagePaste, shutdownImagePaste, initImagePasteSession } =
97
98
  await import("@pi-archimedes/image-paste");
99
+ const { unpatchConsoleLog } = await import("@pi-archimedes/ui");
98
100
 
99
101
  const { offerKeybindingFix } =
100
102
  await import("@pi-archimedes/image-paste/keybinding-offer");
103
+ const { runOnboarding } = await import("./onboarding/index.js");
101
104
 
102
105
  // ── Stub pi: record pi.on() registrations and command/tool registrations ───
103
106
 
@@ -132,10 +135,13 @@ function freshFactory(): {
132
135
  harness: PiHarness;
133
136
  startSession: (ctx: unknown) => Promise<unknown>;
134
137
  shutdownSession: () => unknown;
135
- /** Fire ONLY the keybinding-offer session_start handler (the one registered
136
- * before the lazy-load handler). Useful for isolating offer behaviour
137
- * without triggering the heavy dynamic-import path. */
138
- fireOfferHandler: (ctx: unknown) => void;
138
+ /** Fire ONLY the first-run (merged) session_start handler (the one
139
+ * registered before the lazy-load handler). Useful for isolating the
140
+ * offer → onboarding sequencing without triggering the heavy
141
+ * dynamic-import path. */
142
+ fireFirstRunHandler: (ctx: unknown) => void;
143
+ /** Number of session_start handlers the factory registered. */
144
+ startRcCount: number;
139
145
  } {
140
146
  const harness = makePi();
141
147
  metaFactory(harness.pi);
@@ -147,18 +153,20 @@ function freshFactory(): {
147
153
  // The LAST session_start handler is the lazy-load one (existing tests rely on this).
148
154
  const startRc = (startRcs[startRcs.length - 1] ?? expect.fail("no session_start handler")) as (...args: unknown[]) => unknown;
149
155
  const shutdownRc = (shutdownRcs[shutdownRcs.length - 1] ?? expect.fail("no session_shutdown handler")) as (...args: unknown[]) => unknown;
150
- // The keybinding-offer handler is at index 0 in this fully-mocked harness —
151
- // all earlier package registrations are stubbed to no-op vi.fn(); if a
152
- // package is un-mocked here, update the index.
153
- const offerRc = (startRcs[0] ?? expect.fail("no offer session_start handler")) as (...args: unknown[]) => unknown;
156
+ // The first-run MERGED handler (offer + onboarding, sequenced) is at
157
+ // index 0 in this fully-mocked harness — all earlier package
158
+ // registrations are stubbed to no-op vi.fn(); if a package is un-mocked
159
+ // here, update the index.
160
+ const firstRunRc = (startRcs[0] ?? expect.fail("no first-run session_start handler")) as (...args: unknown[]) => unknown;
154
161
 
155
162
  return {
156
163
  harness,
157
164
  startSession: (ctx: unknown) => startRc(undefined, ctx) as Promise<unknown>,
158
165
  shutdownSession: () => shutdownRc(undefined, {}),
159
- fireOfferHandler: (ctx: unknown) => {
160
- offerRc(undefined, ctx);
166
+ fireFirstRunHandler: (ctx: unknown) => {
167
+ firstRunRc(undefined, ctx);
161
168
  },
169
+ startRcCount: startRcs.length,
162
170
  };
163
171
  }
164
172
 
@@ -182,6 +190,7 @@ describe("image-paste factory lifecycle (registration is config-gated, teardown
182
190
  // Session ends → cleanup MUST still run for this session's registration.
183
191
  shutdownSession();
184
192
  expect(vi.mocked(shutdownImagePaste)).toHaveBeenCalledTimes(1);
193
+ expect(vi.mocked(unpatchConsoleLog)).toHaveBeenCalledTimes(1);
185
194
  });
186
195
 
187
196
  it("registers nothing and tears down nothing when config is off at session start", async () => {
@@ -214,30 +223,68 @@ describe("image-paste factory lifecycle (registration is config-gated, teardown
214
223
  });
215
224
  });
216
225
 
217
- describe("keybinding-offer wiring (session_start → offerKeybindingFix, fire-and-forget)", () => {
218
- it("calls offerKeybindingFix exactly once with the session ctx", () => {
226
+ describe("first-run sequencing (session_start → offerKeybindingFix, then runOnboarding)", () => {
227
+ it("registers exactly two session_start handlers (merged first-run + lazy-load)", () => {
228
+ const { startRcCount } = freshFactory();
229
+ // The keybinding offer and the onboarding are ONE merged handler — not
230
+ // two stacked first-run dialogs (which is what this guards against).
231
+ expect(startRcCount).toBe(2);
232
+ });
233
+
234
+ it("calls offerKeybindingFix exactly once with the session ctx (before the onboarding)", () => {
219
235
  const ctx = { ui: { theme: "dark" } };
220
- // Return a resolved promise so the handler's .catch() has nothing to report.
221
- vi.mocked(offerKeybindingFix).mockResolvedValueOnce(undefined);
222
- const { fireOfferHandler } = freshFactory();
236
+ // The merged handler awaits the offer; resolve false so the onboarding
237
+ // branch runs (a resolved promise, so nothing is left pending).
238
+ vi.mocked(offerKeybindingFix).mockResolvedValueOnce(false);
239
+ const { fireFirstRunHandler } = freshFactory();
223
240
 
224
- // The offer handler is synchronous from the caller's perspective (fire-and-forget).
225
- fireOfferHandler(ctx);
241
+ fireFirstRunHandler(ctx);
226
242
 
227
243
  expect(vi.mocked(offerKeybindingFix)).toHaveBeenCalledTimes(1);
228
244
  expect(vi.mocked(offerKeybindingFix)).toHaveBeenCalledWith(ctx);
229
245
  });
230
246
 
247
+ it("when offerKeybindingFix resolves true (reloaded), runOnboarding is NOT called", async () => {
248
+ // The offer triggered a reload: the reloaded session runs this handler
249
+ // again (where the offer is a no-op), so the onboarding must NOT run on
250
+ // the now-stale ctx.
251
+ vi.mocked(offerKeybindingFix).mockResolvedValueOnce(true);
252
+ const ctx = { ui: { theme: "dark" } };
253
+ const { fireFirstRunHandler } = freshFactory();
254
+
255
+ fireFirstRunHandler(ctx);
256
+ // Drain the microtask queue so the awaited offer has resolved.
257
+ await new Promise((r) => setTimeout(r, 0));
258
+
259
+ expect(vi.mocked(offerKeybindingFix)).toHaveBeenCalledTimes(1);
260
+ expect(vi.mocked(runOnboarding)).not.toHaveBeenCalled();
261
+ });
262
+
263
+ it("when offerKeybindingFix resolves false, runOnboarding IS called (fire-and-forget)", async () => {
264
+ vi.mocked(offerKeybindingFix).mockResolvedValueOnce(false);
265
+ const ctx = { ui: { theme: "dark" } };
266
+ const { fireFirstRunHandler } = freshFactory();
267
+
268
+ fireFirstRunHandler(ctx);
269
+ // Drain the microtask queue so the awaited offer has resolved and the
270
+ // (fire-and-forget) onboarding has been kicked off.
271
+ await new Promise((r) => setTimeout(r, 0));
272
+
273
+ expect(vi.mocked(offerKeybindingFix)).toHaveBeenCalledTimes(1);
274
+ expect(vi.mocked(runOnboarding)).toHaveBeenCalledTimes(1);
275
+ expect(vi.mocked(runOnboarding)).toHaveBeenCalledWith(ctx);
276
+ });
277
+
231
278
  it("session_start resolves even when offerKeybindingFix rejects (fire-and-forget safety)", async () => {
232
279
  // Arrange: make the mock reject once to simulate an unexpected failure.
233
280
  vi.mocked(offerKeybindingFix).mockRejectedValueOnce(new Error("boom"));
234
281
  const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
235
282
 
236
283
  const ctx = { ui: { theme: "dark" } };
237
- const { fireOfferHandler } = freshFactory();
284
+ const { fireFirstRunHandler } = freshFactory();
238
285
 
239
- // Act: invoke the offer handler; it must not throw synchronously.
240
- expect(() => fireOfferHandler(ctx)).not.toThrow();
286
+ // Act: invoke the handler; it must not throw synchronously.
287
+ expect(() => fireFirstRunHandler(ctx)).not.toThrow();
241
288
 
242
289
  // Drain the microtask queue so the .catch() branch has had time to run.
243
290
  await new Promise((r) => setTimeout(r, 0));
@@ -247,6 +294,8 @@ describe("keybinding-offer wiring (session_start → offerKeybindingFix, fire-an
247
294
  "[archimedes] keybinding offer failed:",
248
295
  expect.any(Error),
249
296
  );
297
+ // A failed offer must not block the onboarding (reloaded stays false).
298
+ expect(vi.mocked(runOnboarding)).toHaveBeenCalledTimes(1);
250
299
 
251
300
  consoleSpy.mockRestore();
252
301
  });
package/src/index.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import { time as archTime, print as archPrintTimings, reset as archResetTimings } from "@pi-archimedes/core/profiler";
4
- import { registerCore, unpatchConsoleLog } from "@pi-archimedes/core";
4
+ import { registerCore } from "@pi-archimedes/core";
5
+ import { registerUI, unpatchConsoleLog } from "@pi-archimedes/ui";
5
6
  import { registerFooter } from "@pi-archimedes/footer";
6
7
 
7
8
  // Mark when module finishes evaluating (before factory runs) for gap analysis
@@ -15,9 +16,10 @@ import { registerNotify } from "@pi-archimedes/notify";
15
16
  import { registerSessionName } from "@pi-archimedes/session-name";
16
17
  import { registerSudo } from "@pi-archimedes/sudo";
17
18
  // Light module from the image-paste package (no heavy deps) — the offer
18
- // gates are self-contained, so it is statically imported and runs in its
19
- // own top-level session_start handler, independent of the lazy-load.
19
+ // gates are self-contained, so it is statically imported and sequenced in
20
+ // the first-run session_start handler below, before the lazy-load.
20
21
  import { offerKeybindingFix } from "@pi-archimedes/image-paste/keybinding-offer";
22
+ import { runOnboarding } from "./onboarding/index.js";
21
23
  import { loadDiffConfig } from "./config.js";
22
24
  import { openSettings } from "./settings.js"
23
25
  import { registerPluginsCommand } from "./plugin-manager.js"
@@ -39,6 +41,10 @@ export default function (pi: ExtensionAPI): void {
39
41
  // Register all component extensions (static imports already compiled by jiti above)
40
42
  registerCore(pi);
41
43
  archTime("registerCore");
44
+ if (isPluginEnabled("ui")) {
45
+ registerUI(pi);
46
+ archTime("registerUI");
47
+ }
42
48
  if (isPluginEnabled("footer")) registerFooter(pi);
43
49
  archTime("registerFooter");
44
50
 
@@ -79,31 +85,34 @@ export default function (pi: ExtensionAPI): void {
79
85
  archPrintTimings();
80
86
  });
81
87
 
82
- // First-run keybinding offer (see the module doc in
83
- // packages/image-paste/src/keybinding-offer.ts). Registered at top level
84
- // (AGENTS.md), and placed BEFORE the lazy-load handler below: the
85
- // module's own gates (config enabled → TUI → flag unset → file absent
86
- // → confirm) are self-contained, so ordering with that handler is not a
87
- // correctness issue.
88
+ // First-run flows, sequenced in ONE handler (see the module docs in
89
+ // packages/image-paste/src/keybinding-offer.ts and meta/src/onboarding/
90
+ // index.ts): the keybinding offer (if any) fully resolves BEFORE the
91
+ // onboarding overlay shows — no stacked first-run dialogs.
88
92
  //
89
93
  // Deliberately NOT gated with isPluginEnabled("image-paste"): that call
90
94
  // resolves to isConfigEnabled("archimedes.imagePaste") (ADR 0012), which
91
95
  // is exactly the same key the module's own gate 1 already checks — adding
92
96
  // a wrapper here would be a redundant duplicate of the identical check.
93
- // This handler runs unconditionally on every session_start; the module's
94
- // five gates make it a no-op when not applicable.
95
- pi.on("session_start", (_event, ctx: ExtensionContext) => {
96
- // Fire-and-forget: the offer is async (it may block on a user
97
- // confirm) and must never block or take down session startup — any
98
- // throw is logged, never propagated.
97
+ // This handler runs unconditionally on every session_start; the offer's
98
+ // five gates (and the onboarding's two) make it a no-op when not
99
+ // applicable.
100
+ pi.on("session_start", async (_event, ctx: ExtensionContext) => {
101
+ // The offer may block on a user confirm; it must never fail session
102
+ // startup — any throw is logged, never propagated.
103
+ let reloaded = false;
99
104
  try {
100
- void offerKeybindingFix(ctx).catch((e) => {
101
- console.error("[archimedes] keybinding offer failed:", e);
102
- });
105
+ reloaded = await offerKeybindingFix(ctx);
103
106
  } catch (e) {
104
107
  console.error("[archimedes] keybinding offer failed:", e);
105
108
  }
109
+ // If the offer triggered a reload, the fresh session runs this handler
110
+ // again (where the offer is a no-op — keybindings.json now exists), so
111
+ // do NOT run the onboarding on the now-stale ctx.
112
+ if (reloaded) return;
113
+ void runOnboarding(ctx).catch((err) => console.error("[archimedes] onboarding failed:", err));
106
114
  });
115
+ archTime("first-run handler");
107
116
 
108
117
  pi.on("session_start", async (_event, ctx: ExtensionContext) => {
109
118
  archTime(`session_start (factory was ${Date.now() - _moduleEvalAt}ms ago)`);
@@ -111,8 +120,8 @@ export default function (pi: ExtensionAPI): void {
111
120
  // Update module-level context ref so lazy-loaded callbacks always see current session
112
121
  currentCtx = ctx;
113
122
 
114
- // ── Parallel lazy-load all three packages (saves ~100ms vs sequential) ──
115
- const [diffMod, ipMod, saMod, mcpMod] = await Promise.all([
123
+ // ── Parallel lazy-load all four packages (saves ~100ms vs sequential) ──
124
+ const [diffMod, ipMod, saMod, webMod] = await Promise.all([
116
125
  isPluginEnabled("diff")
117
126
  ? import("@pi-archimedes/diff").catch((e) => { console.error("[archimedes] diff load failed:", e); return null; })
118
127
  : Promise.resolve(null),
@@ -122,8 +131,11 @@ export default function (pi: ExtensionAPI): void {
122
131
  isPluginEnabled("subagent")
123
132
  ? import("@pi-archimedes/subagent").catch((e) => { console.error("[archimedes] subagent load failed:", e); return null; })
124
133
  : Promise.resolve(null),
125
- isPluginEnabled("mcp")
126
- ? import("@pi-archimedes/mcp").catch((e) => { console.error("[archimedes] mcp load failed:", e); return null; })
134
+ isPluginEnabled("web")
135
+ ? import("@pi-archimedes/web").catch((e) => {
136
+ console.error("[archimedes] web load failed:", e);
137
+ return null;
138
+ })
127
139
  : Promise.resolve(null),
128
140
  ]);
129
141
  archTime("4 packages loaded in parallel");
@@ -151,8 +163,8 @@ export default function (pi: ExtensionAPI): void {
151
163
  saMod.registerSubagent(pi);
152
164
  saMod.registerAgentsCommand(pi);
153
165
  }
154
- if (mcpMod) {
155
- mcpMod.registerMcp(pi);
166
+ if (webMod) {
167
+ webMod.registerWeb(pi);
156
168
  }
157
169
  });
158
170