omkit 0.4.8 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/README.md +169 -13
  3. package/dist/actions/chrome-page.d.ts +41 -0
  4. package/dist/actions/chrome-page.js +77 -29
  5. package/dist/actions/drive-page.d.ts +50 -0
  6. package/dist/actions/drive-page.js +68 -0
  7. package/dist/actions/index.d.ts +5 -3
  8. package/dist/actions/index.js +2 -1
  9. package/dist/actions/prompt.d.ts +32 -5
  10. package/dist/actions/prompt.js +92 -8
  11. package/dist/cli/commands/help.js +7 -0
  12. package/dist/cli/commands/init.js +1 -1
  13. package/dist/cli/commands/ls.d.ts +11 -4
  14. package/dist/cli/commands/ls.js +35 -15
  15. package/dist/cli/commands/mcp.d.ts +10 -0
  16. package/dist/cli/commands/mcp.js +13 -0
  17. package/dist/cli/commands/skill.d.ts +18 -0
  18. package/dist/cli/commands/skill.js +62 -0
  19. package/dist/cli/index.d.ts +8 -0
  20. package/dist/cli/index.js +78 -30
  21. package/dist/client/discover-child.d.ts +12 -0
  22. package/dist/client/discover-child.js +71 -0
  23. package/dist/client/discovery.d.ts +21 -1
  24. package/dist/client/discovery.js +139 -2
  25. package/dist/client/index.d.ts +2 -1
  26. package/dist/client/index.js +15 -5
  27. package/dist/client/outline.d.ts +35 -0
  28. package/dist/client/outline.js +100 -0
  29. package/dist/client/registry.d.ts +59 -0
  30. package/dist/client/runner.d.ts +12 -0
  31. package/dist/client/runner.js +16 -7
  32. package/dist/client/types.d.ts +14 -1
  33. package/dist/client/types.js +5 -1
  34. package/dist/core/ActionRun.d.ts +5 -0
  35. package/dist/core/ActionRun.js +9 -0
  36. package/dist/core/ExecutionTree.d.ts +40 -2
  37. package/dist/core/ExecutionTree.js +66 -2
  38. package/dist/core/action.d.ts +2 -1
  39. package/dist/core/action.js +93 -9
  40. package/dist/core/args.d.ts +50 -0
  41. package/dist/core/args.js +166 -0
  42. package/dist/core/discovery-mode.d.ts +37 -0
  43. package/dist/core/discovery-mode.js +54 -0
  44. package/dist/core/interaction.d.ts +3 -1
  45. package/dist/core/om.d.ts +11 -9
  46. package/dist/core/om.js +182 -13
  47. package/dist/core/schema-hint.d.ts +20 -0
  48. package/dist/core/schema-hint.js +73 -0
  49. package/dist/core/schema-json.d.ts +12 -0
  50. package/dist/core/schema-json.js +12 -0
  51. package/dist/core/types.d.ts +113 -1
  52. package/dist/foundation/LogEntry.d.ts +1 -1
  53. package/dist/foundation/canonicalPath.d.ts +26 -0
  54. package/dist/foundation/canonicalPath.js +34 -0
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.js +1 -1
  57. package/dist/mcp/action-host.d.ts +1 -0
  58. package/dist/mcp/action-host.js +65 -0
  59. package/dist/mcp/action-identity.d.ts +23 -0
  60. package/dist/mcp/action-identity.js +35 -0
  61. package/dist/mcp/progress.d.ts +61 -0
  62. package/dist/mcp/progress.js +92 -0
  63. package/dist/mcp/resources.d.ts +55 -0
  64. package/dist/mcp/resources.js +208 -0
  65. package/dist/mcp/runs.d.ts +37 -0
  66. package/dist/mcp/runs.js +60 -0
  67. package/dist/mcp/server.d.ts +19 -0
  68. package/dist/mcp/server.js +38 -0
  69. package/dist/mcp/tail.d.ts +37 -0
  70. package/dist/mcp/tail.js +64 -0
  71. package/dist/mcp/tools.d.ts +54 -0
  72. package/dist/mcp/tools.js +378 -0
  73. package/dist/mcp/validate.d.ts +14 -0
  74. package/dist/mcp/validate.js +54 -0
  75. package/dist/mcp/verdict.d.ts +31 -0
  76. package/dist/mcp/verdict.js +47 -0
  77. package/dist/output/artifact/ArtifactStore.d.ts +24 -0
  78. package/dist/output/artifact/ArtifactStore.js +49 -0
  79. package/dist/output/folder/RunFolder.d.ts +23 -1
  80. package/dist/output/folder/RunFolder.js +70 -11
  81. package/dist/output/writers/views.d.ts +10 -0
  82. package/dist/skill/file.d.ts +26 -0
  83. package/dist/skill/file.js +58 -0
  84. package/dist/skill/index.d.ts +12 -0
  85. package/dist/skill/index.js +10 -0
  86. package/dist/skill/model.d.ts +69 -0
  87. package/dist/skill/model.js +200 -0
  88. package/dist/skill/render.d.ts +18 -0
  89. package/dist/skill/render.js +159 -0
  90. package/dist/skill/root.d.ts +15 -0
  91. package/dist/skill/root.js +27 -0
  92. package/package.json +5 -2
