@moxxy/core 0.6.2 → 0.7.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.
Files changed (70) hide show
  1. package/dist/index.d.ts +3 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +5 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/plugins/host-options.d.ts +2 -0
  6. package/dist/plugins/host-options.d.ts.map +1 -1
  7. package/dist/plugins/registry-kinds.d.ts +1 -0
  8. package/dist/plugins/registry-kinds.d.ts.map +1 -1
  9. package/dist/plugins/registry-kinds.js +12 -0
  10. package/dist/plugins/registry-kinds.js.map +1 -1
  11. package/dist/registries/active-def-registry.d.ts +46 -9
  12. package/dist/registries/active-def-registry.d.ts.map +1 -1
  13. package/dist/registries/active-def-registry.js +39 -10
  14. package/dist/registries/active-def-registry.js.map +1 -1
  15. package/dist/registries/event-stores.d.ts +13 -0
  16. package/dist/registries/event-stores.d.ts.map +1 -0
  17. package/dist/registries/event-stores.js +14 -0
  18. package/dist/registries/event-stores.js.map +1 -0
  19. package/dist/registries/modes.d.ts +6 -0
  20. package/dist/registries/modes.d.ts.map +1 -1
  21. package/dist/registries/modes.js +10 -0
  22. package/dist/registries/modes.js.map +1 -1
  23. package/dist/registries/services.d.ts +15 -0
  24. package/dist/registries/services.d.ts.map +1 -0
  25. package/dist/registries/services.js +26 -0
  26. package/dist/registries/services.js.map +1 -0
  27. package/dist/run-turn.d.ts.map +1 -1
  28. package/dist/run-turn.js +3 -0
  29. package/dist/run-turn.js.map +1 -1
  30. package/dist/session-runtime.d.ts +3 -1
  31. package/dist/session-runtime.d.ts.map +1 -1
  32. package/dist/session.d.ts +25 -3
  33. package/dist/session.d.ts.map +1 -1
  34. package/dist/session.js +78 -8
  35. package/dist/session.js.map +1 -1
  36. package/dist/sessions/jsonl-event-store.d.ts +16 -0
  37. package/dist/sessions/jsonl-event-store.d.ts.map +1 -0
  38. package/dist/sessions/jsonl-event-store.js +30 -0
  39. package/dist/sessions/jsonl-event-store.js.map +1 -0
  40. package/dist/sessions/persistence.d.ts +3 -58
  41. package/dist/sessions/persistence.d.ts.map +1 -1
  42. package/dist/sessions/persistence.js +2 -0
  43. package/dist/sessions/persistence.js.map +1 -1
  44. package/dist/subagents/run-child.d.ts.map +1 -1
  45. package/dist/subagents/run-child.js +2 -0
  46. package/dist/subagents/run-child.js.map +1 -1
  47. package/package.json +2 -2
  48. package/src/index.ts +11 -6
  49. package/src/plugins/host-options.ts +2 -0
  50. package/src/plugins/registry-kinds.test.ts +17 -0
  51. package/src/plugins/registry-kinds.ts +14 -0
  52. package/src/registries/active-def-registry.test.ts +42 -0
  53. package/src/registries/active-def-registry.ts +70 -13
  54. package/src/registries/event-stores.test.ts +77 -0
  55. package/src/registries/event-stores.ts +15 -0
  56. package/src/registries/modes.ts +12 -0
  57. package/src/registries/services.test.ts +29 -0
  58. package/src/registries/services.ts +33 -0
  59. package/src/run-turn.ts +3 -0
  60. package/src/session-runtime.ts +3 -0
  61. package/src/session.ts +89 -8
  62. package/src/sessions/jsonl-event-store.ts +31 -0
  63. package/src/sessions/persistence.ts +11 -58
  64. package/src/subagents/run-child.ts +2 -0
  65. package/dist/preferences.d.ts +0 -40
  66. package/dist/preferences.d.ts.map +0 -1
  67. package/dist/preferences.js +0 -59
  68. package/dist/preferences.js.map +0 -1
  69. package/src/preferences.test.ts +0 -156
  70. package/src/preferences.ts +0 -85
