omkit 0.8.6 → 0.8.8
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/README.md +2 -2
- package/dist/actions/drive-page.d.ts +8 -0
- package/dist/actions/drive-page.js +15 -3
- package/dist/cli/ui/app.js +5 -5
- package/dist/cli/ui/redrawLoop.d.ts +16 -0
- package/dist/cli/ui/redrawLoop.js +40 -0
- package/dist/client/channel.js +11 -3
- package/dist/client/types.d.ts +12 -0
- package/dist/core/ExecutionTree.js +22 -0
- package/dist/core/action.js +19 -5
- package/dist/core/args.d.ts +2 -0
- package/dist/core/args.js +5 -4
- package/dist/core/interaction.d.ts +30 -5
- package/dist/core/interaction.js +42 -4
- package/dist/core/om.js +1 -1
- package/dist/core/types.d.ts +13 -4
- package/dist/mcp/action-host.js +9 -1
- package/dist/mcp/runs.d.ts +8 -1
- package/dist/mcp/runs.js +8 -0
- package/dist/mcp/tools.js +106 -32
- package/dist/mcp/verdict.d.ts +2 -0
- package/dist/mcp/verdict.js +2 -0
- package/dist/output/writers/views.d.ts +5 -0
- package/dist/skill/render.js +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
|
|
|
87
87
|
const activity = build(); // calling launches; returns the live Activity
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
Chain `.describe({ summary })` before `.run(...)` to attach a human-readable summary — stored on the definition, for `omkit ls` and future tooling to read; nothing reads it yet, so today it is documentation that travels with the code. Same shape as `om`'s, below. `.args(schema)` pins the type of `.run`'s second parameter to a zod schema's inferred type.
|
|
90
|
+
Chain `.describe({ summary })` before `.run(...)` to attach a human-readable summary — stored on the definition, for `omkit ls` and future tooling to read; nothing reads it yet, so today it is documentation that travels with the code. Same shape as `om`'s, below. `.args(schema)` pins the type of `.run`'s second parameter to a zod schema's inferred type. In code it's **type-level only**: launching neither resolves, prompts for, nor validates the value — an action launched from an om body is passed its arguments directly in code, so the schema types that call site rather than gating it. The exception is an action run as an MCP tool: the JSON an assistant sends is resolved through this same schema before the body runs, so `.default()`s are filled and bad values are refused — the default `list_oms` advertises is the one the body gets. (Prompting for arguments stays an `om`-level concern.)
|
|
91
91
|
|
|
92
92
|
```ts
|
|
93
93
|
import { z } from "zod";
|
|
@@ -96,7 +96,7 @@ const seed = action("seed")
|
|
|
96
96
|
.describe({ summary: "Seeds the database" })
|
|
97
97
|
.args(z.object({ rows: z.number() }))
|
|
98
98
|
.run(async (_ctx, { rows }) => {
|
|
99
|
-
/* rows: number — typed
|
|
99
|
+
/* rows: number — typed; validated only when run as an MCP tool */
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
seed({ rows: 500 }); // calling launches it; the shape is pinned, not checked at runtime
|
|
@@ -48,3 +48,11 @@ export interface DrivePageResult {
|
|
|
48
48
|
* failing, or being cancelled never tears down the stack it attached to, in either mode.
|
|
49
49
|
*/
|
|
50
50
|
export declare const drivePage: import("../core/types.ts").Action<[opts: DrivePageOptions], DrivePageResult, import("../core/types.ts").NoEvents, void>;
|
|
51
|
+
/**
|
|
52
|
+
* The message for an expression that threw in the page. Leads with what the page said —
|
|
53
|
+
* the first line of the cause, which is the `TypeError: …` a caller can act on — and names
|
|
54
|
+
* the expression only briefly: the caller sent it, so echoing all of it adds nothing, and a
|
|
55
|
+
* page-side stack means little to someone who only handed over a string. The original
|
|
56
|
+
* error stays attached as the cause.
|
|
57
|
+
*/
|
|
58
|
+
export declare function evaluateErrorMessage(js: string, cause: unknown): string;
|
|
@@ -39,6 +39,20 @@ export const drivePage = action("drivePage")
|
|
|
39
39
|
attached.cancel();
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
|
+
/** Longest slice of the expression an error message repeats back. */
|
|
43
|
+
const JS_EXCERPT = 160;
|
|
44
|
+
/**
|
|
45
|
+
* The message for an expression that threw in the page. Leads with what the page said —
|
|
46
|
+
* the first line of the cause, which is the `TypeError: …` a caller can act on — and names
|
|
47
|
+
* the expression only briefly: the caller sent it, so echoing all of it adds nothing, and a
|
|
48
|
+
* page-side stack means little to someone who only handed over a string. The original
|
|
49
|
+
* error stays attached as the cause.
|
|
50
|
+
*/
|
|
51
|
+
export function evaluateErrorMessage(js, cause) {
|
|
52
|
+
const said = (cause instanceof Error ? cause.message : String(cause)).split("\n")[0].trim();
|
|
53
|
+
const excerpt = js.length > JS_EXCERPT ? `${js.slice(0, JS_EXCERPT)}…` : js;
|
|
54
|
+
return `the expression threw in the page: ${said} · expression: ${excerpt}`;
|
|
55
|
+
}
|
|
42
56
|
/** The work itself, split out so the daemon handle above has one obvious release point. */
|
|
43
57
|
async function evaluateIn(page, opts, { snapshot, artifact, artifactsFolder }) {
|
|
44
58
|
const { js, screenshot = true } = opts;
|
|
@@ -52,9 +66,7 @@ async function evaluateIn(page, opts, { snapshot, artifact, artifactsFolder }) {
|
|
|
52
66
|
value = await page.evaluate(js);
|
|
53
67
|
}
|
|
54
68
|
catch (cause) {
|
|
55
|
-
|
|
56
|
-
// expression that failed and keep the original as the cause.
|
|
57
|
-
throw new Error(`the expression threw in the page: ${js}`, { cause });
|
|
69
|
+
throw new Error(evaluateErrorMessage(js, cause), { cause });
|
|
58
70
|
}
|
|
59
71
|
void snapshot("result", { js, value });
|
|
60
72
|
console.log(`result: ${JSON.stringify(value) ?? "undefined"}`);
|
package/dist/cli/ui/app.js
CHANGED
|
@@ -5,6 +5,7 @@ import { RunModel } from "../../output/RunModel.js";
|
|
|
5
5
|
import { OmList } from "./views/OmList.js";
|
|
6
6
|
import { RunView } from "./views/RunView.js";
|
|
7
7
|
import { Spinner } from "./Spinner.js";
|
|
8
|
+
import { redrawLoop } from "./redrawLoop.js";
|
|
8
9
|
/**
|
|
9
10
|
* How often the run screen redraws while the log streams in.
|
|
10
11
|
*
|
|
@@ -13,7 +14,8 @@ import { Spinner } from "./Spinner.js";
|
|
|
13
14
|
* dev server emits. The app then falls permanently behind: `settled` is the *last* message on
|
|
14
15
|
* the channel, so it only arrives once every backlogged entry has been drawn, and the run looks
|
|
15
16
|
* hung long after the child has finished. Folding into the model is cheap; drawing is not, so
|
|
16
|
-
* the two run at different rates
|
|
17
|
+
* the two run at different rates — and the gap is kept *after* each frame (see {@link redrawLoop}),
|
|
18
|
+
* since a frame slower than this would otherwise leave no time between frames for the log at all.
|
|
17
19
|
*/
|
|
18
20
|
const REDRAW_MS = 80;
|
|
19
21
|
/** The interactive app: discover → list/search → run → live milestones + prompts. */
|
|
@@ -34,9 +36,7 @@ export function App({ client, onExit, oms: initialOms, }) {
|
|
|
34
36
|
const tearingRef = useRef(false);
|
|
35
37
|
const redrawTimer = useRef(undefined);
|
|
36
38
|
const stopRedraw = () => {
|
|
37
|
-
|
|
38
|
-
return;
|
|
39
|
-
clearInterval(redrawTimer.current);
|
|
39
|
+
redrawTimer.current?.stop();
|
|
40
40
|
redrawTimer.current = undefined;
|
|
41
41
|
};
|
|
42
42
|
// The redraw timer holds the event loop open, so it must not outlive the app — an unmount
|
|
@@ -69,7 +69,7 @@ export function App({ client, onExit, oms: initialOms, }) {
|
|
|
69
69
|
setLines(model.milestones());
|
|
70
70
|
setStatus(model.summary());
|
|
71
71
|
};
|
|
72
|
-
redrawTimer.current =
|
|
72
|
+
redrawTimer.current = redrawLoop(redraw, REDRAW_MS);
|
|
73
73
|
s.on("log", (entry) => {
|
|
74
74
|
model.apply(entry);
|
|
75
75
|
dirty = true;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call `draw` repeatedly, leaving `everyMs` of free event-loop time **after** each frame
|
|
3
|
+
* finishes — not every `everyMs` from when the last one started, as `setInterval` does.
|
|
4
|
+
*
|
|
5
|
+
* The difference only shows when a frame is slower than the cadence, which a full Ink milestone
|
|
6
|
+
* tail is. A fixed-rate timer is then overdue the moment a frame ends, so the loop alternates one
|
|
7
|
+
* frame with one turn of I/O — and on Windows a turn of fork IPC is a single message. The
|
|
8
|
+
* interactive app then drains its log backlog a frame per entry, and a run that finished minutes
|
|
9
|
+
* ago is still "streaming" on screen, with `settled` stuck behind the backlog.
|
|
10
|
+
*
|
|
11
|
+
* The next frame is armed from a `setImmediate`, so the gap is measured from after anything the
|
|
12
|
+
* frame deferred to microtasks (React's commit, Ink's write) has run too.
|
|
13
|
+
*/
|
|
14
|
+
export declare function redrawLoop(draw: () => void, everyMs: number): {
|
|
15
|
+
stop(): void;
|
|
16
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call `draw` repeatedly, leaving `everyMs` of free event-loop time **after** each frame
|
|
3
|
+
* finishes — not every `everyMs` from when the last one started, as `setInterval` does.
|
|
4
|
+
*
|
|
5
|
+
* The difference only shows when a frame is slower than the cadence, which a full Ink milestone
|
|
6
|
+
* tail is. A fixed-rate timer is then overdue the moment a frame ends, so the loop alternates one
|
|
7
|
+
* frame with one turn of I/O — and on Windows a turn of fork IPC is a single message. The
|
|
8
|
+
* interactive app then drains its log backlog a frame per entry, and a run that finished minutes
|
|
9
|
+
* ago is still "streaming" on screen, with `settled` stuck behind the backlog.
|
|
10
|
+
*
|
|
11
|
+
* The next frame is armed from a `setImmediate`, so the gap is measured from after anything the
|
|
12
|
+
* frame deferred to microtasks (React's commit, Ink's write) has run too.
|
|
13
|
+
*/
|
|
14
|
+
export function redrawLoop(draw, everyMs) {
|
|
15
|
+
let stopped = false;
|
|
16
|
+
let timer;
|
|
17
|
+
let immediate;
|
|
18
|
+
const arm = () => {
|
|
19
|
+
immediate = undefined;
|
|
20
|
+
if (!stopped)
|
|
21
|
+
timer = setTimeout(tick, everyMs);
|
|
22
|
+
};
|
|
23
|
+
const tick = () => {
|
|
24
|
+
timer = undefined;
|
|
25
|
+
if (stopped)
|
|
26
|
+
return;
|
|
27
|
+
draw();
|
|
28
|
+
immediate = setImmediate(arm);
|
|
29
|
+
};
|
|
30
|
+
timer = setTimeout(tick, everyMs);
|
|
31
|
+
return {
|
|
32
|
+
stop: () => {
|
|
33
|
+
stopped = true;
|
|
34
|
+
if (timer)
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
if (immediate)
|
|
37
|
+
clearImmediate(immediate);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
package/dist/client/channel.js
CHANGED
|
@@ -8,15 +8,18 @@ export function createChannel(transport) {
|
|
|
8
8
|
log: [],
|
|
9
9
|
prompt: [],
|
|
10
10
|
promptDone: [],
|
|
11
|
+
up: [],
|
|
11
12
|
settled: [],
|
|
12
13
|
};
|
|
13
14
|
let settled = false;
|
|
14
15
|
let resolveResult;
|
|
15
16
|
const result = new Promise((resolve) => (resolveResult = resolve));
|
|
16
17
|
transport.onMessage((message) => {
|
|
17
|
-
if (message.kind === "
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
if (message.kind === "logs") {
|
|
19
|
+
// Batched on the wire (see Supervisor.log); consumers still see one `log` per entry.
|
|
20
|
+
for (const entry of message.entries)
|
|
21
|
+
for (const h of handlers.log)
|
|
22
|
+
h(entry);
|
|
20
23
|
}
|
|
21
24
|
else if (message.kind === "prompt") {
|
|
22
25
|
for (const h of handlers.prompt)
|
|
@@ -26,6 +29,11 @@ export function createChannel(transport) {
|
|
|
26
29
|
for (const h of handlers.promptDone)
|
|
27
30
|
h(message.id);
|
|
28
31
|
}
|
|
32
|
+
else if (message.kind === "up") {
|
|
33
|
+
const info = { ok: message.ok, folder: message.folder };
|
|
34
|
+
for (const h of handlers.up)
|
|
35
|
+
h(info);
|
|
36
|
+
}
|
|
29
37
|
else if (message.kind === "settled") {
|
|
30
38
|
settled = true;
|
|
31
39
|
const verdict = { ok: message.ok, folder: message.folder, summary: message.summary };
|
package/dist/client/types.d.ts
CHANGED
|
@@ -15,6 +15,16 @@ export interface Verdict {
|
|
|
15
15
|
* block a bare run prints to its terminal. Empty if the child closed without settling. */
|
|
16
16
|
readonly summary: readonly string[];
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* The run's body has returned. For a long-lived run this is "the stack is up": a caller
|
|
20
|
+
* that wants to drive it (attach to its browser, hit its server) waits for this, not for
|
|
21
|
+
* `settled`, which never comes on its own. `ok` is the verdict so far; `folder` is the
|
|
22
|
+
* dated run folder, known from here on.
|
|
23
|
+
*/
|
|
24
|
+
export interface RunUp {
|
|
25
|
+
readonly ok: boolean;
|
|
26
|
+
readonly folder: string;
|
|
27
|
+
}
|
|
18
28
|
/** A typecheck diagnostic from `check()`. */
|
|
19
29
|
export interface Diagnostic {
|
|
20
30
|
readonly file: string;
|
|
@@ -46,6 +56,8 @@ export interface RunEvents {
|
|
|
46
56
|
prompt: (request: PromptRequest) => void;
|
|
47
57
|
/** A pending prompt was withdrawn by the child (timed out or torn down) — clear it from the UI. */
|
|
48
58
|
promptDone: (id: string) => void;
|
|
59
|
+
/** The root body returned — see {@link RunUp}. Fires at most once, before `settled`. */
|
|
60
|
+
up: (info: RunUp) => void;
|
|
49
61
|
settled: (verdict: Verdict) => void;
|
|
50
62
|
}
|
|
51
63
|
/** A live handle to one supervised run. */
|
|
@@ -247,6 +247,11 @@ export class ExecutionTree {
|
|
|
247
247
|
const rootPromise = currentNode.run(this.root, () => this.root.run(body, []));
|
|
248
248
|
this.trackNode(rootPromise);
|
|
249
249
|
await rootPromise;
|
|
250
|
+
// The body has returned. A long-lived run is "up" from here — its daemons keep it
|
|
251
|
+
// open — and a supervisor that started it wants to know, because nothing else on the
|
|
252
|
+
// channel distinguishes "still booting" from "up and driveable" before `settled`.
|
|
253
|
+
if (supervisor)
|
|
254
|
+
supervisor.up(this.verdictOk(), this.folder.path());
|
|
250
255
|
await this.drive();
|
|
251
256
|
await this.finalize();
|
|
252
257
|
}
|
|
@@ -377,6 +382,7 @@ export class ExecutionTree {
|
|
|
377
382
|
return this.store.entries();
|
|
378
383
|
}
|
|
379
384
|
runView() {
|
|
385
|
+
const value = serialisable(this.root.value);
|
|
380
386
|
return {
|
|
381
387
|
ok: this.faults.length === 0,
|
|
382
388
|
failures: [...this.faults],
|
|
@@ -387,6 +393,7 @@ export class ExecutionTree {
|
|
|
387
393
|
rawStream: this.rawStreamPath(),
|
|
388
394
|
root: this.nodeView(this.root),
|
|
389
395
|
artifacts: this.curatedArtifacts(),
|
|
396
|
+
...(value === undefined ? {} : { value }),
|
|
390
397
|
};
|
|
391
398
|
}
|
|
392
399
|
/**
|
|
@@ -440,3 +447,18 @@ export class ExecutionTree {
|
|
|
440
447
|
}
|
|
441
448
|
}
|
|
442
449
|
const messageOf = (error) => error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
450
|
+
/**
|
|
451
|
+
* The value as `result.json` will carry it, or undefined when it cannot be carried. A
|
|
452
|
+
* body may hand back anything — a capability handle, a cyclic object — and the run must
|
|
453
|
+
* still write its record; a value that does not survive JSON is dropped, not fatal.
|
|
454
|
+
*/
|
|
455
|
+
function serialisable(value) {
|
|
456
|
+
if (value === undefined)
|
|
457
|
+
return undefined;
|
|
458
|
+
try {
|
|
459
|
+
return JSON.parse(JSON.stringify(value));
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
}
|
package/dist/core/action.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ExecutionTree } from "./ExecutionTree.js";
|
|
2
2
|
import { callerSite } from "../foundation/callsite.js";
|
|
3
3
|
import { describeSchema } from "./discovery-mode.js";
|
|
4
|
+
import { formatIssues } from "./args.js";
|
|
4
5
|
/**
|
|
5
6
|
* The builder returned by `action(name)`: declare metadata (`.describe(…)`), events
|
|
6
7
|
* (`.emits<…>()`), an imperative handle (`.ref<…>()`), and/or pin the args type
|
|
@@ -45,8 +46,9 @@ class Builder {
|
|
|
45
46
|
const description = this.description;
|
|
46
47
|
const mcp = this.exposure;
|
|
47
48
|
const definedAt = callerSite(); // the `.run(...)` call site — where this action lives
|
|
48
|
-
// No `.args()` on this path, so there is nothing to describe.
|
|
49
|
+
// No `.args()` on this path, so there is nothing to describe — or to resolve against.
|
|
49
50
|
const describeArgs = () => describeSchema(undefined);
|
|
51
|
+
const parseArgs = (supplied) => supplied;
|
|
50
52
|
const exec = body;
|
|
51
53
|
const create = (...args) => {
|
|
52
54
|
const spec = { name, args, tags: [], body: exec, definedAt };
|
|
@@ -58,13 +60,16 @@ class Builder {
|
|
|
58
60
|
description,
|
|
59
61
|
mcp,
|
|
60
62
|
describeArgs,
|
|
63
|
+
parseArgs,
|
|
61
64
|
});
|
|
62
65
|
}
|
|
63
66
|
}
|
|
64
67
|
/**
|
|
65
|
-
* The builder after `.args(schema)`. The schema pins `.run`'s parameter type;
|
|
66
|
-
* validated
|
|
67
|
-
*
|
|
68
|
+
* The builder after `.args(schema)`. The schema pins `.run`'s parameter type; launching is
|
|
69
|
+
* not validated — an action invoked from an om body is passed its args directly in code,
|
|
70
|
+
* and the type already holds. The one untyped caller is the MCP action host, which hands
|
|
71
|
+
* the JSON it was sent through `parseArgs` first, so defaults and validation come from
|
|
72
|
+
* this same schema rather than from nothing.
|
|
68
73
|
*/
|
|
69
74
|
class ArgsBuilder {
|
|
70
75
|
name;
|
|
@@ -72,7 +77,7 @@ class ArgsBuilder {
|
|
|
72
77
|
description;
|
|
73
78
|
exposure;
|
|
74
79
|
constructor(name,
|
|
75
|
-
/**
|
|
80
|
+
/** `S` pins `.run`'s parameter; the value backs `describeArgs` and `parseArgs`. */
|
|
76
81
|
schema, description, exposure) {
|
|
77
82
|
this.name = name;
|
|
78
83
|
this.schema = schema;
|
|
@@ -97,6 +102,14 @@ class ArgsBuilder {
|
|
|
97
102
|
// that built it — rather than in whichever copy the discovery child is running.
|
|
98
103
|
const schema = this.schema;
|
|
99
104
|
const describeArgs = () => describeSchema(schema);
|
|
105
|
+
// `safeParse` is what applies `.default()`s, so this is where an advertised default
|
|
106
|
+
// becomes the effective one. A refusal names the action and every failing field.
|
|
107
|
+
const parseArgs = (supplied) => {
|
|
108
|
+
const parsed = schema.safeParse(supplied);
|
|
109
|
+
if (parsed.success)
|
|
110
|
+
return parsed.data;
|
|
111
|
+
throw new Error(`invalid args for ${name}: ${formatIssues(parsed.error)}`);
|
|
112
|
+
};
|
|
100
113
|
const exec = body;
|
|
101
114
|
const create = (...args) => {
|
|
102
115
|
const spec = { name, args, tags: [], body: exec, definedAt };
|
|
@@ -108,6 +121,7 @@ class ArgsBuilder {
|
|
|
108
121
|
description,
|
|
109
122
|
mcp,
|
|
110
123
|
describeArgs,
|
|
124
|
+
parseArgs,
|
|
111
125
|
});
|
|
112
126
|
}
|
|
113
127
|
}
|
package/dist/core/args.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface ResolveOptions {
|
|
|
37
37
|
* the second is unanswerable.
|
|
38
38
|
*/
|
|
39
39
|
export declare function resolveArgs(schema: z.ZodType, opts: ResolveOptions): Promise<unknown>;
|
|
40
|
+
/** Every issue on one line, `path — reason`, for an error message a caller can act on. */
|
|
41
|
+
export declare function formatIssues(error: z.ZodError): string;
|
|
40
42
|
/**
|
|
41
43
|
* True once a JSON-kind answer is finished. A `{`/`[` opener is a pasted blob and is done
|
|
42
44
|
* when it parses; anything else is a path to a JSON file, and one line is the whole answer.
|
package/dist/core/args.js
CHANGED
|
@@ -45,13 +45,13 @@ export async function resolveArgs(schema, opts) {
|
|
|
45
45
|
// refinement, or a schema that isn't an object at all). No question would make progress,
|
|
46
46
|
// so fail now with the real reason instead of looping silently to the attempt cap.
|
|
47
47
|
if (blocking.length === 0)
|
|
48
|
-
throw new Error(`could not resolve args: ${
|
|
48
|
+
throw new Error(`could not resolve args: ${formatIssues(parsed.error)}`);
|
|
49
49
|
if (!opts.interactive) {
|
|
50
50
|
const anySupplied = blocking.some((field) => current[field] !== undefined);
|
|
51
|
-
throw new MissingArgsError(blocking, anySupplied ?
|
|
51
|
+
throw new MissingArgsError(blocking, anySupplied ? formatIssues(parsed.error) : undefined);
|
|
52
52
|
}
|
|
53
53
|
if (attempt >= MAX_ATTEMPTS) {
|
|
54
|
-
throw new Error(`could not resolve args after ${MAX_ATTEMPTS} attempts: ${
|
|
54
|
+
throw new Error(`could not resolve args after ${MAX_ATTEMPTS} attempts: ${formatIssues(parsed.error)}`);
|
|
55
55
|
}
|
|
56
56
|
for (const field of blocking) {
|
|
57
57
|
const fieldSchema = propertySchema(root(), field);
|
|
@@ -85,7 +85,8 @@ function blockingFields(error) {
|
|
|
85
85
|
const names = error.issues.map((i) => String(i.path[0] ?? "")).filter((n) => n !== "");
|
|
86
86
|
return [...new Set(names)];
|
|
87
87
|
}
|
|
88
|
-
|
|
88
|
+
/** Every issue on one line, `path — reason`, for an error message a caller can act on. */
|
|
89
|
+
export function formatIssues(error) {
|
|
89
90
|
return error.issues.map((i) => `${i.path.join(".") || "(root)"} — ${i.message}`).join("; ");
|
|
90
91
|
}
|
|
91
92
|
function propertySchema(root, field) {
|
|
@@ -22,9 +22,21 @@ export type ChildMessage = {
|
|
|
22
22
|
} | {
|
|
23
23
|
kind: "prompt-done";
|
|
24
24
|
id: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
}
|
|
26
|
+
/** Log entries, oldest first — batched, see {@link Supervisor.log}. */
|
|
27
|
+
| {
|
|
28
|
+
kind: "logs";
|
|
29
|
+
entries: LogEntry[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The root body has returned. For a long-lived run this is the moment its stack is up
|
|
33
|
+
* and can be reached from outside; the run itself goes on until it is torn down.
|
|
34
|
+
* Carries the dated folder, which exists by now — the body ran inside it.
|
|
35
|
+
*/
|
|
36
|
+
| {
|
|
37
|
+
kind: "up";
|
|
38
|
+
ok: boolean;
|
|
39
|
+
folder: string;
|
|
28
40
|
} | {
|
|
29
41
|
kind: "settled";
|
|
30
42
|
ok: boolean;
|
|
@@ -49,13 +61,26 @@ type OnMessage = (handler: (message: SupervisorMessage) => void) => void;
|
|
|
49
61
|
* decides nothing (the child keeps its own prompt timeout; see the `prompt` action).
|
|
50
62
|
*/
|
|
51
63
|
export declare class Supervisor {
|
|
52
|
-
private readonly
|
|
64
|
+
private readonly sendRaw;
|
|
53
65
|
private seq;
|
|
54
66
|
private readonly pending;
|
|
55
67
|
private cancelHandler;
|
|
56
|
-
|
|
68
|
+
private batch;
|
|
69
|
+
private flushTimer;
|
|
70
|
+
private readonly send;
|
|
71
|
+
constructor(sendRaw: Send, onMessage: OnMessage);
|
|
57
72
|
request(spec: PromptSpec, signal: AbortSignal): Promise<AnswerMessage>;
|
|
73
|
+
/**
|
|
74
|
+
* Queue a log entry; it goes up with whatever else arrives within {@link LOG_BATCH_MS}.
|
|
75
|
+
*
|
|
76
|
+
* Batched because each IPC message costs the supervisor an event-loop turn (on Windows, Node
|
|
77
|
+
* delivers at most one per turn), and a frontend with slow frames can only take so many turns
|
|
78
|
+
* a second. A message per entry let a chatty run outpace the interactive app, which then kept
|
|
79
|
+
* replaying the log long after the run had finished, with `settled` stuck behind it.
|
|
80
|
+
*/
|
|
58
81
|
log(entry: LogEntry): void;
|
|
82
|
+
private flush;
|
|
83
|
+
up(ok: boolean, folder: string): void;
|
|
59
84
|
settled(ok: boolean, folder: string, summary: string[]): void;
|
|
60
85
|
onCancel(handler: () => void): void;
|
|
61
86
|
}
|
package/dist/core/interaction.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long log entries wait to share one message. Short enough to read as live on screen; long
|
|
3
|
+
* enough that a burst — a build or test suite printing hundreds of lines — crosses as a handful
|
|
4
|
+
* of messages rather than hundreds.
|
|
5
|
+
*/
|
|
6
|
+
const LOG_BATCH_MS = 25;
|
|
1
7
|
/**
|
|
2
8
|
* The child-side handle to an out-of-process supervisor. Sends prompt requests, live log
|
|
3
9
|
* entries, and the final verdict up the channel; resolves prompts when the matching answer
|
|
@@ -5,12 +11,22 @@
|
|
|
5
11
|
* decides nothing (the child keeps its own prompt timeout; see the `prompt` action).
|
|
6
12
|
*/
|
|
7
13
|
export class Supervisor {
|
|
8
|
-
|
|
14
|
+
sendRaw;
|
|
9
15
|
seq = 0;
|
|
10
16
|
pending = new Map();
|
|
11
17
|
cancelHandler;
|
|
12
|
-
|
|
13
|
-
|
|
18
|
+
batch = [];
|
|
19
|
+
flushTimer;
|
|
20
|
+
send;
|
|
21
|
+
constructor(sendRaw, onMessage) {
|
|
22
|
+
this.sendRaw = sendRaw;
|
|
23
|
+
// Every other message leaves through here, so a pending batch always goes first: the entries
|
|
24
|
+
// that led to a prompt are the context it is read in, and a run's closing entries must land
|
|
25
|
+
// before its verdict.
|
|
26
|
+
this.send = (message) => {
|
|
27
|
+
this.flush();
|
|
28
|
+
sendRaw(message);
|
|
29
|
+
};
|
|
14
30
|
onMessage((message) => {
|
|
15
31
|
if (message.kind === "answer") {
|
|
16
32
|
const resolve = this.pending.get(message.id);
|
|
@@ -45,8 +61,30 @@ export class Supervisor {
|
|
|
45
61
|
this.send({ kind: "prompt", id, spec });
|
|
46
62
|
});
|
|
47
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Queue a log entry; it goes up with whatever else arrives within {@link LOG_BATCH_MS}.
|
|
66
|
+
*
|
|
67
|
+
* Batched because each IPC message costs the supervisor an event-loop turn (on Windows, Node
|
|
68
|
+
* delivers at most one per turn), and a frontend with slow frames can only take so many turns
|
|
69
|
+
* a second. A message per entry let a chatty run outpace the interactive app, which then kept
|
|
70
|
+
* replaying the log long after the run had finished, with `settled` stuck behind it.
|
|
71
|
+
*/
|
|
48
72
|
log(entry) {
|
|
49
|
-
this.
|
|
73
|
+
this.batch.push(entry);
|
|
74
|
+
this.flushTimer ??= setTimeout(() => this.flush(), LOG_BATCH_MS);
|
|
75
|
+
}
|
|
76
|
+
flush() {
|
|
77
|
+
if (this.flushTimer)
|
|
78
|
+
clearTimeout(this.flushTimer);
|
|
79
|
+
this.flushTimer = undefined;
|
|
80
|
+
if (this.batch.length === 0)
|
|
81
|
+
return;
|
|
82
|
+
const entries = this.batch;
|
|
83
|
+
this.batch = [];
|
|
84
|
+
this.sendRaw({ kind: "logs", entries });
|
|
85
|
+
}
|
|
86
|
+
up(ok, folder) {
|
|
87
|
+
this.send({ kind: "up", ok, folder });
|
|
50
88
|
}
|
|
51
89
|
settled(ok, folder, summary) {
|
|
52
90
|
this.send({ kind: "settled", ok, folder, summary });
|
package/dist/core/om.js
CHANGED
|
@@ -117,7 +117,7 @@ class ArgsBuilder {
|
|
|
117
117
|
ask: askForArg,
|
|
118
118
|
}));
|
|
119
119
|
ExecutionTree.current?.setRootArgs(args);
|
|
120
|
-
|
|
120
|
+
return body(ctx, args);
|
|
121
121
|
},
|
|
122
122
|
// Handed to `launch` for discovery only: in a real run the schema is resolved above,
|
|
123
123
|
// inside the body. Discovery never reaches that body, so it converts the schema here.
|
package/dist/core/types.d.ts
CHANGED
|
@@ -98,9 +98,10 @@ export interface OmBuilder {
|
|
|
98
98
|
* Launch the run with `body` as its root. The body runs inside the root node's
|
|
99
99
|
* ambient scope, so any action call / `step(...)` / `console.*` it reaches attributes
|
|
100
100
|
* correctly. Resolves once the run has torn down and produced its artifacts; never
|
|
101
|
-
* rejects (failures are recorded in the tree and set the exit code).
|
|
101
|
+
* rejects (failures are recorded in the tree and set the exit code). Whatever the body
|
|
102
|
+
* returns is recorded as the run's `value` in `result.json`.
|
|
102
103
|
*/
|
|
103
|
-
run(body: (ctx: OmContext) => Awaitable<
|
|
104
|
+
run(body: (ctx: OmContext) => Awaitable<unknown>): Promise<void>;
|
|
104
105
|
}
|
|
105
106
|
/** After `.args(schema)`: `.run`'s body receives the resolved, typed args. */
|
|
106
107
|
export interface OmBuilderArgs<S extends ZodTypeLike> {
|
|
@@ -111,9 +112,10 @@ export interface OmBuilderArgs<S extends ZodTypeLike> {
|
|
|
111
112
|
* Launch the run, resolving the declared args first — supplied values, then defaults,
|
|
112
113
|
* then prompting — and pass them to `body` as its second parameter. Resolution happens
|
|
113
114
|
* *inside* the run, so a prompt and its answer land on the run's own timeline; an
|
|
114
|
-
* unresolvable arg fails the run like any other error in the body.
|
|
115
|
+
* unresolvable arg fails the run like any other error in the body. Whatever the body
|
|
116
|
+
* returns is recorded as the run's `value` in `result.json`.
|
|
115
117
|
*/
|
|
116
|
-
run(body: (ctx: OmContext, args: InferSchema<S>) => Awaitable<
|
|
118
|
+
run(body: (ctx: OmContext, args: InferSchema<S>) => Awaitable<unknown>): Promise<void>;
|
|
117
119
|
}
|
|
118
120
|
/** What the `om` body receives — the run-level counterpart to {@link ActionContext}. */
|
|
119
121
|
export interface OmContext {
|
|
@@ -201,6 +203,13 @@ export interface Action<Args extends unknown[], Result, Events extends object =
|
|
|
201
203
|
* throws — an unconvertible schema comes back as `schemaError`.
|
|
202
204
|
*/
|
|
203
205
|
readonly describeArgs: () => DescribedArgs;
|
|
206
|
+
/**
|
|
207
|
+
* Resolve args that arrived as untyped JSON — the MCP host's — against the declared
|
|
208
|
+
* schema: defaults filled, everything validated, or a throw naming the field. Identity
|
|
209
|
+
* without `.args()`. Runs in the defining copy of omkit for the same reason
|
|
210
|
+
* `describeArgs` does. In-code callers never need it: their args are typed.
|
|
211
|
+
*/
|
|
212
|
+
readonly parseArgs: (supplied: unknown) => unknown;
|
|
204
213
|
}
|
|
205
214
|
/** Intermediate step from `action(name)`: declare metadata/events/handle, then the impl. */
|
|
206
215
|
export interface ActionBuilderEvents<Events extends object, Handle = void> {
|
package/dist/mcp/action-host.js
CHANGED
|
@@ -47,13 +47,21 @@ om(hostOmName(name, file))
|
|
|
47
47
|
if (typeof launch !== "function") {
|
|
48
48
|
throw new Error(`${file} has no exported action "${exportName}"`);
|
|
49
49
|
}
|
|
50
|
+
const action = launch;
|
|
51
|
+
// The server only checked that the JSON *looked* right (`checkArgs`). The schema that
|
|
52
|
+
// list_oms advertised — defaults included — lives on the action, so the action applies
|
|
53
|
+
// it: what the caller was told is what the body receives. An om does the same through
|
|
54
|
+
// `resolveArgs` before its body runs.
|
|
55
|
+
const resolved = action.parseArgs(args);
|
|
50
56
|
// The action reaches `ExecutionTree.require()` through the copy of omkit *its* file
|
|
51
57
|
// imported, and `current` is a static — so this only works when both resolve to the
|
|
52
58
|
// same installed omkit. True for a normal install and for the fixtures; false under
|
|
53
59
|
// `npm link`, a non-hoisted pnpm layout, or two omkit versions in one tree. The bare
|
|
54
60
|
// error is undiagnosable from a client, so name the cause here.
|
|
55
61
|
try {
|
|
56
|
-
|
|
62
|
+
// The action's result becomes the run's `value`: the one thing an action-backed tool
|
|
63
|
+
// call exists to hand back, read from `result.json` by the server's verdict.
|
|
64
|
+
return await action(resolved).result;
|
|
57
65
|
}
|
|
58
66
|
catch (e) {
|
|
59
67
|
if (e instanceof Error && /no active om\(\) run/.test(e.message)) {
|
package/dist/mcp/runs.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LogEntry } from "../foundation/LogEntry.ts";
|
|
2
|
-
import type { RunSession, Verdict } from "../client/types.ts";
|
|
2
|
+
import type { RunSession, RunUp, Verdict } from "../client/types.ts";
|
|
3
3
|
export interface LiveRun {
|
|
4
4
|
readonly id: string;
|
|
5
5
|
/** The om or action name the client asked for. */
|
|
@@ -9,6 +9,13 @@ export interface LiveRun {
|
|
|
9
9
|
readonly session: RunSession;
|
|
10
10
|
readonly startedAt: number;
|
|
11
11
|
status: "running" | "settled";
|
|
12
|
+
/** Set once the body has returned — for a long-lived run, once its stack is up. */
|
|
13
|
+
up?: RunUp;
|
|
14
|
+
/**
|
|
15
|
+
* Resolves with the `up` report, or with undefined if the run settles (or dies) without
|
|
16
|
+
* ever getting there — so a caller waiting to drive the stack is never left hanging.
|
|
17
|
+
*/
|
|
18
|
+
readonly whenUp: Promise<RunUp | undefined>;
|
|
12
19
|
verdict?: Verdict;
|
|
13
20
|
/** The tail buffer. Entries are appended in sequence order, oldest dropped first. */
|
|
14
21
|
readonly entries: LogEntry[];
|
package/dist/mcp/runs.js
CHANGED
|
@@ -20,6 +20,8 @@ export class RunRegistry {
|
|
|
20
20
|
* starts at the tool boundary has already missed the run's opening lines.
|
|
21
21
|
*/
|
|
22
22
|
start(name, folderName, session) {
|
|
23
|
+
let resolveUp;
|
|
24
|
+
const whenUp = new Promise((resolve) => (resolveUp = resolve));
|
|
23
25
|
const run = {
|
|
24
26
|
id: shortId(newUuid()),
|
|
25
27
|
name,
|
|
@@ -29,7 +31,12 @@ export class RunRegistry {
|
|
|
29
31
|
status: "running",
|
|
30
32
|
entries: [],
|
|
31
33
|
dropped: 0,
|
|
34
|
+
whenUp,
|
|
32
35
|
};
|
|
36
|
+
session.on("up", (info) => {
|
|
37
|
+
run.up = info;
|
|
38
|
+
resolveUp(info);
|
|
39
|
+
});
|
|
33
40
|
session.on("log", (entry) => {
|
|
34
41
|
run.entries.push(entry);
|
|
35
42
|
if (run.entries.length > BUFFER_LIMIT) {
|
|
@@ -40,6 +47,7 @@ export class RunRegistry {
|
|
|
40
47
|
void session.result.then((verdict) => {
|
|
41
48
|
run.status = "settled";
|
|
42
49
|
run.verdict = verdict;
|
|
50
|
+
resolveUp(run.up); // no-op if `up` already resolved it; otherwise: never got there
|
|
43
51
|
});
|
|
44
52
|
this.runs.set(run.id, run);
|
|
45
53
|
return run;
|
package/dist/mcp/tools.js
CHANGED
|
@@ -17,6 +17,25 @@ import { attachElicitation, attachProgress } from "./progress.js";
|
|
|
17
17
|
const SETTLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
18
18
|
/** How long teardown gets to finish writing the run folder after a backstop cancel. */
|
|
19
19
|
const TEARDOWN_GRACE_MS = 10_000;
|
|
20
|
+
/**
|
|
21
|
+
* How long `start_om` waits for a run to come up when asked to. A dev stack that cold-starts
|
|
22
|
+
* a bundler and a browser can take a minute; past this the handle comes back with
|
|
23
|
+
* `up: false` and the run carries on — the wait is a convenience, not a gate.
|
|
24
|
+
*/
|
|
25
|
+
const UP_TIMEOUT_MS = 2 * 60 * 1000;
|
|
26
|
+
/** `run.whenUp`, bounded: resolves undefined once `ms` have passed without an `up` report. */
|
|
27
|
+
async function upWithin(run, ms) {
|
|
28
|
+
let timer;
|
|
29
|
+
const expired = new Promise((resolve) => {
|
|
30
|
+
timer = setTimeout(() => resolve(undefined), ms);
|
|
31
|
+
});
|
|
32
|
+
try {
|
|
33
|
+
return await Promise.race([run.whenUp, expired]);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
20
39
|
/**
|
|
21
40
|
* Wait for a run to settle, or cancel it and report that it did not.
|
|
22
41
|
*
|
|
@@ -120,6 +139,19 @@ export function both(structuredContent) {
|
|
|
120
139
|
export function toolError(message) {
|
|
121
140
|
return { content: [{ type: "text", text: message }], isError: true };
|
|
122
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* What `get_run` says about a run that has not settled. The dated folder is stamped inside
|
|
144
|
+
* the child on first access and reported with `up`; until then there is genuinely nothing
|
|
145
|
+
* to name — not an empty string standing in for one.
|
|
146
|
+
*/
|
|
147
|
+
function runningReport(live) {
|
|
148
|
+
return {
|
|
149
|
+
status: "running",
|
|
150
|
+
name: live.name,
|
|
151
|
+
up: live.up !== undefined,
|
|
152
|
+
folder: live.up?.folder ?? "",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
123
155
|
export function registerTools(server, ctx) {
|
|
124
156
|
const registry = createRegistryCache(ctx.client);
|
|
125
157
|
server.registerTool("list_oms", {
|
|
@@ -155,6 +187,28 @@ export function registerTools(server, ctx) {
|
|
|
155
187
|
return `"${name}" is unavailable: ${entry.unavailable}`;
|
|
156
188
|
return undefined;
|
|
157
189
|
};
|
|
190
|
+
/**
|
|
191
|
+
* The gate both launching tools pass through: the entry must exist, be callable, be of a
|
|
192
|
+
* mode the tool accepts, and be handed arguments that at least look right — all before
|
|
193
|
+
* anything is forked. Mode is checked ahead of arguments on purpose: "use start_om" is
|
|
194
|
+
* the answer a caller needs first, and a missing argument would only bury it.
|
|
195
|
+
*/
|
|
196
|
+
const admit = async (name, args, accepts = {}) => {
|
|
197
|
+
const entry = await findEntry(name);
|
|
198
|
+
const refusal = notRunnable(name, entry);
|
|
199
|
+
if (refusal || !entry)
|
|
200
|
+
return { ok: false, refusal: refusal };
|
|
201
|
+
if (accepts.settlingOnly && entry.mode === "long-lived") {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
refusal: `"${name}" is long-lived — start it with start_om, not run_om.`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const problems = checkArgs(entry.inputSchema, args);
|
|
208
|
+
if (problems.length)
|
|
209
|
+
return { ok: false, refusal: problems.join("\n") };
|
|
210
|
+
return { ok: true, entry };
|
|
211
|
+
};
|
|
158
212
|
/**
|
|
159
213
|
* Launch an entry as a supervised run.
|
|
160
214
|
*
|
|
@@ -188,9 +242,10 @@ export function registerTools(server, ctx) {
|
|
|
188
242
|
server.registerTool("run_om", {
|
|
189
243
|
title: "Run an om and wait for its verdict",
|
|
190
244
|
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,
|
|
192
|
-
"
|
|
193
|
-
"
|
|
245
|
+
"passed, where its run folder is, its assert tally, any failures, and — as `value` — " +
|
|
246
|
+
"what it returned (an action's result travels back here). Call list_oms first to " +
|
|
247
|
+
"learn what `args` to pass. A long-lived entry is refused — use start_om for those, " +
|
|
248
|
+
"and for anything that takes more than about a minute.",
|
|
194
249
|
inputSchema: {
|
|
195
250
|
name: z.string().describe("The om or action name, as reported by list_oms."),
|
|
196
251
|
args: z
|
|
@@ -210,19 +265,17 @@ export function registerTools(server, ctx) {
|
|
|
210
265
|
assertions: z.object({ passed: z.number(), failed: z.number() }),
|
|
211
266
|
failures: z.array(z.object({ action: z.string(), error: z.string() })),
|
|
212
267
|
summary: z.array(z.string()),
|
|
268
|
+
value: z
|
|
269
|
+
.unknown()
|
|
270
|
+
.optional()
|
|
271
|
+
.describe("What the om body (or the hosted action) returned, when it returned something."),
|
|
213
272
|
},
|
|
214
273
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
215
274
|
}, async ({ name, args, settleTimeoutMs }, extra) => {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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"));
|
|
275
|
+
const admitted = await admit(name, args ?? {}, { settlingOnly: true });
|
|
276
|
+
if (!admitted.ok)
|
|
277
|
+
return toolError(admitted.refusal);
|
|
278
|
+
const { entry } = admitted;
|
|
226
279
|
const run = launch(entry, args ?? {});
|
|
227
280
|
// This request lives for the whole run, so it can carry progress and it owns the
|
|
228
281
|
// run's cancellation: `extra.signal` aborting means the client withdrew the call.
|
|
@@ -239,12 +292,26 @@ export function registerTools(server, ctx) {
|
|
|
239
292
|
});
|
|
240
293
|
server.registerTool("start_om", {
|
|
241
294
|
title: "Start an om without waiting for it",
|
|
242
|
-
description: "Starts an om or exposed action and returns a handle
|
|
243
|
-
"
|
|
244
|
-
"
|
|
295
|
+
description: "Starts an om or exposed action and returns a handle. Use this for a long-lived " +
|
|
296
|
+
"entry (a server, a watcher), or for a long suite you want to check on later. Pass " +
|
|
297
|
+
"`waitUntilUp: true` to return only once the om's body has returned — for a dev " +
|
|
298
|
+
"stack, once it is up and can be driven — rather than as soon as it is forked. " +
|
|
299
|
+
"Follow up with get_run, tail_run and cancel_run.",
|
|
245
300
|
inputSchema: {
|
|
246
301
|
name: z.string().describe("The om or action name, as reported by list_oms."),
|
|
247
302
|
args: z.record(z.string(), z.unknown()).optional(),
|
|
303
|
+
waitUntilUp: z
|
|
304
|
+
.boolean()
|
|
305
|
+
.optional()
|
|
306
|
+
.describe("Wait for the body to return before answering. The result then says `up: true` " +
|
|
307
|
+
"and names the run folder; if it takes longer than upTimeoutMs the handle comes " +
|
|
308
|
+
"back anyway with `up: false` and the run keeps going."),
|
|
309
|
+
upTimeoutMs: z
|
|
310
|
+
.number()
|
|
311
|
+
.int()
|
|
312
|
+
.positive()
|
|
313
|
+
.optional()
|
|
314
|
+
.describe("How long waitUntilUp waits. Default 2 minutes."),
|
|
248
315
|
},
|
|
249
316
|
outputSchema: {
|
|
250
317
|
runId: z.string(),
|
|
@@ -252,52 +319,59 @@ export function registerTools(server, ctx) {
|
|
|
252
319
|
// The dated run folder does not exist yet — this is the folder *family*.
|
|
253
320
|
folderName: z.string(),
|
|
254
321
|
mode: z.enum(["settling", "long-lived"]),
|
|
322
|
+
up: z.boolean().describe("Whether the om's body had returned when this answered."),
|
|
323
|
+
folder: z.string().describe("The dated run folder — known once the run is up, else empty."),
|
|
255
324
|
},
|
|
256
325
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
257
|
-
}, async ({ name, args }) => {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const problems = checkArgs(entry.inputSchema, args ?? {});
|
|
263
|
-
if (problems.length)
|
|
264
|
-
return toolError(problems.join("\n"));
|
|
326
|
+
}, async ({ name, args, waitUntilUp, upTimeoutMs }) => {
|
|
327
|
+
const admitted = await admit(name, args ?? {});
|
|
328
|
+
if (!admitted.ok)
|
|
329
|
+
return toolError(admitted.refusal);
|
|
330
|
+
const { entry } = admitted;
|
|
265
331
|
const run = launch(entry, args ?? {});
|
|
266
332
|
// Elicitation only — deliberately no `attachProgress`. This request ends as soon as
|
|
267
333
|
// the handle is returned, so its `extra.signal` aborts immediately, and wiring that
|
|
268
334
|
// to `session.cancel()` would kill the run this tool exists to keep alive. A client
|
|
269
335
|
// that wants progress from a started run polls `tail_run`.
|
|
270
336
|
attachElicitation(server, run.session);
|
|
337
|
+
// Optionally hold the answer until the body has returned. Bounded, and never an
|
|
338
|
+
// error: the run is not at fault for a client that would not wait long enough.
|
|
339
|
+
const up = waitUntilUp ? await upWithin(run, upTimeoutMs ?? UP_TIMEOUT_MS) : undefined;
|
|
271
340
|
return both({
|
|
272
341
|
runId: run.id,
|
|
273
342
|
name: entry.name,
|
|
274
343
|
folderName: run.folderName,
|
|
275
344
|
mode: entry.mode,
|
|
345
|
+
up: up !== undefined,
|
|
346
|
+
folder: up?.folder ?? "",
|
|
276
347
|
});
|
|
277
348
|
});
|
|
278
349
|
server.registerTool("get_run", {
|
|
279
350
|
title: "Check on a run",
|
|
280
|
-
description: "Reports whether a run is still going and,
|
|
281
|
-
"
|
|
282
|
-
"
|
|
351
|
+
description: "Reports whether a run is still going — and, for one still going, whether its body " +
|
|
352
|
+
"has returned yet (`up`, the moment a long-lived stack can be driven) — and, once it " +
|
|
353
|
+
"has settled, its verdict and links to the files it produced. `run` is a runId from " +
|
|
354
|
+
"start_om, or a run folder path under logs/.",
|
|
283
355
|
inputSchema: { run: z.string() },
|
|
284
356
|
outputSchema: {
|
|
285
357
|
status: z.enum(["running", "settled"]),
|
|
286
358
|
name: z.string(),
|
|
287
359
|
folder: z.string(),
|
|
360
|
+
up: z
|
|
361
|
+
.boolean()
|
|
362
|
+
.optional()
|
|
363
|
+
.describe("While running: whether the om's body has returned. Absent once settled."),
|
|
288
364
|
ok: z.boolean().optional(),
|
|
289
365
|
assertions: z.object({ passed: z.number(), failed: z.number() }).optional(),
|
|
290
366
|
failures: z.array(z.object({ action: z.string(), error: z.string() })).optional(),
|
|
291
367
|
summary: z.array(z.string()).optional(),
|
|
368
|
+
value: z.unknown().optional(),
|
|
292
369
|
},
|
|
293
370
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
294
371
|
}, async ({ run }) => {
|
|
295
372
|
const live = ctx.runs.get(run);
|
|
296
|
-
if (live?.status === "running")
|
|
297
|
-
|
|
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
|
-
}
|
|
373
|
+
if (live?.status === "running")
|
|
374
|
+
return both(runningReport(live));
|
|
301
375
|
// A run this server started, or a folder already on disk — confined either way.
|
|
302
376
|
const folder = live?.verdict?.folder ?? resolveRunFolder(ctx.root, run);
|
|
303
377
|
if (!folder) {
|
package/dist/mcp/verdict.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type RunVerdict = {
|
|
|
21
21
|
}>;
|
|
22
22
|
/** The recap block the terminal would have printed. */
|
|
23
23
|
readonly summary: readonly string[];
|
|
24
|
+
/** What the run's body returned — for an action-backed tool, the action's result. */
|
|
25
|
+
readonly value?: unknown;
|
|
24
26
|
};
|
|
25
27
|
export declare function readVerdict(verdict: Verdict): Promise<RunVerdict>;
|
|
26
28
|
/**
|
package/dist/mcp/verdict.js
CHANGED
|
@@ -28,6 +28,7 @@ export async function readVerdict(verdict) {
|
|
|
28
28
|
...base,
|
|
29
29
|
assertions: parsed.assertions ?? base.assertions,
|
|
30
30
|
failures: parsed.failures ?? base.failures,
|
|
31
|
+
...(parsed.value === undefined ? {} : { value: parsed.value }),
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
@@ -43,5 +44,6 @@ export async function readVerdictAt(folder) {
|
|
|
43
44
|
summary: [],
|
|
44
45
|
assertions: parsed?.assertions ?? { passed: 0, failed: 0 },
|
|
45
46
|
failures: parsed?.failures ?? [],
|
|
47
|
+
...(parsed?.value === undefined ? {} : { value: parsed.value }),
|
|
46
48
|
};
|
|
47
49
|
}
|
|
@@ -47,4 +47,9 @@ export interface RunView {
|
|
|
47
47
|
readonly root: NodeView;
|
|
48
48
|
/** Files the run labelled via `ctx.artifact`, in registration order. */
|
|
49
49
|
readonly artifacts: readonly ArtifactView[];
|
|
50
|
+
/**
|
|
51
|
+
* What the om body returned, when it returned something JSON can carry. Absent
|
|
52
|
+
* otherwise — most bodies return nothing; an action host returns its action's result.
|
|
53
|
+
*/
|
|
54
|
+
readonly value?: unknown;
|
|
50
55
|
}
|
package/dist/skill/render.js
CHANGED
|
@@ -66,8 +66,10 @@ function howToRun(model) {
|
|
|
66
66
|
"receive is filled from the schema's defaults, and only then by asking.",
|
|
67
67
|
"",
|
|
68
68
|
'**MCP — if configured:** `run_om("<name>", { …args })` runs a settling workflow to',
|
|
69
|
-
"completion and returns its verdict
|
|
70
|
-
"
|
|
69
|
+
"completion and returns its verdict — with what it returned as `value` (an action's result",
|
|
70
|
+
"comes back this way). `start_om` starts a long-lived one and returns a run id to poll with",
|
|
71
|
+
"`get_run`, read with `tail_run`, and end with `cancel_run`; pass `waitUntilUp: true` to get",
|
|
72
|
+
"the id back only once the workflow's body has returned — for a dev stack, once it is up.",
|
|
71
73
|
"",
|
|
72
74
|
`To wire it up: \`claude mcp add omkit -- npx omkit mcp${config}\``,
|
|
73
75
|
"",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omkit",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.8",
|
|
4
4
|
"description": "A tiny runtime for developer workflows: typed actions that pass runtime capabilities and emit typed events, under one run and one shared log. The operational/runtime layer to tskb's knowledge layer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|