@moxxy/core 0.4.0 → 0.5.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.
Files changed (151) hide show
  1. package/dist/events/log.d.ts.map +1 -1
  2. package/dist/events/log.js +38 -1
  3. package/dist/events/log.js.map +1 -1
  4. package/dist/permissions/engine.d.ts.map +1 -1
  5. package/dist/permissions/engine.js +80 -17
  6. package/dist/permissions/engine.js.map +1 -1
  7. package/dist/plugins/discovery.d.ts.map +1 -1
  8. package/dist/plugins/discovery.js +48 -7
  9. package/dist/plugins/discovery.js.map +1 -1
  10. package/dist/plugins/host.d.ts.map +1 -1
  11. package/dist/plugins/host.js +54 -11
  12. package/dist/plugins/host.js.map +1 -1
  13. package/dist/plugins/lifecycle.d.ts.map +1 -1
  14. package/dist/plugins/lifecycle.js +7 -0
  15. package/dist/plugins/lifecycle.js.map +1 -1
  16. package/dist/plugins/loader.d.ts +9 -0
  17. package/dist/plugins/loader.d.ts.map +1 -1
  18. package/dist/plugins/loader.js +34 -8
  19. package/dist/plugins/loader.js.map +1 -1
  20. package/dist/plugins/registry-kinds.d.ts +21 -2
  21. package/dist/plugins/registry-kinds.d.ts.map +1 -1
  22. package/dist/plugins/registry-kinds.js +3 -1
  23. package/dist/plugins/registry-kinds.js.map +1 -1
  24. package/dist/plugins/toposort.d.ts.map +1 -1
  25. package/dist/plugins/toposort.js +43 -18
  26. package/dist/plugins/toposort.js.map +1 -1
  27. package/dist/preferences.d.ts.map +1 -1
  28. package/dist/preferences.js +5 -4
  29. package/dist/preferences.js.map +1 -1
  30. package/dist/registries/active-backend-registry.d.ts.map +1 -1
  31. package/dist/registries/active-backend-registry.js +10 -1
  32. package/dist/registries/active-backend-registry.js.map +1 -1
  33. package/dist/registries/channels.d.ts +2 -1
  34. package/dist/registries/channels.d.ts.map +1 -1
  35. package/dist/registries/channels.js +30 -20
  36. package/dist/registries/channels.js.map +1 -1
  37. package/dist/registries/commands.d.ts.map +1 -1
  38. package/dist/registries/commands.js +27 -8
  39. package/dist/registries/commands.js.map +1 -1
  40. package/dist/registries/isolators.d.ts +27 -1
  41. package/dist/registries/isolators.d.ts.map +1 -1
  42. package/dist/registries/isolators.js +35 -1
  43. package/dist/registries/isolators.js.map +1 -1
  44. package/dist/registries/modes.d.ts +4 -0
  45. package/dist/registries/modes.d.ts.map +1 -1
  46. package/dist/registries/modes.js +15 -4
  47. package/dist/registries/modes.js.map +1 -1
  48. package/dist/registries/providers.d.ts +8 -0
  49. package/dist/registries/providers.d.ts.map +1 -1
  50. package/dist/registries/providers.js +8 -0
  51. package/dist/registries/providers.js.map +1 -1
  52. package/dist/registries/skills.d.ts +9 -0
  53. package/dist/registries/skills.d.ts.map +1 -1
  54. package/dist/registries/skills.js +22 -0
  55. package/dist/registries/skills.js.map +1 -1
  56. package/dist/requirements.d.ts.map +1 -1
  57. package/dist/requirements.js +7 -1
  58. package/dist/requirements.js.map +1 -1
  59. package/dist/run-turn.d.ts.map +1 -1
  60. package/dist/run-turn.js +65 -17
  61. package/dist/run-turn.js.map +1 -1
  62. package/dist/session.d.ts +9 -0
  63. package/dist/session.d.ts.map +1 -1
  64. package/dist/session.js +25 -3
  65. package/dist/session.js.map +1 -1
  66. package/dist/sessions/persistence.d.ts +15 -0
  67. package/dist/sessions/persistence.d.ts.map +1 -1
  68. package/dist/sessions/persistence.js +116 -13
  69. package/dist/sessions/persistence.js.map +1 -1
  70. package/dist/skills/loader.d.ts.map +1 -1
  71. package/dist/skills/loader.js +11 -2
  72. package/dist/skills/loader.js.map +1 -1
  73. package/dist/skills/synthesize.d.ts.map +1 -1
  74. package/dist/skills/synthesize.js +87 -36
  75. package/dist/skills/synthesize.js.map +1 -1
  76. package/dist/subagents/events.js +33 -2
  77. package/dist/subagents/events.js.map +1 -1
  78. package/dist/subagents/registry.d.ts +22 -1
  79. package/dist/subagents/registry.d.ts.map +1 -1
  80. package/dist/subagents/registry.js +72 -2
  81. package/dist/subagents/registry.js.map +1 -1
  82. package/dist/subagents/run-child.d.ts.map +1 -1
  83. package/dist/subagents/run-child.js +138 -46
  84. package/dist/subagents/run-child.js.map +1 -1
  85. package/dist/surfaces/host.d.ts +10 -0
  86. package/dist/surfaces/host.d.ts.map +1 -1
  87. package/dist/surfaces/host.js +41 -3
  88. package/dist/surfaces/host.js.map +1 -1
  89. package/dist/usage-stats.d.ts +7 -1
  90. package/dist/usage-stats.d.ts.map +1 -1
  91. package/dist/usage-stats.js +41 -15
  92. package/dist/usage-stats.js.map +1 -1
  93. package/dist/view/parse.d.ts.map +1 -1
  94. package/dist/view/parse.js +119 -19
  95. package/dist/view/parse.js.map +1 -1
  96. package/package.json +4 -4
  97. package/src/events/log.test.ts +45 -0
  98. package/src/events/log.ts +39 -1
  99. package/src/permissions/engine.test.ts +60 -0
  100. package/src/permissions/engine.ts +81 -16
  101. package/src/plugins/discovery.test.ts +59 -1
  102. package/src/plugins/discovery.ts +57 -9
  103. package/src/plugins/host.test.ts +40 -0
  104. package/src/plugins/host.ts +48 -10
  105. package/src/plugins/lifecycle.test.ts +29 -0
  106. package/src/plugins/lifecycle.ts +7 -0
  107. package/src/plugins/loader.ts +45 -7
  108. package/src/plugins/registry-kinds.test.ts +53 -0
  109. package/src/plugins/registry-kinds.ts +25 -3
  110. package/src/plugins/toposort.ts +45 -17
  111. package/src/preferences.test.ts +52 -1
  112. package/src/preferences.ts +5 -4
  113. package/src/registries/active-backend-registry.ts +10 -1
  114. package/src/registries/channels.test.ts +79 -0
  115. package/src/registries/channels.ts +33 -18
  116. package/src/registries/commands.test.ts +65 -0
  117. package/src/registries/commands.ts +25 -8
  118. package/src/registries/isolators.test.ts +37 -0
  119. package/src/registries/isolators.ts +48 -1
  120. package/src/registries/modes.test.ts +21 -1
  121. package/src/registries/modes.ts +15 -2
  122. package/src/registries/providers.ts +8 -0
  123. package/src/registries/semantics.test.ts +33 -1
  124. package/src/registries/skills.ts +28 -0
  125. package/src/registries/transcribers.test.ts +13 -0
  126. package/src/requirements.test.ts +35 -0
  127. package/src/requirements.ts +7 -1
  128. package/src/run-turn.test.ts +219 -0
  129. package/src/run-turn.ts +68 -18
  130. package/src/session.test.ts +24 -0
  131. package/src/session.ts +24 -3
  132. package/src/sessions/page.test.ts +17 -0
  133. package/src/sessions/persistence.test.ts +93 -1
  134. package/src/sessions/persistence.ts +138 -30
  135. package/src/skills/loader.test.ts +18 -0
  136. package/src/skills/loader.ts +12 -1
  137. package/src/skills/synthesize.test.ts +59 -0
  138. package/src/skills/synthesize.ts +89 -36
  139. package/src/subagents/events.test.ts +56 -0
  140. package/src/subagents/events.ts +32 -2
  141. package/src/subagents/registry.test.ts +106 -0
  142. package/src/subagents/registry.ts +77 -3
  143. package/src/subagents/run-child.test.ts +125 -2
  144. package/src/subagents/run-child.ts +150 -47
  145. package/src/surfaces/host.test.ts +71 -1
  146. package/src/surfaces/host.ts +39 -2
  147. package/src/usage-stats.test.ts +99 -1
  148. package/src/usage-stats.ts +42 -17
  149. package/src/view/parse-extended.test.ts +80 -0
  150. package/src/view/parse.test.ts +14 -0
  151. package/src/view/parse.ts +113 -18
