pi-archimedes 2.6.3 → 2.7.1

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
@@ -64,7 +64,7 @@ Then run `/reload` in your session (or start a new one) to pick it up — that r
64
64
  }
65
65
  ```
66
66
 
67
- If it already exists, just add/set `"app.clipboard.pasteImage": []` in there and run `/reload`.
67
+ 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 it and reloads the terminal automatically (the cleared binding applies immediately); declining or cancelling (Esc) never asks again.
68
68
 
69
69
  `/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
70
 
@@ -170,7 +170,7 @@ Only want the diffs, footer, or MCP tools? Each component is available separatel
170
170
 
171
171
  ## Settings
172
172
 
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), 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.
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.
174
174
 
175
175
  ---
176
176
 
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "pi-archimedes",
3
- "version": "2.6.3",
3
+ "version": "2.7.1",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/danielcherubini/pi-archimedes.git"
7
+ },
4
8
  "type": "module",
5
9
  "keywords": [
6
10
  "pi-package"
@@ -12,17 +16,17 @@
12
16
  ],
13
17
  "main": "./src/index.ts",
14
18
  "dependencies": {
15
- "@pi-archimedes/core": "2.6.3",
16
- "@pi-archimedes/diff": "2.6.3",
17
- "@pi-archimedes/footer": "2.6.3",
18
- "@pi-archimedes/image-paste": "2.6.3",
19
- "@pi-archimedes/ask": "2.6.3",
20
- "@pi-archimedes/subagent": "2.6.3",
21
- "@pi-archimedes/notify": "2.6.3",
22
- "@pi-archimedes/todo": "2.6.3",
23
- "@pi-archimedes/mcp": "2.6.3",
24
- "@pi-archimedes/sudo": "2.6.3",
25
- "@pi-archimedes/session-name": "2.6.3"
19
+ "@pi-archimedes/core": "2.7.1",
20
+ "@pi-archimedes/footer": "2.7.1",
21
+ "@pi-archimedes/diff": "2.7.1",
22
+ "@pi-archimedes/image-paste": "2.7.1",
23
+ "@pi-archimedes/ask": "2.7.1",
24
+ "@pi-archimedes/subagent": "2.7.1",
25
+ "@pi-archimedes/notify": "2.7.1",
26
+ "@pi-archimedes/todo": "2.7.1",
27
+ "@pi-archimedes/mcp": "2.7.1",
28
+ "@pi-archimedes/session-name": "2.7.1",
29
+ "@pi-archimedes/sudo": "2.7.1"
26
30
  },
