@moxxy/sdk 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/compactor-helpers.d.ts.map +1 -1
- package/dist/compactor-helpers.js +6 -3
- package/dist/compactor-helpers.js.map +1 -1
- package/dist/events.d.ts +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/frontmatter.d.ts +40 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +161 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/json-file-store.d.ts +84 -0
- package/dist/json-file-store.d.ts.map +1 -0
- package/dist/json-file-store.js +56 -0
- package/dist/json-file-store.js.map +1 -0
- package/dist/mode.d.ts +10 -0
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +5 -2
- package/dist/tool-dispatch.js.map +1 -1
- package/dist/types.test-d.js +5 -0
- package/dist/types.test-d.js.map +1 -1
- package/package.json +1 -1
- package/src/compactor-helpers.test.ts +52 -0
- package/src/compactor-helpers.ts +6 -2
- package/src/events.ts +8 -1
- package/src/frontmatter.test.ts +141 -0
- package/src/frontmatter.ts +159 -0
- package/src/index.ts +12 -0
- package/src/json-file-store.test.ts +207 -0
- package/src/json-file-store.ts +144 -0
- package/src/mode.ts +10 -0
- package/src/tool-dispatch.ts +5 -2
- package/src/types.test-d.ts +13 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { createJsonFileStore } from './json-file-store.js';
|
|
7
|
+
|
|
8
|
+
interface Item {
|
|
9
|
+
id: string;
|
|
10
|
+
n: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const itemSchema = z.object({ id: z.string(), n: z.number() });
|
|
14
|
+
const fileSchema = z.object({ version: z.literal(1), items: z.array(itemSchema) });
|
|
15
|
+
|
|
16
|
+
/** A `load` that mirrors the scheduler policy: corrupt/invalid -> empty. */
|
|
17
|
+
function lenientLoad(raw: string | null): Item[] {
|
|
18
|
+
if (raw === null) return [];
|
|
19
|
+
try {
|
|
20
|
+
const parsed = fileSchema.safeParse(JSON.parse(raw));
|
|
21
|
+
return parsed.success ? [...parsed.data.items] : [];
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('createJsonFileStore', () => {
|
|
28
|
+
let dir: string;
|
|
29
|
+
let file: string;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
dir = await mkdtemp(join(tmpdir(), 'moxxy-jfs-'));
|
|
33
|
+
file = join(dir, 'sub', 'store.json');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
await rm(dir, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function store() {
|
|
41
|
+
return createJsonFileStore<Item>({ file, load: lenientLoad });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
it('returns an empty list when the file is missing (ENOENT)', async () => {
|
|
45
|
+
expect(await store().read()).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('persists { version: 1, items } pretty-printed and creates parent dirs', async () => {
|
|
49
|
+
const s = store();
|
|
50
|
+
await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
|
|
51
|
+
const raw = await readFile(file, 'utf8');
|
|
52
|
+
expect(JSON.parse(raw)).toEqual({ version: 1, items: [{ id: 'a', n: 1 }] });
|
|
53
|
+
// pretty-printed with 2-space indent
|
|
54
|
+
expect(raw).toContain('\n "version": 1');
|
|
55
|
+
// a fresh instance reads the persisted state back
|
|
56
|
+
expect(await store().read()).toEqual([{ id: 'a', n: 1 }]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('get() finds by id and returns null for misses', async () => {
|
|
60
|
+
const s = store();
|
|
61
|
+
await s.mutate((items) => [...items, { id: 'a', n: 1 }, { id: 'b', n: 2 }]);
|
|
62
|
+
expect(await s.get('b')).toEqual({ id: 'b', n: 2 });
|
|
63
|
+
expect(await s.get('zzz')).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('read() and the mutator each get a fresh copy (caller cannot mutate cache)', async () => {
|
|
67
|
+
const s = store();
|
|
68
|
+
await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
|
|
69
|
+
|
|
70
|
+
// Mutating the array returned by read() must not affect the store.
|
|
71
|
+
const snap = await s.read();
|
|
72
|
+
snap.push({ id: 'evil', n: 99 });
|
|
73
|
+
expect(await s.read()).toEqual([{ id: 'a', n: 1 }]);
|
|
74
|
+
|
|
75
|
+
// The mutator receives a fresh slice; pushing into it then returning a
|
|
76
|
+
// DIFFERENT array means the push is discarded (proves it's a copy, not the
|
|
77
|
+
// live cache).
|
|
78
|
+
await s.mutate((items) => {
|
|
79
|
+
items.push({ id: 'discarded', n: -1 });
|
|
80
|
+
return [{ id: 'a', n: 2 }];
|
|
81
|
+
});
|
|
82
|
+
expect(await s.read()).toEqual([{ id: 'a', n: 2 }]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('serializes concurrent mutate() calls so neither clobbers the other', async () => {
|
|
86
|
+
const s = store();
|
|
87
|
+
// Fire many overlapping appends. Without the mutex these read the same
|
|
88
|
+
// baseline and the last write wins, dropping entries.
|
|
89
|
+
await Promise.all(
|
|
90
|
+
Array.from({ length: 25 }, (_, i) =>
|
|
91
|
+
s.mutate((items) => [...items, { id: `k${i}`, n: i }]),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
const all = await s.read();
|
|
95
|
+
expect(all).toHaveLength(25);
|
|
96
|
+
expect(new Set(all.map((x) => x.id)).size).toBe(25);
|
|
97
|
+
// The on-disk file agrees (every write landed atomically).
|
|
98
|
+
const onDisk = JSON.parse(await readFile(file, 'utf8')) as { items: Item[] };
|
|
99
|
+
expect(onDisk.items).toHaveLength(25);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('leaves the prior file intact when serializing the new state throws', async () => {
|
|
103
|
+
const s = store();
|
|
104
|
+
await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
|
|
105
|
+
const before = await readFile(file, 'utf8');
|
|
106
|
+
|
|
107
|
+
// A value JSON.stringify cannot serialize (BigInt) makes the persist throw
|
|
108
|
+
// before any bytes hit disk; the previously-persisted file must survive.
|
|
109
|
+
await expect(
|
|
110
|
+
s.mutate(() => [{ id: 'bad', n: 1n as unknown as number }]),
|
|
111
|
+
).rejects.toThrow();
|
|
112
|
+
|
|
113
|
+
expect(await readFile(file, 'utf8')).toBe(before);
|
|
114
|
+
const leftovers = (await readdir(join(dir, 'sub'))).filter((n) => n.includes('.tmp'));
|
|
115
|
+
expect(leftovers).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('leaves the prior file intact when the atomic rename fails mid-write', async () => {
|
|
119
|
+
const s = store();
|
|
120
|
+
await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
|
|
121
|
+
const before = await readFile(file, 'utf8');
|
|
122
|
+
|
|
123
|
+
// Replace the parent dir's write path: make the TARGET a directory so the
|
|
124
|
+
// temp-file rename throws. We can't do that to `file` itself without losing
|
|
125
|
+
// the prior copy, so use a sibling store whose target is a directory and
|
|
126
|
+
// assert no temp file is orphaned. The original store's file is untouched.
|
|
127
|
+
const sib = join(dir, 'sub', 'sibling');
|
|
128
|
+
await mkdir(sib, { recursive: true });
|
|
129
|
+
const s2 = createJsonFileStore<Item>({ file: sib, load: lenientLoad });
|
|
130
|
+
await expect(s2.mutate((items) => [...items, { id: 'x', n: 1 }])).rejects.toThrow();
|
|
131
|
+
|
|
132
|
+
// No orphaned temp files, and the unrelated prior file is intact.
|
|
133
|
+
const leftovers = (await readdir(join(dir, 'sub'))).filter((n) => n.includes('.tmp'));
|
|
134
|
+
expect(leftovers).toEqual([]);
|
|
135
|
+
expect(await readFile(file, 'utf8')).toBe(before);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('lenient load resets a corrupt file to empty (scheduler policy)', async () => {
|
|
139
|
+
await mkdir(join(dir, 'sub'), { recursive: true });
|
|
140
|
+
await writeFile(file, '{ not json', 'utf8');
|
|
141
|
+
const s = store();
|
|
142
|
+
expect(await s.read()).toEqual([]);
|
|
143
|
+
// The bad file is left in place until the next write.
|
|
144
|
+
expect(await readFile(file, 'utf8')).toBe('{ not json');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('lenient load resets a schema-mismatched file to empty', async () => {
|
|
148
|
+
await mkdir(join(dir, 'sub'), { recursive: true });
|
|
149
|
+
await writeFile(file, JSON.stringify({ version: 99, items: [] }), 'utf8');
|
|
150
|
+
expect(await store().read()).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('onReadError lets a store refuse to operate on a non-ENOENT read error', async () => {
|
|
154
|
+
// Point the store at a directory so readFile throws EISDIR (not ENOENT).
|
|
155
|
+
const asDir = join(dir, 'as-dir');
|
|
156
|
+
await mkdir(asDir, { recursive: true });
|
|
157
|
+
const s = createJsonFileStore<Item>({
|
|
158
|
+
file: asDir,
|
|
159
|
+
load: lenientLoad,
|
|
160
|
+
onReadError: (err) => {
|
|
161
|
+
throw new Error(`refusing: ${(err as NodeJS.ErrnoException).code}`);
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
await expect(s.read()).rejects.toThrow(/refusing:/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('re-throws a non-ENOENT read error by default (no onReadError)', async () => {
|
|
168
|
+
const asDir = join(dir, 'as-dir2');
|
|
169
|
+
await mkdir(asDir, { recursive: true });
|
|
170
|
+
const s = createJsonFileStore<Item>({ file: asDir, load: lenientLoad });
|
|
171
|
+
await expect(s.read()).rejects.toThrow();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('honors a custom itemsKey, fileFields, stringify and writeOptions', async () => {
|
|
175
|
+
const f = join(dir, 'custom.json');
|
|
176
|
+
const s = createJsonFileStore<Item>({
|
|
177
|
+
file: f,
|
|
178
|
+
itemsKey: 'rows',
|
|
179
|
+
fileFields: {},
|
|
180
|
+
stringify: (obj) => JSON.stringify(obj) + '\n',
|
|
181
|
+
writeOptions: { mode: 0o600 },
|
|
182
|
+
load: (raw) => {
|
|
183
|
+
if (raw === null) return [];
|
|
184
|
+
const obj = JSON.parse(raw) as { rows: Item[] };
|
|
185
|
+
return obj.rows;
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
|
|
189
|
+
const raw = await readFile(f, 'utf8');
|
|
190
|
+
// versionless, name-key `rows`, compact, trailing newline
|
|
191
|
+
expect(raw).toBe('{"rows":[{"id":"a","n":1}]}\n');
|
|
192
|
+
const { stat } = await import('node:fs/promises');
|
|
193
|
+
expect((await stat(f)).mode & 0o777).toBe(0o600);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('invalidate() forces a re-read from disk', async () => {
|
|
197
|
+
const s = store();
|
|
198
|
+
await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
|
|
199
|
+
// Write a second entry directly to disk behind the store's back.
|
|
200
|
+
await writeFile(file, JSON.stringify({ version: 1, items: [{ id: 'b', n: 2 }] }), 'utf8');
|
|
201
|
+
// Stale cache still shows the first entry…
|
|
202
|
+
expect(await s.read()).toEqual([{ id: 'a', n: 1 }]);
|
|
203
|
+
s.invalidate();
|
|
204
|
+
// …until invalidated.
|
|
205
|
+
expect(await s.read()).toEqual([{ id: 'b', n: 2 }]);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { createMutex, type Mutex } from './mutex.js';
|
|
3
|
+
import { writeFileAtomic, type WriteFileAtomicOptions } from './fs-utils.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generic whole-file JSON collection store: the single home for the repeated
|
|
7
|
+
* "id-collection" persistence skeleton (invariant #5). It owns the in-memory
|
|
8
|
+
* cache, the per-instance write mutex, the read-modify-write `.slice()` copy,
|
|
9
|
+
* and the crash-atomic whole-file write via {@link writeFileAtomic} — so each
|
|
10
|
+
* domain store (scheduler, webhooks, …) no longer re-derives that atomicity
|
|
11
|
+
* by hand and can't silently regress it (forget the mutex, forget the copy,
|
|
12
|
+
* hand-roll a non-unique tmp).
|
|
13
|
+
*
|
|
14
|
+
* The store persists `{ [itemsKey]: T[], ...extraFileFields }` — by default
|
|
15
|
+
* `{ version: 1, items: [...] }`. The shape and key are configurable so a
|
|
16
|
+
* domain store keeps its exact on-disk format.
|
|
17
|
+
*
|
|
18
|
+
* Parsing/validation and corruption policy stay with the caller via
|
|
19
|
+
* {@link JsonFileStoreOptions.load}: it receives the raw file contents (or
|
|
20
|
+
* `null` when the file is absent) and returns the validated item array. This
|
|
21
|
+
* keeps each store's bespoke corrupt handling (silent reset vs. quarantine
|
|
22
|
+
* aside) byte-for-byte unchanged while centralizing the atomicity invariant.
|
|
23
|
+
*
|
|
24
|
+
* SDK-internal-dep-free: uses only node builtins + the SDK's own
|
|
25
|
+
* {@link createMutex} / {@link writeFileAtomic}.
|
|
26
|
+
*/
|
|
27
|
+
export interface JsonFileStoreOptions<T extends { id: string }> {
|
|
28
|
+
/** Absolute path to the JSON file. */
|
|
29
|
+
readonly file: string;
|
|
30
|
+
/**
|
|
31
|
+
* Parse + validate the file contents into the item array. Called once per
|
|
32
|
+
* load with the raw UTF-8 string, or `null` when the file does not exist
|
|
33
|
+
* (ENOENT). Owns all corruption policy (quarantine, silent reset, throw).
|
|
34
|
+
* Any non-ENOENT read error is surfaced through {@link onReadError} first.
|
|
35
|
+
*/
|
|
36
|
+
readonly load: (raw: string | null) => T[] | Promise<T[]>;
|
|
37
|
+
/**
|
|
38
|
+
* Called when reading the file fails with a non-ENOENT error (permissions,
|
|
39
|
+
* I/O, …). Return an item array to recover, or throw to refuse to operate
|
|
40
|
+
* (the safe direction when the file may hold the only copy of secrets).
|
|
41
|
+
* Defaults to re-throwing the original error.
|
|
42
|
+
*/
|
|
43
|
+
readonly onReadError?: (err: unknown) => T[] | Promise<T[]>;
|
|
44
|
+
/**
|
|
45
|
+
* Property name the items array is stored under. Default `'items'`.
|
|
46
|
+
* (scheduler uses `'schedules'`, webhooks `'triggers'`.)
|
|
47
|
+
*/
|
|
48
|
+
readonly itemsKey?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Extra top-level fields written alongside the items. Default
|
|
51
|
+
* `{ version: 1 }`. Pass `{}` for a versionless file.
|
|
52
|
+
*/
|
|
53
|
+
readonly fileFields?: Record<string, unknown>;
|
|
54
|
+
/**
|
|
55
|
+
* How the persisted object is serialized. Default: pretty 2-space JSON
|
|
56
|
+
* (matches the scheduler/webhooks format). Receives the full file object.
|
|
57
|
+
*/
|
|
58
|
+
readonly stringify?: (fileObject: Record<string, unknown>) => string;
|
|
59
|
+
/** Atomic-write options forwarded to {@link writeFileAtomic} (e.g. `mode`). */
|
|
60
|
+
readonly writeOptions?: WriteFileAtomicOptions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The collection store handle. Domain stores compose this and add their own
|
|
65
|
+
* id/createdAt minting and bespoke methods on top of {@link mutate}.
|
|
66
|
+
*/
|
|
67
|
+
export interface JsonFileStore<T extends { id: string }> {
|
|
68
|
+
/** Loaded snapshot (a fresh shallow copy; safe to mutate by the caller). */
|
|
69
|
+
read(): Promise<T[]>;
|
|
70
|
+
/** Find a single item by id, or `null`. */
|
|
71
|
+
get(id: string): Promise<T | null>;
|
|
72
|
+
/**
|
|
73
|
+
* Read-modify-write under the write mutex. The mutator receives a fresh
|
|
74
|
+
* shallow copy of the current items; whatever it returns becomes the new
|
|
75
|
+
* state and is persisted atomically before {@link mutate} resolves.
|
|
76
|
+
*/
|
|
77
|
+
mutate(fn: (items: T[]) => T[] | Promise<T[]>): Promise<void>;
|
|
78
|
+
/** Drop the cache so the next access re-reads from disk. */
|
|
79
|
+
invalidate(): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const prettyJson = (value: Record<string, unknown>): string => JSON.stringify(value, null, 2);
|
|
83
|
+
|
|
84
|
+
export function createJsonFileStore<T extends { id: string }>(
|
|
85
|
+
opts: JsonFileStoreOptions<T>,
|
|
86
|
+
): JsonFileStore<T> {
|
|
87
|
+
const {
|
|
88
|
+
file,
|
|
89
|
+
load,
|
|
90
|
+
onReadError,
|
|
91
|
+
itemsKey = 'items',
|
|
92
|
+
fileFields = { version: 1 },
|
|
93
|
+
stringify = prettyJson,
|
|
94
|
+
writeOptions,
|
|
95
|
+
} = opts;
|
|
96
|
+
|
|
97
|
+
let cache: T[] | null = null;
|
|
98
|
+
const mutex: Mutex = createMutex();
|
|
99
|
+
|
|
100
|
+
async function ensureLoaded(): Promise<void> {
|
|
101
|
+
if (cache) return;
|
|
102
|
+
let raw: string | null;
|
|
103
|
+
try {
|
|
104
|
+
raw = await readFile(file, 'utf8');
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
107
|
+
raw = null;
|
|
108
|
+
} else if (onReadError) {
|
|
109
|
+
cache = await onReadError(err);
|
|
110
|
+
return;
|
|
111
|
+
} else {
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
cache = await load(raw);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function persist(items: T[]): Promise<void> {
|
|
119
|
+
const payload = stringify({ ...fileFields, [itemsKey]: items });
|
|
120
|
+
await writeFileAtomic(file, payload, writeOptions ?? {});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
async read(): Promise<T[]> {
|
|
125
|
+
await ensureLoaded();
|
|
126
|
+
return cache!.slice();
|
|
127
|
+
},
|
|
128
|
+
async get(id: string): Promise<T | null> {
|
|
129
|
+
await ensureLoaded();
|
|
130
|
+
return cache!.find((item) => item.id === id) ?? null;
|
|
131
|
+
},
|
|
132
|
+
async mutate(fn): Promise<void> {
|
|
133
|
+
await mutex.run(async () => {
|
|
134
|
+
await ensureLoaded();
|
|
135
|
+
const updated = await fn(cache!.slice());
|
|
136
|
+
cache = updated;
|
|
137
|
+
await persist(updated);
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
invalidate(): void {
|
|
141
|
+
cache = null;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
package/src/mode.ts
CHANGED
|
@@ -64,6 +64,16 @@ export interface ElisionSettings {
|
|
|
64
64
|
export interface ModeContext {
|
|
65
65
|
readonly sessionId: SessionId;
|
|
66
66
|
readonly turnId: TurnId;
|
|
67
|
+
/**
|
|
68
|
+
* The session's working directory and environment, mirrored from
|
|
69
|
+
* {@link AppContext}. Threaded into the `dispatchToolCall` hook context so
|
|
70
|
+
* `onToolCall` hooks that gate on cwd/env (path-based policy/security hooks)
|
|
71
|
+
* see the real per-session values rather than empty placeholders. Tools
|
|
72
|
+
* themselves get cwd via the tool registry's default; this carries the same
|
|
73
|
+
* truth to the hook layer so the two never disagree.
|
|
74
|
+
*/
|
|
75
|
+
readonly cwd: string;
|
|
76
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
67
77
|
readonly model: string;
|
|
68
78
|
readonly systemPrompt?: string;
|
|
69
79
|
readonly provider: LLMProvider;
|
package/src/tool-dispatch.ts
CHANGED
|
@@ -26,9 +26,12 @@ export async function* dispatchToolCall(
|
|
|
26
26
|
try {
|
|
27
27
|
const verdict = await ctx.hooks.dispatchToolCall({
|
|
28
28
|
sessionId: ctx.sessionId,
|
|
29
|
-
cwd
|
|
29
|
+
// Hand the hook the session's real cwd/env (mirrored onto ModeContext by
|
|
30
|
+
// run-turn / the subagent runtime). Previously hardcoded '' / {}, which
|
|
31
|
+
// silently defeated path-based onToolCall policy/security hooks.
|
|
32
|
+
cwd: ctx.cwd,
|
|
30
33
|
log: ctx.log,
|
|
31
|
-
env:
|
|
34
|
+
env: ctx.env,
|
|
32
35
|
turnId: ctx.turnId,
|
|
33
36
|
iteration,
|
|
34
37
|
call: { callId: asToolCallId(t.id), name: t.name, input: t.input },
|
package/src/types.test-d.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { describe, expectTypeOf, it } from 'vitest';
|
|
|
2
2
|
import type {
|
|
3
3
|
MoxxyEvent,
|
|
4
4
|
MoxxyEventOfType,
|
|
5
|
+
PluginRegisteredEvent,
|
|
5
6
|
ToolCallRequestedEvent,
|
|
6
7
|
UserPromptEvent,
|
|
7
8
|
} from './events.js';
|
|
9
|
+
import type { PluginKind } from './plugin.js';
|
|
8
10
|
|
|
9
11
|
describe('event union narrowing', () => {
|
|
10
12
|
it('MoxxyEventOfType narrows correctly', () => {
|
|
@@ -27,3 +29,14 @@ describe('event union narrowing', () => {
|
|
|
27
29
|
expectTypeOf(handle).returns.toEqualTypeOf<string | null>();
|
|
28
30
|
});
|
|
29
31
|
});
|
|
32
|
+
|
|
33
|
+
describe('PluginRegisteredEvent.kind stays in sync with PluginKind', () => {
|
|
34
|
+
it('the inlined kind union equals PluginKind member-for-member', () => {
|
|
35
|
+
// events.ts inlines the kind union (to avoid an events↔plugin value cycle)
|
|
36
|
+
// with a "keep in sync with PluginKind" note. This bidirectional equality
|
|
37
|
+
// fails the build the moment PluginKind grows a member the inlined union
|
|
38
|
+
// lacks (or vice versa) — exactly the drift the note was meant to prevent.
|
|
39
|
+
type RegisteredKind = PluginRegisteredEvent['kind'][number];
|
|
40
|
+
expectTypeOf<RegisteredKind>().toEqualTypeOf<PluginKind>();
|
|
41
|
+
});
|
|
42
|
+
});
|