package/src/run-turn.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { EmittedEvent, ModeContext, MoxxyEvent, RunTurnOptions } from '@moxxy/sdk';
1
+ import type { EmittedEvent, LLMProvider, ModeContext, MoxxyEvent, RunTurnOptions } from '@moxxy/sdk';
2
2
  import type { SessionRuntime } from './session-runtime.js';
3
3
  import { createSubagentSpawner } from './subagents.js';
4
4
 
@@ -20,16 +20,11 @@ export async function* runTurn(
20
20
  // Use a caller-supplied turnId when present (the runner mints it up front so
21
21
  // it can return the id before the turn runs); otherwise mint one here.
22
22
  const turnId = opts.turnId ?? session.startTurn().turnId;
23
- const provider = session.providers.getActive();
24
- const model = opts.model ?? provider.models[0]?.id ?? 'default';
25
- // Record the resolution so out-of-band spawns (workflow triggers) can
26
- // inherit the conversation's current model. Last-writer-wins when turns
27
- // run concurrently — see the field's doc in SessionRuntime.
28
- session.lastResolvedModel = model;
29
23
 
30
24
  const queue: MoxxyEvent[] = [];
31
25
  const waiters: Array<() => void> = [];
32
26
  let done = false;
27
+ let completed = false;
33
28
  let strategyError: unknown = null;
34
29
  // A mode can ask (via ctx.requestModeSwitch) to hand off to another mode
35
30
  // once this turn finishes — applied after the strategy drains, below.
@@ -42,6 +37,12 @@ export async function* runTurn(
42
37
  wake();
43
38
  });
44
39
 
