@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.
- package/dist/events/log.d.ts.map +1 -1
- package/dist/events/log.js +38 -1
- package/dist/events/log.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/permissions/engine.d.ts.map +1 -1
- package/dist/permissions/engine.js +80 -17
- package/dist/permissions/engine.js.map +1 -1
- package/dist/plugins/discovery.d.ts.map +1 -1
- package/dist/plugins/discovery.js +48 -7
- package/dist/plugins/discovery.js.map +1 -1
- package/dist/plugins/host.d.ts.map +1 -1
- package/dist/plugins/host.js +54 -11
- package/dist/plugins/host.js.map +1 -1
- package/dist/plugins/lifecycle.d.ts.map +1 -1
- package/dist/plugins/lifecycle.js +7 -0
- package/dist/plugins/lifecycle.js.map +1 -1
- package/dist/plugins/loader.d.ts +9 -0
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +34 -8
- package/dist/plugins/loader.js.map +1 -1
- package/dist/plugins/registry-kinds.d.ts +21 -2
- package/dist/plugins/registry-kinds.d.ts.map +1 -1
- package/dist/plugins/registry-kinds.js +3 -1
- package/dist/plugins/registry-kinds.js.map +1 -1
- package/dist/plugins/toposort.d.ts.map +1 -1
- package/dist/plugins/toposort.js +43 -18
- package/dist/plugins/toposort.js.map +1 -1
- package/dist/preferences.d.ts.map +1 -1
- package/dist/preferences.js +5 -4
- package/dist/preferences.js.map +1 -1
- package/dist/registries/active-backend-registry.d.ts.map +1 -1
- package/dist/registries/active-backend-registry.js +10 -1
- package/dist/registries/active-backend-registry.js.map +1 -1
- package/dist/registries/channels.d.ts +2 -1
- package/dist/registries/channels.d.ts.map +1 -1
- package/dist/registries/channels.js +30 -20
- package/dist/registries/channels.js.map +1 -1
- package/dist/registries/commands.d.ts.map +1 -1
- package/dist/registries/commands.js +27 -8
- package/dist/registries/commands.js.map +1 -1
- package/dist/registries/isolators.d.ts +27 -1
- package/dist/registries/isolators.d.ts.map +1 -1
- package/dist/registries/isolators.js +35 -1
- package/dist/registries/isolators.js.map +1 -1
- package/dist/registries/modes.d.ts +4 -0
- package/dist/registries/modes.d.ts.map +1 -1
- package/dist/registries/modes.js +15 -4
- package/dist/registries/modes.js.map +1 -1
- package/dist/registries/providers.d.ts +8 -0
- package/dist/registries/providers.d.ts.map +1 -1
- package/dist/registries/providers.js +8 -0
- package/dist/registries/providers.js.map +1 -1
- package/dist/registries/skills.d.ts +9 -0
- package/dist/registries/skills.d.ts.map +1 -1
- package/dist/registries/skills.js +22 -0
- package/dist/registries/skills.js.map +1 -1
- package/dist/requirements.d.ts.map +1 -1
- package/dist/requirements.js +7 -1
- package/dist/requirements.js.map +1 -1
- package/dist/run-turn.d.ts.map +1 -1
- package/dist/run-turn.js +65 -17
- package/dist/run-turn.js.map +1 -1
- package/dist/session.d.ts +9 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +25 -3
- package/dist/session.js.map +1 -1
- package/dist/sessions/persistence.d.ts +15 -19
- package/dist/sessions/persistence.d.ts.map +1 -1
- package/dist/sessions/persistence.js +116 -49
- package/dist/sessions/persistence.js.map +1 -1
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +11 -2
- package/dist/skills/loader.js.map +1 -1
- package/dist/skills/synthesize.d.ts.map +1 -1
- package/dist/skills/synthesize.js +87 -36
- package/dist/skills/synthesize.js.map +1 -1
- package/dist/subagents/events.js +33 -2
- package/dist/subagents/events.js.map +1 -1
- package/dist/subagents/registry.d.ts +22 -1
- package/dist/subagents/registry.d.ts.map +1 -1
- package/dist/subagents/registry.js +72 -2
- package/dist/subagents/registry.js.map +1 -1
- package/dist/subagents/run-child.d.ts.map +1 -1
- package/dist/subagents/run-child.js +137 -46
- package/dist/subagents/run-child.js.map +1 -1
- package/dist/surfaces/host.d.ts +10 -0
- package/dist/surfaces/host.d.ts.map +1 -1
- package/dist/surfaces/host.js +41 -3
- package/dist/surfaces/host.js.map +1 -1
- package/dist/usage-stats.d.ts +7 -1
- package/dist/usage-stats.d.ts.map +1 -1
- package/dist/usage-stats.js +41 -15
- package/dist/usage-stats.js.map +1 -1
- package/dist/view/parse.d.ts.map +1 -1
- package/dist/view/parse.js +119 -19
- package/dist/view/parse.js.map +1 -1
- package/package.json +2 -2
- package/src/events/log.test.ts +45 -0
- package/src/events/log.ts +39 -1
- package/src/index.ts +0 -1
- package/src/permissions/engine.test.ts +60 -0
- package/src/permissions/engine.ts +81 -16
- package/src/plugins/discovery.test.ts +59 -1
- package/src/plugins/discovery.ts +57 -9
- package/src/plugins/host.test.ts +40 -0
- package/src/plugins/host.ts +48 -10
- package/src/plugins/lifecycle.test.ts +29 -0
- package/src/plugins/lifecycle.ts +7 -0
- package/src/plugins/loader.ts +45 -7
- package/src/plugins/registry-kinds.test.ts +53 -0
- package/src/plugins/registry-kinds.ts +25 -3
- package/src/plugins/toposort.ts +45 -17
- package/src/preferences.test.ts +52 -1
- package/src/preferences.ts +5 -4
- package/src/registries/active-backend-registry.ts +10 -1
- package/src/registries/channels.test.ts +79 -0
- package/src/registries/channels.ts +33 -18
- package/src/registries/commands.test.ts +65 -0
- package/src/registries/commands.ts +25 -8
- package/src/registries/isolators.test.ts +37 -0
- package/src/registries/isolators.ts +48 -1
- package/src/registries/modes.test.ts +21 -1
- package/src/registries/modes.ts +15 -2
- package/src/registries/providers.ts +8 -0
- package/src/registries/semantics.test.ts +33 -1
- package/src/registries/skills.ts +28 -0
- package/src/registries/transcribers.test.ts +13 -0
- package/src/requirements.test.ts +35 -0
- package/src/requirements.ts +7 -1
- package/src/run-turn.test.ts +219 -0
- package/src/run-turn.ts +68 -18
- package/src/session.test.ts +24 -0
- package/src/session.ts +24 -3
- package/src/sessions/page.test.ts +18 -39
- package/src/sessions/persistence.test.ts +93 -1
- package/src/sessions/persistence.ts +138 -68
- package/src/skills/loader.test.ts +18 -0
- package/src/skills/loader.ts +12 -1
- package/src/skills/synthesize.test.ts +59 -0
- package/src/skills/synthesize.ts +89 -36
- package/src/subagents/events.test.ts +56 -0
- package/src/subagents/events.ts +32 -2
- package/src/subagents/registry.test.ts +106 -0
- package/src/subagents/registry.ts +77 -3
- package/src/subagents/run-child.test.ts +163 -2
- package/src/subagents/run-child.ts +151 -47
- package/src/surfaces/host.test.ts +71 -1
- package/src/surfaces/host.ts +39 -2
- package/src/usage-stats.test.ts +99 -1
- package/src/usage-stats.ts +42 -17
- package/src/view/parse-extended.test.ts +80 -0
- package/src/view/parse.test.ts +14 -0
- 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
|
-
|
|
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:
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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 :
|
|
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 (
|
|
653
|
+
if (safeBefore !== null) {
|
|
562
654
|
end = 0;
|
|
563
655
|
for (let i = 0; i < events.length; i += 1) {
|
|
564
|
-
if (events[i]!.seq <
|
|
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
|
});
|
package/src/skills/loader.ts
CHANGED
|
@@ -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
|
-
|
|
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', () => {
|