@moxxy/core 0.5.0 → 0.5.2

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 (156) 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/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/permissions/engine.d.ts.map +1 -1
  9. package/dist/permissions/engine.js +80 -17
  10. package/dist/permissions/engine.js.map +1 -1
  11. package/dist/plugins/discovery.d.ts.map +1 -1
  12. package/dist/plugins/discovery.js +48 -7
  13. package/dist/plugins/discovery.js.map +1 -1
  14. package/dist/plugins/host.d.ts.map +1 -1
  15. package/dist/plugins/host.js +54 -11
  16. package/dist/plugins/host.js.map +1 -1
  17. package/dist/plugins/lifecycle.d.ts.map +1 -1
  18. package/dist/plugins/lifecycle.js +7 -0
  19. package/dist/plugins/lifecycle.js.map +1 -1
  20. package/dist/plugins/loader.d.ts +9 -0
  21. package/dist/plugins/loader.d.ts.map +1 -1
  22. package/dist/plugins/loader.js +34 -8
  23. package/dist/plugins/loader.js.map +1 -1
  24. package/dist/plugins/registry-kinds.d.ts +21 -2
  25. package/dist/plugins/registry-kinds.d.ts.map +1 -1
  26. package/dist/plugins/registry-kinds.js +3 -1
  27. package/dist/plugins/registry-kinds.js.map +1 -1
  28. package/dist/plugins/toposort.d.ts.map +1 -1
  29. package/dist/plugins/toposort.js +43 -18
  30. package/dist/plugins/toposort.js.map +1 -1
  31. package/dist/preferences.d.ts.map +1 -1
  32. package/dist/preferences.js +5 -4
  33. package/dist/preferences.js.map +1 -1
  34. package/dist/registries/active-backend-registry.d.ts.map +1 -1
  35. package/dist/registries/active-backend-registry.js +10 -1
  36. package/dist/registries/active-backend-registry.js.map +1 -1
  37. package/dist/registries/channels.d.ts +2 -1
  38. package/dist/registries/channels.d.ts.map +1 -1
  39. package/dist/registries/channels.js +30 -20
  40. package/dist/registries/channels.js.map +1 -1
  41. package/dist/registries/commands.d.ts.map +1 -1
  42. package/dist/registries/commands.js +27 -8
  43. package/dist/registries/commands.js.map +1 -1
  44. package/dist/registries/isolators.d.ts +27 -1
  45. package/dist/registries/isolators.d.ts.map +1 -1
  46. package/dist/registries/isolators.js +35 -1
  47. package/dist/registries/isolators.js.map +1 -1
  48. package/dist/registries/modes.d.ts +4 -0
  49. package/dist/registries/modes.d.ts.map +1 -1
  50. package/dist/registries/modes.js +15 -4
  51. package/dist/registries/modes.js.map +1 -1
  52. package/dist/registries/providers.d.ts +8 -0
  53. package/dist/registries/providers.d.ts.map +1 -1
  54. package/dist/registries/providers.js +8 -0
  55. package/dist/registries/providers.js.map +1 -1
  56. package/dist/registries/skills.d.ts +9 -0
  57. package/dist/registries/skills.d.ts.map +1 -1
  58. package/dist/registries/skills.js +22 -0
  59. package/dist/registries/skills.js.map +1 -1
  60. package/dist/requirements.d.ts.map +1 -1
  61. package/dist/requirements.js +7 -1
  62. package/dist/requirements.js.map +1 -1
  63. package/dist/run-turn.d.ts.map +1 -1
  64. package/dist/run-turn.js +65 -17
  65. package/dist/run-turn.js.map +1 -1
  66. package/dist/session.d.ts +9 -0
  67. package/dist/session.d.ts.map +1 -1
  68. package/dist/session.js +25 -3
  69. package/dist/session.js.map +1 -1
  70. package/dist/sessions/persistence.d.ts +15 -19
  71. package/dist/sessions/persistence.d.ts.map +1 -1
  72. package/dist/sessions/persistence.js +116 -49
  73. package/dist/sessions/persistence.js.map +1 -1
  74. package/dist/skills/loader.d.ts.map +1 -1
  75. package/dist/skills/loader.js +11 -2
  76. package/dist/skills/loader.js.map +1 -1
  77. package/dist/skills/synthesize.d.ts.map +1 -1
  78. package/dist/skills/synthesize.js +87 -36
  79. package/dist/skills/synthesize.js.map +1 -1
  80. package/dist/subagents/events.js +33 -2
  81. package/dist/subagents/events.js.map +1 -1
  82. package/dist/subagents/registry.d.ts +22 -1
  83. package/dist/subagents/registry.d.ts.map +1 -1
  84. package/dist/subagents/registry.js +72 -2
  85. package/dist/subagents/registry.js.map +1 -1
  86. package/dist/subagents/run-child.d.ts.map +1 -1
  87. package/dist/subagents/run-child.js +137 -46
  88. package/dist/subagents/run-child.js.map +1 -1
  89. package/dist/surfaces/host.d.ts +10 -0
  90. package/dist/surfaces/host.d.ts.map +1 -1
  91. package/dist/surfaces/host.js +41 -3
  92. package/dist/surfaces/host.js.map +1 -1
  93. package/dist/usage-stats.d.ts +7 -1
  94. package/dist/usage-stats.d.ts.map +1 -1
  95. package/dist/usage-stats.js +41 -15
  96. package/dist/usage-stats.js.map +1 -1
  97. package/dist/view/parse.d.ts.map +1 -1
  98. package/dist/view/parse.js +119 -19
  99. package/dist/view/parse.js.map +1 -1
  100. package/package.json +2 -2
  101. package/src/events/log.test.ts +45 -0
  102. package/src/events/log.ts +39 -1
  103. package/src/index.ts +0 -1
  104. package/src/permissions/engine.test.ts +60 -0
  105. package/src/permissions/engine.ts +81 -16
  106. package/src/plugins/discovery.test.ts +59 -1
  107. package/src/plugins/discovery.ts +57 -9
  108. package/src/plugins/host.test.ts +40 -0
  109. package/src/plugins/host.ts +48 -10
  110. package/src/plugins/lifecycle.test.ts +29 -0
  111. package/src/plugins/lifecycle.ts +7 -0
  112. package/src/plugins/loader.ts +45 -7
  113. package/src/plugins/registry-kinds.test.ts +53 -0
  114. package/src/plugins/registry-kinds.ts +25 -3
  115. package/src/plugins/toposort.ts +45 -17
  116. package/src/preferences.test.ts +52 -1
  117. package/src/preferences.ts +5 -4
  118. package/src/registries/active-backend-registry.ts +10 -1
  119. package/src/registries/channels.test.ts +79 -0
  120. package/src/registries/channels.ts +33 -18
  121. package/src/registries/commands.test.ts +65 -0
  122. package/src/registries/commands.ts +25 -8
  123. package/src/registries/isolators.test.ts +37 -0
  124. package/src/registries/isolators.ts +48 -1
  125. package/src/registries/modes.test.ts +21 -1
  126. package/src/registries/modes.ts +15 -2
  127. package/src/registries/providers.ts +8 -0
  128. package/src/registries/semantics.test.ts +33 -1
  129. package/src/registries/skills.ts +28 -0
  130. package/src/registries/transcribers.test.ts +13 -0
  131. package/src/requirements.test.ts +35 -0
  132. package/src/requirements.ts +7 -1
  133. package/src/run-turn.test.ts +219 -0
  134. package/src/run-turn.ts +68 -18
  135. package/src/session.test.ts +24 -0
  136. package/src/session.ts +24 -3
  137. package/src/sessions/page.test.ts +18 -39
  138. package/src/sessions/persistence.test.ts +93 -1
  139. package/src/sessions/persistence.ts +138 -68
  140. package/src/skills/loader.test.ts +18 -0
  141. package/src/skills/loader.ts +12 -1
  142. package/src/skills/synthesize.test.ts +59 -0
  143. package/src/skills/synthesize.ts +89 -36
  144. package/src/subagents/events.test.ts +56 -0
  145. package/src/subagents/events.ts +32 -2
  146. package/src/subagents/registry.test.ts +106 -0
  147. package/src/subagents/registry.ts +77 -3
  148. package/src/subagents/run-child.test.ts +163 -2
  149. package/src/subagents/run-child.ts +151 -47
  150. package/src/surfaces/host.test.ts +71 -1
  151. package/src/surfaces/host.ts +39 -2
  152. package/src/usage-stats.test.ts +99 -1
  153. package/src/usage-stats.ts +42 -17
  154. package/src/view/parse-extended.test.ts +80 -0
  155. package/src/view/parse.test.ts +14 -0
  156. package/src/view/parse.ts +113 -18
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
 
