omkit 0.4.8 → 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.
- package/CHANGELOG.md +153 -0
- package/README.md +168 -12
- package/dist/actions/chrome-page.d.ts +41 -0
- package/dist/actions/chrome-page.js +77 -29
- package/dist/actions/drive-page.d.ts +50 -0
- package/dist/actions/drive-page.js +68 -0
- package/dist/actions/index.d.ts +5 -3
- package/dist/actions/index.js +2 -1
- package/dist/actions/prompt.d.ts +32 -5
- package/dist/actions/prompt.js +92 -8
- package/dist/cli/commands/help.js +6 -0
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/commands/ls.d.ts +11 -4
- package/dist/cli/commands/ls.js +35 -15
- package/dist/cli/commands/mcp.d.ts +10 -0
- package/dist/cli/commands/mcp.js +13 -0
- package/dist/cli/commands/skill.d.ts +17 -0
- package/dist/cli/commands/skill.js +56 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.js +71 -29
- package/dist/client/discover-child.d.ts +12 -0
- package/dist/client/discover-child.js +71 -0
- package/dist/client/discovery.d.ts +21 -1
- package/dist/client/discovery.js +139 -2
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.js +3 -1
- package/dist/client/outline.d.ts +35 -0
- package/dist/client/outline.js +100 -0
- package/dist/client/registry.d.ts +59 -0
- package/dist/client/runner.d.ts +12 -0
- package/dist/client/runner.js +16 -7
- package/dist/client/types.d.ts +9 -1
- package/dist/core/ActionRun.d.ts +5 -0
- package/dist/core/ActionRun.js +9 -0
- package/dist/core/ExecutionTree.d.ts +40 -2
- package/dist/core/ExecutionTree.js +66 -2
- package/dist/core/action.d.ts +2 -1
- package/dist/core/action.js +93 -9
- package/dist/core/args.d.ts +50 -0
- package/dist/core/args.js +166 -0
- package/dist/core/discovery-mode.d.ts +37 -0
- package/dist/core/discovery-mode.js +54 -0
- package/dist/core/interaction.d.ts +3 -1
- package/dist/core/om.d.ts +11 -9
- package/dist/core/om.js +182 -13
- package/dist/core/schema-hint.d.ts +20 -0
- package/dist/core/schema-hint.js +73 -0
- package/dist/core/schema-json.d.ts +12 -0
- package/dist/core/schema-json.js +12 -0
- package/dist/core/types.d.ts +113 -1
- package/dist/foundation/LogEntry.d.ts +1 -1
- package/dist/foundation/canonicalPath.d.ts +26 -0
- package/dist/foundation/canonicalPath.js +34 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/action-host.d.ts +1 -0
- package/dist/mcp/action-host.js +65 -0
- package/dist/mcp/action-identity.d.ts +23 -0
- package/dist/mcp/action-identity.js +35 -0
- package/dist/mcp/progress.d.ts +61 -0
- package/dist/mcp/progress.js +92 -0
- package/dist/mcp/resources.d.ts +55 -0
- package/dist/mcp/resources.js +208 -0
- package/dist/mcp/runs.d.ts +37 -0
- package/dist/mcp/runs.js +60 -0
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +38 -0
- package/dist/mcp/tail.d.ts +37 -0
- package/dist/mcp/tail.js +64 -0
- package/dist/mcp/tools.d.ts +54 -0
- package/dist/mcp/tools.js +374 -0
- package/dist/mcp/validate.d.ts +14 -0
- package/dist/mcp/validate.js +54 -0
- package/dist/mcp/verdict.d.ts +31 -0
- package/dist/mcp/verdict.js +47 -0
- package/dist/output/artifact/ArtifactStore.d.ts +24 -0
- package/dist/output/artifact/ArtifactStore.js +49 -0
- package/dist/output/folder/RunFolder.d.ts +23 -1
- package/dist/output/folder/RunFolder.js +55 -11
- package/dist/output/writers/views.d.ts +10 -0
- package/dist/skill/file.d.ts +26 -0
- package/dist/skill/file.js +58 -0
- package/dist/skill/index.d.ts +11 -0
- package/dist/skill/index.js +9 -0
- package/dist/skill/model.d.ts +55 -0
- package/dist/skill/model.js +116 -0
- package/dist/skill/render.d.ts +18 -0
- package/dist/skill/render.js +133 -0
- 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"
|
|
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");
|
|
@@ -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
|
-
|
|
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"
|
|
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
|
|
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)
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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`):
|
|
@@ -224,7 +291,7 @@ Reusable actions built on the core engine. For anything beyond these, drop down
|
|
|
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
293
|
- **`portFree`** — the mirror of `healthcheck`: poll a TCP port until nothing is listening, so a restart can rebind without racing the old process.
|
|
227
|
-
- **`prompt`** — ask the terminal for input or a
|
|
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.
|
|
228
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`.
|
|
229
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.
|
|
230
297
|
|
|
@@ -235,12 +302,101 @@ Point the `omkit` bin at a project — a `tsconfig.omkit.json` that lists your `
|
|
|
235
302
|
- **`omkit init`** — scaffold a starter project: `tsconfig.omkit.json`, a sample `oms/dev.ts`, and `actions/hello.ts`.
|
|
236
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).
|
|
237
304
|
- **`omkit run <om>`** — run one om directly by name or file path, inheriting the terminal.
|
|
238
|
-
- **`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.
|
|
239
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).
|
|
240
309
|
- **`omkit help`** — print the command overview (also `--help` / `-h`).
|
|
241
310
|
|
|
242
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.
|
|
243
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
|
+
|
|
244
400
|
## Install
|
|
245
401
|
|
|
246
402
|
```sh
|
|
@@ -16,10 +16,51 @@ 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
66
|
* actions can await `instance.ref` and drive the same live page. The source is a
|
|
@@ -5,6 +5,80 @@ const isPage = (x) => typeof x.mainFrame === "function";
|
|
|
5
5
|
const isBrowser = (x) => typeof x.contexts === "function";
|
|
6
6
|
/** Take the first existing page of a context, or open one. */
|
|
7
7
|
const pageOf = (context, fresh) => fresh ? context.newPage() : (context.pages()[0] ?? context.newPage());
|
|
8
|
+
/** Every page open across every context — a reattached browser may have more than one. */
|
|
9
|
+
export const pagesOf = (browser) => browser.contexts().flatMap((c) => c.pages());
|
|
10
|
+
/**
|
|
11
|
+
* Find the one open page a {@link PageMatch} selects, or explain what was open instead.
|
|
12
|
+
*
|
|
13
|
+
* Listing the candidates matters more here than anywhere else in this action: the caller is
|
|
14
|
+
* attaching to a browser they did not launch, so "no match" is nearly always a stale URL or a
|
|
15
|
+
* predicate written against the wrong page — and both are obvious the moment you can see what
|
|
16
|
+
* was actually there.
|
|
17
|
+
*
|
|
18
|
+
* A predicate that throws (a chrome:// page, a document still loading, a cross-origin frame)
|
|
19
|
+
* counts as "did not match" rather than failing the search, but is reported, because a
|
|
20
|
+
* predicate that threw on every page is a very different problem from one that simply found
|
|
21
|
+
* nothing.
|
|
22
|
+
*/
|
|
23
|
+
export async function selectPage(pages, match) {
|
|
24
|
+
const describe = () => pages.length ? pages.map((p) => ` ${p.url()}`).join("\n") : " (no pages are open)";
|
|
25
|
+
if (typeof match === "string" || match instanceof RegExp) {
|
|
26
|
+
const hit = pages.find((p) => typeof match === "string" ? p.url().includes(match) : match.test(p.url()));
|
|
27
|
+
if (hit)
|
|
28
|
+
return hit;
|
|
29
|
+
throw new Error(`no open page matched ${String(match)}. Open pages:\n${describe()}`);
|
|
30
|
+
}
|
|
31
|
+
// Sequential, not parallel: stopping at the first hit keeps the predicate off pages it
|
|
32
|
+
// never needed to touch, which matters because it is someone else's live browser.
|
|
33
|
+
const threw = [];
|
|
34
|
+
for (const page of pages) {
|
|
35
|
+
try {
|
|
36
|
+
if (await page.evaluate(match.js))
|
|
37
|
+
return page;
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
threw.push(` ${page.url()} — ${e instanceof Error ? e.message : String(e)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const errors = threw.length ? `\nThe predicate threw on:\n${threw.join("\n")}` : "";
|
|
44
|
+
throw new Error(`no open page satisfied \`${match.js}\`. Open pages:\n${describe()}${errors}`);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolve a {@link ChromePageSource} down to one live page.
|
|
48
|
+
*
|
|
49
|
+
* With `match` the page must already exist and is searched for across **every** context: a
|
|
50
|
+
* reattached browser belongs to someone else, and nothing says the page you want lives in the
|
|
51
|
+
* context we would otherwise default to. Without `match` the old behaviour stands — take the
|
|
52
|
+
* first page, or open a fresh one when navigating.
|
|
53
|
+
*/
|
|
54
|
+
async function acquire(source, opts) {
|
|
55
|
+
const { url, match } = opts;
|
|
56
|
+
const fresh = url !== undefined;
|
|
57
|
+
if (typeof source === "string") {
|
|
58
|
+
const cdpUrl = source.includes("://") ? source : `http://${source}`;
|
|
59
|
+
let owned;
|
|
60
|
+
try {
|
|
61
|
+
owned = await chromium.connectOverCDP(cdpUrl);
|
|
62
|
+
}
|
|
63
|
+
catch (cause) {
|
|
64
|
+
throw new Error(`could not connect to Chrome over CDP at ${cdpUrl}`, { cause });
|
|
65
|
+
}
|
|
66
|
+
const page = match
|
|
67
|
+
? await selectPage(pagesOf(owned), match)
|
|
68
|
+
: await pageOf(owned.contexts()[0] ?? (await owned.newContext()), fresh);
|
|
69
|
+
return { page, from: cdpUrl, owned };
|
|
70
|
+
}
|
|
71
|
+
if (isPage(source))
|
|
72
|
+
return { page: source, from: "page handle" };
|
|
73
|
+
if (isBrowser(source)) {
|
|
74
|
+
const page = match
|
|
75
|
+
? await selectPage(pagesOf(source), match)
|
|
76
|
+
: await pageOf(source.contexts()[0] ?? (await source.newContext()), fresh);
|
|
77
|
+
return { page, from: "browser handle" };
|
|
78
|
+
}
|
|
79
|
+
const page = match ? await selectPage(source.pages(), match) : await pageOf(source, fresh);
|
|
80
|
+
return { page, from: "context handle" };
|
|
81
|
+
}
|
|
8
82
|
/**
|
|
9
83
|
* Publishes a Chrome/Chromium {@link Page} as this action's handle, so downstream
|
|
10
84
|
* actions can await `instance.ref` and drive the same live page. The source is a
|
|
@@ -20,35 +94,9 @@ export const chromePage = action("chromePage")
|
|
|
20
94
|
.run(async ({ signal, emit, attach, tag }, label, source, opts = {}) => {
|
|
21
95
|
tag(label);
|
|
22
96
|
const { url } = opts;
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
let from;
|
|
27
|
-
if (typeof source === "string") {
|
|
28
|
-
const cdpUrl = source.includes("://") ? source : `http://${source}`;
|
|
29
|
-
from = cdpUrl;
|
|
30
|
-
try {
|
|
31
|
-
owned = await chromium.connectOverCDP(cdpUrl);
|
|
32
|
-
}
|
|
33
|
-
catch (cause) {
|
|
34
|
-
throw new Error(`could not connect to Chrome over CDP at ${cdpUrl}`, { cause });
|
|
35
|
-
}
|
|
36
|
-
const context = owned.contexts()[0] ?? (await owned.newContext());
|
|
37
|
-
page = await pageOf(context, url !== undefined);
|
|
38
|
-
}
|
|
39
|
-
else if (isPage(source)) {
|
|
40
|
-
from = "page handle";
|
|
41
|
-
page = source;
|
|
42
|
-
}
|
|
43
|
-
else if (isBrowser(source)) {
|
|
44
|
-
from = "browser handle";
|
|
45
|
-
const context = source.contexts()[0] ?? (await source.newContext());
|
|
46
|
-
page = await pageOf(context, url !== undefined);
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
49
|
-
from = "context handle";
|
|
50
|
-
page = await pageOf(source, url !== undefined);
|
|
51
|
-
}
|
|
97
|
+
// With both `match` and `url`, acquire finds the existing page and this navigates it —
|
|
98
|
+
// "go to that tab, then send it here" rather than opening a second one.
|
|
99
|
+
const { page, from, owned } = await acquire(source, opts);
|
|
52
100
|
page.on("console", (msg) => {
|
|
53
101
|
console.log(`console: ${msg.text()}`);
|
|
54
102
|
emit("console", `from: ${label}: ${msg.text()}`);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type ChromePageSource, type PageMatch } from "./chrome-page.ts";
|
|
2
|
+
export interface DrivePageOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Where the page is. Either a **CDP endpoint** (`host:port` or a URL) for a browser
|
|
5
|
+
* running in another process, or a **live handle** — a `Page`, `BrowserContext`, or
|
|
6
|
+
* `Browser` — published by an earlier action in this same run.
|
|
7
|
+
*
|
|
8
|
+
* Both matter, and for different reasons. The address form is plain data, so it can be
|
|
9
|
+
* described by a schema and called from outside the process; the handle form skips the
|
|
10
|
+
* network entirely when the browser is already yours, which is what an om driving its own
|
|
11
|
+
* stack wants. Defaults to the endpoint `browser` opens when given a debugging port.
|
|
12
|
+
*/
|
|
13
|
+
target?: ChromePageSource;
|
|
14
|
+
/** Which open tab to drive. Omit to take the first page the browser has open. */
|
|
15
|
+
match?: PageMatch;
|
|
16
|
+
/** Navigate the chosen tab here first. Omit to drive it wherever it already is. */
|
|
17
|
+
url?: string;
|
|
18
|
+
/**
|
|
19
|
+
* The JS to run in the page, as an **expression** — `document.title`, not `return …`.
|
|
20
|
+
* Wrap several statements in an IIFE: `(() => { …; return x })()`. The result travels
|
|
21
|
+
* back through the run's log, so it has to be JSON-serialisable.
|
|
22
|
+
*/
|
|
23
|
+
js: string;
|
|
24
|
+
/** Save a PNG of the page afterwards and register it as a run artifact. Default true. */
|
|
25
|
+
screenshot?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** What {@link drivePage} reports back. */
|
|
28
|
+
export interface DrivePageResult {
|
|
29
|
+
/** Whatever the expression evaluated to. */
|
|
30
|
+
readonly value: unknown;
|
|
31
|
+
readonly title: string;
|
|
32
|
+
readonly url: string;
|
|
33
|
+
/** Absolute path of the screenshot, when one was taken. */
|
|
34
|
+
readonly screenshot?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Evaluate an expression in a live page and report what came back.
|
|
38
|
+
*
|
|
39
|
+
* Reaches its page either way round, and that is the point. Given a **handle** it drives a
|
|
40
|
+
* page an earlier action in the same run already published — the cheap path, and the one an
|
|
41
|
+
* om driving its own stack should take. Given a **CDP address** it attaches to a browser in
|
|
42
|
+
* another process entirely, which is what lets this action be described by a schema and
|
|
43
|
+
* called by name from outside: `browser` and {@link chromePage} publish capability handles,
|
|
44
|
+
* and a handle is not something a caller across a process boundary can pass.
|
|
45
|
+
*
|
|
46
|
+
* Nothing here is owned. `chromePage` closes only a CDP connection it opened, and closing a
|
|
47
|
+
* CDP connection disconnects the client rather than stopping the browser — so this finishing,
|
|
48
|
+
* failing, or being cancelled never tears down the stack it attached to, in either mode.
|
|
49
|
+
*/
|
|
50
|
+
export declare const drivePage: import("../core/types.ts").Action<[opts: DrivePageOptions], DrivePageResult, import("../core/types.ts").NoEvents, void>;
|