@@ -0,0 +1,378 @@
1
+ import path from "node:path";
2
+ import { z } from "zod";
3
+ import { checkArgs } from "./validate.js";
4
+ import { readVerdict, readVerdictAt } from "./verdict.js";
5
+ import { resolveRunFolder, runFileLinks } from "./resources.js";
6
+ import { hostFile, hostFolderName } from "./action-identity.js";
7
+ import { publicEntry, tailFile, tailLive } from "./tail.js";
8
+ import { attachElicitation, attachProgress } from "./progress.js";
9
+ /**
10
+ * How long a "settling" run gets before the server gives up on it. Generous: a real smoke
11
+ * suite can take minutes, and a backstop that fires early is worse than none.
12
+ *
13
+ * This is a server-side safety net, not a promise the call will be awaited — MCP client
14
+ * timeouts are client-side and typically far shorter. Anything routinely over about a
15
+ * minute belongs in `start_om`.
16
+ */
17
+ const SETTLE_TIMEOUT_MS = 10 * 60 * 1000;
18
+ /** How long teardown gets to finish writing the run folder after a backstop cancel. */
19
+ const TEARDOWN_GRACE_MS = 10_000;
20
+ /**
21
+ * Wait for a run to settle, or cancel it and report that it did not.
22
+ *
23
+ * `mode` is the author's claim, and a wrong claim would otherwise hang the tool call until
24
+ * the *client* times out — which surfaces as a transport error with no run folder and
25
+ * nothing to read. Cancelling instead turns the mistake into a message that names it, and
26
+ * the run still writes its folder on the way down, so there is something to inspect.
27
+ */
28
+ async function settleWithin(session, ms) {
29
+ let timer;
30
+ const expired = new Promise((resolve) => {
31
+ timer = setTimeout(() => resolve(undefined), ms);
32
+ });
33
+ try {
34
+ const winner = await Promise.race([session.result, expired]);
35
+ if (winner)
36
+ return winner;
37
+ session.cancel();
38
+ // Give teardown a chance to finish writing before reporting; the run is already lost
39
+ // either way, so a slow teardown must not extend the wait indefinitely.
40
+ await Promise.race([
41
+ session.result,
42
+ new Promise((resolve) => setTimeout(resolve, TEARDOWN_GRACE_MS)),
43
+ ]);
44
+ return undefined;
45
+ }
46
+ finally {
47
+ clearTimeout(timer);
48
+ }
49
+ }
50
+ const entryShape = {
51
+ kind: z.enum(["om", "action"]),
52
+ name: z.string(),
53
+ mode: z.enum(["settling", "long-lived"]),
54
+ file: z.string(),
55
+ summary: z.string().optional(),
56
+ folderName: z.string().optional(),
57
+ exportName: z.string().optional(),
58
+ inputSchema: z.record(z.string(), z.unknown()).optional(),
59
+ unavailable: z.string().optional(),
60
+ };
61
+ /**
62
+ * The process-lifetime registry cache. Discovery forks a child and imports user code, so
63
+ * it is far too expensive to repeat per tool call; a client that has changed a file asks
64
+ * for `refresh: true`. (Watching for changes and emitting `tools/list_changed` is
65
+ * deferred — the tool list itself never changes, only what `list_oms` returns.)
66
+ */
67
+ function createRegistryCache(client) {
68
+ let cached;
69
+ return (refresh = false) => {
70
+ if (refresh || !cached)
71
+ cached = client.discoverRegistrations();
72
+ return cached;
73
+ };
74
+ }
75
+ /** Flatten a registration set into the entries a client may call, dropping the rest. */
76
+ export function toEntries(set) {
77
+ const entries = [];
78
+ for (const om of set.oms) {
79
+ if (!om.mcp)
80
+ continue; // exposure is opt-in
81
+ entries.push({
82
+ kind: "om",
83
+ name: om.name,
84
+ mode: om.mcp.mode,
85
+ file: om.file,
86
+ folderName: om.folderName,
87
+ ...(om.summary ? { summary: om.summary } : {}),
88
+ ...(om.inputSchema ? { inputSchema: om.inputSchema } : {}),
89
+ ...(om.unavailable ? { unavailable: om.unavailable } : {}),
90
+ });
91
+ }
92
+ for (const a of set.actions) {
93
+ if (!a.mcp)
94
+ continue;
95
+ entries.push({
96
+ kind: "action",
97
+ name: a.name,
98
+ mode: a.mcp.mode,
99
+ file: a.file,
100
+ exportName: a.exportName,
101
+ ...(a.summary ? { summary: a.summary } : {}),
102
+ ...(a.inputSchema ? { inputSchema: a.inputSchema } : {}),
103
+ ...(a.unavailable ? { unavailable: a.unavailable } : {}),
104
+ });
105
+ }
106
+ return entries;
107
+ }
108
+ /**
109
+ * Every tool result carries a text block as well as `structuredContent`: a client that
110
+ * ignores structured output still sees the answer. Return types are inferred so they stay
111
+ * assignable to the SDK's `CallToolResult` union — annotating them as tuples does not.
112
+ */
113
+ export function both(structuredContent) {
114
+ return {
115
+ content: [{ type: "text", text: JSON.stringify(structuredContent, null, 2) }],
116
+ structuredContent,
117
+ };
118
+ }
119
+ /** A tool failure, in MCP's shape: a message the model can act on, not a thrown stack. */
120
+ export function toolError(message) {
121
+ return { content: [{ type: "text", text: message }], isError: true };
122
+ }
123
+ export function registerTools(server, ctx) {
124
+ const registry = createRegistryCache(ctx.client);
125
+ server.registerTool("list_oms", {
126
+ title: "List runnable omkit oms and actions",
127
+ description: "Lists everything this project exposes over MCP: its name, what it does, whether " +
128
+ "it settles on its own, and the JSON Schema for its arguments. Call this before " +
129
+ "run_om or start_om — their `args` are not described by their own input schemas.",
130
+ inputSchema: {
131
+ refresh: z
132
+ .boolean()
133
+ .optional()
134
+ .describe("Re-import the project's files. Use after editing an om or action."),
135
+ },
136
+ outputSchema: {
137
+ entries: z.array(z.object(entryShape)),
138
+ warnings: z.array(z.string()),
139
+ },
140
+ annotations: { readOnlyHint: true, openWorldHint: false },
141
+ }, async ({ refresh }) => {
142
+ const set = await registry(refresh);
143
+ return both({ entries: toEntries(set), warnings: set.warnings });
144
+ });
145
+ /** Find an exposed entry by name. Oms win a collision — they are the primary surface. */
146
+ const findEntry = async (name) => {
147
+ const entries = toEntries(await registry());
148
+ return (entries.find((e) => e.kind === "om" && e.name === name) ??
149
+ entries.find((e) => e.name === name));
150
+ };
151
+ const notRunnable = (name, entry) => {
152
+ if (!entry)
153
+ return `no om or action named "${name}" is exposed. Call list_oms.`;
154
+ if (entry.unavailable)
155
+ return `"${name}" is unavailable: ${entry.unavailable}`;
156
+ return undefined;
157
+ };
158
+ /**
159
+ * Launch an entry as a supervised run.
160
+ *
161
+ * `OMKIT_ROOT` is this server's own root, stated rather than inherited: resources here
162
+ * resolve under `<root>/logs`, so a run that writes its folder anywhere else is a run this
163
+ * same server cannot serve, and `get_run`'s resource links would point at nothing. The
164
+ * client would default it to the project owning the tsconfig — the same directory in
165
+ * practice — but "the server serves what it writes" should not rest on the two agreeing.
166
+ */
167
+ const launch = (entry, args) => {
168
+ // An action cannot run standalone, so an action-backed tool forks omkit's shipped host
169
+ // om instead of the action's own file — which defines no om at all.
170
+ const isAction = entry.kind === "action";
171
+ const file = isAction ? hostFile() : entry.file;
172
+ const env = {
173
+ OMKIT_ROOT: ctx.root,
174
+ ...(isAction
175
+ ? {
176
+ OMKIT_ACTION_FILE: entry.file,
177
+ OMKIT_ACTION_NAME: entry.name,
178
+ OMKIT_ACTION_EXPORT: entry.exportName,
179
+ OMKIT_ACTION_ARGS: JSON.stringify(args),
180
+ }
181
+ : { OMKIT_ARGS: JSON.stringify(args) }),
182
+ };
183
+ const folderName = isAction
184
+ ? hostFolderName(entry.name, entry.file)
185
+ : (entry.folderName ?? entry.name);
186
+ return ctx.runs.start(entry.name, folderName, ctx.client.run(file, { cwd: ctx.root, env }));
187
+ };
188
+ server.registerTool("run_om", {
189
+ title: "Run an om and wait for its verdict",
190
+ description: "Runs a settling om (or an exposed action) to completion and returns whether it " +
191
+ "passed, where its run folder is, its assert tally, and any failures. Call " +
192
+ "list_oms first to learn what `args` to pass. A long-lived entry is refused — use " +
193
+ "start_om for those, and for anything that takes more than about a minute.",
194
+ inputSchema: {
195
+ name: z.string().describe("The om or action name, as reported by list_oms."),
196
+ args: z
197
+ .record(z.string(), z.unknown())
198
+ .optional()
199
+ .describe("Arguments matching the entry's inputSchema from list_oms."),
200
+ settleTimeoutMs: z
201
+ .number()
202
+ .int()
203
+ .positive()
204
+ .optional()
205
+ .describe("How long to wait for the run to settle before giving up. Default 10 minutes."),
206
+ },
207
+ outputSchema: {
208
+ ok: z.boolean(),
209
+ folder: z.string(),
210
+ assertions: z.object({ passed: z.number(), failed: z.number() }),
211
+ failures: z.array(z.object({ action: z.string(), error: z.string() })),
212
+ summary: z.array(z.string()),
213
+ },
214
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
215
+ }, async ({ name, args, settleTimeoutMs }, extra) => {
216
+ const entry = await findEntry(name);
217
+ const refusal = notRunnable(name, entry);
218
+ if (refusal || !entry)
219
+ return toolError(refusal);
220
+ if (entry.mode === "long-lived") {
221
+ return toolError(`"${name}" is long-lived — start it with start_om, not run_om.`);
222
+ }
223
+ const problems = checkArgs(entry.inputSchema, args ?? {});
224
+ if (problems.length)
225
+ return toolError(problems.join("\n"));
226
+ const run = launch(entry, args ?? {});
227
+ // This request lives for the whole run, so it can carry progress and it owns the
228
+ // run's cancellation: `extra.signal` aborting means the client withdrew the call.
229
+ attachProgress(run.session, extra);
230
+ attachElicitation(server, run.session);
231
+ const timeout = settleTimeoutMs ?? SETTLE_TIMEOUT_MS;
232
+ const settled = await settleWithin(run.session, timeout);
233
+ if (!settled) {
234
+ return toolError(`"${name}" is declared settling but did not settle within ${timeout}ms, so it ` +
235
+ `was cancelled. If it is meant to stay up, declare ` +
236
+ `.mcp({ mode: "long-lived" }) and start it with start_om.`);
237
+ }
238
+ return both(await readVerdict(settled));
239
+ });
240
+ server.registerTool("start_om", {
241
+ title: "Start an om without waiting for it",
242
+ description: "Starts an om or exposed action and returns a handle straight away. Use this for a " +
243
+ "long-lived entry (a server, a watcher), or for a long suite you want to check on " +
244
+ "later. Follow up with get_run, tail_run and cancel_run.",
245
+ inputSchema: {
246
+ name: z.string().describe("The om or action name, as reported by list_oms."),
247
+ args: z.record(z.string(), z.unknown()).optional(),
248
+ },
249
+ outputSchema: {
250
+ runId: z.string(),
251
+ name: z.string(),
252
+ // The dated run folder does not exist yet — this is the folder *family*.
253
+ folderName: z.string(),
254
+ mode: z.enum(["settling", "long-lived"]),
255
+ },
256
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
257
+ }, async ({ name, args }) => {
258
+ const entry = await findEntry(name);
259
+ const refusal = notRunnable(name, entry);
260
+ if (refusal || !entry)
261
+ return toolError(refusal);
262
+ const problems = checkArgs(entry.inputSchema, args ?? {});
263
+ if (problems.length)
264
+ return toolError(problems.join("\n"));
265
+ const run = launch(entry, args ?? {});
266
+ // Elicitation only — deliberately no `attachProgress`. This request ends as soon as
267
+ // the handle is returned, so its `extra.signal` aborts immediately, and wiring that
268
+ // to `session.cancel()` would kill the run this tool exists to keep alive. A client
269
+ // that wants progress from a started run polls `tail_run`.
270
+ attachElicitation(server, run.session);
271
+ return both({
272
+ runId: run.id,
273
+ name: entry.name,
274
+ folderName: run.folderName,
275
+ mode: entry.mode,
276
+ });
277
+ });
278
+ server.registerTool("get_run", {
279
+ title: "Check on a run",
280
+ description: "Reports whether a run is still going and, once it has settled, its verdict and " +
281
+ "links to the files it produced. `run` is a runId from start_om, or a run folder " +
282
+ "path under logs/.",
283
+ inputSchema: { run: z.string() },
284
+ outputSchema: {
285
+ status: z.enum(["running", "settled"]),
286
+ name: z.string(),
287
+ folder: z.string(),
288
+ ok: z.boolean().optional(),
289
+ assertions: z.object({ passed: z.number(), failed: z.number() }).optional(),
290
+ failures: z.array(z.object({ action: z.string(), error: z.string() })).optional(),
291
+ summary: z.array(z.string()).optional(),
292
+ },
293
+ annotations: { readOnlyHint: true, openWorldHint: false },
294
+ }, async ({ run }) => {
295
+ const live = ctx.runs.get(run);
296
+ if (live?.status === "running") {
297
+ // The dated folder is stamped inside the child on first access, so there is
298
+ // genuinely nothing to report here yet — not an empty string standing in for one.
299
+ return both({ status: "running", name: live.name, folder: "" });
300
+ }
301
+ // A run this server started, or a folder already on disk — confined either way.
302
+ const folder = live?.verdict?.folder ?? resolveRunFolder(ctx.root, run);
303
+ if (!folder) {
304
+ return toolError(`no run "${run}" — pass a runId from start_om, or a folder under logs/.`);
305
+ }
306
+ const name = live?.name ?? path.basename(path.resolve(folder, "../.."));
307
+ const verdict = live?.verdict ? await readVerdict(live.verdict) : await readVerdictAt(folder);
308
+ // Links, not inlined content: `raw.jsonl` on a long run is megabytes, and a model
309
+ // should read only what it turns out to need.
310
+ const structuredContent = { status: "settled", name, ...verdict };
311
+ return {
312
+ content: [
313
+ { type: "text", text: JSON.stringify(structuredContent, null, 2) },
314
+ ...runFileLinks(ctx.root, folder),
315
+ ],
316
+ structuredContent,
317
+ };
318
+ });
319
+ server.registerTool("cancel_run", {
320
+ title: "Cancel a running om",
321
+ description: "Tears a run down gracefully. It still finishes writing its run folder, so the " +
322
+ "logs and verdict of a cancelled run stay readable afterwards.",
323
+ inputSchema: { runId: z.string() },
324
+ outputSchema: { runId: z.string(), cancelled: z.boolean() },
325
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
326
+ }, async ({ runId }) => {
327
+ const live = ctx.runs.get(runId);
328
+ if (!live)
329
+ return toolError(`no run "${runId}".`);
330
+ // Already settled is not an error — cancelling twice, or cancelling something that
331
+ // finished on its own, is a reasonable thing for a client to do.
332
+ if (live.status === "settled")
333
+ return both({ runId, cancelled: false });
334
+ live.session.cancel();
335
+ return both({ runId, cancelled: true });
336
+ });
337
+ server.registerTool("tail_run", {
338
+ title: "Read a run's log from a cursor",
339
+ description: "Returns log lines after `cursor`, from the live run while it is going and from " +
340
+ "its raw.jsonl once it has settled. Pass the returned nextCursor back to continue. " +
341
+ "`run` is a runId from start_om, or a run folder path under logs/.",
342
+ inputSchema: {
343
+ run: z.string(),
344
+ cursor: z.number().int().nonnegative().optional().describe("0, or the last nextCursor."),
345
+ limit: z.number().int().positive().max(1000).optional().describe("Default 200."),
346
+ },
347
+ outputSchema: {
348
+ entries: z.array(z.object({
349
+ sequence: z.number(),
350
+ ts: z.number(),
351
+ path: z.string(),
352
+ level: z.string(),
353
+ source: z.string(),
354
+ message: z.string(),
355
+ })),
356
+ nextCursor: z.number(),
357
+ dropped: z.number(),
358
+ status: z.enum(["running", "settled"]),
359
+ },
360
+ annotations: { readOnlyHint: true, openWorldHint: false },
361
+ }, async ({ run, cursor = 0, limit = 200 }) => {
362
+ const live = ctx.runs.get(run);
363
+ if (live?.status === "running") {
364
+ const tail = tailLive(live, cursor, limit);
365
+ return both({
366
+ ...tail,
367
+ entries: tail.entries.map(publicEntry),
368
+ status: "running",
369
+ });
370
+ }
371
+ const folder = live?.verdict?.folder ?? resolveRunFolder(ctx.root, run);
372
+ if (!folder) {
373
+ return toolError(`no run "${run}" — pass a runId from start_om, or a folder under logs/.`);
374
+ }
375
+ const tail = tailFile(folder, cursor, limit);
376
+ return both({ ...tail, entries: tail.entries.map(publicEntry), status: "settled" });
377
+ });
378
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A shallow check of a tool call's `args` against the declared JSON Schema: required
3
+ * fields present, top-level primitives the right type. Deliberately not a validator.
4
+ *
5
+ * The real schema is a zod object living in the om's own process; the server holds only
6
+ * its JSON Schema projection. `resolveArgs` is what actually validates, fills defaults,
7
+ * and — when something is still missing — prompts, which reaches the client as an
8
+ * elicitation. This check exists so an obviously-wrong call fails immediately with a
9
+ * message naming every problem, instead of spending a fork to find out.
10
+ */
11
+ type JsonSchemaLike = Record<string, unknown> | undefined;
12
+ /** Every problem found, in schema order. Empty means "worth spawning". */
13
+ export declare function checkArgs(schema: JsonSchemaLike, args: unknown): string[];
14
+ export {};
@@ -0,0 +1,54 @@
1
+ const typeName = (value) => Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
2
+ /** True when `value` satisfies a JSON Schema `type` keyword. Unknown types always pass. */
3
+ function matches(type, value) {
4
+ switch (type) {
5
+ case "string":
6
+ return typeof value === "string";
7
+ case "number":
8
+ return typeof value === "number";
9
+ case "integer":
10
+ return typeof value === "number" && Number.isInteger(value);
11
+ case "boolean":
12
+ return typeof value === "boolean";
13
+ case "array":
14
+ return Array.isArray(value);
15
+ case "object":
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ default:
18
+ return true;
19
+ }
20
+ }
21
+ /** Required fields the caller left out. `null` counts as supplied — and wrongly typed. */
22
+ function missingRequired(schema, args) {
23
+ const required = Array.isArray(schema.required) ? schema.required : [];
24
+ return required
25
+ .filter((key) => args[key] === undefined)
26
+ .map((key) => `missing required argument "${key}"`);
27
+ }
28
+ /** Supplied fields whose value contradicts the schema's `type` keyword. */
29
+ function wrongTypes(schema, args) {
30
+ const properties = (schema.properties ?? {});
31
+ const problems = [];
32
+ for (const [key, definition] of Object.entries(properties)) {
33
+ const value = args[key];
34
+ if (value === undefined)
35
+ continue;
36
+ const type = definition?.type;
37
+ if (typeof type !== "string")
38
+ continue; // unions, $ref, anyOf — the child's problem
39
+ if (!matches(type, value)) {
40
+ problems.push(`argument "${key}" must be a ${type}, got ${typeName(value)}`);
41
+ }
42
+ }
43
+ return problems;
44
+ }
45
+ /** Every problem found, in schema order. Empty means "worth spawning". */
46
+ export function checkArgs(schema, args) {
47
+ if (!schema)
48
+ return [];
49
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
50
+ return ["arguments must be a JSON object"];
51
+ }
52
+ const supplied = args;
53
+ return [...missingRequired(schema, supplied), ...wrongTypes(schema, supplied)];
54
+ }
@@ -0,0 +1,31 @@
1
+ import type { Verdict } from "../client/types.ts";
2
+ /**
3
+ * The structured outcome of a settled run — the verdict a client can act on.
4
+ *
5
+ * A `type`, not an `interface`, on purpose: the SDK types `structuredContent` as
6
+ * `{ [x: string]: unknown }`, and TypeScript gives an implicit index signature to type
7
+ * aliases but not to interfaces. As an interface this is not assignable and the tool
8
+ * cannot return it.
9
+ */
10
+ export type RunVerdict = {
11
+ readonly ok: boolean;
12
+ /** Absolute path of the run folder, or "" when the child died before settling. */
13
+ readonly folder: string;
14
+ readonly assertions: {
15
+ readonly passed: number;
16
+ readonly failed: number;
17
+ };
18
+ readonly failures: ReadonlyArray<{
19
+ action: string;
20
+ error: string;
21
+ }>;
22
+ /** The recap block the terminal would have printed. */
23
+ readonly summary: readonly string[];
24
+ };
25
+ export declare function readVerdict(verdict: Verdict): Promise<RunVerdict>;
26
+ /**
27
+ * The verdict of a run this process never supervised — read entirely from disk. There is
28
+ * no channel to trust here, so `ok` comes from the file; a folder without a readable
29
+ * `result.json` is reported as not ok, because a run that never wrote one did not finish.
30
+ */
31
+ export declare function readVerdictAt(folder: string): Promise<RunVerdict>;
@@ -0,0 +1,47 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /** The run's own `result.json`, or undefined when there isn't a readable one. */
4
+ async function readResult(folder) {
5
+ try {
6
+ return JSON.parse(await readFile(path.join(folder, "result.json"), "utf8"));
7
+ }
8
+ catch {
9
+ return undefined;
10
+ }
11
+ }
12
+ export async function readVerdict(verdict) {
13
+ const base = {
14
+ // The channel's `ok` wins over the file's: it also covers a child that died before
15
+ // writing one, which `result.json` by definition cannot report.
16
+ ok: verdict.ok,
17
+ folder: verdict.folder,
18
+ summary: verdict.summary,
19
+ assertions: { passed: 0, failed: 0 },
20
+ failures: [],
21
+ };
22
+ if (!verdict.folder)
23
+ return base;
24
+ const parsed = await readResult(verdict.folder);
25
+ if (!parsed)
26
+ return base;
27
+ return {
28
+ ...base,
29
+ assertions: parsed.assertions ?? base.assertions,
30
+ failures: parsed.failures ?? base.failures,
31
+ };
32
+ }
33
+ /**
34
+ * The verdict of a run this process never supervised — read entirely from disk. There is
35
+ * no channel to trust here, so `ok` comes from the file; a folder without a readable
36
+ * `result.json` is reported as not ok, because a run that never wrote one did not finish.
37
+ */
38
+ export async function readVerdictAt(folder) {
39
+ const parsed = await readResult(folder);
40
+ return {
41
+ ok: parsed?.ok ?? false,
42
+ folder,
43
+ summary: [],
44
+ assertions: parsed?.assertions ?? { passed: 0, failed: 0 },
45
+ failures: parsed?.failures ?? [],
46
+ };
47
+ }
@@ -0,0 +1,24 @@
1
+ /** One curated artifact — a file the om chose to label. */
2
+ export interface ArtifactRecord {
3
+ readonly name: string;
4
+ /** Absolute path to the file. */
5
+ readonly file: string;
6
+ readonly description?: string;
7
+ readonly mime: string;
8
+ /** The node that registered it. */
9
+ readonly nodeId: string;
10
+ }
11
+ /**
12
+ * The run's curated artifacts — the files an om labelled via `ctx.artifact`, in
13
+ * registration order. Holds no I/O: the om already wrote the file, this records what it
14
+ * means. Unregistered files stay in the run folder and stay listed by anything
15
+ * enumerating it; curation raises signal, it does not gate access.
16
+ */
17
+ export declare class ArtifactStore {
18
+ private readonly records;
19
+ register(name: string, file: string, nodeId: string, opts?: {
20
+ description?: string;
21
+ mime?: string;
22
+ }): ArtifactRecord;
23
+ all(): readonly ArtifactRecord[];
24
+ }
@@ -0,0 +1,49 @@
1
+ import path from "node:path";
2
+ const MIME_BY_EXT = {
3
+ ".json": "application/json",
4
+ ".jsonl": "application/x-ndjson",
5
+ ".log": "text/plain",
6
+ ".txt": "text/plain",
7
+ ".md": "text/markdown",
8
+ ".html": "text/html",
9
+ ".csv": "text/csv",
10
+ ".png": "image/png",
11
+ ".jpg": "image/jpeg",
12
+ ".jpeg": "image/jpeg",
13
+ ".gif": "image/gif",
14
+ ".webp": "image/webp",
15
+ ".svg": "image/svg+xml",
16
+ ".pdf": "application/pdf",
17
+ ".zip": "application/zip",
18
+ };
19
+ /**
20
+ * The run's curated artifacts — the files an om labelled via `ctx.artifact`, in
21
+ * registration order. Holds no I/O: the om already wrote the file, this records what it
22
+ * means. Unregistered files stay in the run folder and stay listed by anything
23
+ * enumerating it; curation raises signal, it does not gate access.
24
+ */
25
+ export class ArtifactStore {
26
+ records = [];
27
+ register(name, file, nodeId, opts = {}) {
28
+ // `file` is documented absolute; a caller that passes a relative path still gets
29
+ // one back — resolved against process.cwd(), same base `path.resolve` uses by
30
+ // default — so every consumer (the return value, the timeline message, the
31
+ // on-disk rollups) carries a path that means the same thing without them.
32
+ const resolved = path.resolve(file);
33
+ const record = {
34
+ name,
35
+ file: resolved,
36
+ nodeId,
37
+ mime: opts.mime ?? mimeOf(resolved),
38
+ ...(opts.description === undefined ? {} : { description: opts.description }),
39
+ };
40
+ this.records.push(record);
41
+ return record;
42
+ }
43
+ all() {
44
+ return this.records;
45
+ }
46
+ }
47
+ function mimeOf(file) {
48
+ return MIME_BY_EXT[path.extname(file).toLowerCase()] ?? "application/octet-stream";
49
+ }
@@ -12,10 +12,32 @@ export declare class RunFolder {
12
12
  constructor(runName: string, hash: string);
13
13
  /** `<name>-<hash8>` — the run's folder name, unique per (defining file, name). */
14
14
  name(): string;
15
- /** The absolute run directory, computed once and created on first access. */
15
+ /**
16
+ * The absolute run directory — claimed on first access, stable thereafter.
17
+ *
18
+ * Claiming and knowing the path are the same act: the directory is what makes a run's
19
+ * identity exclusive, so there is no meaningful "what would the path be" to answer
20
+ * before it exists.
21
+ */
16
22
  path(): string;
17
23
  /** Absolute path to `name` inside the run folder; ensures the folder exists. */
18
24
  file(name: string): string;
19
25
  /** Create the run folder synchronously (idempotent). */
20
26
  ensure(): void;
27
+ /**
28
+ * Take exclusive ownership of a `<date>/<time>` directory.
29
+ *
30
+ * `hms` has second resolution, so two runs of the same om starting within one second
31
+ * would otherwise share a folder — and that is not merely "the later one wins":
32
+ * `RawStream` opens `raw.jsonl` with `flags: "w"`, so two live runs both truncate it and
33
+ * then write at their own offsets, leaving bytes that are not valid JSONL. The MCP
34
+ * server makes this easy to reach, since `start_om` can put two children in flight.
35
+ *
36
+ * Finer timestamps would only lower the odds. `mkdirSync` without `recursive` throws
37
+ * `EEXIST` **atomically**, and that atomicity holds between processes — so the directory
38
+ * creation is itself the lock. The common case keeps a clean `16-39-29`; only a real
39
+ * collision produces `16-39-29-02`, zero-padded so lexical order stays chronological
40
+ * (which is what `latest` relies on).
41
+ */
42
+ private claim;
21
43
  }