@@ -3,7 +3,7 @@ import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
4
  import { afterEach, describe, expect, it } from 'vitest';
5
5
  import type { MoxxyEvent } from '@moxxy/sdk';
6
- import { pageEvents, readEventPage, restoreEvents, seedSessionLog } from './persistence.js';
6
+ import { pageEvents, readEventPage } from './persistence.js';
7
7
 
8
8
  const tempDirs: string[] = [];
9
9
 
@@ -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[] = [];
@@ -177,41 +194,3 @@ describe('readEventPage (paged JSONL reader)', () => {
177
194
  expect(after).toBe(before);
178
195
  });
179
196
  });
180
-
181
- describe('seedSessionLog (NDJSON → runner-log migration)', () => {
182
- it('writes a fresh session log, re-sequenced to 0..n-1, ids + content preserved', async () => {
183
- const dir = await makeTempDir();
184
- // Gapped original seqs — as rendered events pulled from the desktop mirror
185
- // (which dropped the interleaved chunk/bookend events) would have.
186
- const events = [
187
- { id: 'a', seq: 3, ts: 0, sessionId: 'sess', turnId: 't0', source: 'user', type: 'user_prompt', text: 'q' },
188
- { id: 'b', seq: 7, ts: 1, sessionId: 'sess', turnId: 't0', source: 'model', type: 'assistant_message', content: 'reply' },
189
- ] as unknown as MoxxyEvent[];
190
- expect(await seedSessionLog('sess', events, dir)).toBe(true);
191
- // The runner restores it like any native log: contiguous seqs, ids preserved.
192
- const restored = await restoreEvents('sess', dir);
193
- expect(restored.map((e) => e.id)).toEqual(['a', 'b']);
194
- expect(restored.map((e) => e.seq)).toEqual([0, 1]);
195
- expect((restored[1] as { content?: string }).content).toBe('reply');
196
- });
197
-
198
- it('never overwrites a NON-EMPTY session the runner already owns', async () => {
199
- const dir = await makeTempDir();
200
- await writeLog(dir, 'sess', makeLog(2, 'sess')); // the runner already owns it
201
- expect(await seedSessionLog('sess', makeLog(5, 'sess'), dir)).toBe(false);
202
- const restored = await restoreEvents('sess', dir);
203
- expect(restored).toHaveLength(2); // untouched
204
- });
205
-
206
- it('seeds OVER a 0-byte session log (the empty file attach leaves on every spawn)', async () => {
207
- const dir = await makeTempDir();
208
- await writeLog(dir, 'sess', []); // 0-byte file, holds no history
209
- expect(await seedSessionLog('sess', makeLog(3, 'sess'), dir)).toBe(true);
210
- expect(await restoreEvents('sess', dir)).toHaveLength(3);
211
- });
212
-
213
- it('is a no-op for an empty event list', async () => {
214
- const dir = await makeTempDir();
215
- expect(await seedSessionLog('sess', [], dir)).toBe(false);
216
- });
217
- });
@@ -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
  }
