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
package/CHANGELOG.md ADDED
@@ -0,0 +1,153 @@
1
+ # Changelog
2
+
3
+ ## 0.7.0
4
+
5
+ ### Added
6
+
7
+ - **`omkit skill` — generate the map an assistant reads before its first call.** Writes
8
+ `.claude/skills/omkit-runs/SKILL.md` from what a project's oms and actions declare: what each
9
+ one does, the arguments it takes, and how to invoke it — both the shell form, which always
10
+ works, and the MCP form when the server is wired up. The MCP server answers the same questions
11
+ at runtime, but charges a round trip for it: an assistant has to call `list_oms` before it can
12
+ build arguments for anything. A skill file is already in context.
13
+ - **A static "calls" outline per workflow.** Read from the body rather than from a run: the
14
+ imported calls it makes, in source order, each with the `.tag("…")` its author wrote. Tags turn
15
+ out to be the right signal precisely because they were already an outline written by the person
16
+ who wrote the run. Labelled in the generated file as the approximation it is — a conditional
17
+ call is listed unconditionally, a loop once, a dynamically chosen action not at all, and a call
18
+ made inside a helper function is invisible to it.
19
+ - **`omkit skill --check`.** Compares a `registry-hash` carried in the file's header against a
20
+ fresh read of the project and exits non-zero on drift, for a pre-commit hook or a CI step. The
21
+ hash covers the registry — names, paths, summaries, modes, schemas, outlines — and deliberately
22
+ not the rendered markdown, so reformatting the file is not drift while an edited om is.
23
+ - **`omkit ls --describe`.** Renders each entry's summary and an `[mcp]` / `[mcp, long-lived]`
24
+ marker, from the same discovery fork. Opt-in on purpose: plain `omkit ls` stays the static scan
25
+ and nothing else, which is what keeps it instant and safe to run in a repo whose oms would
26
+ otherwise start servers.
27
+ - `--out` and `--root` for `omkit skill` — where the file lands, and what its recorded paths are
28
+ relative to. The root defaults to the tsconfig's directory, which is the same place for the
29
+ common layout; `--root` covers a repo that keeps its om project in a subfolder, where the
30
+ reader's working directory is the repo and so the paths must be too.
31
+ - `RegistrationSet.registry` — the static scan that chose the candidate files, carried through so
32
+ a caller wanting both halves does not build a second TypeScript program.
33
+
34
+ - **`drivePage` — evaluate JS in a live page and report what came back.** Reaches its page
35
+ either way round: given a capability handle it drives a page an earlier action in the same
36
+ run published, and given a CDP address it attaches to a browser in another process. That
37
+ second mode is what makes browser work callable from outside — `browser` and `chromePage`
38
+ publish handles, and a handle is not something a caller across a process boundary can pass.
39
+ Nothing is owned in either mode, so finishing, failing, or being cancelled never tears down
40
+ the stack it attached to.
41
+ - **`chromePage` can attach to a tab that is already open**, via `match`. A string or regex
42
+ tests the URL; a `{ js }` predicate is evaluated inside each page until one returns something
43
+ truthy, which is the only way to select on what a URL cannot express — the title, a rendered
44
+ element, whether the app has finished booting. No match is an error listing what _was_ open,
45
+ rather than a silent blank tab.
46
+
47
+ ### Fixed
48
+
49
+ - **Two spellings of one path no longer mean two different things** (Windows). A
50
+ case-insensitive filesystem let `d:\repo\om.ts` and `D:\repo\om.ts` name the same file, and
51
+ omkit keyed on the string twice. Node caches ES modules by URL, so a child forked with one
52
+ spelling loaded a _second_ copy of omkit — and an action could not see the run it was
53
+ running inside, failing with a bare "no active om() run". The same mismatch gave one om two
54
+ `omHash` identities, so `logs/<name>-<hash>/` stopped meaning "every run of this om" and the
55
+ folder `start_om` predicted was not the folder the run created. Paths are now canonicalised
56
+ through the OS at the two points that matter: the static scan, and the fork.
57
+ Existing run folders are unaffected — they were already written under the canonical spelling;
58
+ it was discovery that disagreed with them.
59
+
60
+ ### Changed
61
+
62
+ - `DiscoveredOm` gained `calls`, so `omkit ls --json` carries the outline. Additive, and derived
63
+ statically, so `discover()` is still the pass that never executes user code.
64
+ - The generated `description` in the skill's frontmatter is preserved across regeneration. It is
65
+ the field that decides whether an assistant loads the file at the right moment, so it has to say
66
+ _when to use this_ — which is authored prose, not something a generator should overwrite. The
67
+ built-in wording is only ever written into a file that does not exist yet.
68
+
69
+ ## 0.6.0
70
+
71
+ ### Added
72
+
73
+ - **`omkit mcp` — serve a project over the Model Context Protocol.** A third frontend beside
74
+ the terminal commands and the interactive app, on stdio, so Claude and other assistants can
75
+ list a project's runnable oms, run one, and read what the run produced. Six tools, fixed
76
+ however many oms a project has: list them, run one and wait for a verdict, start one and get
77
+ a handle, check on a run, tail its log from a cursor, cancel it.
78
+ - **`om(name).mcp({ mode })` and `action(name).mcp({ mode })`** — the exposure marker. Without
79
+ it an entry is invisible to the server: `.describe()` and `.args()` alone expose nothing, so
80
+ a project opts in per entry. `mode` is `"settling"` (the default) or `"long-lived"`; MCP has
81
+ no way to say a tool never finishes, so omkit says it instead, and `run_om` refuses a
82
+ long-lived entry rather than blocking on it.
83
+ - **Run folders as MCP resources**, under `omkit://runs/{run}/latest/{file}` and
84
+ `omkit://runs/{run}/{date}/{time}/{file}`. The `latest` form is what a stable run identity
85
+ buys: an assistant can ask for the last run of an om without having watched it happen. Every
86
+ read is confined under the project's `logs/`, checked lexically and through the resolved real
87
+ path, size-capped, and read-only.
88
+ - **Progress, cancellation and elicitation during a run.** Milestones become progress
89
+ notifications keyed on the run's own log sequence; a cancelled request tears the run down and
90
+ it still writes its record; and a prompt raised mid-run reaches the client as an elicitation.
91
+ A client that declines, or cannot elicit, gets the prompt's default instead of leaving the run
92
+ waiting.
93
+ - **`discoverRegistrations()` / `readRegistrations()` on the client.** Reads what a project's
94
+ oms and actions declare — summaries and real argument schemas — by importing them in a
95
+ throwaway child with `OMKIT_DISCOVER=1`, where `.run(body)` registers instead of launching.
96
+ Necessary because importing an om file runs it. `discover()` is unchanged and still never
97
+ executes user code, so `omkit ls` is exactly as fast as before.
98
+ - **Action-backed tools.** An exposed action is forked through a host om omkit ships, since an
99
+ action cannot run outside a run. Its runs land in the same `logs/` tree, under a name that
100
+ carries the action's own identity, so two same-named actions in different files never share a
101
+ folder.
102
+ - `RunOptions.env` — extra environment for a run's child process. This is how arguments reach
103
+ a run the server starts.
104
+
105
+ ### Fixed
106
+
107
+ - **Two runs of the same om starting within the same second no longer share a run folder.**
108
+ Folder names have second resolution, and `raw.jsonl` is opened truncating — so a collision
109
+ did not mean "the later run wins", it meant both processes truncated the same file and wrote
110
+ at independent offsets, leaving bytes that were not valid JSONL. Creating the directory is now
111
+ itself the lock (`mkdir` without `recursive` fails atomically, across processes), and a
112
+ genuine collision gets a `-02` suffix. Names are unchanged in the ordinary case.
113
+
114
+ ### Notes
115
+
116
+ - Nothing changes for a project that never calls `.mcp()`. The MCP SDK is imported only under
117
+ the new `mcp/` folder, so the runtime is untouched for everyone else.
118
+
119
+ ## 0.5.0
120
+
121
+ ### Breaking
122
+
123
+ - **`om(name, body)` removed.** Use `om(name).run(body)`. Run folders are unchanged:
124
+ identity is the om's name plus its defining file, and line numbers are excluded from
125
+ the hash, so migrating a call site preserves its history. A two-argument call now
126
+ throws and names the replacement rather than silently dropping the body.
127
+
128
+ ### Added
129
+
130
+ - `om(name).describe({ summary })` and `action(name).describe({ summary })` — the summary
131
+ survives every builder link and is stored where it can be read back: on the `Action` an
132
+ action's `.run(...)` returns, and on the run an om's `.run(...)` starts. Nothing in the
133
+ runtime reads it yet — `omkit ls` does not show it.
134
+ - `action(name).args(schema)` pins the type of `.run`'s args parameter (the action's first
135
+ argument) to a zod schema's inferred type. Type-level only: the schema is not validated
136
+ at runtime, because an action launched from an om body is passed its arguments in code.
137
+ - `om(name).args(schema)` declares the run's inputs and resolves them at run start, passing
138
+ the typed result to `.run`'s body as its second parameter. Each field is filled from what
139
+ was supplied (`OMKIT_ARGS`, a JSON object, read once at run start and then cleared from the
140
+ environment so subprocesses do not inherit it), then the schema's defaults, then by prompting
141
+ — and if nobody can be asked, the run fails naming every unresolved field at once rather
142
+ than blocking on a question no one will see. A blank answer re-asks instead of coercing;
143
+ an object or array field can be answered with a pasted JSON blob or the path to a JSON file.
144
+ The resolved values are recorded on the root node, so `result.json` and the `main.log`
145
+ header report what the run was actually given.
146
+ - `ctx.artifact(name, file, opts?)` labels a file the run produced. Registrations reach
147
+ the timeline and `artifacts.log` (every call, chronologically) and `result.json`, which
148
+ carries the latest registration per `(node, name)` — so one node re-labelling a name
149
+ updates in place, while two nodes choosing the same name both survive.
150
+ - `prompt({ kind: "multiline" })` reads a block of text, terminated by parseable JSON,
151
+ a sentinel line, or a predicate.
152
+
153
+ _Releases before 0.5.0 predate this file._
package/README.md CHANGED
@@ -7,6 +7,19 @@
7
7
 