40
+ // Generator-scoped controller so an early consumer return/throw (HTTP client
41
+ // disconnect, channel teardown) can abort the in-flight strategy instead of
42
+ // leaving it to run the whole agentic loop to completion in the background
43
+ // (burning tokens, holding resources) while the abandoned `finally` blocks on
44
+ // `strategyPromise`.
45
+ const turnController = new AbortController();
45
46
  let strategyPromise: Promise<void> | null = null;
46
47
 
47
48
  try {
@@ -56,12 +57,46 @@ export async function* runTurn(
56
57
  : {}),
57
58
  });
58
59
 
60
+ // Resolve provider + model AFTER the prompt is recorded so a
61
+ // missing/misconfigured provider doesn't silently discard the user's
62
+ // prompt or orphan the turnId. On failure, append a structured error event
63
+ // (channels see a normal failed turn) and rethrow.
64
+ let provider: LLMProvider;
65
+ let model: string;
66
+ try {
67
+ provider = session.providers.getActive();
68
+ const resolvedModel = opts.model ?? provider.models[0]?.id;
69
+ if (!resolvedModel) {
70
+ throw new Error(
71
+ `Active provider '${provider.name}' has no models configured`,
72
+ );
73
+ }
74
+ model = resolvedModel;
75
+ } catch (err) {
76
+ const message = err instanceof Error ? err.message : String(err);
77
+ await session.log.append({
78
+ type: 'error',
79
+ sessionId: session.id,
80
+ turnId,
81
+ source: 'system',
82
+ kind: 'fatal',
83
+ message,
84
+ });
85
+ throw err;
86
+ }
87
+ // Record the resolution so out-of-band spawns (workflow triggers) can
88
+ // inherit the conversation's current model. Last-writer-wins when turns
89
+ // run concurrently — see the field's doc in SessionRuntime.
90
+ session.lastResolvedModel = model;
91
+
59
92
  const strategy = session.modes.getActive();
60
- // Combine the session's signal with the per-turn one (if provided)
61
- // so either firing cancels the turn.
62
- const effectiveSignal = opts.signal
63
- ? AbortSignal.any([session.signal, opts.signal])
64
- : session.signal;
93
+ // Combine the session's signal, the per-turn one (if provided), and the
94
+ // generator-scoped abandonment signal so any of them firing cancels the turn.
95
+ const effectiveSignal = AbortSignal.any(
96
+ opts.signal
97
+ ? [session.signal, opts.signal, turnController.signal]
98
+ : [session.signal, turnController.signal],
99
+ );
65
100
  // The session's working dir + environment, mirrored onto the ModeContext
66
101
  // so the shared tool dispatcher can hand onToolCall hooks the real cwd/env
67
102
  // (path-based policy hooks gate on these) instead of empty placeholders.