@@ -572,44 +664,6 @@ export function pageEvents(
572
664
  return { events: page, prevCursor };
573
665
  }
574
666
 
575
- /**
576
- * Seed a session's event log from an external event list IFF the session has no
577
- * log on disk yet. This is the migration that makes the runner's authoritative
578
- * log the home of a chat whose history previously lived ONLY in a thin client's
579
- * own mirror (the desktop's NDJSON chat store) — after it runs, `loadHistory`
580
- * serves that chat from the runner like any other.
581
- *
582
- * Idempotent and NON-destructive: if `<sessionId>.jsonl` already exists AND is
583
- * NON-EMPTY this is a no-op returning `false`, so a session the runner already
584
- * owns is NEVER overwritten. A 0-byte log IS seeded: `persistence.attach`
585
- * creates an empty `<id>.jsonl` on every spawn (even a session with zero
586
- * events), so an existence-only guard would skip exactly the legacy chats this
587
- * migration targets — and an empty file holds no history, so seeding over it
588
- * loses nothing. Events are re-sequenced to contiguous `seq` 0..n-1 (order + ids
589
- * preserved) so the seeded log satisfies {@link EventLog}'s seq invariants when
590
- * it is later restored. Written temp+rename so a crash mid-seed can't leave a
591
- * half-written log. Returns `true` iff it wrote the log.
592
- */
593
- export async function seedSessionLog(
594
- sessionId: string,
595
- events: ReadonlyArray<MoxxyEvent>,
596
- dir = defaultSessionsDir(),
597
- ): Promise<boolean> {
598
- if (events.length === 0) return false;
599
- const logPath = path.join(dir, `${sessionId}.jsonl`);
600
- try {
601
- // A non-empty log is a session the runner already owns → never overwrite.
602
- // A 0-byte log is the empty file attach() left behind → seed over it.
603
- if ((await fs.stat(logPath)).size > 0) return false;
604
- } catch {
605
- /* no log yet → seed below */
606
- }
607
- await fs.mkdir(dir, { recursive: true });
608
- const reseq = events.map((e, i) => (e.seq === i ? e : ({ ...e, seq: i } as MoxxyEvent)));
609
- await writeFileAtomic(logPath, reseq.map((e) => JSON.stringify(e) + '\n').join(''));
610
- return true;
611
- }
612
-
613
667
  /**
614
668
  * Remove a session's log file and its sidecar. A leftover legacy `index.json`
615
669
  * row, if any, is harmless — `readIndex` filters out sessions whose `.jsonl` is
@@ -619,6 +673,7 @@ export async function deleteSession(
619
673
  sessionId: string,
620
674
  dir = defaultSessionsDir(),
621
675
  ): Promise<void> {
676
+ assertSafeSessionId(sessionId);
622
677
  await fs.rm(path.join(dir, `${sessionId}.jsonl`), { force: true });
623
678
  await fs.rm(metaPath(dir, sessionId), { force: true });
624
679
  }
@@ -628,6 +683,21 @@ function metaPath(dir: string, id: string): string {
628
683
  return path.join(dir, `${id}.meta.json`);
629
684
  }
630
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
+
631
701
  async function writeJsonAtomic(target: string, value: unknown): Promise<void> {
632
702
  await writeFileAtomic(target, JSON.stringify(value, null, 2) + '\n');
633
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;
@@ -126,6 +126,65 @@ describe('synthesizeSkill', () => {
126
126
  expect(b.path).not.toBe(a.path);
127
127
  expect(b.path).toMatch(/-2\.md$/);
128
128
  });
129
+
130
+ it('does not overwrite a same-slug file already on disk (atomic wx create)', async () => {
131
+ const provider = new InlineProvider([draftReply()]);
132
+ const session = newSessionWithProvider(provider);
133
+ const skillsDir = path.join(tmp, 'skills');
134
+ await fs.mkdir(skillsDir, { recursive: true });
135
+ // Pre-existing skill file with the slug that this synthesis will produce.
136
+ const squatted = path.join(skillsDir, 'refactor-component.md');
137
+ await fs.writeFile(squatted, 'PRE-EXISTING — MUST NOT BE TRUNCATED');
138
+
139
+ const result = await synthesizeSkill(session, 'split component up', 'user', {
140
+ userDir: skillsDir,
141
+ auditPath: path.join(tmp, 'audit.jsonl'),
142
+ });
143
+
144
+ // The pre-existing file is untouched and the new skill landed on a bumped name.
145
+ expect(await fs.readFile(squatted, 'utf8')).toBe('PRE-EXISTING — MUST NOT BE TRUNCATED');
146
+ expect(result.path).toMatch(/-2\.md$/);
147
+ });
148
+
149
+ it('still returns the created skill when the audit append fails (audit is best-effort)', async () => {
150
+ const provider = new InlineProvider([draftReply()]);
151
+ const session = newSessionWithProvider(provider);
152
+ // Make the audit path unwritable by planting a FILE where its parent dir
153
+ // must be — appendAudit's mkdir(dirname) then throws ENOTDIR/EEXIST.
154
+ const blocker = path.join(tmp, 'blocker');
155
+ await fs.writeFile(blocker, 'i am a file, not a directory');
156
+ const auditPath = path.join(blocker, 'created.jsonl');
157
+
158
+ const result = await synthesizeSkill(session, 'split component up', 'user', {
159
+ userDir: path.join(tmp, 'skills'),
160
+ auditPath,
161
+ });
162
+
163
+ // The skill is the product — it was written, registered, and returned despite
164
+ // the telemetry write failing.
165
+ expect(result.skill.frontmatter.name).toBe('refactor-component');
166
+ expect(await fs.readFile(result.path, 'utf8')).toContain('refactor-component');
167
+ expect(session.skills.byName('refactor-component')).toBeDefined();
168
+ });
169
+
170
+ it('caps the audit JSONL at MAX_AUDIT_LINES', async () => {
171
+ const auditPath = path.join(tmp, 'audit.jsonl');
172
+ // Seed the audit with way more than the cap.
173
+ const seed = Array.from({ length: 2100 }, (_, i) => JSON.stringify({ n: i })).join('\n') + '\n';
174
+ await fs.writeFile(auditPath, seed);
175
+
176
+ const provider = new InlineProvider([draftReply()]);
177
+ const session = newSessionWithProvider(provider);
178
+ await synthesizeSkill(session, 'split component up', 'user', {
179
+ userDir: path.join(tmp, 'skills'),
180
+ auditPath,
181
+ });
182
+
183
+ const lines = (await fs.readFile(auditPath, 'utf8')).split('\n').filter((l) => l.length > 0);
184
+ expect(lines.length).toBeLessThanOrEqual(2000);
185
+ // The most-recent line is the one we just appended (the skill slug).
186
+ expect(lines[lines.length - 1]).toContain('refactor-component');
187
+ });
129
188
  });
130
189
 
131
190
  describe('buildSynthesizeSkillPlugin', () => {