omkit 0.4.7 → 0.8.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +170 -13
  3. package/dist/actions/browser.d.ts +1 -1
  4. package/dist/actions/browser.js +1 -1
  5. package/dist/actions/chrome-page.d.ts +46 -4
  6. package/dist/actions/chrome-page.js +81 -33
  7. package/dist/actions/drive-page.d.ts +50 -0
  8. package/dist/actions/drive-page.js +68 -0
  9. package/dist/actions/index.d.ts +7 -3
  10. package/dist/actions/index.js +3 -1
  11. package/dist/actions/port-free.d.ts +29 -0
  12. package/dist/actions/port-free.js +78 -0
  13. package/dist/actions/prompt.d.ts +32 -5
  14. package/dist/actions/prompt.js +92 -8
  15. package/dist/cli/commands/help.js +6 -0
  16. package/dist/cli/commands/init.js +1 -1
  17. package/dist/cli/commands/ls.d.ts +11 -4
  18. package/dist/cli/commands/ls.js +35 -15
  19. package/dist/cli/commands/mcp.d.ts +10 -0
  20. package/dist/cli/commands/mcp.js +13 -0
  21. package/dist/cli/commands/skill.d.ts +17 -0
  22. package/dist/cli/commands/skill.js +56 -0
  23. package/dist/cli/index.d.ts +8 -0
  24. package/dist/cli/index.js +71 -29
  25. package/dist/client/discover-child.d.ts +12 -0
  26. package/dist/client/discover-child.js +71 -0
  27. package/dist/client/discovery.d.ts +21 -1
  28. package/dist/client/discovery.js +139 -2
  29. package/dist/client/index.d.ts +2 -1
  30. package/dist/client/index.js +3 -1
  31. package/dist/client/outline.d.ts +35 -0
  32. package/dist/client/outline.js +100 -0
  33. package/dist/client/registry.d.ts +59 -0
  34. package/dist/client/runner.d.ts +12 -0
  35. package/dist/client/runner.js +16 -7
  36. package/dist/client/types.d.ts +9 -1
  37. package/dist/core/ActionRun.d.ts +5 -0
  38. package/dist/core/ActionRun.js +9 -0
  39. package/dist/core/ExecutionTree.d.ts +40 -2
  40. package/dist/core/ExecutionTree.js +66 -2
  41. package/dist/core/action.d.ts +2 -1
  42. package/dist/core/action.js +93 -9
  43. package/dist/core/args.d.ts +50 -0
  44. package/dist/core/args.js +166 -0
  45. package/dist/core/discovery-mode.d.ts +37 -0
  46. package/dist/core/discovery-mode.js +54 -0
  47. package/dist/core/interaction.d.ts +3 -1
  48. package/dist/core/om.d.ts +11 -9
  49. package/dist/core/om.js +182 -13
  50. package/dist/core/schema-hint.d.ts +20 -0
  51. package/dist/core/schema-hint.js +73 -0
  52. package/dist/core/schema-json.d.ts +12 -0
  53. package/dist/core/schema-json.js +12 -0
  54. package/dist/core/types.d.ts +113 -1
  55. package/dist/foundation/LogEntry.d.ts +1 -1
  56. package/dist/foundation/canonicalPath.d.ts +26 -0
  57. package/dist/foundation/canonicalPath.js +34 -0
  58. package/dist/index.d.ts +2 -2
  59. package/dist/index.js +1 -1
  60. package/dist/mcp/action-host.d.ts +1 -0
  61. package/dist/mcp/action-host.js +65 -0
  62. package/dist/mcp/action-identity.d.ts +23 -0
  63. package/dist/mcp/action-identity.js +35 -0
  64. package/dist/mcp/progress.d.ts +61 -0
  65. package/dist/mcp/progress.js +92 -0
  66. package/dist/mcp/resources.d.ts +55 -0
  67. package/dist/mcp/resources.js +208 -0
  68. package/dist/mcp/runs.d.ts +37 -0
  69. package/dist/mcp/runs.js +60 -0
  70. package/dist/mcp/server.d.ts +19 -0
  71. package/dist/mcp/server.js +38 -0
  72. package/dist/mcp/tail.d.ts +37 -0
  73. package/dist/mcp/tail.js +64 -0
  74. package/dist/mcp/tools.d.ts +54 -0
  75. package/dist/mcp/tools.js +374 -0
  76. package/dist/mcp/validate.d.ts +14 -0
  77. package/dist/mcp/validate.js +54 -0
  78. package/dist/mcp/verdict.d.ts +31 -0
  79. package/dist/mcp/verdict.js +47 -0
  80. package/dist/output/artifact/ArtifactStore.d.ts +24 -0
  81. package/dist/output/artifact/ArtifactStore.js +49 -0
  82. package/dist/output/folder/RunFolder.d.ts +23 -1
  83. package/dist/output/folder/RunFolder.js +55 -11
  84. package/dist/output/writers/views.d.ts +10 -0
  85. package/dist/skill/file.d.ts +26 -0
  86. package/dist/skill/file.js +58 -0
  87. package/dist/skill/index.d.ts +11 -0
  88. package/dist/skill/index.js +9 -0
  89. package/dist/skill/model.d.ts +55 -0
  90. package/dist/skill/model.js +116 -0
  91. package/dist/skill/render.d.ts +18 -0
  92. package/dist/skill/render.js +133 -0
  93. package/package.json +5 -2
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const page = (entries, cursor, dropped) => ({
4
+ entries,
5
+ nextCursor: entries.length ? entries[entries.length - 1].sequence : cursor,
6
+ dropped,
7
+ });
8
+ /**
9
+ * A page from a live run's buffer. `sequence` is the run-global monotonic key the log
10
+ * store stamps, so it doubles as the cursor with nothing extra to track.
11
+ */
12
+ export function tailLive(run, cursor, limit) {
13
+ const after = run.entries.filter((e) => e.sequence > cursor).slice(0, limit);
14
+ return page(after, cursor, run.dropped);
15
+ }
16
+ /**
17
+ * A page from a settled run's `raw.jsonl` — the complete record, which the bounded live
18
+ * buffer is not. Read whole and filtered: run logs are bounded by the run, and a streaming
19
+ * reader would buy little for a lot of machinery.
20
+ *
21
+ * An unparseable line is skipped rather than fatal: `raw.jsonl` is appended to during the
22
+ * run, so a tail can catch a line mid-write.
23
+ */
24
+ export function tailFile(folder, cursor, limit) {
25
+ let raw;
26
+ try {
27
+ raw = fs.readFileSync(path.join(folder, "raw.jsonl"), "utf8");
28
+ }
29
+ catch {
30
+ return { entries: [], nextCursor: cursor, dropped: 0 };
31
+ }
32
+ const entries = [];
33
+ for (const line of raw.split("\n")) {
34
+ if (!line.trim())
35
+ continue;
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(line);
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ if (typeof parsed.sequence !== "number" || parsed.sequence <= cursor)
44
+ continue;
45
+ entries.push(parsed);
46
+ if (entries.length >= limit)
47
+ break;
48
+ }
49
+ return page(entries, cursor, 0);
50
+ }
51
+ /**
52
+ * Trim an entry to what a client can act on. `nodeId` is an internal attribution key and
53
+ * says nothing a reader can use — `path` already names the node readably.
54
+ */
55
+ export function publicEntry(e) {
56
+ return {
57
+ sequence: e.sequence,
58
+ ts: e.ts,
59
+ path: e.path,
60
+ level: e.level,
61
+ source: e.source,
62
+ message: e.message,
63
+ };
64
+ }
@@ -0,0 +1,54 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { RunRegistry } from "./runs.ts";
3
+ import type { OmkitClient } from "../client/index.ts";
4
+ import type { RegistrationSet } from "../client/registry.ts";
5
+ /** Everything the tools need. Passed in so the server stays assembly and these stay logic. */
6
+ export interface ToolContext {
7
+ readonly client: OmkitClient;
8
+ /** Project root — run folders resolve under `<root>/logs`. */
9
+ readonly root: string;
10
+ /** Every run this server started, live or settled. */
11
+ readonly runs: RunRegistry;
12
+ }
13
+ /**
14
+ * One entry in `list_oms`: an om or an exposed action, flattened into a single list so a
15
+ * client has one place to look for "what can I call". `kind` says which it is, because an
16
+ * action's caveats differ — see the action-host notes in the spec.
17
+ */
18
+ export interface ToolEntry {
19
+ readonly kind: "om" | "action";
20
+ readonly name: string;
21
+ readonly mode: "settling" | "long-lived";
22
+ readonly file: string;
23
+ readonly summary?: string;
24
+ /** `<name>-<hash8>` — where this entry's runs land under `logs/`. Oms only. */
25
+ readonly folderName?: string;
26
+ /** The exported binding an action-backed tool is invoked through. Actions only. */
27
+ readonly exportName?: string;
28
+ readonly inputSchema?: Record<string, unknown>;
29
+ /** Set when the entry cannot be called, with the reason. */
30
+ readonly unavailable?: string;
31
+ }
32
+ /** Flatten a registration set into the entries a client may call, dropping the rest. */
33
+ export declare function toEntries(set: RegistrationSet): ToolEntry[];
34
+ /**
35
+ * Every tool result carries a text block as well as `structuredContent`: a client that
36
+ * ignores structured output still sees the answer. Return types are inferred so they stay
37
+ * assignable to the SDK's `CallToolResult` union — annotating them as tuples does not.
38
+ */
39
+ export declare function both<T>(structuredContent: T): {
40
+ content: {
41
+ type: "text";
42
+ text: string;
43
+ }[];
44
+ structuredContent: T;
45
+ };
46
+ /** A tool failure, in MCP's shape: a message the model can act on, not a thrown stack. */
47
+ export declare function toolError(message: string): {
48
+ content: {
49
+ type: "text";
50
+ text: string;
51
+ }[];
52
+ isError: true;
53
+ };
54
+ export declare function registerTools(server: McpServer, ctx: ToolContext): void;
@@ -0,0 +1,374 @@
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
+ * cwd is the project **root**, not the om file's directory. `omkit run` uses the file's
162
+ * directory, but resources here resolve under `<root>/logs` — a run launched elsewhere
163
+ * writes its folder somewhere this same server cannot serve, so `get_run`'s resource
164
+ * links would point at nothing.
165
+ */
166
+ const launch = (entry, args) => {
167
+ // An action cannot run standalone, so an action-backed tool forks omkit's shipped host
168
+ // om instead of the action's own file — which defines no om at all.
169
+ const isAction = entry.kind === "action";
170
+ const file = isAction ? hostFile() : entry.file;
171
+ const env = isAction
172
+ ? {
173
+ OMKIT_ACTION_FILE: entry.file,
174
+ OMKIT_ACTION_NAME: entry.name,
175
+ OMKIT_ACTION_EXPORT: entry.exportName,
176
+ OMKIT_ACTION_ARGS: JSON.stringify(args),
177
+ }
178
+ : { OMKIT_ARGS: JSON.stringify(args) };
179
+ const folderName = isAction
180
+ ? hostFolderName(entry.name, entry.file)
181
+ : (entry.folderName ?? entry.name);
182
+ return ctx.runs.start(entry.name, folderName, ctx.client.run(file, { cwd: ctx.root, env }));
183
+ };
184
+ server.registerTool("run_om", {
185
+ title: "Run an om and wait for its verdict",
186
+ description: "Runs a settling om (or an exposed action) to completion and returns whether it " +
187
+ "passed, where its run folder is, its assert tally, and any failures. Call " +
188
+ "list_oms first to learn what `args` to pass. A long-lived entry is refused — use " +
189
+ "start_om for those, and for anything that takes more than about a minute.",
190
+ inputSchema: {
191
+ name: z.string().describe("The om or action name, as reported by list_oms."),
192
+ args: z
193
+ .record(z.string(), z.unknown())
194
+ .optional()
195
+ .describe("Arguments matching the entry's inputSchema from list_oms."),
196
+ settleTimeoutMs: z
197
+ .number()
198
+ .int()
199
+ .positive()
200
+ .optional()
201
+ .describe("How long to wait for the run to settle before giving up. Default 10 minutes."),
202
+ },
203
+ outputSchema: {
204
+ ok: z.boolean(),
205
+ folder: z.string(),
206
+ assertions: z.object({ passed: z.number(), failed: z.number() }),
207
+ failures: z.array(z.object({ action: z.string(), error: z.string() })),
208
+ summary: z.array(z.string()),
209
+ },
210
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
211
+ }, async ({ name, args, settleTimeoutMs }, extra) => {
212
+ const entry = await findEntry(name);
213
+ const refusal = notRunnable(name, entry);
214
+ if (refusal || !entry)
215
+ return toolError(refusal);
216
+ if (entry.mode === "long-lived") {
217
+ return toolError(`"${name}" is long-lived — start it with start_om, not run_om.`);
218
+ }
219
+ const problems = checkArgs(entry.inputSchema, args ?? {});
220
+ if (problems.length)
221
+ return toolError(problems.join("\n"));
222
+ const run = launch(entry, args ?? {});
223
+ // This request lives for the whole run, so it can carry progress and it owns the
224
+ // run's cancellation: `extra.signal` aborting means the client withdrew the call.
225
+ attachProgress(run.session, extra);
226
+ attachElicitation(server, run.session);
227
+ const timeout = settleTimeoutMs ?? SETTLE_TIMEOUT_MS;
228
+ const settled = await settleWithin(run.session, timeout);
229
+ if (!settled) {
230
+ return toolError(`"${name}" is declared settling but did not settle within ${timeout}ms, so it ` +
231
+ `was cancelled. If it is meant to stay up, declare ` +
232
+ `.mcp({ mode: "long-lived" }) and start it with start_om.`);
233
+ }
234
+ return both(await readVerdict(settled));
235
+ });
236
+ server.registerTool("start_om", {
237
+ title: "Start an om without waiting for it",
238
+ description: "Starts an om or exposed action and returns a handle straight away. Use this for a " +
239
+ "long-lived entry (a server, a watcher), or for a long suite you want to check on " +
240
+ "later. Follow up with get_run, tail_run and cancel_run.",
241
+ inputSchema: {
242
+ name: z.string().describe("The om or action name, as reported by list_oms."),
243
+ args: z.record(z.string(), z.unknown()).optional(),
244
+ },
245
+ outputSchema: {
246
+ runId: z.string(),
247
+ name: z.string(),
248
+ // The dated run folder does not exist yet — this is the folder *family*.
249
+ folderName: z.string(),
250
+ mode: z.enum(["settling", "long-lived"]),
251
+ },
252
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
253
+ }, async ({ name, args }) => {
254
+ const entry = await findEntry(name);
255
+ const refusal = notRunnable(name, entry);
256
+ if (refusal || !entry)
257
+ return toolError(refusal);
258
+ const problems = checkArgs(entry.inputSchema, args ?? {});
259
+ if (problems.length)
260
+ return toolError(problems.join("\n"));
261
+ const run = launch(entry, args ?? {});
262
+ // Elicitation only — deliberately no `attachProgress`. This request ends as soon as
263
+ // the handle is returned, so its `extra.signal` aborts immediately, and wiring that
264
+ // to `session.cancel()` would kill the run this tool exists to keep alive. A client
265
+ // that wants progress from a started run polls `tail_run`.
266
+ attachElicitation(server, run.session);
267
+ return both({
268
+ runId: run.id,
269
+ name: entry.name,
270
+ folderName: run.folderName,
271
+ mode: entry.mode,
272
+ });
273
+ });
274
+ server.registerTool("get_run", {
275
+ title: "Check on a run",
276
+ description: "Reports whether a run is still going and, once it has settled, its verdict and " +
277
+ "links to the files it produced. `run` is a runId from start_om, or a run folder " +
278
+ "path under logs/.",
279
+ inputSchema: { run: z.string() },
280
+ outputSchema: {
281
+ status: z.enum(["running", "settled"]),
282
+ name: z.string(),
283
+ folder: z.string(),
284
+ ok: z.boolean().optional(),
285
+ assertions: z.object({ passed: z.number(), failed: z.number() }).optional(),
286
+ failures: z.array(z.object({ action: z.string(), error: z.string() })).optional(),
287
+ summary: z.array(z.string()).optional(),
288
+ },
289
+ annotations: { readOnlyHint: true, openWorldHint: false },
290
+ }, async ({ run }) => {
291
+ const live = ctx.runs.get(run);
292
+ if (live?.status === "running") {
293
+ // The dated folder is stamped inside the child on first access, so there is
294
+ // genuinely nothing to report here yet — not an empty string standing in for one.
295
+ return both({ status: "running", name: live.name, folder: "" });
296
+ }
297
+ // A run this server started, or a folder already on disk — confined either way.
298
+ const folder = live?.verdict?.folder ?? resolveRunFolder(ctx.root, run);
299
+ if (!folder) {
300
+ return toolError(`no run "${run}" — pass a runId from start_om, or a folder under logs/.`);
301
+ }
302
+ const name = live?.name ?? path.basename(path.resolve(folder, "../.."));
303
+ const verdict = live?.verdict ? await readVerdict(live.verdict) : await readVerdictAt(folder);
304
+ // Links, not inlined content: `raw.jsonl` on a long run is megabytes, and a model
305
+ // should read only what it turns out to need.
306
+ const structuredContent = { status: "settled", name, ...verdict };
307
+ return {
308
+ content: [
309
+ { type: "text", text: JSON.stringify(structuredContent, null, 2) },
310
+ ...runFileLinks(ctx.root, folder),
311
+ ],
312
+ structuredContent,
313
+ };
314
+ });
315
+ server.registerTool("cancel_run", {
316
+ title: "Cancel a running om",
317
+ description: "Tears a run down gracefully. It still finishes writing its run folder, so the " +
318
+ "logs and verdict of a cancelled run stay readable afterwards.",
319
+ inputSchema: { runId: z.string() },
320
+ outputSchema: { runId: z.string(), cancelled: z.boolean() },
321
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
322
+ }, async ({ runId }) => {
323
+ const live = ctx.runs.get(runId);
324
+ if (!live)
325
+ return toolError(`no run "${runId}".`);
326
+ // Already settled is not an error — cancelling twice, or cancelling something that
327
+ // finished on its own, is a reasonable thing for a client to do.
328
+ if (live.status === "settled")
329
+ return both({ runId, cancelled: false });
330
+ live.session.cancel();
331
+ return both({ runId, cancelled: true });
332
+ });
333
+ server.registerTool("tail_run", {
334
+ title: "Read a run's log from a cursor",
335
+ description: "Returns log lines after `cursor`, from the live run while it is going and from " +
336
+ "its raw.jsonl once it has settled. Pass the returned nextCursor back to continue. " +
337
+ "`run` is a runId from start_om, or a run folder path under logs/.",
338
+ inputSchema: {
339
+ run: z.string(),
340
+ cursor: z.number().int().nonnegative().optional().describe("0, or the last nextCursor."),
341
+ limit: z.number().int().positive().max(1000).optional().describe("Default 200."),
342
+ },
343
+ outputSchema: {
344
+ entries: z.array(z.object({
345
+ sequence: z.number(),
346
+ ts: z.number(),
347
+ path: z.string(),
348
+ level: z.string(),
349
+ source: z.string(),
350
+ message: z.string(),
351
+ })),
352
+ nextCursor: z.number(),
353
+ dropped: z.number(),
354
+ status: z.enum(["running", "settled"]),
355
+ },
356
+ annotations: { readOnlyHint: true, openWorldHint: false },
357
+ }, async ({ run, cursor = 0, limit = 200 }) => {
358
+ const live = ctx.runs.get(run);
359
+ if (live?.status === "running") {
360
+ const tail = tailLive(live, cursor, limit);
361
+ return both({
362
+ ...tail,
363
+ entries: tail.entries.map(publicEntry),
364
+ status: "running",
365
+ });
366
+ }
367
+ const folder = live?.verdict?.folder ?? resolveRunFolder(ctx.root, run);
368
+ if (!folder) {
369
+ return toolError(`no run "${run}" — pass a runId from start_om, or a folder under logs/.`);
370
+ }
371
+ const tail = tailFile(folder, cursor, limit);
372
+ return both({ ...tail, entries: tail.entries.map(publicEntry), status: "settled" });
373
+ });
374
+ }
@@ -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
+ }