8
8
  A tiny runtime for the workflows _around_ your code — start servers, wait for health checks, build, watch, drive a browser, read state back, and tear it all down together. You write the orchestration as ordinary TypeScript; the run **narrates itself** into a structured, on-disk record — what launched, what came up, what attached, what failed, what the world actually looked like — that an AI assistant can read instead of guessing from terminal scrollback. Where [tskb](https://www.npmjs.com/package/tskb) is the _knowledge_ layer (what your system **is**), `omkit` is the _operational_ one (what it's **doing right now**).
9
9
 
10
+ ## Migrating from om(name, body)
11
+
12
+ `om(name, body)` was removed in 0.5.0. `om(name)` now returns a builder, and `.run(body)` takes the place of the second argument:
13
+
14
+ ```diff
15
+ -om("dev", async (ctx) => {
16
+ +om("dev").run(async (ctx) => {
17
+ // ...
18
+ });
19
+ ```
20
+
21
+ Run folders are unaffected — a run's identity is its name plus its defining file, and line numbers are not part of the hash, so migrating a call site keeps its history.
22
+
10
23
  ## The problem
11
24
 
12
25
  You know the script. Start the API, start the web server, wait until they're _actually_ serving, open the app, maybe run a smoke check — then Ctrl+C and hope everything shut down. Most of us glue this together with `concurrently`, `wait-on`, a shell script, and a pile of opaque interleaved output. When it breaks, you're grepping stdout to find out which process died.
@@ -19,7 +32,7 @@ Now hand that same script to an AI assistant. It has it even worse: interleaved
19
32
  import { om } from "omkit";
20
33
  import { command, healthcheck, browser, chromePage } from "omkit/actions";
21
34
 
22
- om("dev", async () => {
35
+ om("dev").run(async () => {
23
36
  // `command` names the action after the command and launches it — a normal action call.
24
37
  command("npm run dev", { cwd: "api" }).tag("api"); // start both dev servers as daemons…
25
38
  command("npm run dev", { cwd: "web" }).tag("web");
@@ -33,7 +46,7 @@ om("dev", async () => {
33
46
 
34
47
  // …then launch a real browser and hand its live page downstream — typed, not scraped.
35
48
  const chrome = browser({ headless: false }); // a visible Chromium, closed on teardown
36
- const page = chromePage("app", chrome.ref, { url: "http://localhost:3000" });
49
+ const page = chromePage("app", await chrome.ref, { url: "http://localhost:3000" });
37
50
  console.log(`opened ${(await page.ref).url()}`);
38
51
 
39
52
  console.log("stack is up — press Ctrl+C to stop");
@@ -74,7 +87,46 @@ const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
74
87
  const activity = build(); // calling launches; returns the live Activity
75
88
  ```
76
89
 
77
- `om(name, async (ctx) => …)` hosts the orchestration as the root of a run. The name plus the file it's defined in identify the run — logs land in `logs/<name>-<hash8>/…`, so same-named oms in different files never share a folder. You write ordinary `await` / `if` / loops / variables; the Activities you launch keep running in parallel, and because the body stays in-flight while you `await`, the run never idles shut between steps. Config chained synchronously on an Activity right after launching it (like `withCache`) applies before its body runs — the body commits one microtask later, so chain it in the same tick, before you `await`. For a one-off inline step, `step(name, fn)` runs `fn` as its own node without a reusable definition.
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. It's **type-level only**: nothing here resolves, prompts for, or validates the value — an action launched from an om body is passed its arguments directly in code, so the schema exists to type that call site, not to gate it. (Resolving/prompting for arguments is an `om`-level concern, not `action`'s.)
91
+
92
+ ```ts
93
+ import { z } from "zod";
94
+
95
+ const seed = action("seed")
96
+ .describe({ summary: "Seeds the database" })
97
+ .args(z.object({ rows: z.number() }))
98
+ .run(async (_ctx, { rows }) => {
99
+ /* rows: number — typed, not validated */
100
+ });
101
+
102
+ seed({ rows: 500 }); // calling launches it; the shape is pinned, not checked at runtime
103
+ ```
104
+
105
+ `om(name).run(async (ctx) => …)` hosts the orchestration as the root of a run. `om(name)` returns a builder: chain `.describe({ summary })` to attach a human-readable summary (stored on the run; nothing reads it yet) and/or `.args(schema)` to declare what the run needs, then finish with `.run(body)`, which launches it. The name plus the file it's defined in identify the run — logs land in `logs/<name>-<hash8>/…`, so same-named oms in different files never share a folder. You write ordinary `await` / `if` / loops / variables; the Activities you launch keep running in parallel, and because the body stays in-flight while you `await`, the run never idles shut between steps. Config chained synchronously on an Activity right after launching it (like `withCache`) applies before its body runs — the body commits one microtask later, so chain it in the same tick, before you `await`. For a one-off inline step, `step(name, fn)` runs `fn` as its own node without a reusable definition.
106
+
107
+ ### Run arguments — `om(name).args(schema)`
108
+
109
+ An om declares what it needs, and omkit fills it in. Unlike `action`'s `.args()`, this one is **resolved at runtime**: `.run`'s body receives the resolved, validated values as its second parameter, typed by the schema.
110
+
111
+ ```ts
112
+ import { z } from "zod";
113
+
114
+ om("seed")
115
+ .args(z.object({ rows: z.number(), truncate: z.boolean().default(false) }))
116
+ .run(async (ctx, args) => {
117
+ // args: { rows: number; truncate: boolean } — already resolved and validated
118
+ await command(`./seed.sh --rows ${args.rows}`).result;
119
+ });
120
+ ```
121
+
122
+ Each field is filled from the first of these that can answer:
123
+
124
+ 1. **Supplied** — `OMKIT_ARGS`, a JSON object in the environment. It is read once at run start and then cleared, so a subprocess the run spawns doesn't inherit one om's args and resolve them against another's schema.
125
+ 2. **Defaults** — anything the schema defaults, which is therefore never asked about.
126
+ 3. **Prompt** — whatever is still missing, one field at a time, with a one-line type sketch (`rows (number)`). Objects and arrays are asked for as a multiline JSON block — paste it, or answer with the path to a JSON file. A blank answer is _no answer_, not a value: it re-asks rather than coercing (`Number("")` is `0`). Resolution gives up after three rounds.
127
+ 4. **Fail** — if nobody can be asked (no supervising picker and no TTY), the run fails naming every unresolved field at once, instead of prompting into the void.
128
+
129
+ Resolution happens **inside** the run, so a prompt and its answer land on the run's own timeline, and the values it settled on are recorded in `result.json` and the `main.log` header — a later reader can see exactly what the run was given.
78
130
 
79
131
  ### Typed capabilities
80
132
 
@@ -91,7 +143,7 @@ const server = action("server")
91
143
 
92
144
  const migrate = action("migrate").run((_ctx, port: Promise<number>) => runMigrations(port));
93
145
 
94
- om("migrate", async () => {
146
+ om("migrate").run(async () => {
95
147
  const s = server();
96
148
  migrate(s.ref); // migrate receives the port the moment the server attaches it — typed
97
149
  });
@@ -133,12 +185,12 @@ flakyBackgroundJob().result.catch((e) => console.warn("job failed, carrying on",
133
185
 
134
186
  Every action's output, events, asserts, snapshots, and lifecycle land on **one timeline** — streamed live to the terminal (curated milestones) and written to a per-run folder:
135
187
 
136
- - `result.json` — the run's tree, per-node status, and verdict.
188
+ - `result.json` — the run's tree, per-node status, verdict, and curated artifacts.
137
189
  - `raw.jsonl` — every entry, machine-readable.
138
190
  - `main.log` + one `.log` per action — the human-readable timelines.
139
- - `events.log` / `asserts.log` / `snapshots.log` — cross-cutting rollups.
191
+ - `events.log` / `asserts.log` / `snapshots.log` / `artifacts.log` — cross-cutting rollups.
140
192
 
141
- `ctx` also gives each action `assert(cond, msg)` (tallies into the run's verdict) and `snapshot(name, value)` (captures JSON state to the run folder) — so a run is an inspectable artifact, not just an exit code.
193
+ `ctx` also gives each action `assert(cond, msg)` (tallies into the run's verdict), `snapshot(name, value)` (captures JSON state to the run folder), and `artifact(name, file, opts?)` (labels a file the action wrote — name, optional description, MIME inferred from the extension — and returns its absolute path) — so a run is an inspectable artifact, not just an exit code. Write the files you label under `ctx.artifactsFolder`, the run's own output folder.
142
194
 
143
195
  Run folders have a **stable identity**: `logs/<name>-<hash8>/<date>/<time>/`, keyed by the om's name and the file that defines it. An assistant (or a script) can always find "the latest `tskb-dev` run" without parsing scrollback, diff two runs of the same pipeline, or answer questions with evidence instead of inference: _did the server actually pass its healthcheck? which process died first? what config did the build run with?_ It's all in the folder — attributed per action, timestamped, with a verdict.
144
196
 
@@ -153,7 +205,7 @@ The batteries are built around **ground truth**. `healthcheck` gates on the serv
153
205
  ```ts
154
206
  const build = action("build").run(({ proc }) => proc("tsc")`tsc -b`);
155
207
 
156
- om("build", async () => {
208
+ om("build").run(async () => {
157
209
  const out = await build().withCache(`${process.cwd()}/src`).result;
158
210
  if (out === undefined) console.log("no changes — skipped the build");
159
211
  });
@@ -164,7 +216,7 @@ om("build", async () => {
164
216
  ```ts
165
217
  import { watchDir } from "omkit/actions";
166
218
 
167
- om("watch", async () => {
219
+ om("watch").run(async () => {
168
220
  const watcher = watchDir("src").tag("watch");
169
221
  watcher.on("update", (file) => console.log(`changed: ${file}`));
170
222
  // body returns, but the watcher keeps the run alive until Ctrl+C
@@ -174,7 +226,7 @@ om("watch", async () => {
174
226
  **Gate a deploy on green tests.** A red test rejects at the `await`; catch it to branch:
175
227
 
176
228
  ```ts
177
- om("ship", async () => {
229
+ om("ship").run(async () => {
178
230
  try {
179
231
  await command("npm test").result; // resolves on green, throws on red
180
232
  } catch {
@@ -189,7 +241,7 @@ om("ship", async () => {
189
241
  ```ts
190
242
  import { prompt } from "omkit/actions";
191
243
 
192
- om("deploy", async () => {
244
+ om("deploy").run(async () => {
193
245
  const answer = await prompt({
194
246
  kind: "choice",
195
247
  message: "Deploy to production?",
@@ -201,6 +253,21 @@ om("deploy", async () => {
201
253
  });
202
254
  ```
203
255
 
256
+ **Take a block of text.** `kind: "multiline"` reads lines until `until` says stop: parseable JSON (the default), a sentinel line, or a predicate over the text so far. The JSON default is self-terminating, so a pretty-printed blob can be pasted straight in with nothing to explain:
257
+
258
+ ```ts
259
+ om("seed").run(async () => {
260
+ const blob = await prompt({
261
+ kind: "multiline",
262
+ message: "Paste the service config",
263
+ hint: "{ host: string; port: number }", // a one-line type sketch, shown under the message
264
+ until: "json", // the default; or "." to end on that line, or (text) => text.length > 500
265
+ }).result;
266
+ const config = JSON.parse(blob);
267
+ await command(`./seed.sh --host ${config.host}`).result;
268
+ });
269
+ ```
270
+
204
271
  ## Events
205
272
 
206
273
  An Activity exposes its declared events plus the lifecycle ones (`done`, `error`, `attached`):
@@ -223,7 +290,8 @@ Reusable actions built on the core engine. For anything beyond these, drop down
223
290
  - **`untilLog`** — gate on the first log entry matching a predicate.
224
291
  - **`tailLog`** — tail a file another process writes, folding its lines into the combined log.
225
292
  - **`healthcheck`** — poll a URL/port until the status (and optionally body) matches.
226
- - **`prompt`** — ask the terminal for input or a choice, with a timeout that falls back to a default; the answer is its handle.
293
+ - **`portFree`** — the mirror of `healthcheck`: poll a TCP port until nothing is listening, so a restart can rebind without racing the old process.
294
+ - **`prompt`** — ask the terminal for input, a choice, or a multiline block, with a timeout that falls back to a default; the answer is its handle.
227
295
  - **`browser`** — launch a Chromium browser with Playwright and expose the live `Browser` as a handle (defaults to the installed Chrome); closed on teardown. Feed its `.ref` to `chromePage`.
228
296
  - **`chromePage`** — attach to Chrome over CDP, or to an existing Playwright `Page`/`Browser`/`Context` (including a `browser` handle or an Electron window), and expose the live `Page` as a handle.
229
297
 
@@ -234,12 +302,101 @@ Point the `omkit` bin at a project — a `tsconfig.omkit.json` that lists your `
234
302
  - **`omkit init`** — scaffold a starter project: `tsconfig.omkit.json`, a sample `oms/dev.ts`, and `actions/hello.ts`.
235
303
  - **`omkit run`** — with no argument, open the interactive picker: browse and search your oms, run one, and watch its milestones stream live — answering any `prompt` in-console. A bare `omkit` does the same (`run` is the default command).
236
304
  - **`omkit run <om>`** — run one om directly by name or file path, inheriting the terminal.
237
- - **`omkit ls`** — list the discovered oms and actions.
305
+ - **`omkit ls`** — list the discovered oms and actions. Add `--describe` to show each one's `.describe({ summary })` and whether it's exposed; without it, `ls` is a static scan that never imports your code.
238
306
  - **`omkit check`** — typecheck the project (`tsc --noEmit`) and report diagnostics.
307
+ - **`omkit skill`** — generate `.claude/skills/omkit-runs/SKILL.md`, a map of what's runnable (see below).
308
+ - **`omkit mcp`** — serve the project over the Model Context Protocol on stdio (see below).
239
309
  - **`omkit help`** — print the command overview (also `--help` / `-h`).
240
310
 
241
311
  Both paths run the om in its own child process and narrate it to a run folder (see above). The picker **supervises** the child — piping its output into the live view and answering prompts for it — while `omkit run <om>` runs it **bare**, handing the om your terminal directly.
242
312
 
313
+ ## MCP — let an assistant drive your oms
314
+
315
+ `omkit mcp` serves a project over the [Model Context Protocol](https://modelcontextprotocol.io), so Claude and other assistants can discover your oms, run one, and read what the run produced — instead of shelling out and scraping stdout.
316
+
317
+ **Nothing is exposed unless you say so.** Add `.mcp()` to an om or action; without it the server neither lists nor runs it. `.describe()` and `.args()` alone expose nothing.
318
+
319
+ ```ts
320
+ om("smoke-test")
321
+ .describe({ summary: "Boot the app and run the smoke suite" })
322
+ .args(z.object({ headless: z.boolean().default(true) }))
323
+ .mcp() // ← now an assistant can run it
324
+ .run(async (ctx, { headless }) => {
325
+ /* … */
326
+ });
327
+ ```
328
+
329
+ `mode` defaults to `"settling"` — a run that finishes on its own. Declare `.mcp({ mode: "long-lived" })` for a server or a watcher: MCP has no way to express "this never finishes", so omkit does. `run_om` refuses a long-lived entry and points you at `start_om`.
330
+
331
+ Point your client at the project:
332
+
333
+ ```json
334
+ {
335
+ "mcpServers": {
336
+ "omkit": {
337
+ "command": "npx",
338
+ "args": ["--no", "--", "omkit", "mcp"]
339
+ }
340
+ }
341
+ }
342
+ ```
343
+
344
+ The server takes its **project root from the working directory your client launches it in**, and resolves `logs/` under that root — so runs it starts are the runs its resources can serve. If your config lives at the repo root but the om project sits in a subfolder, point at the config explicitly rather than relying on a `cwd` field your client may not support: `["--no", "--", "omkit", "mcp", "--tsconfig", "om/tsconfig.omkit.json"]`.
345
+
346
+ ### Inspecting it
347
+
348
+ The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) drives the same binary, so you can exercise every tool by hand before pointing an assistant at it:
349
+
350
+ ```sh
351
+ npx -y @modelcontextprotocol/inspector npx --no -- omkit mcp
352
+ ```
353
+
354
+ It serves a UI on `localhost:6274` and prints an auth token to paste in. Because omkit declares real input schemas, you get form fields per tool — and the history pane shows the full request and response for each call, `structuredContent` and resource links included. That is usually all the debugging you need: it is a client, so it shows you what _you_ sent, not what an assistant sent.
355
+
356
+ Two things to know. On Windows the Inspector cannot spawn a `.cmd` shim directly, so if `npx` fails there, point it at the binary instead — `node node_modules/omkit/dist/cli/index.js mcp`. And the Inspector owns the runs it starts: closing it tears down anything `start_om` left running, the same rule that applies to any client.
357
+
358
+ **Six tools**, however many oms you have — adding one changes what `list_oms` returns, never the tool list:
359
+
360
+ | Tool | What it does |
361
+ | ----------------------- | ------------------------------------------------------------------------------- |
362
+ | `list_oms` | Exposed oms and actions: summary, mode, and the JSON Schema for their arguments |
363
+ | `run_om(name, args)` | Runs a settling entry to completion and returns its verdict and assert tally |
364
+ | `start_om(name, args)` | Starts anything and returns a handle immediately |
365
+ | `get_run(run)` | Status, verdict once settled, and links to the files the run produced |
366
+ | `tail_run(run, cursor)` | Log lines since `cursor` — live while running, from `raw.jsonl` once settled |
367
+ | `cancel_run(runId)` | Tears a run down; it still finishes writing its run folder |
368
+
369
+ Call `list_oms` first — `run_om`'s `args` is a plain object, so its schema comes from the listing rather than from the tool definition. That is the cost of a tool list that doesn't churn every time you edit a file.
370
+
371
+ **Run folders are resources.** `omkit://runs/{run}/latest/{file}` reads from the newest run of an om, and `omkit://runs/{run}/{date}/{time}/{file}` from one specific run. Reads are confined under the project's `logs/`, size-capped, and read-only. Files a run labelled with `ctx.artifact` come back named and described.
372
+
373
+ **While a run is going**, milestones arrive as progress notifications, cancelling the request tears the run down (and it still writes its record), and a `prompt` raised mid-run reaches you as an elicitation — the client is the supervisor.
374
+
375
+ ## A skill file — the map, before the first call
376
+
377
+ `list_oms` answers "what can I run here?" at runtime, but charges a round trip for it: an assistant has to call it before it can build arguments for anything. `omkit skill` writes the same answer to disk, where it's already in context:
378
+
379
+ ```sh
380
+ omkit skill # writes .claude/skills/omkit-runs/SKILL.md
381
+ omkit skill --check # exits non-zero when it no longer matches the project
382
+ ```
383
+
384
+ Each exposed workflow gets its summary, its argument shape, the path to its source, and an outline of what it calls — read statically from the body, with the `.tag("…")` names you already wrote:
385
+
386
+ ```
387
+ ### `tskb:build` — settling
388
+
389
+ Rebuild the tskb knowledge graph from this repo's .tskb.tsx docs.
390
+
391
+ - **Defined in:** `om/oms/tskb-build.ts`
392
+ - **Args:** `{ verbose?: boolean, projectName?: string }`
393
+ - **Calls:** `watchDir` [watch:build:daemon] → `buildDocs` [build]
394
+ ```
395
+
396
+ Generating it runs nothing — it uses the same discovery fork as `list_oms`, where `.run(body)` reports what it declares instead of launching. The file documents the shell invocation as well as the MCP one, so it's useful with no MCP wiring at all.
397
+
398
+ It's meant to be committed. A `registry-hash` in the header covers the project's declarations rather than the rendered markdown, so `--check` catches an edited om but not a reformatted file — put it in a pre-commit hook or CI. The one field you should edit by hand is the frontmatter `description`, which decides when an assistant loads the file; regeneration preserves it.
399
+
243
400
  ## Install
244
401
 
245
402
  ```sh
@@ -17,7 +17,7 @@ export interface BrowserOptions {
17
17
  }
18
18
  /**
19
19
  * Launches a Chromium browser with Playwright and publishes the live {@link Browser}
20
- * as this action's handle, so a driver — e.g. `chromePage("Explorer", browser.ref,
20
+ * as this action's handle, so a driver — e.g. `chromePage("Explorer", await browser.ref,
21
21
  * { url })` — can await `instance.ref` and open pages in it. This owns the browser it
22
22
  * launches (unlike a CDP endpoint handed to `chromePage`, which belongs to its opener),
23
23
  * so teardown closes it. Replaces the external chromedriver/WebDriver dance.
@@ -3,7 +3,7 @@ import { action } from "../core/action.js";
3
3
  const delay = (ms) => new Promise((r) => setTimeout(r, ms));
4
4
  /**
5
5
  * Launches a Chromium browser with Playwright and publishes the live {@link Browser}
6
- * as this action's handle, so a driver — e.g. `chromePage("Explorer", browser.ref,
6
+ * as this action's handle, so a driver — e.g. `chromePage("Explorer", await browser.ref,
7
7
  * { url })` — can await `instance.ref` and open pages in it. This owns the browser it
8
8
  * launches (unlike a CDP endpoint handed to `chromePage`, which belongs to its opener),
9
9
  * so teardown closes it. Replaces the external chromedriver/WebDriver dance.
@@ -16,16 +16,58 @@ export interface ChromePageEvents {
16
16
  pageerror: string;
17
17
  navigated: string;
18
18
  }
19
+ /**
20
+ * How to pick one page out of the several a browser may have open.
21
+ *
22
+ * A plain string or regex tests the URL, which costs nothing and touches no page. A
23
+ * `{ js }` predicate is **evaluated inside each page** until one returns something truthy,
24
+ * which is the only way to select on things a URL cannot express — the document's title, a
25
+ * rendered element, whether the app has finished booting.
26
+ *
27
+ * The predicate is a JS **expression**, not a function body: `document.title.includes("x")`,
28
+ * not `return document.title…`. Wrap multiple statements in an IIFE when you need them.
29
+ * Keep it pure — it runs against pages that turn out not to match.
30
+ */
31
+ export type PageMatch = string | RegExp | {
32
+ js: string;
33
+ };
19
34
  export interface ChromePageOptions {
20
35
  /** Navigate the attached page here once acquired. Omit to drive it as-is. */
21
36
  url?: string;
37
+ /**
38
+ * Attach to a page **already open** in the target. Without it the action takes the first
39
+ * page it finds, or opens a fresh one when {@link ChromePageOptions.url} is set — neither
40
+ * is what you want when reattaching to a browser someone else is driving.
41
+ *
42
+ * No match is an error, not a silent new tab: asking for a specific page and quietly
43
+ * getting a blank one hides the failure until something downstream reads the wrong
44
+ * document.
45
+ */
46
+ match?: PageMatch;
22
47
  }
48
+ /** Every page open across every context — a reattached browser may have more than one. */
49
+ export declare const pagesOf: (browser: Browser) => Page[];
50
+ /**
51
+ * Find the one open page a {@link PageMatch} selects, or explain what was open instead.
52
+ *
53
+ * Listing the candidates matters more here than anywhere else in this action: the caller is
54
+ * attaching to a browser they did not launch, so "no match" is nearly always a stale URL or a
55
+ * predicate written against the wrong page — and both are obvious the moment you can see what
56
+ * was actually there.
57
+ *
58
+ * A predicate that throws (a chrome:// page, a document still loading, a cross-origin frame)
59
+ * counts as "did not match" rather than failing the search, but is reported, because a
60
+ * predicate that threw on every page is a very different problem from one that simply found
61
+ * nothing.
62
+ */
63
+ export declare function selectPage(pages: readonly Page[], match: PageMatch): Promise<Page>;
23
64
  /**
24
65
  * Publishes a Chrome/Chromium {@link Page} as this action's handle, so downstream
25
- * actions can await `instance.ref` and drive the same live page. The source may be
26
- * a promise, so it wires straight from another action's `.ref` (e.g.
27
- * `chromePage("Explorer", chromedriver.ref)`). `label` tags this run so several
66
+ * actions can await `instance.ref` and drive the same live page. The source is a
67
+ * resolved handle, not a promise — chain it from another action by awaiting that
68
+ * action's `.ref` at the call site (e.g.
69
+ * `chromePage("Explorer", await chromedriver.ref)`). `label` tags this run so several
28
70
  * pages in one run stay distinguishable. Daemon: acquire + attach, stay alive
29
71
  * until teardown, then close the CDP connection only when it opened one.
30
72
  */
31
- export declare const chromePage: import("../index.ts").Action<[label: string, source: ChromePageSource | Promise<ChromePageSource>, opts?: ChromePageOptions | undefined], void, ChromePageEvents, Page>;
73
+ export declare const chromePage: import("../index.ts").Action<[label: string, source: ChromePageSource, opts?: ChromePageOptions | undefined], void, ChromePageEvents, Page>;