pi-archimedes 2.10.4 → 2.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.
- package/README.md +22 -6
- package/package.json +17 -17
- package/src/factory-lifecycle.test.ts +147 -9
- package/src/index.ts +57 -11
- package/src/onboarding/index.test.ts +2 -0
- package/src/onboarding/index.ts +3 -1
- package/src/overlay-width-audit.test.ts +262 -0
- package/src/pi-support-notice.test.ts +246 -0
- package/src/plugin-manager.ts +3 -3
- package/src/plugins.test.ts +6 -2
- package/src/readme-agreement.test.ts +227 -0
- package/src/settings-manager.test.ts +1085 -0
- package/src/settings-manager.ts +481 -269
- package/src/settings-panel-height.test.ts +525 -0
- package/src/settings.test.ts +62 -2
- package/src/settings.ts +4 -0
package/README.md
CHANGED
|
@@ -23,7 +23,10 @@ One command:
|
|
|
23
23
|
pi install npm:pi-archimedes
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
Your `~/.pi/agent/` stays as it is
|
|
26
|
+
Your `~/.pi/agent/` stays as it is: Archimedes adds its namespaces under `settings.json`, and Pi's own `auth.json` and your sessions are never written. Two files get written, by the features that own them.
|
|
27
|
+
|
|
28
|
+
- **`keybindings.json`** — only ever about the paste key, and only via step 6 below. The offer writes the file **only when it is absent and only if you accept** (an existing file is never edited by the offer), and what it writes is the `app.clipboard.pasteImage: []` unbinding from the snippet below, paired with its `"$schema"` line. Setting `archimedes.imagePaste.suppressNativePaste: false` removes that entry again, leaving `{}`. Both directions act on **the suite's own entry only**: a file holding a key of yours, a different `$schema`, a non-empty binding, or JSON that does not parse is left byte-for-byte alone, and you get one warning about it — naming the entry to remove when the file really holds one, and otherwise saying there is nothing of ours to remove. Your own bindings are never rewritten.
|
|
29
|
+
- **`agents.local.json`**, plus any agent definition you create or edit with `/agents` — the subagent package's own files.
|
|
27
30
|
|
|
28
31
|
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
32
|
|
|
@@ -56,22 +59,29 @@ Then run `/reload` in your session (or start a new one) to pick it up — that r
|
|
|
56
59
|
/model
|
|
57
60
|
```
|
|
58
61
|
|
|
59
|
-
6. **Make image-paste own the paste keys** — Pi's built-in `app.clipboard.pasteImage` owns `Ctrl+V` (Linux/macOS) / `Alt+V` (Windows), and image-paste (on by default in the suite) binds the same keys — clear the built-in in `~/.pi/agent/keybindings.json` so image-paste takes them cleanly
|
|
62
|
+
6. **Make image-paste own the paste keys** — Pi's built-in `app.clipboard.pasteImage` owns `Ctrl+V` (Linux/macOS) / `Alt+V` (Windows), and image-paste (on by default in the suite) binds the same keys — clear the built-in in `~/.pi/agent/keybindings.json` so image-paste takes them cleanly. Left as is, **only one handler runs**: an extension shortcut beats a non-reserved built-in, so Pi adds a `[Extension issues]` line (`Extension shortcut conflict: 'ctrl+v' is built-in shortcut for app.clipboard.pasteImage and <ext>. Using <ext>`) and its own action never fires — measured on Pi 1.2.0, one `Ctrl+V`, one insertion, the extension's (format in Pi's [keybindings docs](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/keybindings.md)). If the file doesn't exist yet, create it with:
|
|
60
63
|
|
|
61
64
|
```json
|
|
62
65
|
{
|
|
66
|
+
"$schema": "https://pi.dev/schemas/keybindings.schema.json",
|
|
63
67
|
"app.clipboard.pasteImage": []
|
|
64
68
|
}
|
|
65
69
|
```
|
|
66
70
|
|
|
67
|
-
|
|
71
|
+
The `$schema` line is optional (Pi skips that key when loading the file) — it exists so an editor or a standalone validator can check the file against Pi's published schema, and it's what the first-run offer below writes.
|
|
72
|
+
|
|
73
|
+
If it already exists, just add/set `"app.clipboard.pasteImage": []` in there and run `/reload`. Or skip the step entirely if the file doesn't exist yet: on the first TUI session the suite offers to create it automatically with the snippet above — accepting creates the file and then tells you to run `/reload` (measured on Pi 1.2.0: the context an event handler receives has no `reload`, so the automatic path is a notification rather than a reload, and the cleared binding applies once you run it); declining or cancelling (Esc) never asks again. The offer only appears in an install that is going to hold the paste key: if you have set `archimedes.imagePaste.suppressNativePaste: false` first, it never shows, because there is nothing to unbind (and it does not spend its "already asked" flag on that way in, so turning the flag back on still gets you the offer).
|
|
74
|
+
|
|
75
|
+
**Want Pi's own paste back?** Set `archimedes.imagePaste.suppressNativePaste: false`. Turning it off removes the suite's `app.clipboard.pasteImage` unbinding from `~/.pi/agent/keybindings.json` (its own entry only, leaving `{}`) **and stops the suite binding that key** — while the flag is off no paste shortcut is registered at all — so Pi's native `app.clipboard.pasteImage` really runs again: `/new` or `/reload` applies it, no restart and no hand-editing files. The cost, in one sentence: with the flag off you get Pi's paste and lose image-paste's image attach, its multi-image queue and the preview **on that key**, because no keypress reaches our handler until you turn it back on. The default (key absent, meaning on) is what keeps the image-attaching paste. If that file holds anything other than the suite's own entry, the suite leaves it alone and tells you so once — and Pi's own paste is answering the key there unless the file itself carries the `[]` unbinding.
|
|
68
76
|
|
|
69
77
|
`/login` signs you into a supported provider (subscription or API key) and `/model` selects a model from it. Model access comes through the providers you configure in Pi — Pi's [provider docs](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/providers.md) list the supported ones, and Archimedes doesn't ship a model of its own. For the broader first run, Pi's [quickstart](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/quickstart.md) is worth a read.
|
|
70
78
|
|
|
71
79
|
<p align="center">
|
|
72
|
-
<img src="https://raw.githubusercontent.com/danielcherubini/pi-archimedes/main/docs/images/splash-screen.png" width="600" alt="
|
|
80
|
+
<img src="https://raw.githubusercontent.com/danielcherubini/pi-archimedes/main/docs/images/splash-screen.png" width="600" alt="The opt-in Archimedes startup splash: the ASCII logo above pi's own startup listing">
|
|
73
81
|
</p>
|
|
74
82
|
|
|
83
|
+
<p align="center"><sub>The startup splash is opt-in — turn it on with <code>/archimedes</code> → Startup Splash. Out of the box Pi's own startup screen is left in place.</sub></p>
|
|
84
|
+
|
|
75
85
|
---
|
|
76
86
|
|
|
77
87
|
## Give your agent some backup.
|
|
@@ -163,21 +173,27 @@ Only want the diffs or footer? Each component is available separately — see [C
|
|
|
163
173
|
|
|
164
174
|
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.
|
|
165
175
|
|
|
176
|
+
### Custom themes must validate against Pi's schema
|
|
177
|
+
|
|
178
|
+
Pi 1.2.0 validates `~/.pi/agent/themes/*.json` at startup against `theme.schema.json`, and that schema is **`additionalProperties: false`** — on the top-level object and on `colors`. A custom theme that carried extra metadata of its own (`"author"`, `"notes"`, a private palette block, a leftover key from another editor) used to load anyway; now it is refused, the theme is not applied, and Pi reports it as a startup warning. Earlier Pi validated theme JSON too, but without the `additionalProperties` clause, which is why this can arrive as a surprise on upgrade. Move whatever Pi doesn't define out of the theme file, and put `"$schema": "https://pi.dev/schemas/theme.schema.json"` in it so your editor tells you before Pi does. This is Pi's own behaviour — the suite writes no theme files.
|
|
179
|
+
|
|
166
180
|
---
|
|
167
181
|
|
|
168
182
|
## Components
|
|
169
183
|
|
|
170
184
|
| Component | npm package | What it adds |
|
|
171
185
|
|-----------|-------------|--------------|
|
|
172
|
-
| **Core** | [`@pi-archimedes/core`](https://www.npmjs.com/package/@pi-archimedes/core) | Shared
|
|
186
|
+
| **Core** | [`@pi-archimedes/core`](https://www.npmjs.com/package/@pi-archimedes/core) | Shared plumbing — `pi.events` channel vocabulary, overlay chrome, settings I/O, tool-row rendering |
|
|
187
|
+
| **UI** | [`@pi-archimedes/ui`](https://www.npmjs.com/package/@pi-archimedes/ui) | Framed editor with a working spinner, bash + codemode tool-row styling, thinking collapse, opt-in startup splash |
|
|
173
188
|
| **Subagent** | [`@pi-archimedes/subagent`](https://www.npmjs.com/package/@pi-archimedes/subagent) | Live subagent dispatch, custom agent definitions; `/agents` editor with the suite |
|
|
174
189
|
| **Todo** | [`@pi-archimedes/todo`](https://www.npmjs.com/package/@pi-archimedes/todo) | Multi-column todo board with subagent columns and auto-clear |
|
|
175
190
|
| **Ask** | [`@pi-archimedes/ask`](https://www.npmjs.com/package/@pi-archimedes/ask) | Structured questions — including subagent questions relayed into your terminal |
|
|
176
191
|
| **Sudo** | [`@pi-archimedes/sudo`](https://www.npmjs.com/package/@pi-archimedes/sudo) | `sudo_exec` with masked password prompt and interactive-sudo guard |
|
|
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 |
|
|
192
|
+
| **Diff** | [`@pi-archimedes/diff`](https://www.npmjs.com/package/@pi-archimedes/diff) | Syntax-highlighted side-by-side and unified diffs with word-level highlights, drawn into pi's `edit`/`write` tool rows |
|
|
178
193
|
| **Footer** | [`@pi-archimedes/footer`](https://www.npmjs.com/package/@pi-archimedes/footer) | Branch, model, context usage, and token/cost status bar |
|
|
179
194
|
| **Image Paste** | [`@pi-archimedes/image-paste`](https://www.npmjs.com/package/@pi-archimedes/image-paste) | Clipboard image paste with inline previews |
|
|
180
195
|
| **Notify** | [`@pi-archimedes/notify`](https://www.npmjs.com/package/@pi-archimedes/notify) | Delayed desktop notifications with input cancellation |
|
|
196
|
+
| **Web** | [`@pi-archimedes/web`](https://www.npmjs.com/package/@pi-archimedes/web) | `web_search`, `fetch_content` and `get_search_content` with multi-provider routing |
|
|
181
197
|
| **Session Name** | [`@pi-archimedes/session-name`](https://www.npmjs.com/package/@pi-archimedes/session-name) | Automatic session titles |
|
|
182
198
|
|
|
183
199
|
The full suite is the supported connected setup — the integrations above (subagent costs in the footer, subagent columns on the todo board, subagent questions in the terminal) light up when the relevant components are loaded together.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-archimedes",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/danielcherubini/pi-archimedes.git"
|
|
@@ -16,26 +16,26 @@
|
|
|
16
16
|
],
|
|
17
17
|
"main": "./src/index.ts",
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@pi-archimedes/core": "2.
|
|
20
|
-
"@pi-archimedes/footer": "2.
|
|
21
|
-
"@pi-archimedes/diff": "2.
|
|
22
|
-
"@pi-archimedes/ask": "2.
|
|
23
|
-
"@pi-archimedes/image-paste": "2.
|
|
24
|
-
"@pi-archimedes/subagent": "2.
|
|
25
|
-
"@pi-archimedes/todo": "2.
|
|
26
|
-
"@pi-archimedes/notify": "2.
|
|
27
|
-
"@pi-archimedes/session-name": "2.
|
|
28
|
-
"@pi-archimedes/sudo": "2.
|
|
29
|
-
"@pi-archimedes/ui": "2.
|
|
30
|
-
"@pi-archimedes/web": "2.
|
|
19
|
+
"@pi-archimedes/core": "2.11.0",
|
|
20
|
+
"@pi-archimedes/footer": "2.11.0",
|
|
21
|
+
"@pi-archimedes/diff": "2.11.0",
|
|
22
|
+
"@pi-archimedes/ask": "2.11.0",
|
|
23
|
+
"@pi-archimedes/image-paste": "2.11.0",
|
|
24
|
+
"@pi-archimedes/subagent": "2.11.0",
|
|
25
|
+
"@pi-archimedes/todo": "2.11.0",
|
|
26
|
+
"@pi-archimedes/notify": "2.11.0",
|
|
27
|
+
"@pi-archimedes/session-name": "2.11.0",
|
|
28
|
+
"@pi-archimedes/sudo": "2.11.0",
|
|
29
|
+
"@pi-archimedes/ui": "2.11.0",
|
|
30
|
+
"@pi-archimedes/web": "2.11.0"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
|
-
"@earendil-works/pi-coding-agent": ">=
|
|
34
|
-
"@earendil-works/pi-tui": ">=
|
|
33
|
+
"@earendil-works/pi-coding-agent": ">=1.2.0",
|
|
34
|
+
"@earendil-works/pi-tui": ">=1.2.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
-
"@earendil-works/pi-coding-agent": "
|
|
38
|
-
"@earendil-works/pi-tui": "
|
|
37
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
38
|
+
"@earendil-works/pi-tui": "1.2.0",
|
|
39
39
|
"typescript": "^6.0.0"
|
|
40
40
|
},
|
|
41
41
|
"pi": {
|
|
@@ -51,12 +51,18 @@ vi.mock("@pi-archimedes/core/profiler", () => ({
|
|
|
51
51
|
print: vi.fn(),
|
|
52
52
|
reset: vi.fn(),
|
|
53
53
|
}));
|
|
54
|
+
// Diagnostics go to the log sink, never the console (core/src/log.ts), so the
|
|
55
|
+
// failure assertions below watch `diag`.
|
|
56
|
+
vi.mock("@pi-archimedes/core/log", async (importOriginal) => {
|
|
57
|
+
const real = await importOriginal<typeof import("@pi-archimedes/core/log")>();
|
|
58
|
+
return { ...real, diag: vi.fn() };
|
|
59
|
+
});
|
|
60
|
+
import { diag } from "@pi-archimedes/core/log";
|
|
54
61
|
vi.mock("@pi-archimedes/core", () => ({
|
|
55
62
|
registerCore: vi.fn(),
|
|
56
63
|
}));
|
|
57
64
|
vi.mock("@pi-archimedes/ui", () => ({
|
|
58
65
|
registerUI: vi.fn(),
|
|
59
|
-
unpatchConsoleLog: vi.fn(),
|
|
60
66
|
}));
|
|
61
67
|
vi.mock("@pi-archimedes/footer", () => ({ registerFooter: vi.fn() }));
|
|
62
68
|
vi.mock("@pi-archimedes/todo", () => ({ registerTodo: vi.fn() }));
|
|
@@ -78,11 +84,15 @@ vi.mock("@pi-archimedes/image-paste", () => ({
|
|
|
78
84
|
}));
|
|
79
85
|
vi.mock("@pi-archimedes/image-paste/keybinding-offer", () => ({
|
|
80
86
|
offerKeybindingFix: vi.fn(),
|
|
87
|
+
runNativePasteRestore: vi.fn(async () => ({ kind: "suppressed" })),
|
|
81
88
|
}));
|
|
82
89
|
vi.mock("@pi-archimedes/subagent", () => ({
|
|
83
90
|
registerSubagent: vi.fn(),
|
|
84
91
|
registerAgentsCommand: vi.fn(),
|
|
85
92
|
}));
|
|
93
|
+
// Mocked so the composed-handler tests can order-check EVERY registration in
|
|
94
|
+
// the lazy-load handler (and so those tests do not load the real web package).
|
|
95
|
+
vi.mock("@pi-archimedes/web", () => ({ registerWeb: vi.fn() }));
|
|
86
96
|
|
|
87
97
|
// Meta-local modules the factory imports — not under test here
|
|
88
98
|
vi.mock("./config.js", () => ({ loadDiffConfig: vi.fn(() => ({})) }));
|
|
@@ -96,10 +106,12 @@ const { default: metaFactory } = await import("./index.js");
|
|
|
96
106
|
|
|
97
107
|
const { registerImagePaste, shutdownImagePaste, initImagePasteSession } =
|
|
98
108
|
await import("@pi-archimedes/image-paste");
|
|
99
|
-
const { unpatchConsoleLog } = await import("@pi-archimedes/ui");
|
|
100
109
|
|
|
101
|
-
const { offerKeybindingFix } =
|
|
110
|
+
const { offerKeybindingFix, runNativePasteRestore } =
|
|
102
111
|
await import("@pi-archimedes/image-paste/keybinding-offer");
|
|
112
|
+
const { registerDiffTools } = await import("@pi-archimedes/diff");
|
|
113
|
+
const { registerSubagent, registerAgentsCommand } = await import("@pi-archimedes/subagent");
|
|
114
|
+
const { registerWeb } = await import("@pi-archimedes/web");
|
|
103
115
|
const { runOnboarding } = await import("./onboarding/index.js");
|
|
104
116
|
|
|
105
117
|
// ── Stub pi: record pi.on() registrations and command/tool registrations ───
|
|
@@ -190,7 +202,6 @@ describe("image-paste factory lifecycle (registration is config-gated, teardown
|
|
|
190
202
|
// Session ends → cleanup MUST still run for this session's registration.
|
|
191
203
|
shutdownSession();
|
|
192
204
|
expect(vi.mocked(shutdownImagePaste)).toHaveBeenCalledTimes(1);
|
|
193
|
-
expect(vi.mocked(unpatchConsoleLog)).toHaveBeenCalledTimes(1);
|
|
194
205
|
});
|
|
195
206
|
|
|
196
207
|
it("registers nothing and tears down nothing when config is off at session start", async () => {
|
|
@@ -280,6 +291,8 @@ describe("first-run sequencing (session_start → offerKeybindingFix, then runOn
|
|
|
280
291
|
vi.mocked(offerKeybindingFix).mockRejectedValueOnce(new Error("boom"));
|
|
281
292
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
282
293
|
|
|
294
|
+
const diagBefore = vi.mocked(diag).mock.calls.length;
|
|
295
|
+
|
|
283
296
|
const ctx = { ui: { theme: "dark" } };
|
|
284
297
|
const { fireFirstRunHandler } = freshFactory();
|
|
285
298
|
|
|
@@ -289,14 +302,139 @@ describe("first-run sequencing (session_start → offerKeybindingFix, then runOn
|
|
|
289
302
|
// Drain the microtask queue so the .catch() branch has had time to run.
|
|
290
303
|
await new Promise((r) => setTimeout(r, 0));
|
|
291
304
|
|
|
292
|
-
// The rejection must have been swallowed and
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
);
|
|
305
|
+
// The rejection must have been swallowed and diagnosed — not re-thrown,
|
|
306
|
+
// and not printed to the console.
|
|
307
|
+
const lines = vi.mocked(diag).mock.calls.slice(diagBefore).map(([scope, message]) => `${scope} ${message}`);
|
|
308
|
+
expect(lines.some((l) => l.includes("keybinding offer failed") && l.includes("boom"))).toBe(true);
|
|
297
309
|
// A failed offer must not block the onboarding (reloaded stays false).
|
|
298
310
|
expect(vi.mocked(runOnboarding)).toHaveBeenCalledTimes(1);
|
|
311
|
+
// …and nothing about the failure reaches the console.
|
|
312
|
+
expect(consoleSpy).not.toHaveBeenCalled();
|
|
313
|
+
|
|
314
|
+
consoleSpy.mockRestore();
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ── the composed-suite call site for restoreNativePaste (ADR 0030 amendment) ─
|
|
319
|
+
//
|
|
320
|
+
// `suppressNativePaste: false` has to give ctrl+v back on a machine that already
|
|
321
|
+
// accepted the first-run offer. The composed suite — not image-paste's own
|
|
322
|
+
// standalone handler — is what loads the package here, so meta's session_start
|
|
323
|
+
// is the call site. Registration stays gated: a disabled image-paste must not
|
|
324
|
+
// reach into the user's keybindings.json (ADR 0012 — the plugin gate is
|
|
325
|
+
// registration, not an in-package `enabled` read).
|
|
326
|
+
describe("native-paste restore call site (composed suite)", () => {
|
|
327
|
+
// The placement of the restore in the composed handler is a deliberate
|
|
328
|
+
// deviation (ADR 0030 amendment): it is the LAST statement, because a
|
|
329
|
+
// successful restore can call ctx.reload(), which invalidates this ctx.
|
|
330
|
+
// These two tests are the fence around that placement — one for "nothing
|
|
331
|
+
// earlier is skipped", one for "nothing later touches the ctx".
|
|
332
|
+
it("runs AFTER every other registration the composed handler performs (it is last)", async () => {
|
|
333
|
+
const { startSession } = freshFactory();
|
|
334
|
+
|
|
335
|
+
await startSession({ ui: { theme: "dark" }, hasUI: true });
|
|
336
|
+
|
|
337
|
+
const restoreOrder = vi.mocked(runNativePasteRestore).mock.invocationCallOrder[0];
|
|
338
|
+
expect(restoreOrder, "the restore did not run").toBeDefined();
|
|
339
|
+
const before: Array<[string, unknown]> = [
|
|
340
|
+
["registerDiffTools", registerDiffTools],
|
|
341
|
+
["registerImagePaste", registerImagePaste],
|
|
342
|
+
["initImagePasteSession", initImagePasteSession],
|
|
343
|
+
["registerSubagent", registerSubagent],
|
|
344
|
+
["registerAgentsCommand", registerAgentsCommand],
|
|
345
|
+
["registerWeb", registerWeb],
|
|
346
|
+
];
|
|
347
|
+
for (const [name, fn] of before) {
|
|
348
|
+
const spy = vi.mocked(fn as (...args: unknown[]) => unknown);
|
|
349
|
+
expect(spy, `${name} never ran`).toHaveBeenCalledTimes(1);
|
|
350
|
+
expect(
|
|
351
|
+
spy.mock.invocationCallOrder[0],
|
|
352
|
+
`${name} must run before the restore — the restore's reload invalidates ctx`,
|
|
353
|
+
).toBeLessThan(restoreOrder as number);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("when the restore reloads, that reload is the LAST interaction with the session ctx", async () => {
|
|
358
|
+
const used: string[] = [];
|
|
359
|
+
let reloadCalls = 0;
|
|
360
|
+
const ui = {
|
|
361
|
+
theme: "dark",
|
|
362
|
+
notify: () => {
|
|
363
|
+
used.push("ui.notify");
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
const ctx = {
|
|
367
|
+
hasUI: true,
|
|
368
|
+
mode: "tui",
|
|
369
|
+
ui: new Proxy(ui, {
|
|
370
|
+
get(target, prop) {
|
|
371
|
+
used.push(`ui.${String(prop)}`);
|
|
372
|
+
return (target as Record<string, unknown>)[prop as string];
|
|
373
|
+
},
|
|
374
|
+
}),
|
|
375
|
+
reload: async () => {
|
|
376
|
+
reloadCalls += 1;
|
|
377
|
+
used.push("reload");
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
// What a real successful restore does: reload, and never use ctx again.
|
|
381
|
+
vi.mocked(runNativePasteRestore).mockImplementationOnce(async (c: unknown) => {
|
|
382
|
+
used.push("restore");
|
|
383
|
+
await (c as { reload: () => Promise<void> }).reload();
|
|
384
|
+
return { kind: "restored", path: "/tmp/keybindings.json", reloadRequired: false };
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const { startSession } = freshFactory();
|
|
388
|
+
|
|
389
|
+
await startSession(ctx);
|
|
390
|
+
|
|
391
|
+
expect(reloadCalls).toBe(1);
|
|
392
|
+
expect(used[used.length - 1], `ctx used after reload: ${used.join(" → ")}`).toBe("reload");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("runs once per session with that session's ctx when image-paste was loaded", async () => {
|
|
396
|
+
const ctx = { ui: { theme: "dark" }, hasUI: true };
|
|
397
|
+
const { startSession } = freshFactory();
|
|
299
398
|
|
|
399
|
+
await startSession(ctx);
|
|
400
|
+
|
|
401
|
+
expect(vi.mocked(runNativePasteRestore)).toHaveBeenCalledTimes(1);
|
|
402
|
+
expect(vi.mocked(runNativePasteRestore)).toHaveBeenCalledWith(ctx);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("does not run when image-paste is disabled at session start (the plugin gate covers it)", async () => {
|
|
406
|
+
mockStore["archimedes.imagePaste"] = { enabled: false };
|
|
407
|
+
const { startSession } = freshFactory();
|
|
408
|
+
|
|
409
|
+
await startSession({ ui: { theme: "dark" } });
|
|
410
|
+
|
|
411
|
+
expect(vi.mocked(registerImagePaste)).not.toHaveBeenCalled();
|
|
412
|
+
expect(vi.mocked(runNativePasteRestore)).not.toHaveBeenCalled();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("session_start still resolves when the restore runner rejects, and the failure is diagnosed — never printed", async () => {
|
|
416
|
+
vi.mocked(runNativePasteRestore).mockRejectedValueOnce(new Error("restore boom"));
|
|
417
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
418
|
+
const diagBefore = vi.mocked(diag).mock.calls.length;
|
|
419
|
+
|
|
420
|
+
const { startSession } = freshFactory();
|
|
421
|
+
|
|
422
|
+
let thrown: unknown = null;
|
|
423
|
+
try {
|
|
424
|
+
await startSession({ ui: { theme: "dark" }, hasUI: true });
|
|
425
|
+
} catch (error) {
|
|
426
|
+
thrown = error;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
expect(thrown).toBeNull();
|
|
430
|
+
const lines = vi
|
|
431
|
+
.mocked(diag)
|
|
432
|
+
.mock.calls.slice(diagBefore)
|
|
433
|
+
.map(([scope, message]) => `${scope} ${message}`);
|
|
434
|
+
expect(lines.some((l) => l.includes("native paste restore failed") && l.includes("restore boom"))).toBe(
|
|
435
|
+
true,
|
|
436
|
+
);
|
|
437
|
+
expect(consoleSpy).not.toHaveBeenCalled();
|
|
300
438
|
consoleSpy.mockRestore();
|
|
301
439
|
});
|
|
302
440
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
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 { diag, describeError } from "@pi-archimedes/core/log";
|
|
5
|
+
import { takePiSupportNotice } from "@pi-archimedes/core/pi-support";
|
|
4
6
|
import { registerCore } from "@pi-archimedes/core";
|
|
5
|
-
import { registerUI
|
|
7
|
+
import { registerUI } from "@pi-archimedes/ui";
|
|
6
8
|
import { registerFooter } from "@pi-archimedes/footer";
|
|
7
9
|
|
|
8
10
|
// Mark when module finishes evaluating (before factory runs) for gap analysis
|
|
@@ -18,7 +20,12 @@ import { registerSudo } from "@pi-archimedes/sudo";
|
|
|
18
20
|
// Light module from the image-paste package (no heavy deps) — the offer
|
|
19
21
|
// gates are self-contained, so it is statically imported and sequenced in
|
|
20
22
|
// the first-run session_start handler below, before the lazy-load.
|
|
21
|
-
|
|
23
|
+
// `runNativePasteRestore` is the composed-suite call site for the same module
|
|
24
|
+
// (ADR 0030 amendment — see the bottom of the lazy-load session_start handler).
|
|
25
|
+
import {
|
|
26
|
+
offerKeybindingFix,
|
|
27
|
+
runNativePasteRestore,
|
|
28
|
+
} from "@pi-archimedes/image-paste/keybinding-offer";
|
|
22
29
|
import { runOnboarding } from "./onboarding/index.js";
|
|
23
30
|
import { loadDiffConfig } from "./config.js";
|
|
24
31
|
import { openSettings } from "./settings.js"
|
|
@@ -81,7 +88,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
81
88
|
// re-execute a stale ref.
|
|
82
89
|
imagePasteShutdown?.();
|
|
83
90
|
imagePasteShutdown = undefined;
|
|
84
|
-
unpatchConsoleLog();
|
|
85
91
|
archPrintTimings();
|
|
86
92
|
});
|
|
87
93
|
|
|
@@ -95,8 +101,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
95
101
|
// is exactly the same key the module's own gate 1 already checks — adding
|
|
96
102
|
// a wrapper here would be a redundant duplicate of the identical check.
|
|
97
103
|
// This handler runs unconditionally on every session_start; the offer's
|
|
98
|
-
//
|
|
99
|
-
// applicable.
|
|
104
|
+
// gates (1, 1b, 2–4; and the onboarding's two) make it a no-op when not
|
|
105
|
+
// applicable. Gate 1b is the one that keeps the two paste-key flows apart:
|
|
106
|
+
// under one setting value the offer acts only while the suite will hold the
|
|
107
|
+
// key and `runNativePasteRestore`, at the end of the registration handler
|
|
108
|
+
// below, only while it will not — so a single session can never create
|
|
109
|
+
// keybindings.json and empty it again (issue #133).
|
|
100
110
|
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
101
111
|
// The offer may block on a user confirm; it must never fail session
|
|
102
112
|
// startup — any throw is logged, never propagated.
|
|
@@ -104,13 +114,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
104
114
|
try {
|
|
105
115
|
reloaded = await offerKeybindingFix(ctx);
|
|
106
116
|
} catch (e) {
|
|
107
|
-
|
|
117
|
+
diag("meta", `[archimedes] keybinding offer failed: ${describeError(e)}`);
|
|
108
118
|
}
|
|
109
119
|
// If the offer triggered a reload, the fresh session runs this handler
|
|
110
120
|
// again (where the offer is a no-op — keybindings.json now exists), so
|
|
111
121
|
// do NOT run the onboarding on the now-stale ctx.
|
|
112
122
|
if (reloaded) return;
|
|
113
|
-
void runOnboarding(ctx).catch((err) =>
|
|
123
|
+
void runOnboarding(ctx).catch((err) =>
|
|
124
|
+
diag("meta", `[archimedes] onboarding failed: ${describeError(err)}`),
|
|
125
|
+
);
|
|
114
126
|
});
|
|
115
127
|
archTime("first-run handler");
|
|
116
128
|
|
|
@@ -123,17 +135,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
123
135
|
// ── Parallel lazy-load all four packages (saves ~100ms vs sequential) ──
|
|
124
136
|
const [diffMod, ipMod, saMod, webMod] = await Promise.all([
|
|
125
137
|
isPluginEnabled("diff")
|
|
126
|
-
? import("@pi-archimedes/diff").catch((e) => {
|
|
138
|
+
? import("@pi-archimedes/diff").catch((e) => { diag("meta", `[archimedes] diff load failed: ${describeError(e)}`); return null; })
|
|
127
139
|
: Promise.resolve(null),
|
|
128
140
|
isPluginEnabled("image-paste")
|
|
129
|
-
? import("@pi-archimedes/image-paste").catch((e) => {
|
|
141
|
+
? import("@pi-archimedes/image-paste").catch((e) => { diag("meta", `[archimedes] image-paste load failed: ${describeError(e)}`); return null; })
|
|
130
142
|
: Promise.resolve(null),
|
|
131
143
|
isPluginEnabled("subagent")
|
|
132
|
-
? import("@pi-archimedes/subagent").catch((e) => {
|
|
144
|
+
? import("@pi-archimedes/subagent").catch((e) => { diag("meta", `[archimedes] subagent load failed: ${describeError(e)}`); return null; })
|
|
133
145
|
: Promise.resolve(null),
|
|
134
146
|
isPluginEnabled("web")
|
|
135
147
|
? import("@pi-archimedes/web").catch((e) => {
|
|
136
|
-
|
|
148
|
+
diag("meta", `[archimedes] web load failed: ${describeError(e)}`);
|
|
137
149
|
return null;
|
|
138
150
|
})
|
|
139
151
|
: Promise.resolve(null),
|
|
@@ -166,6 +178,40 @@ export default function (pi: ExtensionAPI): void {
|
|
|
166
178
|
if (webMod) {
|
|
167
179
|
webMod.registerWeb(pi);
|
|
168
180
|
}
|
|
181
|
+
|
|
182
|
+
// One aggregated host-version notice, never one per package (ADR 0026).
|
|
183
|
+
// Every package reports during its own registration — the ones above, and
|
|
184
|
+
// `ui`/`footer`/… from the factory — so this is the only place the suite
|
|
185
|
+
// shows it. Reading the notice spends it: a second `session_start` (`/new`,
|
|
186
|
+
// `/fork`) or a `/reload` (fresh modules, same process, and the same pi
|
|
187
|
+
// version — it cannot change without a restart) stays quiet. `notify` is a
|
|
188
|
+
// no-op outside a UI, so `diag` records it too: headless runs and subagent
|
|
189
|
+
// children have no surface to show, but they do have the log file.
|
|
190
|
+
const piNotice = takePiSupportNotice();
|
|
191
|
+
if (piNotice) {
|
|
192
|
+
diag("meta", piNotice);
|
|
193
|
+
ctx.ui?.notify?.(piNotice, "warning");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Native-paste restoration (ADR 0030 amendment, 2026-10-13) ──
|
|
197
|
+
// `archimedes.imagePaste.suppressNativePaste: false` hands ctrl+v back to
|
|
198
|
+
// pi in two halves: this call deletes the suite's own
|
|
199
|
+
// `app.clipboard.pasteImage` unbinding from
|
|
200
|
+
// ~/.pi/agent/keybindings.json, and the gate inside `registerImagePaste`
|
|
201
|
+
// (above) stops image-paste registering a shortcut for the key — without it
|
|
202
|
+
// pi 1.2.0 keeps the extension shortcut and the restored binding never
|
|
203
|
+
// runs. Gated on ipMod: the registration gate is what ADR 0012 permits (a
|
|
204
|
+
// package never reads its own `enabled` flag), so a disabled image-paste
|
|
205
|
+
// never reaches into the file. Deliberately the LAST thing in this handler —
|
|
206
|
+
// a successful restore can call ctx.reload(), which invalidates this ctx, so
|
|
207
|
+
// nothing may use it afterwards.
|
|
208
|
+
if (ipMod) {
|
|
209
|
+
try {
|
|
210
|
+
await runNativePasteRestore(ctx);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
diag("meta", `[archimedes] native paste restore failed: ${describeError(e)}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
169
215
|
});
|
|
170
216
|
|
|
171
217
|
// Register /archimedes command
|
|
@@ -362,6 +362,8 @@ describe("runOnboarding write logic (onDone)", () => {
|
|
|
362
362
|
// The failed write aborts before the marker write → NOT set (a later
|
|
363
363
|
// session re-runs the onboarding and retries).
|
|
364
364
|
expect(mockStore["archimedes.meta"]?.onboarded).not.toBe(true);
|
|
365
|
+
// The cause is diagnosed in the log file, never printed to the console.
|
|
366
|
+
expect(consoleSpy).not.toHaveBeenCalled();
|
|
365
367
|
consoleSpy.mockRestore();
|
|
366
368
|
});
|
|
367
369
|
|
package/src/onboarding/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { loadConfig, updateConfig } from "@pi-archimedes/core/settings-io";
|
|
3
|
+
import { diag, describeError } from "@pi-archimedes/core/log";
|
|
3
4
|
import { OVERLAY_CHROME } from "@pi-archimedes/core/overlay";
|
|
4
5
|
import {
|
|
5
6
|
DEFAULT_UI_CONFIG,
|
|
@@ -57,7 +58,8 @@ export async function runOnboarding(ctx: ExtensionContext): Promise<void> {
|
|
|
57
58
|
}
|
|
58
59
|
updateConfig(META_NS, { onboarded: false }, (c) => ({ ...c, onboarded: true })); // marker last
|
|
59
60
|
} catch (err) {
|
|
60
|
-
|
|
61
|
+
// marker NOT set → self-heals; the cause belongs in the log file.
|
|
62
|
+
diag("meta", `[archimedes] onboarding save failed: ${describeError(err)}`);
|
|
61
63
|
}
|
|
62
64
|
};
|
|
63
65
|
|