package/src/session.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type {
2
2
  AppContext,
3
3
  ClientSession,
4
+ EmittedEvent,
5
+ LoopGuardSettings,
4
6
  MoxxyEvent,
5
7
  RunTurnOptions,
6
8
  SessionId,
@@ -32,6 +34,9 @@ import { SynthesizerRegistry } from './registries/synthesizers.js';
32
34
  import { EmbedderRegistry } from './registries/embedders.js';
33
35
  import { IsolatorRegistry } from './registries/isolators.js';
34
36
  import { WorkflowExecutorRegistry } from './registries/workflow-executors.js';
37
+ import { EventStoreRegistry } from './registries/event-stores.js';
38
+ import { ServiceRegistryImpl } from './registries/services.js';
39
+ import { jsonlEventStore } from './sessions/jsonl-event-store.js';
35
40
  import { RequirementRegistry } from './requirements.js';
36
41
  import { PermissionEngine } from './permissions/engine.js';
37
42
  import { autoAllowResolver } from './permissions/resolvers.js';
@@ -129,6 +134,9 @@ export class Session implements ClientSession, SessionRuntime {
129
134
  readonly embedders: EmbedderRegistry;
130
135
  readonly isolators: IsolatorRegistry;
131
136
  readonly workflowExecutors: WorkflowExecutorRegistry;
137
+ readonly eventStores: EventStoreRegistry;
138
+ /** Inter-plugin service registry — plugins publish/consume services in onInit. */
139
+ readonly services: ServiceRegistryImpl;
132
140
  readonly requirements: RequirementRegistry;
133
141
  readonly permissions: PermissionEngine;
134
142
  /** Current PermissionResolver. Update via `setPermissionResolver(r)`. */
@@ -155,6 +163,12 @@ export class Session implements ClientSession, SessionRuntime {
155
163
  * when the active model advertises `supportsReasoning`. Undefined → off.
156
164
  */
157
165
  reasoning: { readonly effort?: 'low' | 'medium' | 'high' } | boolean | undefined = undefined;
166
+ /**
167
+ * Stuck-loop guard tuning, from `config.context.loopGuard`. Forwarded to each
168
+ * turn's ModeContext and on to the mode's stuck-loop detector. Undefined →
169
+ * the detector's defaults.
170
+ */
171
+ loopGuard: LoopGuardSettings | undefined = undefined;
158
172
  /**
159
173
  * Model id resolved by the most recent `runTurn()` (see SessionRuntime).
160
174
  * Last-writer-wins for concurrent turns; null until the first turn runs.
@@ -168,8 +182,6 @@ export class Session implements ClientSession, SessionRuntime {
168
182
  */
169
183
  readyProviders?: Set<string>;
170
184
  credentialResolver?: CredentialResolver;
171
- mcpAdmin?: McpAdminView;
172
- providerAdmin?: ProviderAdminView;
173
185
  workflows?: WorkflowsView;
174
186
  pluginsAdmin?: PluginsAdminView;
175
187
  readonly dispatcher: HookDispatcherImpl;
@@ -191,13 +203,15 @@ export class Session implements ClientSession, SessionRuntime {
191
203
  this.compactors = new CompactorRegistry();
192
204
  this.cacheStrategies = new CacheStrategyRegistry();
193
205
  this.viewRenderers = new ViewRendererRegistry();
194
- // Seed the built-in renderer so `present_view` always has one to parse
195
- // with; plugins can register/replace and `setActive` an alternative.
196
- this.viewRenderers.register(defaultViewRenderer);
206
+ // Seed the built-in renderer as the protected floor so `present_view`
207
+ // always has one to parse with; plugins can register/replace and
208
+ // `setActive` an alternative, but it can never be removed (reverts here).
209
+ this.viewRenderers.register(defaultViewRenderer, { protected: true });
197
210
  this.tunnelProviders = new TunnelProviderRegistry();
198
- // Seed the no-op localhost provider so the web surface always resolves a
199
- // URL; plugins (the proxy relay) register/setActive a real tunnel.
200
- this.tunnelProviders.register(localhostTunnel);
211
+ // Seed the no-op localhost provider as the protected floor so the web
212
+ // surface always resolves a URL; plugins (the proxy relay) register/
213
+ // setActive a real tunnel, reverting here if it's removed.
214
+ this.tunnelProviders.register(localhostTunnel, { protected: true });
201
215
  this.channels = new ChannelRegistryImpl();
202
216
  this.surfaceRegistry = new SurfaceRegistryImpl();
203
217
  this.surfaces = new SurfaceHostImpl(this.surfaceRegistry, { cwd: this.cwd, logger: this.logger }, this.logger);
@@ -211,6 +225,49 @@ export class Session implements ClientSession, SessionRuntime {
211
225
  this.embedders = new EmbedderRegistry();
212
226
  this.isolators = new IsolatorRegistry();
213
227
  this.workflowExecutors = new WorkflowExecutorRegistry();
228
+ this.services = new ServiceRegistryImpl();
229
+ // Publish the core registries on the inter-plugin service registry under
230
+ // well-known names, so a discovery-loaded plugin can resolve one in its
231
+ // onInit (typed as the SDK's minimal NamedRegistry) instead of being
232
+ // hand-built with a `() => session.<registry>` closure. The registry
233
+ // objects are stable; their contents grow as plugins register — consumers
234
+ // read them lazily at tool-call time, after all registration has run.
235
+ this.services.register('agents', this.agents);
236
+ this.services.register('tools', this.tools);
237
+ this.services.register('providers', this.providers);
238
+ this.services.register('viewRenderers', this.viewRenderers);
239
+ this.services.register('synthesizers', this.synthesizers);
240
+ this.services.register('skills', this.skills);
241
+ this.services.register('tunnelProviders', this.tunnelProviders);
242
+ // A stable accessor for the active provider's stored credentials. The
243
+ // resolver itself is installed late (by activateProvider, after plugins are
244
+ // built), so close over `this` and read it lazily at call time — lets
245
+ // provider-admin rebuild a reconfigured provider's instance without a
246
+ // host-injected `resolveActiveConfig` closure.
247
+ this.services.register(
248
+ 'resolveCredentials',
249
+ (name: string): Promise<Record<string, unknown>> | Record<string, unknown> =>
250
+ this.credentialResolver ? this.credentialResolver(name) : {},
251
+ );
252
+ // A live registry-name snapshot (per kind) + a writable event-append fn, for
253
+ // plugins (self-update) that need them in onInit without a host closure.
254
+ // appendEvent is the writable counterpart to the read-only `ctx.log`.
255
+ this.services.register('registrySnapshot', () => ({
256
+ tools: this.tools.list().map((t) => t.name),
257
+ agents: this.agents.list().map((a) => a.name),
258
+ providers: this.providers.list().map((p) => p.name),
259
+ modes: this.modes.list().map((m) => m.name),
260
+ compactors: this.compactors.list().map((c) => c.name),
261
+ channels: this.channels.list().map((c) => c.name),
262
+ }));
263
+ this.services.register(
264
+ 'appendEvent',
265
+ (event: EmittedEvent): Promise<void> => this.log.append(event).then(() => undefined),
266
+ );
267
+ this.eventStores = new EventStoreRegistry();
268
+ // Seed the built-in JSONL store as the protected floor — the storage backend
269
+ // behind the event log always exists and can be swapped but never removed.
270
+ this.eventStores.register(jsonlEventStore, { protected: true });
214
271
  this.requirements = new RequirementRegistry({
215
272
  tools: this.tools,
216
273
  providers: this.providers,
@@ -257,12 +314,16 @@ export class Session implements ClientSession, SessionRuntime {
257
314
  embedders: this.embedders,
258
315
  isolators: this.isolators,
259
316
  workflowExecutors: this.workflowExecutors,
317
+ eventStores: this.eventStores,
260
318
  requirements: this.requirements,
261
319
  dispatcher: this.dispatcher,
262
320
  loader: opts.pluginLoader,
263
321
  ...(opts.pluginDiscoveryPaths ? { userPaths: opts.pluginDiscoveryPaths } : {}),
264
322
  ...(opts.isPluginDisabled ? { isDisabled: opts.isPluginDisabled } : {}),
265
323
  });
324
+ // Published after construction (the host is built late) so a discovery-loaded
325
+ // plugin (self-update) can reach reload/unload/listSkipped in its onInit.
326
+ this.services.register('pluginHost', this.pluginHost);
266
327
 
267
328
  // Fan every appended event out to plugin `onEvent` hooks. Without this
268
329
  // wiring the hook is dead code — declared on the SDK, dispatched by
@@ -367,6 +428,25 @@ export class Session implements ClientSession, SessionRuntime {
367
428
  */
368
429
  private envSnapshot: Readonly<NodeJS.ProcessEnv> | null = null;
369
430
 
431
+ /**
432
+ * The provider-admin capability (configure/refresh stored providers), read by
433
+ * the runner's provider handlers + the desktop. The @moxxy/plugin-provider-admin
434
+ * plugin publishes it on the service registry in its onInit, so discovery-loading
435
+ * it needs no host stash. (RemoteSession sets its own field for thin clients.)
436
+ */
437
+ get providerAdmin(): ProviderAdminView | undefined {
438
+ return this.services.get<ProviderAdminView>('providerAdmin');
439
+ }
440
+
441
+ /**
442
+ * The MCP-admin capability (enable/detach/list MCP servers), read by the
443
+ * runner's mcp handlers + the desktop. @moxxy/plugin-mcp publishes it on the
444
+ * service registry in its onInit. (RemoteSession sets its own field.)
445
+ */
446
+ get mcpAdmin(): McpAdminView | undefined {
447
+ return this.services.get<McpAdminView>('mcpAdmin');
448
+ }
449
+
370
450
  appContext(): AppContext {
371
451
  if (!this.envSnapshot) {
372
452
  this.envSnapshot = Object.freeze({ ...process.env });
@@ -376,6 +456,7 @@ export class Session implements ClientSession, SessionRuntime {
376
456
  cwd: this.cwd,
377
457
  log: this.log.asReader(),
378
458
  env: this.envSnapshot,
459
+ services: this.services,
379
460
  };
380
461
  }
381
462
 
@@ -0,0 +1,31 @@
1
+ import type { EventStoreDef, EventStoreScope, EventStoreSession } from '@moxxy/sdk';
2
+ import { SessionPersistence, readEventPage, restoreEvents } from './persistence.js';
3
+
4
+ /**
5
+ * The built-in EventStore: per-session JSONL (`~/.moxxy/sessions/<id>.jsonl`) +
6
+ * a `.json` meta sidecar. A thin adapter over the battle-tested
7
+ * {@link SessionPersistence} (write) and `restoreEvents`/`readEventPage` (read)
8
+ * — zero behaviour change, just expressed behind the {@link EventStoreDef}
9
+ * contract. Core seeds this as the protected floor: a plugin can register an
10
+ * alternative store, but it never auto-activates (the user opts in by name via
11
+ * `plugins.eventStore.default`).
12
+ *
13
+ * `SessionPersistence` already exposes the full {@link EventStoreSession}
14
+ * surface (attach/flush/settleWrites/updateHeader/degraded), so `open` returns
15
+ * one directly.
16
+ */
17
+ export const jsonlEventStore: EventStoreDef = {
18
+ name: 'jsonl',
19
+ open(scope: EventStoreScope): EventStoreSession {
20
+ return new SessionPersistence({
21
+ sessionId: scope.sessionId,
22
+ cwd: scope.cwd,
23
+ ...(scope.dir ? { dir: scope.dir } : {}),
24
+ ...(scope.providerName ? { providerName: scope.providerName } : {}),
25
+ ...(scope.modelId ? { modelId: scope.modelId } : {}),
26
+ ...(scope.source ? { source: scope.source } : {}),
27
+ });
28
+ },
29
+ restore: (sessionId, dir) => restoreEvents(sessionId, dir),
30
+ readPage: (sessionId, opts, dir) => readEventPage(sessionId, opts, dir),
31
+ };
@@ -19,59 +19,22 @@
19
19
  import { constants as fsConstants, promises as fs } from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { createMutex, type Mutex, type MoxxyEvent, type SessionId } from '@moxxy/sdk';
22
+ import type { EventLogLike, EventPage, SessionMeta, SessionSource } from '@moxxy/sdk';
22
23
  import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
23
- import type { EventLog } from '../events/log.js';
24
24
  import { createLogger, type Logger } from '../logger.js';
25
25
 
26
- /**
27
- * The channel that originated a session. Persisted into the sidecar by the
28
- * runner so every surface (desktop/TUI/mobile) derives the session list from a
29
- * single source instead of each keeping its own copy. `desktop`/`mobile`
30
- * sessions are kept in the derived workspace list even before they have a first
31
- * prompt (a brand-new chat the user just opened); empty `cli`/`tui` sidecars are
32
- * dropped as noise.
33
- */
34
- export type SessionSource = 'cli' | 'tui' | 'desktop' | 'mobile';
26
+ // `SessionSource`, `SessionMeta` and `EventPage` are the EventStore contract's
27
+ // data shapes defined in @moxxy/sdk so `EventStoreDef` can reference them.
28
+ // Re-exported here (and onward from @moxxy/core) so existing importers are
29
+ // unaffected by the move.
30
+ export type { EventLogLike, EventPage, SessionMeta, SessionSource } from '@moxxy/sdk';
35
31
 
36
32
  /** Schema version of the per-session metadata file (`<id>.json`). Bump when the
37
33
  * shape changes incompatibly; readers tolerate a missing/older version. */
38
34
  export const SESSION_META_VERSION = 1;
39
35
 
40
- /**
41
- * The single per-session metadata file: `~/.moxxy/sessions/<id>.json`.
42
- *
43
- * ONE file per session, the unit every surface (TUI/desktop/mobile) lists,
44
- * searches and caches. The conversation itself lives in the append-only
45
- * `<id>.jsonl`. Fields split by owner:
46
- * - the RUNNER owns the content fields (`firstPrompt`, `eventCount`,
47
- * `lastActivity`, `provider`, `model`, `startedAt`, `source`) and rewrites
48
- * them on a debounce;
49
- * - the UI owns `title` (a rename) and `groupId` (which desk it belongs to).
50
- * Because both write the same file, the runner ADOPTS the UI fields on attach
51
- * and re-merges them just before each write, so a live session never clobbers a
52
- * rename or a move.
53
- */
54
- export interface SessionMeta {
55
- /** Schema version; absent on older files (treated as v0). */
56
- readonly version?: number;
57
- readonly id: string;
58
- readonly cwd: string;
59
- readonly startedAt: string;
60
- readonly lastActivity: string;
61
- readonly eventCount: number;
62
- /** First 80 chars of the first user_prompt. The list/search label. */
63
- readonly firstPrompt: string | null;
64
- readonly provider: string | null;
65
- readonly model: string | null;
66
- /** Originating channel, when known. Written by the runner via `opts.source`. */
67
- readonly source?: SessionSource;
68
- /** Explicit workspace/desk membership (UI-owned). `null`/absent → grouped by
69
- * cwd containment (CLI/TUI sessions that don't know about desks). */
70
- readonly groupId?: string | null;
71
- /** User-set display name, the rename (UI-owned). `null`/absent → the name is
72
- * derived from `firstPrompt`. */
73
- readonly title?: string | null;
74
- }
36
+ // `SessionMeta` is defined in @moxxy/sdk (the EventStore contract's listing
37
+ // shape) and re-exported above. The JSONL impl below reads/writes it.
75
38
 
76
39
  export interface SessionPersistenceOpts {
77
40
  readonly sessionId: SessionId;
@@ -216,7 +179,7 @@ export class SessionPersistence {
216
179
  * in the same (now empty) JSONL, matching how the in-memory log
217
180
  * reuses the same Session object.
218
181
  */
219
- attach(log: EventLog): () => void {
182
+ attach(log: EventLogLike): () => void {
220
183
  void this.ensureReady()
221
184
  .then(() => this.scheduleIndexWrite())
222
185
  .catch(() => undefined);
@@ -679,18 +642,8 @@ export async function restoreEvents(
679
642
  return events;
680
643
  }
681
644
 
682
- /** One page of persisted events, newest-page-first paging (see
683
- * {@link readEventPage}). */
684
- export interface EventPage {
685
- /** The events in this page, in ascending `seq` order. */
686
- readonly events: MoxxyEvent[];
687
- /**
688
- * Cursor to pass as `before` for the NEXT (older) page — the `seq` of the
689
- * OLDEST event in this page. `null` once the start of history (the first
690
- * persisted event) is included, signalling there is no older page.
691
- */
692
- readonly prevCursor: number | null;
693
- }
645
+ // `EventPage` is defined in @moxxy/sdk (the EventStore read contract) and
646
+ // re-exported above; `readEventPage`/`pageEvents` below produce it.
694
647
 
695
648
  /**
696
649
  * Read ONE page of a persisted session's events without re-materializing the
@@ -447,6 +447,7 @@ function buildChildContext(
447
447
  turnId: childTurnId,
448
448
  cwd: parentAppCtx.cwd,
449
449
  env: parentAppCtx.env,
450
+ services: parentAppCtx.services,
450
451
  model,
451
452
  ...(spec.systemPrompt !== undefined ? { systemPrompt: spec.systemPrompt } : {}),
452
453
  provider: parentSession.providers.getActive(),
@@ -457,6 +458,7 @@ function buildChildContext(
457
458
  cacheStrategy: parentSession.cacheStrategies.getActive(),
458
459
  ...(parentSession.elisionSettings ? { elision: parentSession.elisionSettings } : {}),
459
460
  ...(parentSession.lazyTools ? { lazyTools: true } : {}),
461
+ ...(parentSession.loopGuard ? { loopGuard: parentSession.loopGuard } : {}),
460
462
  permissions: parentSession.resolver,
461
463
  // Intentionally no `approval` — fanning approval gates out to N
462
464
  // children in parallel would prompt the user N times. Strategies
@@ -1,40 +0,0 @@
1
- /**
2
- * User-level runtime preferences persisted at ~/.moxxy/preferences.json.
3
- * Distinct from `~/.moxxy/config.yaml` (user-edited, source of truth for
4
- * provider/plugin wiring) — this file is mutated by the TUI as the user
5
- * picks a model / loop strategy via slash commands, so it survives across
6
- * CLI invocations. A missing file or unreadable contents is a no-op:
7
- * preferences are best-effort, never load-blocking.
8
- */
9
- export interface MoxxyPreferences {
10
- /** Active provider name (e.g. "openai", "anthropic"). */
11
- readonly providerName?: string;
12
- /** Active model id under that provider (e.g. "gpt-5.4-mini"). */
13
- readonly model?: string;
14
- /** Active loop strategy name (e.g. "default", "research", "goal"). */
15
- readonly mode?: string;
16
- /**
17
- * Providers the user disabled (desktop Settings → Providers toggle).
18
- * Seeded into the ProviderRegistry at boot; a listed provider stays
19
- * registered but can't be activated until re-enabled.
20
- */
21
- readonly disabledProviders?: ReadonlyArray<string>;
22
- }
23
- export declare function preferencesPath(): string;
24
- /**
25
- * Read preferences from disk. Returns an empty object when the file
26
- * doesn't exist or fails to parse — preferences are an optional layer,
27
- * never a hard dependency of session bootstrap.
28
- */
29
- export declare function loadPreferences(): Promise<MoxxyPreferences>;
30
- /**
31
- * Merge-and-write preferences. Reads the current file (if any), merges
32
- * the patch on top so we don't blow away unrelated fields, and writes
33
- * the result back atomically. The whole read-merge-write runs inside a
34
- * module-level mutex so overlapping saves serialize (invariant #5) and
35
- * never lose an update. Best-effort: a write failure logs to stderr but
36
- * does not throw — the user's pick still takes effect in this session,
37
- * just won't persist across invocations.
38
- */
39
- export declare function savePreferences(patch: Partial<MoxxyPreferences>): Promise<void>;
40
- //# sourceMappingURL=preferences.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"preferences.d.ts","sourceRoot":"","sources":["../src/preferences.ts"],"names":[],"mappings":"AAIA;;;;;;;GAOG;AACH,MAAM,WAAW,gBAAgB;IAC/B,yDAAyD;IACzD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,sEAAsE;IACtE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CACpD;AAED,wBAAgB,eAAe,IAAI,MAAM,CAKxC;AAED;;;;GAIG;AACH,wBAAsB,eAAe,IAAI,OAAO,CAAC,gBAAgB,CAAC,CAYjE;AAUD;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CAAC,KAAK,EAAE,OAAO,CAAC,gBAAgB,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAcrF"}
@@ -1,59 +0,0 @@
1
- import { promises as fs } from 'node:fs';
2
- import { createMutex, migrateModeName } from '@moxxy/sdk';
3
- import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
4
- export function preferencesPath() {
5
- // Route through `moxxyPath` so a `$MOXXY_HOME` override relocates preferences
6
- // alongside the rest of the data dir (config.yaml, vault, providers.json, …).
7
- // Identical to `~/.moxxy/preferences.json` when MOXXY_HOME is unset.
8
- return moxxyPath('preferences.json');
9
- }
10
- /**
11
- * Read preferences from disk. Returns an empty object when the file
12
- * doesn't exist or fails to parse — preferences are an optional layer,
13
- * never a hard dependency of session bootstrap.
14
- */
15
- export async function loadPreferences() {
16
- try {
17
- const raw = await fs.readFile(preferencesPath(), 'utf8');
18
- const parsed = JSON.parse(raw);
19
- if (parsed && typeof parsed === 'object') {
20
- const prefs = parsed;
21
- return prefs.mode ? { ...prefs, mode: migrateModeName(prefs.mode) } : prefs;
22
- }
23
- return {};
24
- }
25
- catch {
26
- return {};
27
- }
28
- }
29
- // Serializes the read-modify-write in `savePreferences` so two concurrent
30
- // saves (e.g. a /model pick and a provider-disable toggle firing close
31
- // together) can't both read the same snapshot and have the second write
32
- // clobber the first's field. This is the single write path for the file —
33
- // other packages (CLI, runner, channels) route their fire-and-forget saves
34
- // through it, and any load-modify-save must go through it too.
35
- const writeMutex = createMutex();
36
- /**
37
- * Merge-and-write preferences. Reads the current file (if any), merges
38
- * the patch on top so we don't blow away unrelated fields, and writes
39
- * the result back atomically. The whole read-merge-write runs inside a
40
- * module-level mutex so overlapping saves serialize (invariant #5) and
41
- * never lose an update. Best-effort: a write failure logs to stderr but
42
- * does not throw — the user's pick still takes effect in this session,
43
- * just won't persist across invocations.
44
- */
45
- export async function savePreferences(patch) {
46
- return writeMutex.run(async () => {
47
- const current = await loadPreferences();
48
- const next = { ...current, ...patch };
49
- const target = preferencesPath();
50
- try {
51
- await writeFileAtomic(target, JSON.stringify(next, null, 2) + '\n');
52
- }
53
- catch (err) {
54
- process.stderr.write(`moxxy: failed to persist preferences to ${target}: ` +
55
- `${err instanceof Error ? err.message : String(err)}\n`);
56
- }
57
- });
58
- }
59
- //# sourceMappingURL=preferences.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"preferences.js","sourceRoot":"","sources":["../src/preferences.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,SAAS,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAC1D,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAyB/D,MAAM,UAAU,eAAe;IAC7B,8EAA8E;IAC9E,8EAA8E;IAC9E,qEAAqE;IACrE,OAAO,SAAS,CAAC,kBAAkB,CAAC,CAAC;AACvC,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe;IACnC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,QAAQ,CAAC,eAAe,EAAE,EAAE,MAAM,CAAC,CAAC;QACzD,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAY,CAAC;QAC1C,IAAI,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;YACzC,MAAM,KAAK,GAAG,MAA0B,CAAC;YACzC,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,IAAI,EAAE,eAAe,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC;QAC9E,CAAC;QACD,OAAO,EAAE,CAAC;IACZ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;AACH,CAAC;AAED,0EAA0E;AAC1E,uEAAuE;AACvE,wEAAwE;AACxE,0EAA0E;AAC1E,2EAA2E;AAC3E,+DAA+D;AAC/D,MAAM,UAAU,GAAG,WAAW,EAAE,CAAC;AAEjC;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,KAAgC;IACpE,OAAO,UAAU,CAAC,GAAG,CAAC,KAAK,IAAI,EAAE;QAC/B,MAAM,OAAO,GAAG,MAAM,eAAe,EAAE,CAAC;QACxC,MAAM,IAAI,GAAqB,EAAE,GAAG,OAAO,EAAE,GAAG,KAAK,EAAE,CAAC;QACxD,MAAM,MAAM,GAAG,eAAe,EAAE,CAAC;QACjC,IAAI,CAAC;YACH,MAAM,eAAe,CAAC,MAAM,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;QACtE,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,2CAA2C,MAAM,IAAI;gBACnD,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAC1D,CAAC;QACJ,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -1,156 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { promises as fs } from 'node:fs';
3
- import * as os from 'node:os';
4
- import * as path from 'node:path';
5
- import { loadPreferences, preferencesPath, savePreferences } from './preferences.js';
6
-
7
- // preferencesPath() resolves ~/.moxxy/preferences.json via os.homedir(), which
8
- // derives from HOME (POSIX) / USERPROFILE (Windows). Point both at a tmpdir per
9
- // test so the suite never touches the developer's real preferences file.
10
- let tmpHome: string;
11
- let savedHome: string | undefined;
12
- let savedUserProfile: string | undefined;
13
- let savedMoxxyHome: string | undefined;
14
-
15
- beforeEach(async () => {
16
- tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-prefs-'));
17
- savedHome = process.env.HOME;
18
- savedUserProfile = process.env.USERPROFILE;
19
- savedMoxxyHome = process.env.MOXXY_HOME;
20
- process.env.HOME = tmpHome;
21
- process.env.USERPROFILE = tmpHome;
22
- // The default-home tests below assert the `~/.moxxy` fallback, so MOXXY_HOME
23
- // must be unset for them (it would otherwise win).
24
- delete process.env.MOXXY_HOME;
25
- await fs.mkdir(path.join(tmpHome, '.moxxy'), { recursive: true });
26
- });
27
-
28
- afterEach(async () => {
29
- if (savedHome === undefined) delete process.env.HOME;
30
- else process.env.HOME = savedHome;
31
- if (savedUserProfile === undefined) delete process.env.USERPROFILE;
32
- else process.env.USERPROFILE = savedUserProfile;
33
- if (savedMoxxyHome === undefined) delete process.env.MOXXY_HOME;
34
- else process.env.MOXXY_HOME = savedMoxxyHome;
35
- await fs.rm(tmpHome, { recursive: true, force: true });
36
- });
37
-
38
- const readRaw = async (): Promise<unknown> =>
39
- JSON.parse(await fs.readFile(preferencesPath(), 'utf8'));
40
-
41
- describe('preferences store', () => {
42
- it('resolves the path under the (overridden) home dir', () => {
43
- expect(preferencesPath()).toBe(path.join(tmpHome, '.moxxy', 'preferences.json'));
44
- });
45
-
46
- it('honors $MOXXY_HOME so preferences follow the relocated data dir', async () => {
47
- const altHome = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-prefs-alt-'));
48
- const prevMoxxyHome = process.env.MOXXY_HOME;
49
- process.env.MOXXY_HOME = altHome;
50
- try {
51
- // Path now lives directly under MOXXY_HOME, NOT under ~/.moxxy.
52
- expect(preferencesPath()).toBe(path.join(altHome, 'preferences.json'));
53
- await savePreferences({ model: 'relocated' });
54
- // Written to the relocated dir, not the homedir fallback.
55
- const raw = await fs.readFile(path.join(altHome, 'preferences.json'), 'utf8');
56
- expect(JSON.parse(raw).model).toBe('relocated');
57
- // The homedir fallback location must remain empty.
58
- await expect(
59
- fs.readFile(path.join(tmpHome, '.moxxy', 'preferences.json'), 'utf8'),
60
- ).rejects.toMatchObject({ code: 'ENOENT' });
61
- } finally {
62
- if (prevMoxxyHome === undefined) delete process.env.MOXXY_HOME;
63
- else process.env.MOXXY_HOME = prevMoxxyHome;
64
- await fs.rm(altHome, { recursive: true, force: true });
65
- }
66
- });
67
-
68
- it('returns an empty object when the file is missing', async () => {
69
- expect(await loadPreferences()).toEqual({});
70
- });
71
-
72
- it('returns an empty object when the file is corrupt', async () => {
73
- await fs.writeFile(preferencesPath(), '{ not json', 'utf8');
74
- expect(await loadPreferences()).toEqual({});
75
- });
76
-
77
- it('round-trips a patch through disk', async () => {
78
- await savePreferences({ model: 'gpt-5.4-mini', providerName: 'openai' });
79
- const loaded = await loadPreferences();
80
- expect(loaded.model).toBe('gpt-5.4-mini');
81
- expect(loaded.providerName).toBe('openai');
82
- });
83
-
84
- it('merges a second patch without clobbering unrelated fields', async () => {
85
- await savePreferences({ providerName: 'openai' });
86
- await savePreferences({ model: 'gpt-5.4-mini' });
87
- const loaded = await loadPreferences();
88
- expect(loaded.providerName).toBe('openai');
89
- expect(loaded.model).toBe('gpt-5.4-mini');
90
- });
91
-
92
- it('migrates legacy mode names on load', async () => {
93
- // "tool-use" is the legacy id that migrateModeName maps to "default".
94
- await fs.writeFile(
95
- preferencesPath(),
96
- JSON.stringify({ mode: 'tool-use' }) + '\n',
97
- 'utf8',
98
- );
99
- const loaded = await loadPreferences();
100
- expect(loaded.mode).toBe('default');
101
- });
102
-
103
- it('writes a trailing newline atomically (no partial file)', async () => {
104
- await savePreferences({ model: 'm1' });
105
- const raw = await fs.readFile(preferencesPath(), 'utf8');
106
- expect(raw.endsWith('\n')).toBe(true);
107
- expect(JSON.parse(raw)).toEqual({ model: 'm1' });
108
- });
109
-
110
- it('serializes overlapping saves so no update is lost (invariant #5)', async () => {
111
- // Two patches firing concurrently each read-merge-write the same file.
112
- // Without the mutex the later writer clobbers the earlier writer's field.
113
- await Promise.all([
114
- savePreferences({ model: 'a' }),
115
- savePreferences({ mode: 'goal' }),
116
- ]);
117
- const loaded = await loadPreferences();
118
- expect(loaded.model).toBe('a');
119
- expect(loaded.mode).toBe('goal');
120
- });
121
-
122
- it('swallows a write failure: logs to stderr but never throws (best-effort contract)', async () => {
123
- // savePreferences is documented best-effort — a persist failure must NOT
124
- // bubble out and break the slash-command / shutdown that triggered it. Force
125
- // an unwritable target by pointing MOXXY_HOME *under a regular file*, so the
126
- // atomic writer's `mkdir(dirname)` fails with ENOTDIR.
127
- const blocker = path.join(tmpHome, 'not-a-dir');
128
- await fs.writeFile(blocker, 'x', 'utf8');
129
- const prevMoxxyHome = process.env.MOXXY_HOME;
130
- process.env.MOXXY_HOME = path.join(blocker, 'nested'); // dirname is a file → mkdir fails
131
- const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
132
- try {
133
- // Must resolve, not reject — the pick still took effect in-session.
134
- await expect(savePreferences({ model: 'm' })).resolves.toBeUndefined();
135
- // The failure is surfaced (not silently swallowed) on stderr.
136
- expect(stderr).toHaveBeenCalled();
137
- } finally {
138
- stderr.mockRestore();
139
- if (prevMoxxyHome === undefined) delete process.env.MOXXY_HOME;
140
- else process.env.MOXXY_HOME = prevMoxxyHome;
141
- }
142
- });
143
-
144
- it('keeps ALL distinct keys present under many overlapping writers', async () => {
145
- const patches: Array<Partial<Record<`k${number}`, string>>> = [];
146
- for (let i = 0; i < 25; i++) patches.push({ [`k${i}`]: `v${i}` });
147
- // Fire all saves at once; each merges a distinct key.
148
- await Promise.all(
149
- patches.map((p) => savePreferences(p as Record<string, string>)),
150
- );
151
- const loaded = (await readRaw()) as Record<string, string>;
152
- for (let i = 0; i < 25; i++) {
153
- expect(loaded[`k${i}`]).toBe(`v${i}`);
154
- }
155
- });
156
- });