@pi-archimedes/core 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.
@@ -0,0 +1,166 @@
1
+ // ── Bridge public API ────────────────────────────────────────────────────
2
+ //
3
+ // getBridge() is the singleton Tasks 2-3 import. The bridge is env-gated and
4
+ // root-only: active iff (all 4 env vars present ∧ ctx.mode !== "tui" ∧
5
+ // PI_SUBAGENT_SOCKET absent), evaluated at session_start. TUI always wins; a
6
+ // subagent child (env present) is inactive. The `active` flag does not depend
7
+ // on a successful connection (lazy, per-message connect).
8
+ //
9
+ // Inactive API: ask → throws BridgeInactiveError (loud); confirm → false;
10
+ // password → "" (cancel).
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import type { AskResponsePayload, AskRequestPayload } from "../bus.js";
14
+ import * as channel from "./channel.js";
15
+ import * as events from "./events.js";
16
+
17
+ export { BridgeTransportError, BridgeCancelledError } from "./channel.js";
18
+
19
+ export class BridgeInactiveError extends Error {
20
+ constructor() {
21
+ super("bridge is not active");
22
+ this.name = "BridgeInactiveError";
23
+ }
24
+ }
25
+
26
+ function channelActive(): boolean {
27
+ return channel.isActive();
28
+ }
29
+
30
+ function eventsState(): "working" | "idle" | "blocked" {
31
+ return events.state();
32
+ }
33
+
34
+ export function getBridge() {
35
+ return { active: channelActive(), ask, confirm, password, dispatch, state: eventsState };
36
+ }
37
+
38
+ /**
39
+ * Delegate a subagent task to the Client (desktop) instead of forking a
40
+ * child pi process. The desktop runs the subagent session (a full ACP
41
+ * session on its worker runtime) and answers with the final output +
42
+ * metrics.
43
+ *
44
+ * The request opts OUT of the 5-minute timeout (`timeoutMs: null`): the
45
+ * desktop's lifecycle (parent close / EOF / app exit) is the cancel path —
46
+ * the tool's AbortSignal cancels the request (the promise settles
47
+ * deterministically with a BridgeCancelledError — NOT a
48
+ * BridgeTransportError, so it can never trigger a fork fallback) and the
49
+ * socket close delivers the desktop's EOF → the subagent session cancels.
50
+ * An unreachable bridge rejects with BridgeTransportError (the subagent's
51
+ * fork-fallback trigger); a response-carrying `error` (incl. the terminal
52
+ * `"cancelled"` frame) is authoritative and never a fallback.
53
+ */
54
+ export interface DispatchParams {
55
+ agentName: string;
56
+ task: string;
57
+ systemPrompt: string | null;
58
+ model: string | null;
59
+ thinking: string | null;
60
+ tools: string[] | null;
61
+ }
62
+
63
+ export interface DispatchResult {
64
+ output: string;
65
+ metrics: { inputTokens: number; outputTokens: number; cost: number; durationMs: number };
66
+ }
67
+
68
+ export function dispatch(params: DispatchParams, toolCallId: string | undefined, signal?: AbortSignal): Promise<DispatchResult> {
69
+ if (!channelActive()) throw new BridgeInactiveError();
70
+ const handle = channel.request<DispatchResult>("dispatch_subagent", params, { toolCallId, source: "main", timeoutMs: null });
71
+ if (signal) {
72
+ // Same abort wiring as ask (see there for the full rationale): a
73
+ // pre-aborted signal cancels immediately, the listener is removed on
74
+ // settle via the .then(onFulfilled, onRejected) form.
75
+ if (signal.aborted) handle.cancel();
76
+ else signal.addEventListener("abort", handle.cancel, { once: true });
77
+ handle.promise.then(
78
+ () => { signal?.removeEventListener("abort", handle.cancel); },
79
+ () => { signal?.removeEventListener("abort", handle.cancel); },
80
+ );
81
+ }
82
+ return handle.promise;
83
+ }
84
+
85
+ /**
86
+ * Ask the Client (the root's direct Client request — NOT the relay/bridge bus
87
+ * subscription). The param is typed structurally from the bus payload —
88
+ * AskQuestion lives in packages/ask, which core cannot import.
89
+ */
90
+ export function ask(params: { questions: AskRequestPayload["questions"] }, toolCallId: string, signal?: AbortSignal): Promise<AskResponsePayload> {
91
+ if (!channelActive()) throw new BridgeInactiveError();
92
+ const handle = channel.request<AskResponsePayload>("ask", params, { toolCallId, source: "main" });
93
+ if (signal) {
94
+ // Abort the turn → cancel the Client request (the tool's catch converts the
95
+ // cancel-rejection into the paired cancelled ASK_RESPONSE, so the refcount
96
+ // stays balanced). A signal that is ALREADY aborted never dispatches its
97
+ // "abort" event (it fires exactly once, at abort time), so an addEventListener
98
+ // alone would leave the request lingering until the Client responds or the
99
+ // 5-minute timeout — call cancel() directly in that case. cancel() is
100
+ // idempotent and settles the promise deterministically (the
101
+ // socket-close handler it triggers is a no-op once settled), so both
102
+ // branches coexist safely. The listener is removed on settle; the
103
+ // .then(onFulfilled, onRejected) form (not .finally) avoids an unhandled
104
+ // rejection from the derived promise when the request rejects.
105
+ if (signal.aborted) handle.cancel();
106
+ else signal.addEventListener("abort", handle.cancel, { once: true });
107
+ handle.promise.then(
108
+ () => { signal?.removeEventListener("abort", handle.cancel); },
109
+ () => { signal?.removeEventListener("abort", handle.cancel); },
110
+ );
111
+ }
112
+ return handle.promise;
113
+ }
114
+
115
+ export function confirm(params: { command: string; reason: string }): Promise<boolean> {
116
+ if (!channelActive()) return Promise.resolve(false);
117
+ return channel.request<{ confirmed: boolean }>("confirm", params, { source: "main" }).promise
118
+ .then((r) => r.confirmed)
119
+ .catch(() => false);
120
+ }
121
+
122
+ export function password(params: { command: string; reason: string }): Promise<string> {
123
+ if (!channelActive()) return Promise.resolve("");
124
+ return channel.request<{ password?: string }>("password", params, { source: "main" }).promise
125
+ .then((r) => r.password ?? "")
126
+ .catch(() => "");
127
+ }
128
+
129
+ /**
130
+ * Bridge-mode gate. PI_ARCHIMEDES_BRIDGE_SERVER_PID is presence-checked only
131
+ * in v1: agent-side server-pid verification is unimplemented on both
132
+ * platforms (Node's `net` exposes neither `GetNamedPipeServerProcessId` nor
133
+ * `SO_PEERCRED`); the desktop-side descendant check is the real defense.
134
+ */
135
+ function isBridgeMode(ctx: { mode: string }): boolean {
136
+ return (
137
+ process.env.PI_ARCHIMEDES_BRIDGE === "1" &&
138
+ !!process.env.PI_ARCHIMEDES_BRIDGE_SOCKET &&
139
+ !!process.env.PI_ARCHIMEDES_BRIDGE_SESSION &&
140
+ !!process.env.PI_ARCHIMEDES_BRIDGE_SERVER_PID &&
141
+ ctx.mode !== "tui" &&
142
+ !process.env.PI_SUBAGENT_SOCKET
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Subscribe the pi EXTENSION events (NOT the bus — nothing emits
148
+ * agent_start/agent_settled/session_start on the bus). The extension events DO
149
+ * fire in RPC mode (the RPC driver runs the same session machinery).
150
+ */
151
+ export function registerBridge(pi: ExtensionAPI): void {
152
+ pi.on("session_start", (_e, ctx) => {
153
+ const active = isBridgeMode(ctx);
154
+ channel.configure({ active, socketPath: process.env.PI_ARCHIMEDES_BRIDGE_SOCKET });
155
+ if (active) {
156
+ events.start();
157
+ events.onSessionStart(ctx);
158
+ }
159
+ });
160
+ pi.on("agent_start", () => {
161
+ if (channelActive()) events.onAgentStart();
162
+ });
163
+ pi.on("agent_settled", () => {
164
+ if (channelActive()) events.onAgentSettled();
165
+ });
166
+ }
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { getBus, Events } from "../bus.js";
4
+ import { registerSelfUsage } from "./self-usage.js";
5
+
6
+ /**
7
+ * Bus discipline (load-bearing): the bus has NO reset seam, `emit` with zero
8
+ * subscribers queues the payload globally, and `on()` replays queued events
9
+ * WITHOUT removing them. So EVERY test subscribes a per-test listener FIRST,
10
+ * then invokes the handler, asserts only on that test's captured array, and
11
+ * unsubscribes via the `on` return value.
12
+ */
13
+ function captureCostUpdates(): { events: unknown[]; done: () => void } {
14
+ const events: unknown[] = [];
15
+ const unsub = getBus().on(Events.COST_UPDATE, (p) => events.push(p));
16
+ return { events, done: unsub };
17
+ }
18
+
19
+ /**
20
+ * Register and capture the turn_end handler, using the repo's mock-ExtensionAPI
21
+ * idiom (verbatim from bridge/index.test.ts — a plain object fails `tsc` with
22
+ * TS2740; the `as unknown as ExtensionAPI` cast and the `mock.calls` re-typing
23
+ * are BOTH required).
24
+ */
25
+ function registerAndGetTurnEndHandler(): (e: unknown, ctx: unknown) => void {
26
+ const onSpy = vi.fn();
27
+ registerSelfUsage({ on: onSpy } as unknown as ExtensionAPI);
28
+ const calls = onSpy.mock.calls as Array<[string, (e: unknown, ctx: unknown) => void]>;
29
+ const handler = calls.find(([event]) => event === "turn_end")![1];
30
+ expect(handler).toBeTruthy();
31
+ return handler;
32
+ }
33
+
34
+ function assistantMessage(overrides: { usage?: unknown }): { role: "assistant"; usage?: unknown } {
35
+ return { role: "assistant", ...overrides };
36
+ }
37
+
38
+ // ── 1. per-turn emit ─────────────────────────────────────────────────────
39
+
40
+ describe("per-turn emit", () => {
41
+ it("emits a COST_UPDATE with the turn's usage (delta, exact shape — no reasoning/totalTokens)", () => {
42
+ const handler = registerAndGetTurnEndHandler();
43
+ const { events, done } = captureCostUpdates();
44
+ try {
45
+ handler({
46
+ type: "turn_end",
47
+ turnIndex: 0,
48
+ message: assistantMessage({
49
+ usage: {
50
+ input: 100,
51
+ output: 50,
52
+ cacheRead: 10,
53
+ cacheWrite: 5,
54
+ totalTokens: 165,
55
+ cost: { input: 0.001, output: 0.002, cacheRead: 0.0001, cacheWrite: 0.0002, total: 0.0033 },
56
+ },
57
+ }),
58
+ }, { mode: "tui" });
59
+ expect(events).toHaveLength(1);
60
+ expect(events[0]).toEqual({
61
+ source: "main",
62
+ inputTokens: 100,
63
+ outputTokens: 50,
64
+ cacheReadTokens: 10,
65
+ cacheWriteTokens: 5,
66
+ cost: 0.0033,
67
+ });
68
+ } finally {
69
+ done();
70
+ }
71
+ });
72
+ });
73
+
74
+ // ── 2. no usage → no emit ────────────────────────────────────────────────
75
+
76
+ describe("no usage", () => {
77
+ it("an assistant message without usage emits nothing", () => {
78
+ const handler = registerAndGetTurnEndHandler();
79
+ const { events, done } = captureCostUpdates();
80
+ try {
81
+ handler({
82
+ type: "turn_end",
83
+ turnIndex: 0,
84
+ message: assistantMessage({}),
85
+ }, { mode: "tui" });
86
+ expect(events).toHaveLength(0);
87
+ } finally {
88
+ done();
89
+ }
90
+ });
91
+ });
92
+
93
+ // ── 3. two turns → two events (delta semantics) ──────────────────────────
94
+
95
+ describe("delta semantics", () => {
96
+ it("two turns emit two events with per-turn values (NOT accumulated)", () => {
97
+ const handler = registerAndGetTurnEndHandler();
98
+ const { events, done } = captureCostUpdates();
99
+ try {
100
+ handler({
101
+ type: "turn_end",
102
+ turnIndex: 0,
103
+ message: assistantMessage({
104
+ usage: { input: 100, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 100, cost: { input: 0.001, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.001 } },
105
+ }),
106
+ }, { mode: "tui" });
107
+ handler({
108
+ type: "turn_end",
109
+ turnIndex: 1,
110
+ message: assistantMessage({
111
+ usage: { input: 200, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 200, cost: { input: 0.002, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.002 } },
112
+ }),
113
+ }, { mode: "tui" });
114
+ expect(events).toHaveLength(2);
115
+ expect((events[0] as { inputTokens: number }).inputTokens).toBe(100);
116
+ expect((events[1] as { inputTokens: number }).inputTokens).toBe(200); // NOT 300
117
+ } finally {
118
+ done();
119
+ }
120
+ });
121
+ });
122
+
123
+ // ── 4. unaffected by bridge mode ────────────────────────────────────────
124
+
125
+ describe("bridge independence", () => {
126
+ it("emits without the bridge channel being configured (bus is in-process; only the bridge forwarder is gated)", () => {
127
+ // NO configure() of the bridge channel anywhere in this file — the channel
128
+ // stays inactive by default. The emitter must still fire the bus event
129
+ // (design decision 3: registered unconditionally, not gated on bridge mode).
130
+ const handler = registerAndGetTurnEndHandler();
131
+ const { events, done } = captureCostUpdates();
132
+ try {
133
+ handler({
134
+ type: "turn_end",
135
+ turnIndex: 0,
136
+ message: assistantMessage({
137
+ usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
138
+ }),
139
+ }, { mode: "tui" });
140
+ expect(events).toHaveLength(1);
141
+ expect((events[0] as { source: string }).source).toBe("main");
142
+ } finally {
143
+ done();
144
+ }
145
+ });
146
+ });
@@ -0,0 +1,37 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { getBus, Events } from "../bus.js";
3
+
4
+ /**
5
+ * Minimal structural mirror of the pi-ai `Usage` type (core does not depend on
6
+ * @earendil-works/pi-ai — keep the dependency surface unchanged).
7
+ */
8
+ interface UsageLike {
9
+ input?: number;
10
+ output?: number;
11
+ cacheRead?: number;
12
+ cacheWrite?: number;
13
+ cost?: { total?: number };
14
+ }
15
+
16
+ /**
17
+ * Emit the session's OWN usage as a per-turn COST_UPDATE (source "main").
18
+ * Per design decision 1-3: per-turn (turn_end), delta (the turn's usage is
19
+ * not cumulative), NOT gated on bridge mode (the TUI footer's
20
+ * CostAccumulator sums it — the footer's cost line becomes the true session
21
+ * cost; in bridge mode the existing events.ts forwarder carries it to the
22
+ * desktop). `reasoning` is excluded (a subset of `output` — double-count).
23
+ */
24
+ export function registerSelfUsage(pi: ExtensionAPI): void {
25
+ pi.on("turn_end", (event) => {
26
+ const usage = (event.message as { usage?: UsageLike } | undefined)?.usage;
27
+ if (!usage) return;
28
+ getBus().emit(Events.COST_UPDATE, {
29
+ source: "main",
30
+ inputTokens: usage.input ?? 0,
31
+ outputTokens: usage.output ?? 0,
32
+ cacheReadTokens: usage.cacheRead ?? 0,
33
+ cacheWriteTokens: usage.cacheWrite ?? 0,
34
+ cost: usage.cost?.total ?? 0,
35
+ });
36
+ });
37
+ }
package/src/bus.ts CHANGED
@@ -104,6 +104,7 @@ export const Events = {
104
104
  TODOS_CLEAR: "archimedes:todos_clear",
105
105
  ASK_REQUEST: "archimedes:ask_request",
106
106
  ASK_RESPONSE: "archimedes:ask_response",
107
+ ASK_CANCEL: "archimedes:ask_cancel",
107
108
  } as const;
108
109
 
109
110
  interface TodoUpdatePayload {
@@ -118,6 +119,7 @@ interface TodoClearPayload {
118
119
  interface AskRequestPayload {
119
120
  source: string; // "subagent:<agent-name>"
120
121
  requestId: string; // unique id to match request → response
122
+ toolCallId?: string; // the tool-call id of the process that invoked the tool (carried for Client correlation)
121
123
  questions: Array<{ id: string; question: string; description?: string; options: Array<{ label: string }>; multi?: boolean; recommended?: number }>;
122
124
  }
123
125
 
@@ -127,4 +129,9 @@ interface AskResponsePayload {
127
129
  results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
128
130
  }
129
131
 
130
- export type { CostUpdatePayload, TodoUpdatePayload, TodoClearPayload, AskRequestPayload, AskResponsePayload };
132
+ interface AskCancelPayload {
133
+ requestId: string;
134
+ source: string;
135
+ }
136
+
137
+ export type { CostUpdatePayload, TodoUpdatePayload, TodoClearPayload, AskRequestPayload, AskResponsePayload, AskCancelPayload };
@@ -1,115 +1,20 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
-
3
- vi.mock("./settings-io.js", () => ({
4
- loadConfig: vi.fn(),
5
- saveConfig: vi.fn(),
6
- }));
7
-
8
- const { loadCoreConfig, saveCoreConfig, DEFAULT_CORE_CONFIG, ANIMATION_STYLES } =
9
- await import("./config.js");
10
- const { loadConfig, saveConfig } = await import("./settings-io.js");
1
+ import { describe, it, expect } from "vitest";
2
+ import { loadCoreConfig, saveCoreConfig, DEFAULT_CORE_CONFIG } from "./config.js";
11
3
 
12
4
  describe("loadCoreConfig", () => {
13
- beforeEach(() => {
14
- vi.clearAllMocks();
15
- });
16
-
17
- it("uses correct namespace", () => {
18
- vi.mocked(loadConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
19
- loadCoreConfig();
20
- expect(loadConfig).toHaveBeenCalledWith("archimedes.core", DEFAULT_CORE_CONFIG);
21
- });
22
-
23
- it("returns default config when no settings exist", () => {
24
- vi.mocked(loadConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
25
- const result = loadCoreConfig();
26
- expect(result).toEqual({
27
- mutedTheme: false,
28
- codeUnindent: true,
29
- labelText: "Thinking...",
30
- labelColor: "255,215,0",
31
- animationStyle: "vertical-up",
32
- editorSpinBorder: true,
33
- editorSpinSpeed: "normal",
34
- editorSpinLabel: "Working",
35
- editorSpinStyle: "pendulum",
36
- });
37
- });
38
-
39
- it("passes through merged config from settings-io", () => {
40
- const merged = { ...DEFAULT_CORE_CONFIG, mutedTheme: true };
41
- vi.mocked(loadConfig).mockReturnValue(merged);
42
- const result = loadCoreConfig();
43
- expect(result).toEqual(merged);
5
+ it("returns empty config", () => {
6
+ expect(loadCoreConfig()).toEqual({});
44
7
  });
45
8
  });
46
9
 
47
10
  describe("saveCoreConfig", () => {
48
- beforeEach(() => {
49
- vi.clearAllMocks();
50
- });
51
-
52
- it("saves with correct namespace", () => {
53
- saveCoreConfig(DEFAULT_CORE_CONFIG);
54
- expect(saveConfig).toHaveBeenCalledWith("archimedes.core", DEFAULT_CORE_CONFIG);
55
- });
56
-
57
- it("passes config through unchanged", () => {
58
- const config = { ...DEFAULT_CORE_CONFIG, mutedTheme: true };
59
- saveCoreConfig(config);
60
- expect(saveConfig).toHaveBeenCalledWith("archimedes.core", config);
11
+ it("does nothing", () => {
12
+ expect(() => saveCoreConfig({})).not.toThrow();
61
13
  });
62
14
  });
63
15
 
64
16
  describe("DEFAULT_CORE_CONFIG", () => {
65
- it("has the expected shape", () => {
66
- expect(DEFAULT_CORE_CONFIG).toEqual({
67
- mutedTheme: false,
68
- codeUnindent: true,
69
- labelText: "Thinking...",
70
- labelColor: "255,215,0",
71
- animationStyle: "vertical-up",
72
- editorSpinBorder: true,
73
- editorSpinSpeed: "normal",
74
- editorSpinLabel: "Working",
75
- editorSpinStyle: "pendulum",
76
- });
77
- });
78
-
79
- it("exposes a speed→multiplier map (slow/normal/fast = 1.5/1/0.6 × native tempo)", async () => {
80
- const { SPIN_SPEED_MULT } = await import("./config.js");
81
- expect(SPIN_SPEED_MULT).toEqual({ slow: 1.5, normal: 1, fast: 0.6 });
82
- });
83
-
84
- it("exposes the SPIN_INTERVALS key set (ten gallery-derived styles)", async () => {
85
- const { SPIN_INTERVALS } = await import("./editor/spin.js");
86
- expect(Object.keys(SPIN_INTERVALS)).toEqual([
87
- "typing",
88
- "pulse",
89
- "rain",
90
- "cascade",
91
- "columns",
92
- "wave-rows",
93
- "diagonal-swipe",
94
- "sparkle",
95
- "pendulum",
96
- "marquee",
97
- ]);
98
- });
99
- });
100
-
101
- describe("ANIMATION_STYLES", () => {
102
- it("contains all expected styles", () => {
103
- expect(ANIMATION_STYLES).toEqual([
104
- "diagonal",
105
- "top-right",
106
- "bottom-left",
107
- "bottom-right",
108
- "center-out",
109
- "wave",
110
- "horizontal",
111
- "vertical",
112
- "vertical-up",
113
- ]);
17
+ it("is empty", () => {
18
+ expect(DEFAULT_CORE_CONFIG).toEqual({});
114
19
  });
115
20
  });
package/src/config.ts CHANGED
@@ -1,68 +1,6 @@
1
1
  import { loadConfig, saveConfig } from "./settings-io.js";
2
2
 
3
- export const ANIMATION_STYLES = [
4
- "diagonal",
5
- "top-right",
6
- "bottom-left",
7
- "bottom-right",
8
- "center-out",
9
- "wave",
10
- "horizontal",
11
- "vertical",
12
- "vertical-up",
13
- ] as const;
14
- export type AnimationStyle = (typeof ANIMATION_STYLES)[number];
15
-
16
- export interface CoreConfig {
17
- mutedTheme: boolean;
18
- codeUnindent: boolean;
19
- labelText: string;
20
- labelColor: string;
21
- animationStyle: AnimationStyle;
22
- editorSpinBorder: boolean;
23
- editorSpinSpeed: "slow" | "normal" | "fast";
24
- editorSpinLabel: string;
25
- editorSpinStyle: SpinnerStyle;
26
- }
27
-
28
- /** Border-spinner style (the ten gallery-derived variants, all registered in `SPIN_VARIANTS` (`editor/spin.ts`); unknown values normalize to `typing` via `normalizeSpinnerStyle`). */
29
- export type SpinnerStyle =
30
- | "typing"
31
- | "pulse"
32
- | "rain"
33
- | "cascade"
34
- | "columns"
35
- | "wave-rows"
36
- | "diagonal-swipe"
37
- | "sparkle"
38
- | "pendulum"
39
- | "marquee";
40
-
41
- export const DEFAULT_CORE_CONFIG: CoreConfig = {
42
- mutedTheme: false,
43
- codeUnindent: true,
44
- labelText: "Thinking...",
45
- labelColor: "255,215,0",
46
- animationStyle: "vertical-up",
47
- editorSpinBorder: true,
48
- editorSpinSpeed: "normal",
49
- editorSpinLabel: "Working",
50
- editorSpinStyle: "pendulum",
51
- };
52
-
53
- /** Border-spinner speed setting → the multipliers the `editorSpinSpeed` setting applies to the style's native per-tick interval (SPIN_INTERVALS, `editor/spin.ts` — typing: 120 / 80 / 48 ms). */
54
- export const SPIN_SPEED_MULT: Record<CoreConfig["editorSpinSpeed"], number> = {
55
- slow: 1.5,
56
- normal: 1,
57
- fast: 0.6,
58
- };
59
-
60
- const NAMESPACE = "archimedes.core";
61
-
62
- export function loadCoreConfig(): CoreConfig {
63
- return loadConfig(NAMESPACE, DEFAULT_CORE_CONFIG);
64
- }
65
-
66
- export function saveCoreConfig(config: CoreConfig): void {
67
- saveConfig(NAMESPACE, config);
68
- }
3
+ export interface CoreConfig {}
4
+ export const DEFAULT_CORE_CONFIG: CoreConfig = {};
5
+ export function loadCoreConfig(): CoreConfig { return {}; }
6
+ export function saveCoreConfig(_config: CoreConfig): void {}