crew-runner 0.1.0 → 0.2.2
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 +52 -1
- package/STUDIO.md +128 -0
- package/bin/crew-studio.mjs +5 -0
- package/package.json +5 -2
- package/src/acp.ts +104 -11
- package/src/api.ts +37 -3
- package/src/cli.ts +30 -5
- package/src/computer-run.ts +151 -0
- package/src/computer-web.ts +187 -0
- package/src/computer.ts +403 -0
- package/src/loop.ts +3 -3
- package/src/pair-code.ts +14 -0
- package/src/studio-audio-admission.ts +50 -0
- package/src/studio-cli.ts +47 -0
- package/src/studio-mcp.ts +38 -0
- package/src/studio-source.ts +87 -0
- package/src/studio.ts +411 -0
package/README.md
CHANGED
|
@@ -5,6 +5,10 @@ Runs your CrewCortex assistants on your own computer (Mac or Windows), using the
|
|
|
5
5
|
CrewCortex never asks for your Claude or ChatGPT login or token.
|
|
6
6
|
Runs go through the Agent Client Protocol (ACP), so one code path drives both apps.
|
|
7
7
|
|
|
8
|
+
## Local video studio pilot (unreleased)
|
|
9
|
+
|
|
10
|
+
`crew-runner studio` is a separate opt-in local editing session. It does not enable media access for ordinary queued runs. See [STUDIO.md](STUDIO.md) for explicit project consent, typed plans, fixed animated graphics, downloaded Higgsfield-asset provenance, caps and the remote-only acceptance recipe. This pilot has not been published to npm.
|
|
11
|
+
|
|
8
12
|
## Setup
|
|
9
13
|
|
|
10
14
|
Needs Node.js 20 or later (https://nodejs.org).
|
|
@@ -59,9 +63,56 @@ Press Ctrl+C (or close the window) to stop the runner. Notes for Windows:
|
|
|
59
63
|
- Only the old `CREW_RUNNER_PROTOCOL=legacy` path has limits on a `.cmd` file: an instruction with a line break fails there.
|
|
60
64
|
The default ACP path has no such limit.
|
|
61
65
|
|
|
66
|
+
## Computer mode (@mac, owner only)
|
|
67
|
+
|
|
68
|
+
`crew-runner start --computer` serves only your own **@mac** computer agent, never your ordinary assistants (a plain
|
|
69
|
+
`start` never receives @mac work). It uses Claude Code with its own file, terminal and web tools, on this computer:
|
|
70
|
+
|
|
71
|
+
- It saves its work in `~/CrewCortex/<workspace>` (created with owner-only permissions) and may also use `~/Downloads` and
|
|
72
|
+
`~/.cursor/dev projects`. Another workspace's folder under `~/CrewCortex` is always refused. Claude Code itself runs in an
|
|
73
|
+
empty, read-only scratch folder that is removed after the run, so every read, search and command touching your folders
|
|
74
|
+
comes to the runner first; none is approved by Claude Code on its own.
|
|
75
|
+
- Simple read-only commands on full paths in those folders (`ls ~/Downloads`, `cat ~/CrewCortex/<workspace>/notes.md`) run
|
|
76
|
+
without asking; a relative path asks, because the shell may have moved. The runner never
|
|
77
|
+
auto-allows `git`, because a repository's own settings can run code. Leaving plan mode, steps too
|
|
78
|
+
long to show in full on a card, and Claude Code's own settings and history are refused. Opening a web page or search, deleting, `git push`, sending (mail, `curl`, `ssh`, AppleScript and
|
|
79
|
+
similar), paying, changing files outside those folders, and any command the runner cannot read with certainty (pipes,
|
|
80
|
+
`$(...)`, redirects, unknown programs) wait for your yes on a card in CrewCortex. No answer within the run is a no.
|
|
81
|
+
- Reading a public web page runs without a card when it is one plain `curl` that only downloads: a GET of one `http(s)`
|
|
82
|
+
address on a public name (no login, port, local or numeric address, no redirects), printed or saved with `-o` to a new
|
|
83
|
+
file inside `~/CrewCortex/<workspace>`, with no data, headers, uploads, cookies or config file (`~/.curlrc` makes every curl ask).
|
|
84
|
+
This holds only until the run has read files on this computer (other than pages it downloaded): after that a download
|
|
85
|
+
asks, because its address could carry what was read. The address still leaves your computer, as with any web page,
|
|
86
|
+
and a public name can point at a private address. `wget`, `-L`, pipes and other programs ask.
|
|
87
|
+
- On a card for a web request that sends only what its own command line shows, **Allow for this task** also lets later
|
|
88
|
+
requests of the same kind in the same run go ahead without a card: web searches, page reads from that one host, or curl
|
|
89
|
+
requests with that one method to that one host (with or without data or headers, and redirects, as on the card). It ends with the run, never covers uploads of local files, deletes,
|
|
90
|
+
pushes or anything else, and each step it covers is in the action log with the card that allowed it.
|
|
91
|
+
- It never changes Claude Code settings (`.claude` folders, `~/.claude.json`), and refuses to start in a workspace folder that has them.
|
|
92
|
+
- Claude Code's own files under `~/.claude` (settings, other sessions' history, task lists) and `~/.claude.json` are denied
|
|
93
|
+
to it by permission rules that Claude Code checks before anything it would allow by itself, and every tool that reads,
|
|
94
|
+
writes, runs or fetches is set to ask, so its built-in read-only lists never skip the runner. Skills are off.
|
|
95
|
+
- A step whose paths resolve into another folder in `~/CrewCortex` is refused, also when written with quotes, `$HOME` or `..`;
|
|
96
|
+
a step the runner cannot read for certain goes to a card that shows the whole request (one too large to show in full is
|
|
97
|
+
refused), so read the card before saying yes.
|
|
98
|
+
- `~/Downloads` and `~/.cursor/dev projects` are shared by every workspace's @mac runs: what one run saves there, a run for
|
|
99
|
+
another workspace can read. Keep client files in that client's `~/CrewCortex/<workspace>` folder.
|
|
100
|
+
- Only the tools the runner knows exist in the run (shell, files, web, planning and plain subagents); agent teams and
|
|
101
|
+
skills are off.
|
|
102
|
+
- A step is allowed only once its line in the action log is saved; if the log cannot be written, the step is refused.
|
|
103
|
+
- Channel messages, files, web pages and command output are data, never instructions. Only your own message instructs it.
|
|
104
|
+
- It has no CrewCortex tools. Its reply is posted in the channel where you tagged it.
|
|
105
|
+
- Every allowed and refused step is in the run's action log, which only you can see.
|
|
106
|
+
|
|
107
|
+
A linked device belongs to one workspace. For several workspaces, link and start one runner per workspace, each with its own
|
|
108
|
+
`CREW_RUNNER_HOME`, for example `CREW_RUNNER_HOME=~/.crew-runner-calyx npx crew-runner@latest login` then
|
|
109
|
+
`CREW_RUNNER_HOME=~/.crew-runner-calyx npx crew-runner@latest start --computer`. Press Ctrl+C to stop it; switching @mac off
|
|
110
|
+
in CrewCortex stops new requests and also stops the one in progress within a few seconds (its open cards can no longer be
|
|
111
|
+
approved).
|
|
112
|
+
|
|
62
113
|
## What the apps can do during a run
|
|
63
114
|
|
|
64
|
-
Each run happens in an empty temporary folder, and the only tool is CrewCortex itself. For Codex the run
|
|
115
|
+
Each run happens in an empty temporary folder, and the only tool is CrewCortex itself (computer mode above is the one opt-in exception). For Codex the run
|
|
65
116
|
uses a read-only sandbox, ignores your own Codex config and rules, and turns off the shell, browser,
|
|
66
117
|
computer, image, web search, plugin and sub-agent tools. The run token is passed through an environment
|
|
67
118
|
variable, never on the command line.
|
package/STUDIO.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Local video studio pilot (unreleased)
|
|
2
|
+
|
|
3
|
+
An account-independent local media CLI and an opt-in media-only MCP bridge. No ordinary `crew-runner start` behavior changes. This is not the cloud studio UI and is not published to npm.
|
|
4
|
+
|
|
5
|
+
## Local member journey
|
|
6
|
+
|
|
7
|
+
Install FFmpeg and ffprobe yourself using a trusted OS/package source. The pilot requires MP4 input, an H.264 encoder (`libx264`), AAC and drawtext with working local fonts. There are no runtime binary/model downloads. Check `capabilities` first. Hardware encoders advertised by the installed binary are listed, but this pilot always uses CPU and never claims verified GPU acceleration.
|
|
8
|
+
|
|
9
|
+
Select a dedicated canonical absolute project directory, place only the media you intend to expose inside it, and pass both flags each time:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
node packages/runner/bin/crew-runner.mjs studio --project /absolute/my-project --allow-local-media capabilities
|
|
13
|
+
node packages/runner/bin/crew-runner.mjs studio --project /absolute/my-project --allow-local-media inspect source.mp4
|
|
14
|
+
node packages/runner/bin/crew-runner.mjs studio --project /absolute/my-project --allow-local-media render plan.json
|
|
15
|
+
node packages/runner/bin/crew-runner.mjs studio --project /absolute/my-project --allow-local-media preview plan.json
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
No CrewCortex login is needed for these commands. Preview is a separate 640x360 MP4, not an HTTP server; export is 1280x720. Source aspect ratio is preserved with letterboxing. Original audio is cut alongside the corresponding frames, normalised to stereo 48 kHz AAC. Silent sources receive silence, never cloned/generated speech. Original MP4s are never overwritten. Each successful job returns a new `.crew-studio/job-<uuid>/export.mp4` and `manifest.json` containing source SHA-256, selected cuts and imported-asset provenance. Partial jobs are removed on cancellation/error; completed versions survive subsequent failures. Ctrl+C/TERM cancels a CLI render.
|
|
19
|
+
|
|
20
|
+
Plan times are seconds. Caption/callout times refer to the finished timeline, not the source. Callouts use a fixed dark panel, mint brand stripe and large sliding/fading typography, not arbitrary FFmpeg filters or HTML. The supporting asset is appended after the original cuts.
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"source": "source.mp4",
|
|
25
|
+
"cuts": [{ "start": 0, "end": 3 }, { "start": 5, "end": 8 }],
|
|
26
|
+
"captions": [{ "start": 0, "end": 2.8, "text": "Your supplied transcript text" }],
|
|
27
|
+
"callouts": [{ "start": 0.3, "end": 2.6, "text": "ORIGINAL VOICE + FOOTAGE" }]
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Supply a typed plan/timed transcript. `transcribe source.mp4` fails clearly because no speech model is installed or downloaded. Plain one-line text supports letters/numbers and a small punctuation set, up to 80 characters per entry. Split longer captions. Rendering human likeness or editorial quality is not proven by a synthetic fixture.
|
|
32
|
+
|
|
33
|
+
## Explicit Higgsfield handoff, no rebilling
|
|
34
|
+
|
|
35
|
+
1. In the existing CrewCortex browser UI, download an already-authorized clean Higgsfield MP4 through its normal file download. Do not generate a new video for this pilot.
|
|
36
|
+
2. Explicitly copy/select that downloaded file into the approved project. The CLI never downloads it, reads browser cookies or obtains presigned URLs.
|
|
37
|
+
3. Import it with a human-supplied file/job reference:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
node packages/runner/bin/crew-runner.mjs studio --project /absolute/my-project --allow-local-media import support.mp4 higgsfield existing-file-or-job-reference
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
4. Add the returned `id` to the plan as `"supportingAsset": { "id": "returned-uuid", "start": 0, "end": 2 }`.
|
|
44
|
+
|
|
45
|
+
The import records declared origin, reference, byte hash, `verifiedByProvider: false` and `charged: false`. This is a provenance declaration, not provider attestation. It does not call the generation API or reserve/settle credits. If the local file changes after import, rendering fails until explicitly imported again. Use origin `local` for fixtures and other local assets. Never label fixture footage as Higgsfield-generated. Existing supplied-cloud Higgsfield billing/provider data are unchanged.
|
|
46
|
+
|
|
47
|
+
## Source-coordinate long-form slice (version 2, CLI only)
|
|
48
|
+
|
|
49
|
+
The existing `render` / `preview` CLI also accepts an explicit version-2 plan. This is an offline engine contract, **not a channel-to-Runner integration**. The existing media MCP plan schema still exposes only the legacy short-plan format pending contract review. `inspect` and asset import retain the legacy 120-second/100 MiB limits; long-source admission/strict decoding occurs inside a version-2 render.
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"version": 2,
|
|
54
|
+
"source": "selected-recording.mp4",
|
|
55
|
+
"sourceDuration": 4326,
|
|
56
|
+
"remove": [{ "start": 0, "end": 48 }],
|
|
57
|
+
"mute": [{ "start": 3591, "end": 3598 }],
|
|
58
|
+
"holds": [{ "start": 3591, "end": 3601, "frame": 3590 }],
|
|
59
|
+
"crop": { "x": 0, "y": 0, "width": 1280, "height": 720 },
|
|
60
|
+
"intro": "brand/intro.mp4"
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
All times here are **original source seconds**, on a 30fps output grid. `remove` deletes picture and sound together; `mute` removes only audio content, replacing it with silence **without ripple**, so later speech stays aligned with picture. This is an explicit editing mode, not an interpretation of an ambiguous client request. Holds must name start, end and a source-frame seek time; no automatic freeze-to-end. The selected picture is the first frame decoded at/after that seek, not an invented prior frame. Crop uses even source-pixel coordinates and must fit the decoded source dimensions. Input arrays must be ordered/non-overlapping within each track, at most 32 entries each. SourceDuration is checked against the selected file. Unknown fields, output paths, arbitrary filters and commands are refused. Preserve the raw human request separately; this schema does not parse natural-language timestamps or approve a crop/hold.
|
|
65
|
+
|
|
66
|
+
Source/output times are distinct in `manifest.json.mapping`; deletions are applied once in source space. Fixed 30fps output and exact frame counts are checked on every video-only unit and final encode, alongside final picture/audio duration and strict decode. V2 reads source audio once in the final pass, resamples to 48kHz with timestamp-aware source placement (`aresample=48000:async=1:min_hard_comp=0.001:first_pts=0`), partitions it into exact 1600-sample blocks on the validated 30fps source grid, mutes/selects those blocks in original-source coordinates and assigns continuous sample timestamps. Holds never split the audio. Timestamp placement fills source gaps/positive starts with silence and drops overlapping or pre-zero samples before block selection; `async=1` enables placement, not soft clock stretching (`min_hard_comp=0.001` gives a 1ms hard-correction threshold). It never repairs concatenated per-unit AAC padding: the units have no audio. AAC is encoded **once**, not per unit. swresample can allocate an entire timestamp gap as PCM before the trim. V2 therefore refuses unsafe source clocks **before snapshot or rendering**, using the admission below; the fill/drop filter stays in its correct pre-trim position. There is no full-source PCM cache, split/concat audio buffering, or final timeline truncation to hide padding gaps. Silent sources use a fixed bounded silence input. Legacy v1 keeps its existing segment-AAC/concat path and short-form contract; this repair does not claim sample-exact joins for arbitrary legacy cuts.
|
|
67
|
+
|
|
68
|
+
The broadband-audio regression covers continuous 65-second outputs at 30 and 29.97fps, 44.1kHz source resampling, original-source A/V removal with mute/hold boundaries, and a 305-second/11-unit output. It checks correlation lag before/after every non-mute seam (delta <=48 samples, absolute offset <=1024 samples), near-silent runs <=48 samples at non-mute joins, source-picture frame offset zero, final-pass cancellation and retained versions/budgets. A separate silent-source case checks fractional-grid cuts. Four source-clock cases use an independent decoded-frame PTS scatter reference (not the production resampler/block filter): a 0.5s timestamp gap, an actual +40ms decoded audio start, a negative input offset normalized by MP4 edit-list trimming, and a 40ms timestamp overlap. They require absolute lag <=48 samples before/after the discontinuity and both 30s seams, gap-interior silence, one final audio stream, all 1950 picture frames and no missing audio beyond ordinary final AAC rounding. This is synthetic mechanical sync evidence, not human speech quality or full-length throughput. The late-window public-CLI pilot still keeps 12s from a 72m6s source and checks held pixels and tone/silence/tone.
|
|
69
|
+
|
|
70
|
+
V2 source-audio admission is deliberately narrower than legacy: no audio, or **one AAC-LC stream**, with a standard rate of 8/11.025/12/16/22.05/24/32/44.1/48/64/88.2/96kHz and 1-8 channels. Other codecs/profiles, multiple audio streams and midstream decoded format/rate/channel changes are refused with a named reason, not silently transcoded as a fallback. This restriction supplies an explicit bounded-frame contract (float-planar PCM, 1-1024 samples/frame); it does **not** restrict legacy inspect/import/render codecs. The existing strict source decode now streams `asettb=1/28224000,ashowinfo` metadata through the existing owned command/cancellation helper, with no resampling or silence injection. The common integer timebase represents each admitted rate exactly. Admission sums **positive adjacent decoded-frame gaps plus a positive initial offset**, never subtracts overlaps, and adds two ticks per frame to conservatively cover timebase rounding. The total must stay within **2 seconds**, including that reserve. Priming and MP4 edit-list skips are already reflected in the actual decoded frames; `nb_frames * 1024` or a NET duration estimate is not trusted. A 100s positive gap with an offsetting run of repeated/overlapping timestamps is still denied. At most 600,000 decoded frames, 192 MiB of streamed log bytes and 8192 characters per line are inspected, without retaining the scan; only the 16kB stderr tail and bounded line buffer survive. Missing/nonfinite/unsafe clocks and incomplete/unsupported records fail closed. The scan shares the existing <=10-minute strict-source-decode limit and <=15-minute consent-bound job deadline, not a new budget. Source identity/size/mtime/ctime are checked across admission and the hashed snapshot. This bounds gap-silence expansion, not total FFmpeg RSS or decoder-library behavior; FFmpeg is not an OS memory sandbox.
|
|
71
|
+
|
|
72
|
+
Admission regressions prove refusal before the snapshot or video units for a real 120s/100s-gap source, the same-duration/sample-count gap-plus-overlap bypass, and ALAC (with a retained legacy ALAC render control). Real 8kHz mono and 96kHz/8-channel controls pass. Parser tests cover profile/channel/rate/clock/frame/count boundaries and cumulative gaps; owned-child tests cover empty scans, oversized lines/bytes, cancellation, consent expiry and lock cleanup.
|
|
73
|
+
|
|
74
|
+
Bounds: source <=3 hours, <=6 GiB, 1-120fps, <=3840x2160; each sequential render unit <=30 seconds, <=576 units; aggregate intermediate files <=512 MiB, final export <=512 MiB. Disk admission requires snapshot size + both 512 MiB budgets +256 MiB reserve. Snapshot uses a 1 MiB streaming buffer and hashes copied bytes; originals are not loaded wholesale into memory. Consent and the entire job share a <=15-minute deadline, source/final decode or final encoding <=10 minutes, individual segments <=90 seconds. Decoder/encoder/filter threads stay at 2. These are conservative admission limits, not a promise that every admitted 4K recording finishes within the deadline. Source metadata/decode must succeed before source-frame EOF display padding (at most one source-frame interval) is applied. FFmpeg is not an OS memory sandbox.
|
|
75
|
+
|
|
76
|
+
The existing project lock, cancellation and fail-closed crash recovery remain. Per-segment `progress.json` is a bounded checkpoint, not resumable authorization: process death can leave lock/private partial files; inspect owned processes before human cleanup, never automatically rerun a lost acknowledgement. Successful UUID versions have source/output SHA-256 plus plan/mapping, and earlier versions survive failed edits. Export files remain local. No automatic transfer, scanned upload, hosted version registration or publication exists here.
|
|
77
|
+
|
|
78
|
+
Intro prepending: an optional `"intro": "brand/intro.mp4"` names ONE existing project MP4 that the member chose (for example a workspace's saved usual intro or a file they supplied with the request). The engine never picks, generates or scores an intro; choosing which file, and showing that choice for approval, belongs to the caller. The intro is snapshotted and hashed like the source, then admitted before the source with the same strict v2 decode and audio-clock rules (one AAC-LC stream or none, positive gaps <=2s), at most 120 seconds (checked from metadata before any full decode) and 100 MiB; its copy is disk-checked before it is written, and an edited programme plus intro over 3 hours is refused before the source is opened. Its picture is letterboxed/pillarboxed into the export frame (never stretched or cropped; the source crop does not apply to it) and resampled to the 30fps grid; only its whole 30fps frames are used, in <=30s video-only units. Its audio is placed by PTS, converted to 48kHz stereo with FFmpeg's standard rematrix (mono goes to both channels at -3 dB, as for sources without an intro; multichannel is downmixed), padded with silence or trimmed to exactly 1600 samples per intro frame and joined once before the unchanged source chain; an intro with no audio contributes silence, never music. Source mapping keeps original-source times; `manifest.json.intro` records path, sha256, frames and output span, and every `mapping` outputStart is shifted by the intro's whole frames. Regression: a 1080p 25fps mono 44.1kHz intro and a 31s silent portrait intro both give exact frame counts, the intro picture to the last intro frame, source frame 5.000s as the first programme frame and 0-sample source lag after the seam. Legacy `supportingAsset` still appends and is not an intro. Ripple-audio deletion remains a separate slice. Actual source, hold duration/frame, crop and intro must be reviewed before editing private client footage.
|
|
79
|
+
|
|
80
|
+
Remote reproduction (owned Linux checkout only):
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
bash scripts/ci-ffmpeg.sh /owned/tools
|
|
84
|
+
PATH=/owned/tools:$PATH CREW_STUDIO_MEDIA_TESTS=1 pnpm --filter crew-runner test --maxWorkers=1
|
|
85
|
+
PATH=/owned/tools:$PATH node packages/runner/test/studio-source-pilot.mjs /owned/synthetic-project
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Hosted webinar edits (render image Studio mode)
|
|
89
|
+
|
|
90
|
+
The `crew-render` image also runs this engine for hosted webinar edits, one job per disposable Fly machine (`infra/render/studio.mjs`, chosen when the machine gets `RENDER_STUDIO_URL`). The image owns the FFmpeg 7.1 static digest pin, which `scripts/ci-ffmpeg.sh` reads from `infra/render/Dockerfile`; it is installed at `/opt/ffmpeg-7.1`, first on PATH for Studio mode only; the motion-video modes keep the distribution FFmpeg. The engine source is copied unchanged and run by Node's type stripping.
|
|
91
|
+
|
|
92
|
+
The machine downloads the approved source and optional intro from short-lived URLs, refusing more bytes than the worker declared and checking Drive's md5 when one was given. It fills `sourceDuration` from ffprobe on the 30fps grid, so the approved edit lists (source seconds) are the only plan input. It calls `openStudio(grant, limits)` with hosted limits instead of `LOCAL_LIMITS`: a job deadline of at most `HOSTED_MAX_JOB_MS` (four hours, downloads included), longer per-unit and decode/final windows, more encoder threads, a 1080p export and intermediate budgets of up to `HOSTED_EXPORT_BYTES` (4 GiB) and `HOSTED_INTERMEDIATE_BYTES` (16 GiB). When the export budget could bind for the programme's length, the final encode gets a peak video rate (`-maxrate`, 2x `-bufsize`) sized so the whole export fits (90% of the budget less 200 kbit/s for audio and container); short and local exports keep their exact argv. A dropped or stalled download resumes with an HTTP Range request (up to five attempts, 90 s stall timeout) and the md5 covers the whole stream. Measured on performance-4x (4 dedicated CPUs, 8 GB): a 75-minute 1080p30 webinar-like source rendered in 37 minutes (about half real time), with a 1.42 GB export. The local CLI and MCP bridge always use `LOCAL_LIMITS` (15-minute job, 90s units, two threads, 720p), so their behaviour and pilot outputs are unchanged. It uploads the export and a JPEG still, then writes a status JSON (bytes, SHA-256, duration, size, render time, peak memory) or a failure reason.
|
|
93
|
+
|
|
94
|
+
Real-media check, inside the built image: `node --test /app/studio.test.mjs` with `/opt/ffmpeg-7.1` first on PATH (synthetic 1080p 25fps source, 44.1kHz intro, a cut and a mute; md5 and size refusals; edit past the end refused).
|
|
95
|
+
|
|
96
|
+
## Supported subscription planning path
|
|
97
|
+
|
|
98
|
+
Opt-in is a standalone, member-authored local session, not a blanket attachment to queued/shared cloud tasks:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
node packages/runner/bin/crew-runner.mjs studio --project /absolute/my-project --allow-local-media agent --cli claude --prompt 'Inspect source.mp4 and render a short edit with the supplied captions ...'
|
|
102
|
+
# Same interface: --cli codex
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The normal installed Claude Code/Codex app must already be signed in by the member. No vendor credential files are read, exported or copied, and no sign-in bypass is implemented. Planning uses the member's supported subscription account and quota, not unlimited/free inference. Local processing is a separate CPU operation. **The provider receives the prompt, selected tool metadata and timed caption/plan text during planning.** Do not use sensitive footage/transcripts without approving that provider audience. The bridge does not return original video/audio bytes to the model, but metadata can still be sensitive.
|
|
106
|
+
|
|
107
|
+
The adapter retains an empty temporary cwd, disabled built-in filesystem/shell/terminal tools, read-only Codex sandbox and disabled member MCP servers. Codex studio sessions refuse a pre-existing member `crew_studio` entry (even disabled): rename that member server first, because recursive config merging could otherwise inherit its environment or working directory. Studio-only sessions explicitly disable `crew` rather than omitting it. Only `probe`, `capabilities`, `render`, `preview` and `cancel` are added under `crew_studio`. Permissions use exact tool names and expiry; `import`, network, upload and arbitrary shell tools are not model-exposed. Root and expiry are captured by the host before the model starts and cannot be set through tool inputs. Consent defaults to 30 minutes and cannot exceed one hour. The `stdio` entrypoint is private local plumbing using the maintained MCP SDK. Claude and Codex config/permission paths have deterministic tests, not live subscription-account acceptance.
|
|
108
|
+
|
|
109
|
+
## Boundaries and caps
|
|
110
|
+
|
|
111
|
+
- MP4-only filename, actual `ftyp` bytes plus ffprobe metadata validation; URLs, protocols, absolute/traversal paths, symlinks and nonregular files denied. Relative subdirectories are allowed.
|
|
112
|
+
- Maximum input/output file 100 MiB, media/timeline 120 seconds, source 3840x2160, 8 streams, 12 original cuts, 24 captions and 24 callouts. Plan file max64 kB. Every segment and final export is stat-checked, strictly decoded and compared against the requested video/audio duration before publication. FFmpeg's zero exit or `-fs` limit alone is not completion. Exact 120-second video is allowed with bounded frame/AAC packet mux rounding, not extra timeline seconds.
|
|
113
|
+
- One render per project via an atomic filesystem lock, 2 encode/filter threads and 90-second timeout per media subprocess, also bounded by consent expiry. Fixed codecs, fixed filter expressions, fixed output dimensions, no user command/filter/script/HTML options. FFmpeg protocols restricted to local file reads. Output processes inherit the owning adapter's process group so abrupt group shutdown cannot orphan an encoder.
|
|
114
|
+
- This is not an OS security sandbox against a hostile same-user process replacing directories concurrently. Put the project on trusted local storage, outside a shared writable folder. Decoder libraries remain an untrusted-media surface: use a current trusted FFmpeg installation. Windows/macOS media execution has not been exercised by this Linux-only acceptance.
|
|
115
|
+
- A crash/SIGKILL can leave a partial private job and lock. It fails closed on next render. After verifying no owned encoder is running, the member may remove only that project's `.crew-studio/lock` and abandoned job. No automatic stale-lock takeover or duplicate work.
|
|
116
|
+
- No HTTP listener, auto download/upload, social publish/schedule, provider key export, new generation billing, changes to ordinary runs or automatic cloud-to-local exchange. Any later upload must use the existing untrusted-upload scanning and limits, not bypass them. Authoritative run/output audience scope (PR529) remains a prerequisite for automatic exchange.
|
|
117
|
+
|
|
118
|
+
## Remote-only reproducible evidence
|
|
119
|
+
|
|
120
|
+
On an authorized isolated Linux checkout, install as the runner user with a real own `node_modules` (never a cache symlink). Put existing isolated FFmpeg binaries on PATH. No tests/install/build/devserver/render on Haydo's Mac.
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
CREW_STUDIO_MEDIA_TESTS=1 pnpm --filter crew-runner test --maxWorkers=2
|
|
124
|
+
pnpm --filter crew-runner typecheck
|
|
125
|
+
node packages/runner/test/studio-pilot.mjs /owned/new-pilot-project
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The media tests are opt-in because ordinary CI hosts do not include FFmpeg. They execute real encoding, preserved-source hashing, asset insertion, active cancellation/process exit, abrupt-owner termination and denial controls. Ordinary CI runs the policy/SDK/ACP tests without external binaries. The fixture script exercises the public CLI and creates source MP4, a **local, not Higgsfield-generated** supporting fixture, real export/preview, 32-frame chronological contact sheet, ffprobe JSON, source hashes and audio-volume log. Sine audio proves retention of an audio signal, not authentic human voice. Genuine user footage, actual downloaded Higgsfield asset, nominated GPU and live subscription acceptance remain separate human/provider gates.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crew-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runs a CrewCortex assistant on your own computer using the Claude Code or Codex app you already signed in to.",
|
|
6
6
|
"keywords": [
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"src/**/*.ts",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
22
|
"README.md",
|
|
23
|
+
"STUDIO.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
25
26
|
"engines": {
|
|
@@ -32,8 +33,10 @@
|
|
|
32
33
|
"dependencies": {
|
|
33
34
|
"@agentclientprotocol/codex-acp": "^2.1.1",
|
|
34
35
|
"@agentclientprotocol/sdk": "^1.7.0",
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
35
37
|
"@zed-industries/claude-agent-acp": "^0.23.1",
|
|
36
|
-
"tsx": "^4.20.0"
|
|
38
|
+
"tsx": "^4.20.0",
|
|
39
|
+
"zod": "^4.6.7"
|
|
37
40
|
},
|
|
38
41
|
"devDependencies": {
|
|
39
42
|
"@types/node": "^26.6.3",
|
package/src/acp.ts
CHANGED
|
@@ -9,8 +9,35 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import * as acp from "@agentclientprotocol/sdk";
|
|
10
10
|
import type { Claimed, RunEvent } from "./api.ts";
|
|
11
11
|
import { promptFrom, type Outcome } from "./claude.ts";
|
|
12
|
+
import { CLAUDE_DISALLOWED, CLAUDE_ENV, CLAUDE_PERMISSIONS, CLAUDE_TOOLS } from "./computer.ts";
|
|
12
13
|
import { DISABLED_FEATURES } from "./codex.ts";
|
|
13
14
|
import { claudeExecutable, killTree } from "./spawn.ts";
|
|
15
|
+
import { assertGrant, openStudio, type StudioGrant } from "./studio.ts";
|
|
16
|
+
import { MEDIA_TOOLS } from "./studio-mcp.ts";
|
|
17
|
+
|
|
18
|
+
const STUDIO_NAME = "crew_studio";
|
|
19
|
+
function studioServer(grant: StudioGrant): acp.McpServerStdio {
|
|
20
|
+
assertGrant(grant);
|
|
21
|
+
return { name: STUDIO_NAME, command: process.execPath, args: [fileURLToPath(new URL("../bin/crew-studio.mjs", import.meta.url)), "stdio", "--project", grant.root, "--expires", String(grant.expiresAt), "--allow-local-media"], env: [] };
|
|
22
|
+
}
|
|
23
|
+
export function mediaPermission(agent: AgentName, p: acp.RequestPermissionRequest, calls: ReadonlyMap<string, SeenCall>, grant?: StudioGrant): boolean {
|
|
24
|
+
if (!grant) return false;
|
|
25
|
+
try { assertGrant(grant); } catch { return false; }
|
|
26
|
+
if (agent === "claude") return MEDIA_TOOLS.some((tool) => p.toolCall.title === `mcp__${STUDIO_NAME}__${tool}`);
|
|
27
|
+
if (field(p._meta, "is_mcp_tool_approval") !== true || (p.toolCall.kind && p.toolCall.kind !== "execute")) return false;
|
|
28
|
+
const seen = calls.get(p.toolCall.toolCallId);
|
|
29
|
+
const raw = seen ? seen.rawInput : p.toolCall.rawInput;
|
|
30
|
+
const server = field(raw, seen ? "server" : "serverName");
|
|
31
|
+
return server === STUDIO_NAME && MEDIA_TOOLS.some((tool) => field(raw, "tool") === tool);
|
|
32
|
+
}
|
|
33
|
+
export function studioCodexConfig(pkg: RunPkg, memberServers: readonly string[], grant: StudioGrant): Record<string, unknown> {
|
|
34
|
+
// Codex recursively merges member settings. Do not re-enable an existing entry:
|
|
35
|
+
// inherited env/env_vars/cwd could execute member code in our owned stdio bridge.
|
|
36
|
+
if (memberServers.includes(STUDIO_NAME)) throw new Error("Rename the member crew_studio MCP server before enabling studio");
|
|
37
|
+
const cfg = codexConfig(pkg, memberServers);
|
|
38
|
+
const bridge = studioServer(grant);
|
|
39
|
+
return { ...cfg, mcp_servers: { ...(cfg.mcp_servers as Record<string, unknown>), [STUDIO_NAME]: { enabled: true, command: bridge.command, args: bridge.args } } };
|
|
40
|
+
}
|
|
14
41
|
|
|
15
42
|
// One Agent Client Protocol client for both vendors. We spawn a small adapter (claude-agent-acp or codex-acp) that
|
|
16
43
|
// wraps the member's own signed-in app. The adapter holds the login. We never read the vendors' files, never call
|
|
@@ -84,9 +111,10 @@ const LIST_TIMEOUT_MS = 20_000;
|
|
|
84
111
|
export function memberMcpNames(listJson: string): string[] {
|
|
85
112
|
const list: unknown = JSON.parse(listJson);
|
|
86
113
|
if (!Array.isArray(list)) throw new Error("codex mcp list returned no list");
|
|
87
|
-
// Already-off servers
|
|
114
|
+
// Already-off servers stay off, except our reserved name: even a disabled
|
|
115
|
+
// entry can carry inherited stdio settings and must be detected as a collision.
|
|
88
116
|
return list
|
|
89
|
-
.filter((s) => field(s, "enabled") !== false)
|
|
117
|
+
.filter((s) => field(s, "enabled") !== false || field(s, "name") === STUDIO_NAME)
|
|
90
118
|
.map((s) => field(s, "name"))
|
|
91
119
|
.filter((n): n is string => typeof n === "string" && n !== MCP_NAME);
|
|
92
120
|
}
|
|
@@ -162,7 +190,10 @@ export function decide(
|
|
|
162
190
|
p: acp.RequestPermissionRequest,
|
|
163
191
|
calls: ReadonlyMap<string, SeenCall> = new Map(),
|
|
164
192
|
): { allowed: boolean; optionId: string | null } {
|
|
165
|
-
|
|
193
|
+
return optionFor(p, spec.allowTool(p, calls));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function optionFor(p: acp.RequestPermissionRequest, allowed: boolean): { allowed: boolean; optionId: string | null } {
|
|
166
197
|
const pick = (kinds: string[], ids: string[]): Option | undefined =>
|
|
167
198
|
p.options.find((o) => kinds.includes(o.kind)) ?? p.options.find((o) => ids.includes(o.optionId));
|
|
168
199
|
// Always the one-shot option, never "always": the next call is judged on its own.
|
|
@@ -178,6 +209,20 @@ export type RunAcpOptions = {
|
|
|
178
209
|
bin?: string;
|
|
179
210
|
// Abort = run cancelled: session/cancel first, kill after a short grace.
|
|
180
211
|
signal?: AbortSignal;
|
|
212
|
+
// Explicit host-selected project only. Never read this authority from a cloud task/model.
|
|
213
|
+
studio?: StudioGrant;
|
|
214
|
+
// Standalone member-authored local session: no cloud tools or events.
|
|
215
|
+
studioOnly?: boolean;
|
|
216
|
+
// Computer mode (@mac, Claude Code only): Claude Code's own tools, from a scratch folder the caller owns, every tool request
|
|
217
|
+
// judged by `decide` (which may wait for the owner's card). No CrewCortex MCP server: the reply is the only output.
|
|
218
|
+
computer?: ComputerSession;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export type ComputerSession = {
|
|
222
|
+
cwd: string;
|
|
223
|
+
additionalDirectories: string[];
|
|
224
|
+
systemPrompt: string;
|
|
225
|
+
decide: (p: acp.RequestPermissionRequest, calls: ReadonlyMap<string, SeenCall>) => Promise<boolean>;
|
|
181
226
|
};
|
|
182
227
|
|
|
183
228
|
type Sink = (events: RunEvent[]) => Promise<void>;
|
|
@@ -185,16 +230,31 @@ type Sink = (events: RunEvent[]) => Promise<void>;
|
|
|
185
230
|
const failed = (error: string, inputTokens = 0, outputTokens = 0): Outcome => ({ status: "failed", text: "", inputTokens, outputTokens, error });
|
|
186
231
|
|
|
187
232
|
export async function runAcp(agent: AgentName, pkg: Claimed, sink: Sink, opts: RunAcpOptions): Promise<Outcome> {
|
|
188
|
-
const
|
|
233
|
+
const originalSpec = AGENTS[agent];
|
|
234
|
+
const grant = opts.studio ? Object.freeze({ ...opts.studio }) : undefined;
|
|
235
|
+
const spec = grant ? { ...originalSpec, allowTool: (p: acp.RequestPermissionRequest, calls: ReadonlyMap<string, SeenCall>) =>
|
|
236
|
+
(!opts.studioOnly && originalSpec.allowTool(p, calls)) || mediaPermission(agent, p, calls, grant) } : originalSpec;
|
|
237
|
+
if (opts.computer && (agent !== "claude" || grant)) return failed("runner_failed");
|
|
238
|
+
const computer = opts.computer;
|
|
189
239
|
// Empty temp directory: even if a built-in tool were somehow on, there is nothing of the member's to touch.
|
|
190
|
-
|
|
240
|
+
// Computer mode works in the caller's scratch folder instead, which the caller removes.
|
|
241
|
+
const cwd = computer ? computer.cwd : await mkdtemp(join(tmpdir(), "crew-run-"));
|
|
191
242
|
let child: ReturnType<typeof spawn> | undefined;
|
|
192
243
|
let timer: NodeJS.Timeout | undefined;
|
|
244
|
+
let ended = false;
|
|
193
245
|
let flushTimer: NodeJS.Timeout | undefined;
|
|
194
246
|
let graceTimer: NodeJS.Timeout | undefined;
|
|
195
247
|
let onAbort: (() => void) | undefined;
|
|
196
248
|
try {
|
|
249
|
+
if (grant) await openStudio(grant);
|
|
250
|
+
if (opts.studioOnly && !grant) throw new Error("Local studio consent required");
|
|
197
251
|
const launch = opts.launch ?? spec.launch(pkg, { bin: opts.bin });
|
|
252
|
+
if (agent === "codex" && grant) {
|
|
253
|
+
const config = studioCodexConfig(pkg, listCodexMcp(), grant);
|
|
254
|
+
// Absence would merge the member's enabled `crew` back into Codex settings.
|
|
255
|
+
if (opts.studioOnly) (config.mcp_servers as Record<string, unknown>)[MCP_NAME] = { enabled: false };
|
|
256
|
+
launch.env = { ...launch.env, CODEX_CONFIG: JSON.stringify(config) };
|
|
257
|
+
}
|
|
198
258
|
// POSIX: detached makes the adapter lead a process group, so killTree ends it and the app it started. Windows uses taskkill.
|
|
199
259
|
child = spawn(launch.command, launch.args, { cwd, stdio: ["pipe", "pipe", "ignore"], detached: process.platform !== "win32", windowsHide: true, env: { ...process.env, ...launch.env } });
|
|
200
260
|
const proc = child;
|
|
@@ -203,10 +263,34 @@ export async function runAcp(agent: AgentName, pkg: Claimed, sink: Sink, opts: R
|
|
|
203
263
|
proc.on("close", () => reject(new Error("runner_failed")));
|
|
204
264
|
});
|
|
205
265
|
exited.catch(() => undefined);
|
|
266
|
+
// The run clock. It stops while a computer-mode request waits on `decide` (the owner's card, which expires on its own timer),
|
|
267
|
+
// so time the owner takes to answer never counts toward the run limit. Claude Code may ask about parallel tool calls at once,
|
|
268
|
+
// hence a count of pending decisions rather than a flag. Computer mode has no MCP server, so no run token can lapse meanwhile.
|
|
269
|
+
let left = opts.timeoutMs;
|
|
270
|
+
let since = 0;
|
|
271
|
+
let waiting = 0;
|
|
272
|
+
let expire: () => void = () => undefined;
|
|
206
273
|
const timedOut = new Promise<never>((_, reject) => {
|
|
207
|
-
|
|
274
|
+
expire = () => reject(new Error("runner_timeout"));
|
|
208
275
|
});
|
|
209
276
|
timedOut.catch(() => undefined);
|
|
277
|
+
const resume = () => {
|
|
278
|
+
if (ended) return; // a decision that outlived the run
|
|
279
|
+
since = Date.now();
|
|
280
|
+
timer = setTimeout(expire, left);
|
|
281
|
+
};
|
|
282
|
+
const paused = async <T>(work: () => Promise<T>): Promise<T> => {
|
|
283
|
+
if (waiting++ === 0) {
|
|
284
|
+
clearTimeout(timer);
|
|
285
|
+
left -= Date.now() - since;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
return await work();
|
|
289
|
+
} finally {
|
|
290
|
+
if (--waiting === 0) resume();
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
resume();
|
|
210
294
|
|
|
211
295
|
// Serialised sink, text coalesced so a token stream is not one HTTP call per chunk.
|
|
212
296
|
let chain: Promise<void> = Promise.resolve();
|
|
@@ -260,8 +344,8 @@ export async function runAcp(agent: AgentName, pkg: Claimed, sink: Sink, opts: R
|
|
|
260
344
|
|
|
261
345
|
const session = acp
|
|
262
346
|
.client({ name: "crew-runner" })
|
|
263
|
-
.onRequest(acp.methods.client.session.requestPermission, (ctx) => {
|
|
264
|
-
const d = decide(spec, ctx.params, calls);
|
|
347
|
+
.onRequest(acp.methods.client.session.requestPermission, async (ctx) => {
|
|
348
|
+
const d = computer ? optionFor(ctx.params, await paused(() => computer.decide(ctx.params, calls)).catch(() => false)) : decide(spec, ctx.params, calls);
|
|
265
349
|
return { outcome: d.optionId ? { outcome: "selected" as const, optionId: d.optionId } : { outcome: "cancelled" as const } };
|
|
266
350
|
})
|
|
267
351
|
.connectWith(stream, async (ctx) => {
|
|
@@ -272,8 +356,16 @@ export async function runAcp(agent: AgentName, pkg: Claimed, sink: Sink, opts: R
|
|
|
272
356
|
});
|
|
273
357
|
const request: acp.NewSessionRequest = {
|
|
274
358
|
cwd,
|
|
275
|
-
mcpServers:
|
|
276
|
-
|
|
359
|
+
mcpServers: computer ? [] : spec.acpMcp ? [
|
|
360
|
+
...(!opts.studioOnly ? [{ type: "http" as const, name: MCP_NAME, url: pkg.mcp_url, headers: [{ name: "Authorization", value: `Bearer ${pkg.token}` }] }] : []),
|
|
361
|
+
...(grant ? [studioServer(grant)] : []),
|
|
362
|
+
] : [],
|
|
363
|
+
_meta: computer
|
|
364
|
+
? { systemPrompt: computer.systemPrompt, claudeCode: { options: {
|
|
365
|
+
settingSources: [], strictMcpConfig: true, additionalDirectories: computer.additionalDirectories,
|
|
366
|
+
settings: { permissions: CLAUDE_PERMISSIONS }, tools: CLAUDE_TOOLS, disallowedTools: CLAUDE_DISALLOWED, env: CLAUDE_ENV,
|
|
367
|
+
} } }
|
|
368
|
+
: spec.sessionMeta(pkg),
|
|
277
369
|
};
|
|
278
370
|
const active = await ctx.buildSession(request).start();
|
|
279
371
|
try {
|
|
@@ -314,11 +406,12 @@ export async function runAcp(agent: AgentName, pkg: Claimed, sink: Sink, opts: R
|
|
|
314
406
|
} catch {
|
|
315
407
|
return failed("runner_failed");
|
|
316
408
|
} finally {
|
|
409
|
+
ended = true;
|
|
317
410
|
clearTimeout(timer);
|
|
318
411
|
clearTimeout(flushTimer);
|
|
319
412
|
clearTimeout(graceTimer);
|
|
320
413
|
if (onAbort) opts.signal?.removeEventListener("abort", onAbort);
|
|
321
414
|
if (child) killTree(child);
|
|
322
|
-
await rm(cwd, { recursive: true, force: true });
|
|
415
|
+
if (!computer) await rm(cwd, { recursive: true, force: true });
|
|
323
416
|
}
|
|
324
417
|
}
|
package/src/api.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CARD_DETAIL_MAX } from "./computer.ts";
|
|
1
2
|
import type { Stored } from "./store.ts";
|
|
2
3
|
|
|
3
4
|
export type Claimed = {
|
|
@@ -6,8 +7,14 @@ export type Claimed = {
|
|
|
6
7
|
messages: { role: string; content: unknown }[];
|
|
7
8
|
mcp_url: string;
|
|
8
9
|
token: string;
|
|
10
|
+
// Only on a computer-mode claim (crew-runner start --computer): the workspace whose folder the run uses.
|
|
11
|
+
computer?: { workspace_slug: string };
|
|
9
12
|
};
|
|
10
13
|
|
|
14
|
+
export type ClaimMode = "default" | "computer";
|
|
15
|
+
// "approved_task": approved with "Allow for this task" (the server sends status approved, scope task).
|
|
16
|
+
export type CardAnswer = "pending" | "approved" | "approved_task" | "rejected" | "expired";
|
|
17
|
+
|
|
11
18
|
export type RunEvent =
|
|
12
19
|
| { type: "text"; text: string }
|
|
13
20
|
| { type: "step"; kind: "model" | "tool"; name?: string; input?: unknown; output?: unknown };
|
|
@@ -35,19 +42,46 @@ export function createApi(cfg: Stored, fetchImpl: typeof fetch = fetch) {
|
|
|
35
42
|
return (await call("ping", {})).ok;
|
|
36
43
|
},
|
|
37
44
|
// null = no work (204). Throws on auth or server errors so the loop can back off.
|
|
38
|
-
async claim(signal?: AbortSignal): Promise<Claimed | null> {
|
|
39
|
-
const res = await call("claim", {}, signal);
|
|
45
|
+
async claim(signal?: AbortSignal, mode: ClaimMode = "default"): Promise<Claimed | null> {
|
|
46
|
+
const res = await call("claim", mode === "computer" ? { mode } : {}, signal);
|
|
40
47
|
if (res.status === 204) return null;
|
|
41
48
|
if (!res.ok) throw new Error(`claim failed (${res.status})`);
|
|
42
49
|
return (await res.json()) as Claimed;
|
|
43
50
|
},
|
|
51
|
+
// Throws when the server did not take them (computer mode then refuses the step it could not record).
|
|
44
52
|
async events(runId: string, events: RunEvent[]): Promise<void> {
|
|
45
53
|
if (events.length === 0) return;
|
|
46
|
-
await call("events", { run_id: runId, events });
|
|
54
|
+
const res = await call("events", { run_id: runId, events });
|
|
55
|
+
if (!res.ok) throw new Error(`events failed (${res.status})`);
|
|
47
56
|
},
|
|
48
57
|
async complete(body: Complete): Promise<void> {
|
|
49
58
|
await call("complete", body);
|
|
50
59
|
},
|
|
60
|
+
// Computer mode: a confirmation card for the owner. Null when it could not be asked (the step is then refused).
|
|
61
|
+
// `grant`: the card may offer "Allow for this task" for later steps of the same kind in this run.
|
|
62
|
+
async askOwner(runId: string, category: string, detail: string, grant?: string): Promise<string | null> {
|
|
63
|
+
// The policy refuses anything longer than a card shows; never cut what the owner approves.
|
|
64
|
+
if (detail.length > CARD_DETAIL_MAX) return null;
|
|
65
|
+
const res = await call("approval", { run_id: runId, category, detail, ...(grant ? { grant } : {}) });
|
|
66
|
+
if (!res.ok) return null;
|
|
67
|
+
const body = (await res.json()) as { approval_id?: unknown };
|
|
68
|
+
return typeof body.approval_id === "string" ? body.approval_id : null;
|
|
69
|
+
},
|
|
70
|
+
async ownerAnswer(runId: string, approvalId: string): Promise<CardAnswer | null> {
|
|
71
|
+
const res = await call("approval/status", { run_id: runId, approval_id: approvalId });
|
|
72
|
+
if (!res.ok) return null;
|
|
73
|
+
const body = (await res.json()) as { status?: unknown; scope?: unknown };
|
|
74
|
+
if (body.status === "approved" && body.scope === "task") return "approved_task";
|
|
75
|
+
return body.status === "pending" || body.status === "approved" || body.status === "rejected" || body.status === "expired" ? body.status : null;
|
|
76
|
+
},
|
|
77
|
+
// Computer mode: is this run still running? false only when the server says so (the owner's off switch); null when
|
|
78
|
+
// it could not tell (network, an older server), which never stops a run.
|
|
79
|
+
async alive(runId: string): Promise<boolean | null> {
|
|
80
|
+
const res = await call("alive", { run_id: runId });
|
|
81
|
+
if (!res.ok) return null;
|
|
82
|
+
const body = (await res.json()) as { running?: unknown };
|
|
83
|
+
return typeof body.running === "boolean" ? body.running : null;
|
|
84
|
+
},
|
|
51
85
|
async revoke(): Promise<boolean> {
|
|
52
86
|
return (await call("revoke", {})).ok;
|
|
53
87
|
},
|
package/src/cli.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { createApi } from "./api.ts";
|
|
3
|
+
import { createApi, type ClaimMode, type RunnerApi } from "./api.ts";
|
|
4
4
|
import { runAcp } from "./acp.ts";
|
|
5
|
+
import { runComputer } from "./computer-run.ts";
|
|
5
6
|
import { checkClaude, runClaude, type Check } from "./claude.ts";
|
|
6
7
|
import { checkCodex, runCodex } from "./codex.ts";
|
|
7
8
|
import { loop, RUN_TIMEOUT_MS, type Runner } from "./loop.ts";
|
|
9
|
+
import { pairConfirmationCode } from "./pair-code.ts";
|
|
8
10
|
import * as store from "./store.ts";
|
|
9
11
|
|
|
10
12
|
const DEFAULT_APP_URL = "https://app.crewcortex.com";
|
|
@@ -35,6 +37,7 @@ async function login(appUrlArg: string | undefined): Promise<number> {
|
|
|
35
37
|
const code = createHash("sha256").update(token).digest("hex");
|
|
36
38
|
const url = `${appUrl}/runner/link?code=${code}`;
|
|
37
39
|
console.log(`Open this page, sign in and confirm this computer:\n${url}`);
|
|
40
|
+
console.log(`\nConfirmation code: ${pairConfirmationCode(code)}\nOnly approve if the page shows this exact code. If it differs, or you did not run this command, close the page.`);
|
|
38
41
|
openBrowser(url);
|
|
39
42
|
const api = createApi({ appUrl, token });
|
|
40
43
|
const until = Date.now() + LINK_WAIT_MS;
|
|
@@ -103,24 +106,45 @@ async function start(args: string[]): Promise<number> {
|
|
|
103
106
|
console.error("Not linked. Run `npx crew-runner@latest login` first.");
|
|
104
107
|
return 1;
|
|
105
108
|
}
|
|
109
|
+
if (args.includes("--computer")) return startComputer(cfg);
|
|
106
110
|
const chosen = await chooseCli(args);
|
|
107
111
|
if ("error" in chosen) {
|
|
108
112
|
console.error(chosen.error);
|
|
109
113
|
return 1;
|
|
110
114
|
}
|
|
115
|
+
const cli = CLIS[chosen.name];
|
|
116
|
+
return serve(cfg, [`Ready. ${cli.label} ${chosen.version}. Waiting for runs. Press Ctrl+C to stop.`], () => cli.runner(cli.bin()), "default");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Computer mode: only this member's @mac runs, with Claude Code's own tools in the allowed folders. An ordinary
|
|
120
|
+
// `start` never receives them. Claude Code only (Codex is not supported here).
|
|
121
|
+
async function startComputer(cfg: store.Stored): Promise<number> {
|
|
122
|
+
const bin = CLIS.claude.bin();
|
|
123
|
+
const check = await CLIS.claude.check(bin);
|
|
124
|
+
if (!check.ok) {
|
|
125
|
+
console.error(check.message);
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
return serve(cfg, [
|
|
129
|
+
`Computer mode. Claude Code ${check.version}. Works in ~/CrewCortex/<workspace>, ~/Downloads and ~/.cursor/dev projects.`,
|
|
130
|
+
"Deletes, git push, sends, payments and changes outside those folders wait for your yes in CrewCortex. Press Ctrl+C to stop.",
|
|
131
|
+
], (api) => (pkg, sink, signal) => runComputer(api, pkg, sink, { bin, timeoutMs: RUN_TIMEOUT_MS, signal }), "computer");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Both modes: check the device, then claim and run until Ctrl+C (or SIGTERM, or SIGBREAK on Windows).
|
|
135
|
+
async function serve(cfg: store.Stored, banner: string[], runnerFor: (api: RunnerApi) => Runner, mode: ClaimMode): Promise<number> {
|
|
111
136
|
const api = createApi(cfg);
|
|
112
137
|
if (!(await api.ping().catch(() => false))) {
|
|
113
138
|
console.error("The server did not accept this device. Run `npx crew-runner@latest login` again.");
|
|
114
139
|
return 1;
|
|
115
140
|
}
|
|
116
|
-
const
|
|
117
|
-
console.log(`Ready. ${cli.label} ${chosen.version}. Waiting for runs. Press Ctrl+C to stop.`);
|
|
141
|
+
for (const line of banner) console.log(line);
|
|
118
142
|
const ac = new AbortController();
|
|
119
143
|
process.once("SIGINT", () => ac.abort());
|
|
120
144
|
process.once("SIGTERM", () => ac.abort());
|
|
121
145
|
// Windows sends SIGBREAK (Ctrl+Break, closing the console window) instead of SIGTERM.
|
|
122
146
|
if (process.platform === "win32") process.once("SIGBREAK", () => ac.abort());
|
|
123
|
-
await loop(api,
|
|
147
|
+
await loop(api, runnerFor(api), ac.signal, (m) => console.log(m), mode);
|
|
124
148
|
return 0;
|
|
125
149
|
}
|
|
126
150
|
|
|
@@ -152,10 +176,11 @@ async function logout(): Promise<number> {
|
|
|
152
176
|
|
|
153
177
|
export async function main(argv: string[]): Promise<number> {
|
|
154
178
|
const [cmd, arg] = argv;
|
|
179
|
+
if (cmd === "studio") return (await import("./studio-cli.ts")).studioMain(argv.slice(1));
|
|
155
180
|
if (cmd === "login") return login(arg);
|
|
156
181
|
if (cmd === "start") return start(argv.slice(1));
|
|
157
182
|
if (cmd === "status") return status();
|
|
158
183
|
if (cmd === "logout") return logout();
|
|
159
|
-
console.log("Usage: crew-runner <login [app url] | start [--cli claude|codex] | status | logout>");
|
|
184
|
+
console.log("Usage: crew-runner <login [app url] | start [--cli claude|codex] | start --computer | status | logout>");
|
|
160
185
|
return cmd ? 2 : 0;
|
|
161
186
|
}
|