@aexol/spectral 0.9.20 → 0.9.24

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.
@@ -49,6 +49,7 @@
49
49
  * instance is reused across `prompt()` calls).
50
50
  */
51
51
  import { type AgentSessionEvent } from "../sdk/coding-agent/index.js";
52
+ import { type AgentSettings } from "./handlers/agent-settings.js";
52
53
  import { fetchAllowedModels as defaultFetchAllowedModels } from "../relay/models-fetch.js";
53
54
  import type { SessionMemorySnapshot } from "./storage.js";
54
55
  import type { ImageAttachment, ServerEvent, WireMessage } from "./wire.js";
@@ -116,6 +117,13 @@ export interface AgentBridgeOptions {
116
117
  * can run without a live backend.
117
118
  */
118
119
  fetchAllowedModels?: FetchAllowedModelsFn;
120
+ /**
121
+ * Agent-behavior toggles (primary-agent injection, subagent tool).
122
+ * When omitted, settings are loaded from `settings.json` at `start()`
123
+ * time. Changes take effect on the next session/bridge — a running
124
+ * session is not hot-reloaded.
125
+ */
126
+ agentSettings?: AgentSettings;
119
127
  }
120
128
  export declare class AgentBridge {
121
129
  private session?;
@@ -1 +1 @@
1
- {"version":3,"file":"agent-bridge.d.ts","sourceRoot":"","sources":["../../src/server/agent-bridge.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"agent-bridge.d.ts","sourceRoot":"","sources":["../../src/server/agent-bridge.ts"],"names":[],"mappings":"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"}
@@ -55,6 +55,8 @@ import { existsSync, statSync } from "node:fs";
55
55
  import { dirname, join, resolve } from "node:path";
56
56
  import { fileURLToPath } from "node:url";
57
57
  import aexolMcpExtension from "../extensions/aexol-mcp.js";
58
+ import { discoverPrimaryAgent, injectPrimaryAgentPrompt } from "../agent/agents.js";
59
+ import { loadAgentSettings } from "./handlers/agent-settings.js";
58
60
  import kanbanBridgeExtension from "../extensions/kanban-bridge.js";
59
61
  import spectralVisionExtension from "../extensions/spectral-vision-fallback.js";
60
62
  import subagentExt from "../agent/index.js";
@@ -417,7 +419,30 @@ export class AgentBridge {
417
419
  async start() {
418
420
  if (this.disposed)
419
421
  throw new Error("AgentBridge already disposed");
420
- const extensionFactories = [aexolMcpExtension, kanbanBridgeExtension, async (ext) => { spectralVisionExtension(ext); }, async (ext) => { subagentExt(ext); }, async (ext) => { designerExtension(ext); }, async (ext) => { observationalMemory(ext); }];
422
+ const extensionFactories = [
423
+ // Primary-agent prompt is injected per-turn via before_agent_start so the
424
+ // primaryAgentEnabled toggle takes effect mid-session (next user message).
425
+ async (ext) => {
426
+ ext.on("before_agent_start", (event) => {
427
+ const settings = loadAgentSettings(this.opts.cwd, this.opts.agentDir);
428
+ if (!settings.primaryAgentEnabled)
429
+ return;
430
+ const primaryAgent = discoverPrimaryAgent(this.opts.cwd, "both", this.opts.agentDir);
431
+ const injected = injectPrimaryAgentPrompt(event.systemPrompt, primaryAgent);
432
+ if (injected === event.systemPrompt)
433
+ return;
434
+ return { systemPrompt: injected };
435
+ });
436
+ },
437
+ aexolMcpExtension, kanbanBridgeExtension, async (ext) => { spectralVisionExtension(ext); }, async (ext) => { designerExtension(ext); }, async (ext) => { observationalMemory(ext); }
438
+ ];
439
+ // Agent-behavior toggles. Loaded once at bridge construction so changes
440
+ // take effect on the next session (not mid-turn).
441
+ const agentSettings = this.opts.agentSettings ?? loadAgentSettings(this.opts.cwd, this.opts.agentDir);
442
+ // Only register the subagent tool when the toggle is enabled.
443
+ if (agentSettings.subagentsEnabled) {
444
+ extensionFactories.splice(4, 0, async (ext) => { subagentExt(ext, { disabledAgents: agentSettings.disabledSubagents }); });
445
+ }
421
446
  // Load spectral-mcp-adapter via jiti so tsc never crawls its .ts files in
422
447
  // node_modules. The static `import` was causing tsc to type-check
423
448
  // spectral-mcp-adapter's source and fail the build on its type errors.
@@ -448,6 +473,9 @@ export class AgentBridge {
448
473
  // .opencode/skills and .aexol/skills so OpenCode/Codex/Aexol skills
449
474
  // work out of the box.
450
475
  const extraSkillPaths = collectAncestorSkillDirs(this.opts.cwd, [".opencode/skills", ".aexol/skills"]);
476
+ // Primary-agent prompt injection happens per-turn via the before_agent_start
477
+ // extension registered above, so the primaryAgentEnabled toggle takes effect
478
+ // mid-session (on the next user message) rather than only at session start.
451
479
  const resourceLoader = new DefaultResourceLoader({
452
480
  cwd: this.opts.cwd,
453
481
  agentDir: this.opts.agentDir ?? `${process.env.HOME ?? ""}/.spectral/agent`,
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Agent-behavior toggles stored under the `agent` namespace in
3
+ * `settings.json`. These control whether the primary-agent system prompt
4
+ * is injected and whether the `subagent` tool is registered.
5
+ *
6
+ * Both default to `true` so existing behaviour is preserved when the
7
+ * namespace is absent.
8
+ */
9
+ export interface AgentSettings {
10
+ primaryAgentEnabled: boolean;
11
+ subagentsEnabled: boolean;
12
+ /** Names of individual subagents that have been explicitly disabled. */
13
+ disabledSubagents: string[];
14
+ }
15
+ export declare const AGENT_SETTINGS_DEFAULTS: AgentSettings;
16
+ export interface PutAgentSettingsInput {
17
+ primaryAgentEnabled?: unknown;
18
+ subagentsEnabled?: unknown;
19
+ disabledSubagents?: unknown;
20
+ }
21
+ export interface PutAgentSettingsOutput extends AgentSettings {
22
+ ok: true;
23
+ }
24
+ /**
25
+ * Load agent settings by merging defaults ← global ← project overrides.
26
+ * Mirrors the precedence pattern used by `loadConfig` for observational
27
+ * memory config.
28
+ */
29
+ export declare function loadAgentSettings(cwd: string, agentDir?: string): AgentSettings;
30
+ export declare function handleGetAgentSettings(cwd: string, agentDir?: string): AgentSettings;
31
+ /**
32
+ * Persist a partial update to the `agent` namespace in the **global**
33
+ * `settings.json` (per-machine, matching the existing memory-settings
34
+ * behaviour). Project-level overrides are read-only from the REST API.
35
+ *
36
+ * Only boolean values are accepted for the two known keys; anything else
37
+ * is a `400 BadRequestError`.
38
+ */
39
+ export declare function handlePutAgentSettings(cwd: string, body: PutAgentSettingsInput, agentDir?: string): PutAgentSettingsOutput;
40
+ export interface AgentInfo {
41
+ name: string;
42
+ description: string;
43
+ mode: string;
44
+ source: string;
45
+ }
46
+ export declare function handleListAgents(cwd: string, agentDir?: string): {
47
+ agents: AgentInfo[];
48
+ };
49
+ //# sourceMappingURL=agent-settings.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-settings.d.ts","sourceRoot":"","sources":["../../../src/server/handlers/agent-settings.ts"],"names":[],"mappings":"AAOA;;;;;;;GAOG;AACH,MAAM,WAAW,aAAa;IAC5B,mBAAmB,EAAE,OAAO,CAAC;IAC7B,gBAAgB,EAAE,OAAO,CAAC;IAC1B,wEAAwE;IACxE,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B;AAED,eAAO,MAAM,uBAAuB,EAAE,aAIrC,CAAC;AAKF,MAAM,WAAW,qBAAqB;IACpC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,sBAAuB,SAAQ,aAAa;IAC3D,EAAE,EAAE,IAAI,CAAC;CACV;AA+BD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,EACX,QAAQ,CAAC,EAAE,MAAM,GAChB,aAAa,CAQf;AAED,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,EACX,QAAQ,CAAC,EAAE,MAAM,GAChB,aAAa,CAEf;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,qBAAqB,EAC3B,QAAQ,CAAC,EAAE,MAAM,GAChB,sBAAsB,CAuCxB;AAMD,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,MAAM,EACX,QAAQ,CAAC,EAAE,MAAM,GAChB;IAAE,MAAM,EAAE,SAAS,EAAE,CAAA;CAAE,CAUzB"}
@@ -0,0 +1,109 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "../../sdk/coding-agent/index.js";
4
+ import { discoverAgents } from "../../agent/agents.js";
5
+ import { BadRequestError } from "./errors.js";
6
+ import { withSettingsLock } from "./settings-lock.js";
7
+ export const AGENT_SETTINGS_DEFAULTS = {
8
+ primaryAgentEnabled: true,
9
+ subagentsEnabled: true,
10
+ disabledSubagents: [],
11
+ };
12
+ const AGENT_NAMESPACE = "agent";
13
+ const ALLOWED_KEYS = new Set(["primaryAgentEnabled", "subagentsEnabled", "disabledSubagents"]);
14
+ /**
15
+ * Read the `agent` namespace from a single `settings.json` file.
16
+ * Returns `{}` when the file or namespace is absent or unparseable.
17
+ */
18
+ function readAgentNamespace(path) {
19
+ if (!existsSync(path))
20
+ return {};
21
+ try {
22
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
23
+ const ns = raw[AGENT_NAMESPACE];
24
+ if (!ns || typeof ns !== "object")
25
+ return {};
26
+ const obj = ns;
27
+ const result = {};
28
+ if (typeof obj.primaryAgentEnabled === "boolean") {
29
+ result.primaryAgentEnabled = obj.primaryAgentEnabled;
30
+ }
31
+ if (typeof obj.subagentsEnabled === "boolean") {
32
+ result.subagentsEnabled = obj.subagentsEnabled;
33
+ }
34
+ if (Array.isArray(obj.disabledSubagents)) {
35
+ result.disabledSubagents = obj.disabledSubagents.filter((s) => typeof s === "string");
36
+ }
37
+ return result;
38
+ }
39
+ catch {
40
+ return {};
41
+ }
42
+ }
43
+ /**
44
+ * Load agent settings by merging defaults ← global ← project overrides.
45
+ * Mirrors the precedence pattern used by `loadConfig` for observational
46
+ * memory config.
47
+ */
48
+ export function loadAgentSettings(cwd, agentDir) {
49
+ const globalPath = join(agentDir ?? getAgentDir(), "settings.json");
50
+ const projectPath = join(cwd, ".spectral", "settings.json");
51
+ return {
52
+ ...AGENT_SETTINGS_DEFAULTS,
53
+ ...readAgentNamespace(globalPath),
54
+ ...readAgentNamespace(projectPath),
55
+ };
56
+ }
57
+ export function handleGetAgentSettings(cwd, agentDir) {
58
+ return loadAgentSettings(cwd, agentDir);
59
+ }
60
+ /**
61
+ * Persist a partial update to the `agent` namespace in the **global**
62
+ * `settings.json` (per-machine, matching the existing memory-settings
63
+ * behaviour). Project-level overrides are read-only from the REST API.
64
+ *
65
+ * Only boolean values are accepted for the two known keys; anything else
66
+ * is a `400 BadRequestError`.
67
+ */
68
+ export function handlePutAgentSettings(cwd, body, agentDir) {
69
+ const patch = {};
70
+ for (const key of ["primaryAgentEnabled", "subagentsEnabled"]) {
71
+ if (body[key] !== undefined) {
72
+ if (typeof body[key] !== "boolean") {
73
+ throw new BadRequestError(`agent setting "${key}" must be a boolean, got ${typeof body[key]}`);
74
+ }
75
+ patch[key] = body[key];
76
+ }
77
+ }
78
+ // disabledSubagents is a full-replacement string array
79
+ if (body.disabledSubagents !== undefined) {
80
+ if (!Array.isArray(body.disabledSubagents) ||
81
+ !body.disabledSubagents.every((s) => typeof s === "string")) {
82
+ throw new BadRequestError(`agent setting "disabledSubagents" must be a string array`);
83
+ }
84
+ patch.disabledSubagents = body.disabledSubagents;
85
+ }
86
+ if (Object.keys(patch).length === 0) {
87
+ throw new BadRequestError(`agent settings body must include at least one of: ${[...ALLOWED_KEYS].join(", ")}`);
88
+ }
89
+ // Persist to the global file only. The lock is shared with the
90
+ // observational-memory handler so concurrent writes cannot corrupt
91
+ // each other's namespace.
92
+ withSettingsLock({ agentDir, namespace: AGENT_NAMESPACE }, (current) => {
93
+ return { ...current, ...patch };
94
+ });
95
+ // Return the fully resolved settings (global + project merge) so the
96
+ // caller sees the effective state, not just what we wrote.
97
+ return { ok: true, ...loadAgentSettings(cwd, agentDir) };
98
+ }
99
+ export function handleListAgents(cwd, agentDir) {
100
+ const { agents } = discoverAgents(cwd, "both", agentDir);
101
+ return {
102
+ agents: agents.map((a) => ({
103
+ name: a.name,
104
+ description: a.description,
105
+ mode: a.mode,
106
+ source: a.source,
107
+ })),
108
+ };
109
+ }
@@ -0,0 +1,36 @@
1
+ import { BadRequestError } from "./errors.js";
2
+ /**
3
+ * Acquire a synchronous file lock with bounded retries on `ELOCKED`.
4
+ *
5
+ * Shared by all settings handlers that persist to `settings.json` so
6
+ * concurrent writes from different namespaces (observational-memory,
7
+ * agent) cannot interleave and corrupt the file.
8
+ */
9
+ export declare function acquireLockSyncWithRetry(path: string): () => void;
10
+ /**
11
+ * Resolve the `settings.json` path for a given agent directory.
12
+ * Falls back to the default agent dir (`~/.spectral/agent`) when
13
+ * `agentDir` is omitted.
14
+ */
15
+ export declare function settingsFilePath(agentDir?: string): string;
16
+ /**
17
+ * Read-persist cycle for a single namespace inside `settings.json`,
18
+ * guarded by a file-level lock so concurrent writes across namespaces
19
+ * are serialized.
20
+ *
21
+ * - Reads the **entire** file (preserving all top-level keys).
22
+ * - Extracts `parsed[namespace]` (or `{}` if absent).
23
+ * - Passes the current namespace object to `fn`. If `fn` returns
24
+ * `undefined`, no write occurs. Otherwise the returned object
25
+ * replaces the namespace and the full file is written back.
26
+ *
27
+ * The lock is acquired **before** writing (and also before reading if
28
+ * the file already exists) so we never observe a half-written file.
29
+ */
30
+ export declare function withSettingsLock(opts: {
31
+ agentDir?: string;
32
+ namespace: string;
33
+ }, fn: (current: Record<string, unknown>) => Record<string, unknown> | undefined): void;
34
+ /** Re-exported for handler convenience. */
35
+ export { BadRequestError };
36
+ //# sourceMappingURL=settings-lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings-lock.d.ts","sourceRoot":"","sources":["../../../src/server/handlers/settings-lock.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9C;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,IAAI,CAsBjE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,EAC9C,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAC5E,IAAI,CA4BN;AAED,2CAA2C;AAC3C,OAAO,EAAE,eAAe,EAAE,CAAC"}
@@ -0,0 +1,89 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import lockfile from "proper-lockfile";
4
+ import { getAgentDir } from "../../sdk/coding-agent/index.js";
5
+ import { BadRequestError } from "./errors.js";
6
+ /**
7
+ * Acquire a synchronous file lock with bounded retries on `ELOCKED`.
8
+ *
9
+ * Shared by all settings handlers that persist to `settings.json` so
10
+ * concurrent writes from different namespaces (observational-memory,
11
+ * agent) cannot interleave and corrupt the file.
12
+ */
13
+ export function acquireLockSyncWithRetry(path) {
14
+ const maxAttempts = 10;
15
+ const delayMs = 20;
16
+ let lastError;
17
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
18
+ try {
19
+ return lockfile.lockSync(path, { realpath: false });
20
+ }
21
+ catch (error) {
22
+ const code = typeof error === "object" && error !== null && "code" in error
23
+ ? String(error.code)
24
+ : undefined;
25
+ if (code !== "ELOCKED" || attempt === maxAttempts) {
26
+ throw error;
27
+ }
28
+ lastError = error;
29
+ const start = Date.now();
30
+ while (Date.now() - start < delayMs) {
31
+ }
32
+ }
33
+ }
34
+ throw lastError ?? new Error("Failed to acquire settings lock");
35
+ }
36
+ /**
37
+ * Resolve the `settings.json` path for a given agent directory.
38
+ * Falls back to the default agent dir (`~/.spectral/agent`) when
39
+ * `agentDir` is omitted.
40
+ */
41
+ export function settingsFilePath(agentDir) {
42
+ return join(agentDir ?? getAgentDir(), "settings.json");
43
+ }
44
+ /**
45
+ * Read-persist cycle for a single namespace inside `settings.json`,
46
+ * guarded by a file-level lock so concurrent writes across namespaces
47
+ * are serialized.
48
+ *
49
+ * - Reads the **entire** file (preserving all top-level keys).
50
+ * - Extracts `parsed[namespace]` (or `{}` if absent).
51
+ * - Passes the current namespace object to `fn`. If `fn` returns
52
+ * `undefined`, no write occurs. Otherwise the returned object
53
+ * replaces the namespace and the full file is written back.
54
+ *
55
+ * The lock is acquired **before** writing (and also before reading if
56
+ * the file already exists) so we never observe a half-written file.
57
+ */
58
+ export function withSettingsLock(opts, fn) {
59
+ const path = settingsFilePath(opts.agentDir);
60
+ const dir = dirname(path);
61
+ let release;
62
+ try {
63
+ const fileExists = existsSync(path);
64
+ if (fileExists) {
65
+ release = acquireLockSyncWithRetry(path);
66
+ }
67
+ const raw = fileExists ? readFileSync(path, "utf-8") : "{}";
68
+ const parsed = JSON.parse(raw);
69
+ const ns = (parsed[opts.namespace] ?? {});
70
+ const next = fn(ns);
71
+ if (next !== undefined) {
72
+ parsed[opts.namespace] = { ...ns, ...next };
73
+ if (!existsSync(dir)) {
74
+ mkdirSync(dir, { recursive: true });
75
+ }
76
+ if (!release) {
77
+ release = acquireLockSyncWithRetry(path);
78
+ }
79
+ writeFileSync(path, JSON.stringify(parsed, null, 2), "utf-8");
80
+ }
81
+ }
82
+ finally {
83
+ if (release) {
84
+ release();
85
+ }
86
+ }
87
+ }
88
+ /** Re-exported for handler convenience. */
89
+ export { BadRequestError };
@@ -1,14 +1,3 @@
1
- /**
2
- * Pure REST handler for `GET /api/settings` and `PUT /api/settings`.
3
- *
4
- * Settings are stored in the machine's global `settings.json` under the
5
- * `"observational-memory"` namespace (see `src/memory/config.ts`).
6
- *
7
- * GET /api/settings → returns full observational-memory config
8
- * PUT /api/settings → updates a single key via `{ key, value }` body
9
- *
10
- * All paths are machine-level — no session context needed.
11
- */
12
1
  export interface PutSettingsInput {
13
2
  key?: unknown;
14
3
  value?: unknown;
@@ -1 +1 @@
1
- {"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../../../src/server/handlers/settings.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAaH,MAAM,WAAW,gBAAgB;IAC/B,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,IAAI,CAAC;IACT,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,iBAAiB;IAChC,0BAA0B,EAAE,MAAM,CAAC;IACnC,yBAAyB,EAAE,MAAM,CAAC;IAClC,yBAAyB,EAAE,MAAM,CAAC;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAsFD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,CAShE;AAMD,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,gBAAgB,GACrB,iBAAiB,CA0BnB"}
1
+ {"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../../../src/server/handlers/settings.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,gBAAgB;IAC/B,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AACD,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,IAAI,CAAC;IACT,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;CACf;AACD,MAAM,WAAW,iBAAiB;IAChC,0BAA0B,EAAE,MAAM,CAAC;IACnC,yBAAyB,EAAE,MAAM,CAAC;IAClC,yBAAyB,EAAE,MAAM,CAAC;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAgBD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,CAShE;AACD,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,gBAAgB,GACrB,iBAAiB,CAoBnB"}
@@ -1,93 +1,19 @@
1
- /**
2
- * Pure REST handler for `GET /api/settings` and `PUT /api/settings`.
3
- *
4
- * Settings are stored in the machine's global `settings.json` under the
5
- * `"observational-memory"` namespace (see `src/memory/config.ts`).
6
- *
7
- * GET /api/settings → returns full observational-memory config
8
- * PUT /api/settings → updates a single key via `{ key, value }` body
9
- *
10
- * All paths are machine-level — no session context needed.
11
- */
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
- import { dirname, join } from "node:path";
14
- import lockfile from "proper-lockfile";
15
- import { getAgentDir } from "../../sdk/coding-agent/index.js";
16
1
  import { loadConfig } from "../../memory/config.js";
17
2
  import { BadRequestError } from "./errors.js";
18
- /** Recognised setting keys (dot-separated path within the config object). */
3
+ import { withSettingsLock } from "./settings-lock.js";
19
4
  const ALLOWED_KEYS = new Set([
20
5
  "observationThresholdTokens",
21
6
  "compactionThresholdTokens",
22
7
  "reflectionThresholdTokens",
23
8
  ]);
24
- /** Sane bounds for token thresholds. */
25
9
  const MIN_TOKENS = 1_000;
26
10
  const MAX_TOKENS = 1_000_000;
27
- // ---------------------------------------------------------------------------
28
- // File helpers (inlined to avoid pulling in the full SettingsManager)
29
- // ---------------------------------------------------------------------------
30
- function acquireLockSyncWithRetry(path) {
31
- const maxAttempts = 10;
32
- const delayMs = 20;
33
- let lastError;
34
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
35
- try {
36
- return lockfile.lockSync(path, { realpath: false });
37
- }
38
- catch (error) {
39
- const code = typeof error === "object" && error !== null && "code" in error
40
- ? String(error.code)
41
- : undefined;
42
- if (code !== "ELOCKED" || attempt === maxAttempts) {
43
- throw error;
44
- }
45
- lastError = error;
46
- const start = Date.now();
47
- while (Date.now() - start < delayMs) {
48
- // busy-wait; synchronous by design
49
- }
50
- }
51
- }
52
- throw lastError ?? new Error("Failed to acquire settings lock");
53
- }
54
- /**
55
- * Atomically read-modify-write the global `settings.json` under the
56
- * `"observational-memory"` namespace.
57
- */
58
11
  function withObservationalMemoryLock(fn) {
59
- const path = join(getAgentDir(), "settings.json");
60
- const dir = dirname(path);
61
- let release;
62
- try {
63
- const fileExists = existsSync(path);
64
- if (fileExists) {
65
- release = acquireLockSyncWithRetry(path);
66
- }
67
- const raw = fileExists ? readFileSync(path, "utf-8") : "{}";
68
- const parsed = JSON.parse(raw);
69
- const ns = (parsed["observational-memory"] ?? {});
70
- const next = fn(ns);
71
- if (next !== undefined) {
72
- parsed["observational-memory"] = { ...ns, ...next };
73
- if (!existsSync(dir)) {
74
- mkdirSync(dir, { recursive: true });
75
- }
76
- if (!release) {
77
- release = acquireLockSyncWithRetry(path);
78
- }
79
- writeFileSync(path, JSON.stringify(parsed, null, 2), "utf-8");
80
- }
81
- }
82
- finally {
83
- if (release) {
84
- release();
85
- }
86
- }
12
+ withSettingsLock({ namespace: "observational-memory" }, (current) => {
13
+ const next = fn(current);
14
+ return next;
15
+ });
87
16
  }
88
- // ---------------------------------------------------------------------------
89
- // GET handler
90
- // ---------------------------------------------------------------------------
91
17
  export function handleGetSettings(cwd) {
92
18
  const config = loadConfig(cwd);
93
19
  return {
@@ -98,16 +24,11 @@ export function handleGetSettings(cwd) {
98
24
  debugLog: config.debugLog,
99
25
  };
100
26
  }
101
- // ---------------------------------------------------------------------------
102
- // PUT handler
103
- // ---------------------------------------------------------------------------
104
27
  export function handlePutSettings(cwd, body) {
105
- // Validate key
106
28
  if (typeof body.key !== "string" || !ALLOWED_KEYS.has(body.key)) {
107
29
  throw new BadRequestError(`settings key must be one of: ${[...ALLOWED_KEYS].join(", ")}`);
108
30
  }
109
31
  const key = body.key;
110
- // Validate value
111
32
  if (typeof body.value !== "number" || !Number.isInteger(body.value)) {
112
33
  throw new BadRequestError("settings value must be an integer");
113
34
  }
@@ -115,7 +36,6 @@ export function handlePutSettings(cwd, body) {
115
36
  throw new BadRequestError(`token threshold must be between ${MIN_TOKENS.toLocaleString()} and ${MAX_TOKENS.toLocaleString()}`);
116
37
  }
117
38
  const value = body.value;
118
- // Persist
119
39
  withObservationalMemoryLock((current) => {
120
40
  return { ...current, [key]: value };
121
41
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.9.20",
3
+ "version": "0.9.24",
4
4
  "description": "AI coding agent for Aexol with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,