27
31
  "peerDependencies": {
28
32
  "@earendil-works/pi-coding-agent": ">=0.1.0",
@@ -60,6 +60,7 @@ vi.mock("@pi-archimedes/todo", () => ({ registerTodo: vi.fn() }));
60
60
  vi.mock("@pi-archimedes/ask", () => ({ registerAsk: vi.fn() }));
61
61
  vi.mock("@pi-archimedes/notify", () => ({ registerNotify: vi.fn() }));
62
62
  vi.mock("@pi-archimedes/session-name", () => ({ registerSessionName: vi.fn() }));
63
+ vi.mock("@pi-archimedes/sudo", () => ({ registerSudo: vi.fn() }));
63
64
 
64
65
  // Dynamic imports done in the session_start handler — mock EXACTLY the
65
66
  // properties index.ts uses via destructured `ipMod.*` / `diffMod` / `saMod` /
@@ -72,6 +73,9 @@ vi.mock("@pi-archimedes/image-paste", () => ({
72
73
  shutdownImagePaste: vi.fn(),
73
74
  initImagePasteSession: vi.fn(),
74
75
  }));
76
+ vi.mock("@pi-archimedes/image-paste/keybinding-offer", () => ({
77
+ offerKeybindingFix: vi.fn(),
78
+ }));
75
79
  vi.mock("@pi-archimedes/subagent", () => ({
76
80
  registerSubagent: vi.fn(),
77
81
  registerAgentsCommand: vi.fn(),
@@ -92,6 +96,9 @@ const { default: metaFactory } = await import("./index.js");
92
96
  const { registerImagePaste, shutdownImagePaste, initImagePasteSession } =
93
97
  await import("@pi-archimedes/image-paste");
94
98
 
99
+ const { offerKeybindingFix } =
100
+ await import("@pi-archimedes/image-paste/keybinding-offer");
101
+
95
102
  // ── Stub pi: record pi.on() registrations and command/tool registrations ───
96
103
 
97
104
  interface PiHarness {
@@ -125,6 +132,12 @@ function freshFactory(): {
125
132
  harness: PiHarness;
126
133
  startSession: (ctx: unknown) => Promise<unknown>;
127
134
  shutdownSession: () => unknown;
135
+ /** Fire EVERY registered session_start handler in registration order. */
136
+ fireAllStartHandlers: (ctx: unknown) => Promise<void>;
137
+ /** Fire ONLY the keybinding-offer session_start handler (the one registered
138
+ * before the lazy-load handler). Useful for isolating offer behaviour
139
+ * without triggering the heavy dynamic-import path. */
140
+ fireOfferHandler: (ctx: unknown) => void;
128
141
  } {
129
142
  const harness = makePi();
130
143
  metaFactory(harness.pi);
@@ -133,13 +146,26 @@ function freshFactory(): {
133
146
  const shutdownRcs = harness.handlers["session_shutdown"] ?? [];
134
147
  expect(startRcs.length).toBeGreaterThan(0);
135
148
  expect(shutdownRcs.length).toBeGreaterThan(0);
149
+ // The LAST session_start handler is the lazy-load one (existing tests rely on this).
136
150
  const startRc = (startRcs[startRcs.length - 1] ?? expect.fail("no session_start handler")) as (...args: unknown[]) => unknown;
137
151
  const shutdownRc = (shutdownRcs[shutdownRcs.length - 1] ?? expect.fail("no session_shutdown handler")) as (...args: unknown[]) => unknown;
152
+ // The keybinding-offer handler is at index 0 in this fully-mocked harness —
153
+ // all earlier package registrations are stubbed to no-op vi.fn(); if a
154
+ // package is un-mocked here, update the index.
155
+ const offerRc = (startRcs[0] ?? expect.fail("no offer session_start handler")) as (...args: unknown[]) => unknown;
138
156
 
139
157
  return {
140
158
  harness,
141
159
  startSession: (ctx: unknown) => startRc(undefined, ctx) as Promise<unknown>,
142
160
  shutdownSession: () => shutdownRc(undefined, {}),
161
+ fireAllStartHandlers: async (ctx: unknown) => {
162
+ for (const handler of startRcs) {
163
+ await (handler(undefined, ctx) as Promise<unknown>);
164
+ }
165
+ },
166
+ fireOfferHandler: (ctx: unknown) => {
167
+ offerRc(undefined, ctx);
168
+ },
143
169
  };
144
170
  }
145
171
 
@@ -194,3 +220,41 @@ describe("image-paste factory lifecycle (registration is config-gated, teardown
194
220
  expect(vi.mocked(shutdownImagePaste)).toHaveBeenCalledTimes(1); // exactly once total
195
221
  });
196
222
  });
223
+
224
+ describe("keybinding-offer wiring (session_start → offerKeybindingFix, fire-and-forget)", () => {
225
+ it("calls offerKeybindingFix exactly once with the session ctx", () => {
226
+ const ctx = { ui: { theme: "dark" } };
227
+ // Return a resolved promise so the handler's .catch() has nothing to report.
228
+ vi.mocked(offerKeybindingFix).mockResolvedValueOnce(undefined);
229
+ const { fireOfferHandler } = freshFactory();
230
+
231
+ // The offer handler is synchronous from the caller's perspective (fire-and-forget).
232
+ fireOfferHandler(ctx);
233
+
234
+ expect(vi.mocked(offerKeybindingFix)).toHaveBeenCalledTimes(1);
235
+ expect(vi.mocked(offerKeybindingFix)).toHaveBeenCalledWith(ctx);
236
+ });
237
+
238
+ it("session_start resolves even when offerKeybindingFix rejects (fire-and-forget safety)", async () => {
239
+ // Arrange: make the mock reject once to simulate an unexpected failure.
240
+ vi.mocked(offerKeybindingFix).mockRejectedValueOnce(new Error("boom"));
241
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
242
+
243
+ const ctx = { ui: { theme: "dark" } };
244
+ const { fireOfferHandler } = freshFactory();
245
+
246
+ // Act: invoke the offer handler; it must not throw synchronously.
247
+ expect(() => fireOfferHandler(ctx)).not.toThrow();
248
+
249
+ // Drain the microtask queue so the .catch() branch has had time to run.
250
+ await new Promise((r) => setTimeout(r, 0));
251
+
252
+ // The rejection must have been swallowed and logged — not re-thrown.
253
+ expect(consoleSpy).toHaveBeenCalledWith(
254
+ "[archimedes] keybinding offer failed:",
255
+ expect.any(Error),
256
+ );
257
+
258
+ consoleSpy.mockRestore();
259
+ });
260
+ });
package/src/index.ts CHANGED
@@ -14,6 +14,10 @@ import { isPluginEnabled, migrateLegacyPluginsMap } from "./plugins.js";
14
14
  import { registerNotify } from "@pi-archimedes/notify";
15
15
  import { registerSessionName } from "@pi-archimedes/session-name";
16
16
  import { registerSudo } from "@pi-archimedes/sudo";
17
+ // 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.
20
+ import { offerKeybindingFix } from "@pi-archimedes/image-paste/keybinding-offer";
17
21
  import { loadDiffConfig } from "./config.js";
18
22
  import { openSettings } from "./settings.js"
19
23
  import { registerPluginsCommand } from "./plugin-manager.js"
@@ -75,6 +79,32 @@ export default function (pi: ExtensionAPI): void {
75
79
  archPrintTimings();
76
80
  });
77
81
 
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
+ //
89
+ // Deliberately NOT gated with isPluginEnabled("image-paste"): that call
90
+ // resolves to isConfigEnabled("archimedes.imagePaste") (ADR 0012), which
91
+ // is exactly the same key the module's own gate 1 already checks — adding
92
+ // 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.
99
+ try {
100
+ void offerKeybindingFix(ctx).catch((e) => {
101
+ console.error("[archimedes] keybinding offer failed:", e);
102
+ });
103
+ } catch (e) {
104
+ console.error("[archimedes] keybinding offer failed:", e);
105
+ }
106
+ });
107
+
78
108
  pi.on("session_start", async (_event, ctx: ExtensionContext) => {
79
109
  archTime(`session_start (factory was ${Date.now() - _moduleEvalAt}ms ago)`);
80
110