@@ -105,16 +140,21 @@ export async function* runTurn(
105
140
  const turnStartCtx = { ...appCtx, turnId, iteration: 0 };
106
141
 
107
142
  strategyPromise = (async () => {
143
+ let started = false;
108
144
  try {
109
145
  await session.dispatcher.dispatchTurnStart(turnStartCtx);
146
+ started = true;
110
147
  for await (const _ of strategy.run(ctx)) {
111
148
  // Events are surfaced via the log subscription above.
112
149
  void _;
113
150
  }
114
- await session.dispatcher.dispatchTurnEnd(turnStartCtx);
115
151
  } catch (err) {
116
152
  strategyError = err;
117
153
  } finally {
154
+ // turnEnd must pair with turnStart even when the strategy throws/aborts,
155
+ // so plugins that allocate turn-scoped state in onTurnStart (spans,
156
+ // timers, token meters) always get the matching teardown.
157
+ if (started) await session.dispatcher.dispatchTurnEnd(turnStartCtx);
118
158
  done = true;
119
159
  wake();
120
160
  }
@@ -125,15 +165,25 @@ export async function* runTurn(
125
165
  if (done) break;
126
166
  await new Promise<void>((resolve) => waiters.push(resolve));
127
167
  }
168
+ completed = true;
128
169
  } finally {
129
170
  unsubscribe();
171
+ // If the consumer abandoned iteration early (broke out of the `for await`,
172
+ // an outer error), `completed` is still false: abort the strategy so it
173
+ // unwinds promptly instead of running the full loop in the background while
174
+ // this `finally` blocks on `strategyPromise`.
175
+ if (!completed) turnController.abort('runTurn iteration abandoned');
130
176
  if (strategyPromise) await strategyPromise;
131
177
  // Apply a mode hand-off the strategy requested, now that the turn has
132
- // fully drained. Only on clean completion (a thrown/aborted turn keeps
133
- // the current mode); an unknown target is ignored so a bad name can't
134
- // wedge the session. The registry's setActive triggers the runner's
135
- // InfoChanged broadcast, so channels see the new mode.
136
- if (requestedModeSwitch && !strategyError) {
178
+ // fully drained. Only on clean completion: a thrown turn (strategyError) OR
179
+ // an ABANDONED turn (consumer broke out early `completed` is false, and a
180
+ // mode that returns cleanly on `signal.aborted` leaves strategyError null)
181
+ // keeps the current mode, so an unwatched/cancelled turn can't silently
182
+ // flip the session into a different mode behind the user's back. An unknown
183
+ // target is ignored so a bad name can't wedge the session. The registry's
184
+ // setActive triggers the runner's InfoChanged broadcast, so channels see the
185
+ // new mode.
186
+ if (requestedModeSwitch && completed && !strategyError) {
137
187
  try {
138
188
  session.modes.setActive(requestedModeSwitch);
139
189
  } catch {
@@ -72,6 +72,30 @@ describe('Session', () => {
72
72
  expect(s.signal.aborted).toBe(true);
73
73
  });
74
74
 
75
+ it('close() still runs plugin onShutdown when surface teardown rejects', async () => {
76
+ const s = new Session({ cwd: '/tmp', silent: true });
77
+ const onShutdown = vi.fn();
78
+ s.pluginHost.registerStatic(
79
+ definePlugin({ name: 'p', version: '0.0.0', hooks: { onShutdown } }),
80
+ );
81
+ // A flaky native surface (PTY/browser) throwing during teardown must not
82
+ // pre-empt the plugin shutdown hooks — those are how plugins flush state.
83
+ vi.spyOn(s.surfaces, 'closeAll').mockRejectedValue(new Error('PTY teardown blew up'));
84
+
85
+ await expect(s.close()).resolves.toBeUndefined();
86
+ expect(onShutdown).toHaveBeenCalledTimes(1);
87
+ expect(s.signal.aborted).toBe(true);
88
+ });
89
+
90
+ it('appContext env is a frozen, stable snapshot (reused across calls)', () => {
91
+ const s = new Session({ cwd: '/tmp', silent: true });
92
+ const a = s.appContext();
93
+ const b = s.appContext();
94
+ // Memoized: the same frozen object is handed out, not a fresh clone each call.
95
+ expect(a.env).toBe(b.env);
96
+ expect(Object.isFrozen(a.env)).toBe(true);
97
+ });
98
+
75
99
  it('close() is idempotent', async () => {
76
100
  const s = new Session({ cwd: '/tmp', silent: true });
77
101
  const onShutdown = vi.fn();
package/src/session.ts CHANGED
@@ -332,8 +332,17 @@ export class Session implements ClientSession, SessionRuntime {
332
332
  this.closed = true;
333
333
  try {
334
334
  // Tear down any open surfaces (PTYs, browser screencasts) before the
335
- // plugin shutdown hooks dispose their underlying resources.
336
- await this.surfaces.closeAll();
335
+ // plugin shutdown hooks dispose their underlying resources. Isolated in
336
+ // its own try/catch: a flaky native surface throwing during teardown must
337
+ // not pre-empt the plugin shutdown hooks below, which are how plugins
338
+ // flush state (memory journal, vault, audit logs).
339
+ try {
340
+ await this.surfaces.closeAll();
341
+ } catch (err) {
342
+ this.logger.warn('surface teardown failed during close', {
343
+ error: err instanceof Error ? err.message : String(err),
344
+ });
345
+ }
337
346
  await this.dispatcher.dispatchShutdown(this.appContext());
338
347
  } finally {
339
348
  // Drop THIS session's retained child sessions (the workflow `awaitInput`
@@ -348,13 +357,25 @@ export class Session implements ClientSession, SessionRuntime {
348
357
  }
349
358
 
350
359
  private closed = false;
360
+ /**
361
+ * Memoized one-time snapshot of `process.env`. `appContext()` is called per
362
+ * dispatched event (hundreds per turn during a streaming reply), per turn,
363
+ * and on every shutdown — eagerly spreading the whole env each time is
364
+ * thousands of O(envVars) clones of GC pressure for a value that does not
365
+ * change over a session's life. Frozen so a plugin can't mutate the shared
366
+ * snapshot out from under another.
367
+ */
368
+ private envSnapshot: Readonly<NodeJS.ProcessEnv> | null = null;
351
369
 
352
370
  appContext(): AppContext {
371
+ if (!this.envSnapshot) {
372
+ this.envSnapshot = Object.freeze({ ...process.env });
373
+ }
353
374
  return {
354
375
  sessionId: this.id,
355
376
  cwd: this.cwd,
356
377
  log: this.log.asReader(),
357
- env: { ...process.env },
378
+ env: this.envSnapshot,
358
379
  };
359
380
  }
360
381
 
@@ -78,6 +78,23 @@ describe('pageEvents (pure newest-first paging)', () => {
78
78
  expect(pageEvents([], 3, 5)).toEqual({ events: [], prevCursor: null });
79
79
  });
80
80
 
81
+ it('a non-finite `before` cursor degrades to the newest page (no NaN prevCursor)', () => {
82
+ const log = makeLog(10);
83
+ // `pageEvents` is exported public API; a corrupt cursor (NaN / Infinity from
84
+ // a hand-edited or wire-mangled page request) must not poison the result.
85
+ // Worst case if unguarded: every `seq < NaN` is false → empty page with
86
+ // `prevCursor: NaN`, which JSON-serializes to `null` and wedges the walk.
87
+ for (const bad of [NaN, Infinity, -Infinity, 1.5] as number[]) {
88
+ const page = pageEvents(log, bad, 3);
89
+ // Treated as the newest page (before=null) — last `limit` events.
90
+ expect(page.events.map((e) => e.seq)).toEqual([7, 8, 9]);
91
+ expect(page.prevCursor).toBe(7);
92
+ expect(Number.isNaN(page.prevCursor as number)).toBe(false);
93
+ }
94
+ // Empty-log path also never emits a NaN cursor for a bad `before`.
95
+ expect(pageEvents([], NaN, 5)).toEqual({ events: [], prevCursor: null });
96
+ });
97
+
81
98
  it('walking all pages reconstructs the full log exactly once, in order', () => {
82
99
  const log = makeLog(23);
83
100
  const collected: number[] = [];
@@ -4,7 +4,14 @@ import * as path from 'node:path';
4
4
  import { afterEach, describe, expect, it } from 'vitest';
5
5
  import { EventLog } from '../events/log.js';
6
6
  import type { Logger } from '../logger.js';
7
- import { SessionPersistence, readIndex, restoreEvents, type SessionMeta } from './persistence.js';
7
+ import {
8
+ SessionPersistence,
9
+ deleteSession,
10
+ readEventPage,
11
+ readIndex,
12
+ restoreEvents,
13
+ type SessionMeta,
14
+ } from './persistence.js';
8
15
 
9
16
  interface CapturedLine {
10
17
  readonly level: 'debug' | 'info' | 'warn' | 'error';
@@ -226,6 +233,91 @@ describe('SessionPersistence', () => {
226
233
  expect(again.lines).toHaveLength(0);
227
234
  });
228
235
 
236
+ it('rejects a path-traversal session id before touching the filesystem', async () => {
237
+ const dir = await makeTempDir();
238
+ // A secret file OUTSIDE the sessions dir that a traversal id would target.
239
+ const secretName = `traversal-target-${Date.now()}.jsonl`;
240
+ const secret = path.join(dir, '..', secretName);
241
+ await fs.writeFile(secret, 'do not delete me', 'utf8');
242
+ try {
243
+ await expect(restoreEvents('../etc/passwd', dir)).rejects.toThrow(/invalid session id/i);
244
+ await expect(readEventPage('a/../../b', { before: null, limit: 5 }, dir)).rejects.toThrow(
245
+ /invalid session id/i,
246
+ );
247
+ // deleteSession uses force:true — a traversal delete would otherwise
248
+ // silently succeed. The id resolving to our secret must be rejected so
249
+ // the file stays intact.
250
+ const evil = `..${path.sep}${secretName.replace(/\.jsonl$/, '')}`;
251
+ await expect(deleteSession(evil, dir)).rejects.toThrow(/invalid session id/i);
252
+ await expect(fs.readFile(secret, 'utf8')).resolves.toBe('do not delete me');
253
+ } finally {
254
+ await fs.rm(secret, { force: true });
255
+ }
256
+ });
257
+
258
+ it('firstPrompt label is sliced on code-point boundaries (no split surrogate pair)', async () => {
259
+ const dir = await makeTempDir();
260
+ const id = '01SURROGATE0000000000000AA';
261
+ const log = new EventLog();
262
+ const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/p', dir });
263
+ const detach = persistence.attach(log);
264
+ // 80 emoji (each a surrogate pair) + a trailing char. A naive
265
+ // `slice(0, 80)` on UTF-16 code units would cut emoji #41 in half, leaving a
266
+ // lone surrogate at the boundary.
267
+ const text = '😀'.repeat(90) + 'x';
268
+ await log.append({
269
+ type: 'user_prompt',
270
+ sessionId: id as never,
271
+ turnId: 't1' as never,
272
+ source: 'user',
273
+ text,
274
+ });
275
+ await persistence.flush();
276
+ const [row] = await readIndex(dir);
277
+ const label = row!.firstPrompt!;
278
+ // Exactly 80 code points, all whole emoji — the cut never split a surrogate
279
+ // pair. A naive slice(0, 80) would yield 80 UTF-16 units = 40 emoji and the
280
+ // label would round-trip fine, but the worst case (cut INSIDE a pair) is
281
+ // what we guard: rebuild from code points must reproduce the label exactly.
282
+ expect([...label]).toHaveLength(80);
283
+ expect([...label].every((cp) => cp === '😀')).toBe(true);
284
+ // No lone (unpaired) surrogate survives a JSON round-trip as itself.
285
+ expect(JSON.parse(JSON.stringify(label))).toBe(label);
286
+
287
+ detach();
288
+ });
289
+
290
+ it('a user_prompt with a non-string text does not crash the persistence listener', async () => {
291
+ const dir = await makeTempDir();
292
+ const id = '01BADTEXT00000000000000000';
293
+ const { logger } = captureLogger();
294
+ const log = new EventLog();
295
+ const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/p', dir, logger });
296
+ const detach = persistence.attach(log);
297
+
298
+ // A hostile / hand-built event whose `text` is not a string. `firstPromptLabel`
299
+ // runs `[...text]` inside the log listener chain — without the coercion this
300
+ // throws, latching the misleading "persistence degraded" warning and dropping
301
+ // the row. It must instead degrade gracefully (label coerced, write succeeds).
302
+ await log.append({
303
+ type: 'user_prompt',
304
+ sessionId: id as never,
305
+ turnId: 't1' as never,
306
+ source: 'user',
307
+ text: null as unknown as string,
308
+ });
309
+ await persistence.flush();
310
+ await persistence.settleWrites();
311
+
312
+ // The disk write was not poisoned by the bad label.
313
+ expect(persistence.degraded).toBe(false);
314
+ const [row] = await readIndex(dir);
315
+ expect(row?.id).toBe(id);
316
+ expect(typeof row?.firstPrompt).toBe('string');
317
+
318
+ detach();
319
+ });
320
+
229
321
  it('two concurrent sessions both survive (no shared-index clobber)', async () => {
230
322
  const dir = await makeTempDir();
231
323
  const idA = '01AAAA00000000000000000001';
@@ -57,6 +57,49 @@ export function defaultSessionsDir(): string {
57
57
  return path.join(os.homedir(), '.moxxy', 'sessions');
58
58
  }
59
59
 
60
+ /**
61
+ * Session ids are ULIDs internally, but `restoreEvents`/`readEventPage`/
62
+ * `deleteSession` are public APIs reachable from the runner/desktop IPC and the
63
+ * CLI. Reject anything outside the ULID-safe charset before path-joining so a
64
+ * `../`-laden or absolute-ish id can't read or `fs.rm` files outside the
65
+ * sessions dir (deleteSession uses `force:true`, so a traversal delete would
66
+ * otherwise silently succeed). Defense-in-depth at the trust boundary.
67
+ */
68
+ const SAFE_SESSION_ID = /^[A-Za-z0-9_-]+$/;
69
+
70
+ function assertSafeSessionId(sessionId: string): void {
71
+ if (!SAFE_SESSION_ID.test(sessionId)) {
72
+ throw new Error(`Invalid session id: ${JSON.stringify(sessionId)}`);
73
+ }
74
+ }
75
+
76
+ /** Max concurrent fs operations in `readIndex` — bounds open file handles so a
77
+ * user with thousands of sessions can't hit EMFILE on the list/resume path. */
78
+ const READ_INDEX_CONCURRENCY = 32;
79
+
80
+ /**
81
+ * Map `fn` over `items` with at most `limit` calls in flight at once, preserving
82
+ * input order in the returned array. A small dependency-free concurrency limiter
83
+ * (the framework avoids extra deps for this).
84
+ */
85
+ async function mapWithConcurrency<T, R>(
86
+ items: ReadonlyArray<T>,
87
+ limit: number,
88
+ fn: (item: T) => Promise<R>,
89
+ ): Promise<R[]> {
90
+ const results = new Array<R>(items.length);
91
+ let cursor = 0;
92
+ const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
93
+ for (;;) {
94
+ const index = cursor++;
95
+ if (index >= items.length) return;
96
+ results[index] = await fn(items[index]!);
97
+ }
98
+ });
99
+ await Promise.all(workers);
100
+ return results;
101
+ }
102
+
60
103
  /**
61
104
  * Attaches a listener that streams every appended event to disk and
62
105
  * keeps the index in sync. Returns an `unsubscribe` callback the
@@ -76,6 +119,15 @@ export class SessionPersistence {
76
119
  * can flush.
77
120
  */
78
121
  private writeQueue: Mutex = createMutex();
122
+ /**
123
+ * Single-flight guard for the sidecar (`writeIndex`) write. The debounced
124
+ * timer and an explicit `flush()` can both call `writeIndex()` concurrently;
125
+ * without serialization two `writeFileAtomic`s would race on `metaPath` and
126
+ * the on-disk result would be last-rename-wins between two `this.meta`
127
+ * snapshots non-deterministically. Chaining them makes the latest meta win
128
+ * deterministically and drops the redundant work.
129
+ */
130
+ private indexWriteChain: Promise<void> = Promise.resolve();
79
131
  /**
80
132
  * Memoized one-time setup: `mkdir -p` the sessions dir and create the empty
81
133
  * `.jsonl` (so resume lists the session before any event). Awaited by both
@@ -201,7 +253,7 @@ export class SessionPersistence {
201
253
  lastActivity: new Date().toISOString(),
202
254
  firstPrompt:
203
255
  this.meta.firstPrompt ??
204
- (event.type === 'user_prompt' ? event.text.slice(0, 80) : null),
256
+ (event.type === 'user_prompt' ? firstPromptLabel(event.text) : null),
205
257
  };
206
258
  this.scheduleIndexWrite();
207
259
  const line = JSON.stringify(event) + '\n';
@@ -285,7 +337,20 @@ export class SessionPersistence {
285
337
  this.indexTimer = timer;
286
338
  }
287
339
 
288
- private async writeIndex(): Promise<void> {
340
+ /**
341
+ * Serialize the sidecar write through `indexWriteChain` so a debounced write
342
+ * and a concurrent `flush()` never race on `metaPath`; resolves once THIS
343
+ * call's write (or a later one that supersedes it) has settled.
344
+ */
345
+ private writeIndex(): Promise<void> {
346
+ const next = this.indexWriteChain.then(() => this.doWriteIndex());
347
+ // Keep the chain unbroken even if a write rejects (doWriteIndex already
348
+ // swallows, but guard the chain itself defensively).
349
+ this.indexWriteChain = next.catch(() => undefined);
350
+ return next;
351
+ }
352
+
353
+ private async doWriteIndex(): Promise<void> {
289
354
  try {
290
355
  // Once closed (the final detach/flush write), don't re-create the dir or
291
356
  // log file: setup already made them, and resurrecting a directory a
@@ -352,31 +417,29 @@ export async function readIndex(dir = defaultSessionsDir()): Promise<SessionMeta
352
417
  } catch {
353
418
  dirents = [];
354
419
  }
355
- await Promise.all(
356
- dirents
357
- .filter((d) => d.isFile() && d.name.endsWith('.meta.json'))
358
- .map(async (d) => {
359
- try {
360
- const raw = await fs.readFile(path.join(dir, d.name), 'utf8');
361
- const parsed = JSON.parse(raw) as unknown;
362
- if (isSessionMeta(parsed)) byId.set(parsed.id, parsed);
363
- } catch {
364
- // skip a malformed/half-written sidecar
365
- }
366
- }),
367
- );
420
+ // Cap fan-out: a user with thousands of sidecars in ~/.moxxy/sessions would
421
+ // otherwise open thousands of file handles at once (EMFILE) on every resume/
422
+ // list. Process in bounded batches instead.
423
+ const sidecarFiles = dirents.filter((d) => d.isFile() && d.name.endsWith('.meta.json'));
424
+ await mapWithConcurrency(sidecarFiles, READ_INDEX_CONCURRENCY, async (d) => {
425
+ try {
426
+ const raw = await fs.readFile(path.join(dir, d.name), 'utf8');
427
+ const parsed = JSON.parse(raw) as unknown;
428
+ if (isSessionMeta(parsed)) byId.set(parsed.id, parsed);
429
+ } catch {
430
+ // skip a malformed/half-written sidecar
431
+ }
432
+ });
368
433
 
369
434
  const metas = [...byId.values()];
370
- const checks = await Promise.all(
371
- metas.map(async (meta) => {
372
- try {
373
- await fs.access(path.join(dir, `${meta.id}.jsonl`));
374
- return true;
375
- } catch {
376
- return false;
377
- }
378
- }),
379
- );
435
+ const checks = await mapWithConcurrency(metas, READ_INDEX_CONCURRENCY, async (meta) => {
436
+ try {
437
+ await fs.access(path.join(dir, `${meta.id}.jsonl`));
438
+ return true;
439
+ } catch {
440
+ return false;
441
+ }
442
+ });
380
443
  return metas
381
444
  .filter((_, index) => checks[index])
382
445
  .sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
@@ -411,6 +474,7 @@ export async function restoreEvents(
411
474
  dir = defaultSessionsDir(),
412
475
  logger: Logger = createLogger(),
413
476
  ): Promise<MoxxyEvent[]> {
477
+ assertSafeSessionId(sessionId);
414
478
  const logPath = path.join(dir, `${sessionId}.jsonl`);
415
479
  let raw: string;
416
480
  try {
@@ -448,8 +512,25 @@ export async function restoreEvents(
448
512
  restoredEvents: events.length,
449
513
  });
450
514
  try {
451
- const repaired = events.map((e) => JSON.stringify(e) + '\n').join('');
452
- await writeFileAtomic(logPath, repaired);
515
+ // Conflict guard: only rewrite if the on-disk content still matches what
516
+ // we read. Another process (a desktop runner already attached + a CLI
517
+ // `resume` of the SAME id) may have appended/repaired between our read and
518
+ // this write; its appends are a newer snapshot than our re-sequenced one,
519
+ // and a blind `writeFileAtomic` would clobber them (silent history loss).
520
+ // The single-writer assumption the rest of persistence relies on does not
521
+ // hold across processes, so re-read and compare before the destructive
522
+ // rewrite. Restore still succeeds either way — the in-memory log is
523
+ // already repaired; only the next clean resume would re-run the repair.
524
+ const current = await fs.readFile(logPath, 'utf8').catch(() => null);
525
+ if (current !== raw) {
526
+ logger.warn('skipped repaired-log rewrite — file changed under us (another process attached?)', {
527
+ sessionId,
528
+ path: logPath,
529
+ });
530
+ } else {
531
+ const repaired = events.map((e) => JSON.stringify(e) + '\n').join('');
532
+ await writeFileAtomic(logPath, repaired);
533
+ }
453
534
  } catch (err) {
454
535
  // Restore still succeeds — the in-memory log is repaired; only the
455
536
  // next resume would re-run this same repair.
@@ -512,6 +593,7 @@ export async function readEventPage(
512
593
  opts: { before: number | null; limit: number },
513
594
  dir = defaultSessionsDir(),
514
595
  ): Promise<EventPage> {
596
+ assertSafeSessionId(sessionId);
515
597
  const logPath = path.join(dir, `${sessionId}.jsonl`);
516
598
  let raw: string;
517
599
  try {
@@ -552,16 +634,26 @@ export function pageEvents(
552
634
  limit: number,
553
635
  ): EventPage {
554
636
  const cap = Math.max(0, Math.floor(limit));
637
+ // `pageEvents` is an exported public API (runner + disk paths call it), so it
638
+ // must self-defend rather than trust every caller to pre-validate. A
639
+ // non-finite `before` (NaN/Infinity from a corrupt cursor) would otherwise
640
+ // make every `seq < before` comparison false, collapse the page to empty, and
641
+ // hand back `prevCursor: before` — a NaN that JSON-serializes to `null`,
642
+ // silently wedging the client's backward walk. Coerce any non-integer cursor
643
+ // to the newest page (`null`) so a bad cursor degrades to "start over from the
644
+ // top" instead of a poisoned cursor.
645
+ const safeBefore =
646
+ before === null || !Number.isInteger(before) ? null : before;
555
647
  if (cap === 0 || events.length === 0) {
556
- return { events: [], prevCursor: events.length === 0 ? null : before };
648
+ return { events: [], prevCursor: events.length === 0 ? null : safeBefore };
557
649
  }
558
650
  // Exclusive upper bound by `seq`: the first index whose event is NOT older
559
651
  // than `before`. `before == null` (newest page) keeps the whole array.
560
652
  let end = events.length;
561
- if (before !== null) {
653
+ if (safeBefore !== null) {
562
654
  end = 0;
563
655
  for (let i = 0; i < events.length; i += 1) {
564
- if (events[i]!.seq < before) end = i + 1;
656
+ if (events[i]!.seq < safeBefore) end = i + 1;
565
657
  else break;
566
658
  }
567
659
  }
@@ -581,6 +673,7 @@ export async function deleteSession(
581
673
  sessionId: string,
582
674
  dir = defaultSessionsDir(),
583
675
  ): Promise<void> {
676
+ assertSafeSessionId(sessionId);
584
677
  await fs.rm(path.join(dir, `${sessionId}.jsonl`), { force: true });
585
678
  await fs.rm(metaPath(dir, sessionId), { force: true });
586
679
  }
@@ -590,6 +683,21 @@ function metaPath(dir: string, id: string): string {
590
683
  return path.join(dir, `${id}.meta.json`);
591
684
  }
592
685
 
686
+ /**
687
+ * First 80 chars of a prompt for the picker label, sliced on code-point (not
688
+ * UTF-16 code-unit) boundaries so the cut never splits a surrogate pair (emoji,
689
+ * astral CJK) into a lone half-character that renders as a broken glyph.
690
+ *
691
+ * `text` is typed `string`, but this runs inside the log listener chain — a
692
+ * throw here would latch the misleading "persistence degraded" warning. Coerce
693
+ * a non-string (a hand-built `EmittedEvent`, a future schema variant) instead of
694
+ * letting `[...text]` throw on `undefined`/`null`.
695
+ */
696
+ function firstPromptLabel(text: string): string {
697
+ const s = typeof text === 'string' ? text : String(text ?? '');
698
+ return [...s].slice(0, 80).join('');
699
+ }
700
+
593
701
  async function writeJsonAtomic(target: string, value: unknown): Promise<void> {
594
702
  await writeFileAtomic(target, JSON.stringify(value, null, 2) + '\n');
595
703
  }
@@ -99,4 +99,22 @@ describe('discoverSkills', () => {
99
99
  });
100
100
  expect(skills).toEqual([]);
101
101
  });
102
+
103
+ it('bounds directory recursion depth (deep tree does not crash, deepest skill skipped)', async () => {
104
+ const root = path.join(tmp, 'project');
105
+ // A shallow skill (loaded) and a very deeply-nested one (beyond the depth cap).
106
+ await writeSkill(root, 'shallow');
107
+ let deep = root;
108
+ for (let i = 0; i < 20; i++) deep = path.join(deep, `lvl${i}`);
109
+ await writeSkill(deep, 'too-deep');
110
+
111
+ const skills = await discoverSkills({
112
+ projectDir: root,
113
+ userDir: path.join(tmp, 'noop'),
114
+ logger: silentLogger,
115
+ });
116
+ const names = skills.map((s) => s.frontmatter.name);
117
+ expect(names).toContain('shallow');
118
+ expect(names).not.toContain('too-deep');
119
+ });
102
120
  });
@@ -33,10 +33,14 @@ export async function discoverSkills(opts: SkillLoadOptions = {}): Promise<Reado
33
33
  return [...seenNames.values()];
34
34
  }
35
35
 
36
+ /** Cap directory recursion so a pathologically deep (or hostile) skills tree can't blow the stack at boot. */
37
+ const MAX_SKILL_DIR_DEPTH = 8;
38
+
36
39
  async function loadDir(
37
40
  dir: string,
38
41
  scope: SkillScope,
39
42
  logger?: Logger,
43
+ depth = 0,
40
44
  ): Promise<ReadonlyArray<DiscoveredSkill>> {
41
45
  let entries: import('node:fs').Dirent[];
42
46
  try {
@@ -47,7 +51,14 @@ async function loadDir(
47
51
  const out: DiscoveredSkill[] = [];
48
52
  for (const entry of entries) {
49
53
  if (entry.isDirectory()) {
50
- out.push(...(await loadDir(path.join(dir, entry.name), scope, logger)));
54
+ if (depth >= MAX_SKILL_DIR_DEPTH) {
55
+ logger?.warn('skill: directory too deep, not recursing', {
56
+ path: path.join(dir, entry.name),
57
+ maxDepth: MAX_SKILL_DIR_DEPTH,
58
+ });
59
+ continue;
60
+ }
61
+ out.push(...(await loadDir(path.join(dir, entry.name), scope, logger, depth + 1)));
51
62
  continue;
52
63
  }
53
64
  if (!entry.isFile() || !entry.name.endsWith('.md')) continue;