crew-runner 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +90 -2
- package/bin/crew-runner.mjs +6 -0
- package/package.json +41 -4
- package/src/acp.ts +324 -0
- package/src/api.ts +56 -0
- package/src/claude.ts +155 -0
- package/src/cli.ts +161 -0
- package/src/codex.ts +158 -0
- package/src/loop.ts +38 -0
- package/src/spawn.ts +120 -0
- package/src/store.ts +30 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CrewCortex
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,91 @@
|
|
|
1
|
-
#
|
|
1
|
+
# crew-runner
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Runs your CrewCortex assistants on your own computer (Mac or Windows), using the Claude Code app (Claude plan) or the Codex app
|
|
4
|
+
(ChatGPT plan) you already signed in to. The runner only calls each app's public commands and never reads its files or login.
|
|
5
|
+
CrewCortex never asks for your Claude or ChatGPT login or token.
|
|
6
|
+
Runs go through the Agent Client Protocol (ACP), so one code path drives both apps.
|
|
7
|
+
|
|
8
|
+
## Setup
|
|
9
|
+
|
|
10
|
+
Needs Node.js 20 or later (https://nodejs.org).
|
|
11
|
+
|
|
12
|
+
1. Install and sign in to one of the apps.
|
|
13
|
+
- Claude Code: install it, run `claude`, then type `/login`.
|
|
14
|
+
- Codex: install it, then run `codex login` (a ChatGPT plan works). Codex 0.142 or later is needed.
|
|
15
|
+
2. Link this computer: `npx crew-runner@latest login`. Open the page it prints, sign in and confirm.
|
|
16
|
+
It links to https://app.crewcortex.com. For another CrewCortex address, add it: `npx crew-runner@latest login https://your-app.example`.
|
|
17
|
+
3. Start it: `npx crew-runner@latest start` and leave the window open. It uses Claude Code if that is ready, otherwise Codex.
|
|
18
|
+
To choose one, add `--cli codex` (or `--cli claude`), or set `CREW_RUNNER_CLI`.
|
|
19
|
+
|
|
20
|
+
To skip `npx`, install it once with `npm install -g crew-runner` and use `crew-runner login`, `crew-runner start` and so on.
|
|
21
|
+
|
|
22
|
+
`crew-runner status` shows whether each app is ready. `crew-runner logout` unlinks this computer.
|
|
23
|
+
|
|
24
|
+
### Mac
|
|
25
|
+
|
|
26
|
+
Use Terminal. If the app is not on your PATH, point at it:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
export CREW_CLAUDE_BIN=/opt/homebrew/bin/claude
|
|
30
|
+
export CREW_CODEX_BIN=/opt/homebrew/bin/codex
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Windows (PowerShell)
|
|
34
|
+
|
|
35
|
+
Use PowerShell. Install Node.js, then do the three steps above:
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
npx crew-runner@latest login
|
|
39
|
+
npx crew-runner@latest start
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The apps are found on your PATH, including the `.cmd` files npm creates (`claude.cmd`, `codex.cmd`). If they are not on
|
|
43
|
+
your PATH, set the full path for the session (add it to your PowerShell profile to keep it):
|
|
44
|
+
|
|
45
|
+
```powershell
|
|
46
|
+
$env:CREW_CLAUDE_BIN = "C:\Users\you\AppData\Roaming\npm\claude.cmd"
|
|
47
|
+
$env:CREW_CODEX_BIN = "C:\Users\you\AppData\Roaming\npm\codex.cmd"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Press Ctrl+C (or close the window) to stop the runner. Notes for Windows:
|
|
51
|
+
|
|
52
|
+
- The runner never starts a general shell. For a `.cmd` file it starts `cmd.exe` itself with every argument quoted.
|
|
53
|
+
For Claude Code it reads the `.cmd` file to find the real `claude` program and hands that to the adapter,
|
|
54
|
+
because the adapter cannot start a `.cmd` file. If it cannot find it, set `CREW_CLAUDE_BIN` to `claude.exe` or `cli.js`.
|
|
55
|
+
- Ending a run uses `taskkill /T`, which also ends the app the adapter started.
|
|
56
|
+
- Windows has no file modes, so the 0600 setting on `%USERPROFILE%\.crew-runner\device.json` does nothing there. The file
|
|
57
|
+
is protected by the permissions of your own user folder. Do not share that folder.
|
|
58
|
+
- Set `CREW_RUNNER_HOME` to keep the device file somewhere else.
|
|
59
|
+
- Only the old `CREW_RUNNER_PROTOCOL=legacy` path has limits on a `.cmd` file: an instruction with a line break fails there.
|
|
60
|
+
The default ACP path has no such limit.
|
|
61
|
+
|
|
62
|
+
## What the apps can do during a run
|
|
63
|
+
|
|
64
|
+
Each run happens in an empty temporary folder, and the only tool is CrewCortex itself. For Codex the run
|
|
65
|
+
uses a read-only sandbox, ignores your own Codex config and rules, and turns off the shell, browser,
|
|
66
|
+
computer, image, web search, plugin and sub-agent tools. The run token is passed through an environment
|
|
67
|
+
variable, never on the command line.
|
|
68
|
+
|
|
69
|
+
## How runs work (ACP)
|
|
70
|
+
|
|
71
|
+
For each run the runner starts a small adapter that wraps your own app: `claude-agent-acp` for Claude Code and
|
|
72
|
+
`@agentclientprotocol/codex-acp` for Codex. Both ship as dependencies of this package, so nothing extra is installed. The runner then:
|
|
73
|
+
|
|
74
|
+
1. Sends `initialize` with file system and terminal access switched off.
|
|
75
|
+
2. Opens a session in an empty temporary folder with the CrewCortex MCP server (the run token goes in a bearer header).
|
|
76
|
+
3. Sends the conversation, forwards streamed text, tool calls and plans to CrewCortex, and returns the final answer.
|
|
77
|
+
4. Answers every permission request itself: it allows only CrewCortex tools and refuses all built-in file, shell and edit tools.
|
|
78
|
+
5. Stops the adapter when the run ends, times out (10 minutes) or you press Ctrl+C.
|
|
79
|
+
|
|
80
|
+
The runner never calls the ACP `authenticate` step. If the app is signed out the run fails and you sign in to the app yourself.
|
|
81
|
+
Claude Code runs with no built-in tools and none of your own settings. `CLAUDE_CODE_EXECUTABLE` is set from `CREW_CLAUDE_BIN`
|
|
82
|
+
(or `claude`) so the adapter drives your installed app.
|
|
83
|
+
Codex runs through `@agentclientprotocol/codex-acp`, which bundles its own Codex build and uses the login from `codex login`.
|
|
84
|
+
It runs read only, with shell, apps, plugins, browser and web search off. Before each run the runner asks Codex for the names
|
|
85
|
+
of your own MCP servers (`codex mcp list`, names only) and switches every one of them off for that run, so a run sees only the
|
|
86
|
+
CrewCortex tools. If that list fails, the run fails.
|
|
87
|
+
|
|
88
|
+
### Old direct path
|
|
89
|
+
|
|
90
|
+
`CREW_RUNNER_PROTOCOL=legacy` switches back to the previous direct adapters (`claude -p` and `codex exec`). It is kept for one
|
|
91
|
+
release and then removed. The default is `acp`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crew-runner",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Runs a CrewCortex assistant on your own computer using the Claude Code or Codex app you already signed in to.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"crewcortex",
|
|
8
|
+
"claude-code",
|
|
9
|
+
"codex",
|
|
10
|
+
"acp",
|
|
11
|
+
"agent"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"homepage": "https://crewcortex.com",
|
|
15
|
+
"bin": {
|
|
16
|
+
"crew-runner": "bin/crew-runner.mjs"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"src/**/*.ts",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@agentclientprotocol/codex-acp": "^2.1.1",
|
|
34
|
+
"@agentclientprotocol/sdk": "^1.7.0",
|
|
35
|
+
"@zed-industries/claude-agent-acp": "^0.23.1",
|
|
36
|
+
"tsx": "^4.20.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^26.6.3",
|
|
40
|
+
"typescript": "^7.0.2",
|
|
41
|
+
"vitest": "^5.0.3"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/acp.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
4
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { Readable, Writable } from "node:stream";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import * as acp from "@agentclientprotocol/sdk";
|
|
10
|
+
import type { Claimed, RunEvent } from "./api.ts";
|
|
11
|
+
import { promptFrom, type Outcome } from "./claude.ts";
|
|
12
|
+
import { DISABLED_FEATURES } from "./codex.ts";
|
|
13
|
+
import { claudeExecutable, killTree } from "./spawn.ts";
|
|
14
|
+
|
|
15
|
+
// One Agent Client Protocol client for both vendors. We spawn a small adapter (claude-agent-acp or codex-acp) that
|
|
16
|
+
// wraps the member's own signed-in app. The adapter holds the login. We never read the vendors' files, never call
|
|
17
|
+
// `authenticate` (which could start a browser login) and never see a credential: if the app is signed out the
|
|
18
|
+
// session simply fails to open and we report that.
|
|
19
|
+
const MAX_TEXT = 100_000;
|
|
20
|
+
const MAX_EVENT_TEXT = 20_000;
|
|
21
|
+
const FLUSH_MS = 300;
|
|
22
|
+
const CANCEL_GRACE_MS = 5_000;
|
|
23
|
+
const MCP_NAME = "crew";
|
|
24
|
+
const CLAUDE_TOOL_PREFIX = `mcp__${MCP_NAME}__`;
|
|
25
|
+
|
|
26
|
+
export type AgentName = "claude" | "codex";
|
|
27
|
+
|
|
28
|
+
type Launch = { command: string; args: string[]; env?: Record<string, string> };
|
|
29
|
+
|
|
30
|
+
// Finds an installed adapter by walking up node_modules. require.resolve cannot be used: both packages only
|
|
31
|
+
// declare an "import" export condition.
|
|
32
|
+
function adapterFile(pkgName: string, file: string): string {
|
|
33
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
for (;;) {
|
|
35
|
+
const p = join(dir, "node_modules", pkgName, file);
|
|
36
|
+
if (existsSync(p)) return p;
|
|
37
|
+
const up = dirname(dir);
|
|
38
|
+
if (up === dir) throw new Error(`${pkgName} is not installed`);
|
|
39
|
+
dir = up;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type RunPkg = Pick<Claimed, "instructions" | "mcp_url" | "token">;
|
|
44
|
+
|
|
45
|
+
export type AgentSpec = {
|
|
46
|
+
label: string;
|
|
47
|
+
launch: (pkg: RunPkg, opts: { bin?: string }) => Launch;
|
|
48
|
+
// false = the launch config already carries our MCP server, so session/new sends none.
|
|
49
|
+
acpMcp: boolean;
|
|
50
|
+
// Fields added to session/new for this adapter.
|
|
51
|
+
sessionMeta: (pkg: Pick<Claimed, "instructions">) => Record<string, unknown> | undefined;
|
|
52
|
+
// Idle chat mode to force after session/new, or null.
|
|
53
|
+
mode: string | null;
|
|
54
|
+
allowTool: (p: acp.RequestPermissionRequest, calls: ReadonlyMap<string, SeenCall>) => boolean;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Tool calls the agent announced in this session, by id. Some approvals carry only the id.
|
|
58
|
+
export type SeenCall = { title: string; rawInput: unknown };
|
|
59
|
+
|
|
60
|
+
const field = (v: unknown, key: string): unknown => (v && typeof v === "object" ? (v as Record<string, unknown>)[key] : undefined);
|
|
61
|
+
|
|
62
|
+
// Claude names every MCP tool mcp__<server>__<tool> in the permission title. Anything else is a built-in tool.
|
|
63
|
+
export function allowClaudeTool(p: acp.RequestPermissionRequest): boolean {
|
|
64
|
+
return (p.toolCall.title ?? "").startsWith(CLAUDE_TOOL_PREFIX);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// codex-acp marks MCP tool approvals with _meta.is_mcp_tool_approval. The approval usually carries only the id of a
|
|
68
|
+
// tool call announced earlier with rawInput { server, tool }; a standalone one carries rawInput { serverName }.
|
|
69
|
+
// Only our server passes. Commands, edits and anything else are refused.
|
|
70
|
+
export function allowCodexTool(p: acp.RequestPermissionRequest, calls: ReadonlyMap<string, SeenCall>): boolean {
|
|
71
|
+
if (field(p._meta, "is_mcp_tool_approval") !== true) return false;
|
|
72
|
+
if (p.toolCall.kind && p.toolCall.kind !== "execute") return false;
|
|
73
|
+
const seen = calls.get(p.toolCall.toolCallId);
|
|
74
|
+
const server = seen ? field(seen.rawInput, "server") : field(p.toolCall.rawInput, "serverName");
|
|
75
|
+
return server === MCP_NAME;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Codex also loads the member's own MCP servers from their config, and those tools run without asking us. We ask
|
|
79
|
+
// the bundled Codex for the server names (never the files or values) and switch each one off for this session.
|
|
80
|
+
// Ours goes in the same mcp_servers table: the adapter would replace the table if it added ours from session/new.
|
|
81
|
+
// If the list fails the run fails: no lockdown, no run.
|
|
82
|
+
const LIST_TIMEOUT_MS = 20_000;
|
|
83
|
+
|
|
84
|
+
export function memberMcpNames(listJson: string): string[] {
|
|
85
|
+
const list: unknown = JSON.parse(listJson);
|
|
86
|
+
if (!Array.isArray(list)) throw new Error("codex mcp list returned no list");
|
|
87
|
+
// Already-off servers are left alone: some have no config entry, and a bare { enabled } entry would not load.
|
|
88
|
+
return list
|
|
89
|
+
.filter((s) => field(s, "enabled") !== false)
|
|
90
|
+
.map((s) => field(s, "name"))
|
|
91
|
+
.filter((n): n is string => typeof n === "string" && n !== MCP_NAME);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function listCodexMcp(): string[] {
|
|
95
|
+
const codex = createRequire(realpathSync(adapterFile("@agentclientprotocol/codex-acp", "dist/index.js"))).resolve("@openai/codex/bin/codex.js");
|
|
96
|
+
const r = spawnSync(process.execPath, [codex, "mcp", "list", "--json"], { cwd: tmpdir(), encoding: "utf8", timeout: LIST_TIMEOUT_MS, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
97
|
+
if (r.status !== 0) throw new Error("could not list Codex MCP servers");
|
|
98
|
+
return memberMcpNames(r.stdout);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const CODEX_TOKEN_ENV = "CREW_MCP_TOKEN";
|
|
102
|
+
|
|
103
|
+
export function codexConfig(pkg: RunPkg, memberServers: readonly string[]): Record<string, unknown> {
|
|
104
|
+
return {
|
|
105
|
+
features: Object.fromEntries(DISABLED_FEATURES.map((f) => [f, false])),
|
|
106
|
+
web_search: "disabled",
|
|
107
|
+
sandbox_mode: "read-only",
|
|
108
|
+
approval_policy: "on-request",
|
|
109
|
+
developer_instructions: pkg.instructions,
|
|
110
|
+
mcp_servers: {
|
|
111
|
+
...Object.fromEntries(memberServers.map((n) => [n, { enabled: false }])),
|
|
112
|
+
[MCP_NAME]: { url: pkg.mcp_url, bearer_token_env_var: CODEX_TOKEN_ENV },
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const AGENTS: Record<AgentName, AgentSpec> = {
|
|
118
|
+
claude: {
|
|
119
|
+
label: "Claude Code",
|
|
120
|
+
launch: (_pkg, opts) => ({
|
|
121
|
+
command: process.execPath,
|
|
122
|
+
args: [adapterFile("@zed-industries/claude-agent-acp", "dist/index.js")],
|
|
123
|
+
// On Windows a .cmd shim cannot be started by the adapter, so it gets the real file the shim points at.
|
|
124
|
+
env: opts.bin ? { CLAUDE_CODE_EXECUTABLE: claudeExecutable(opts.bin) } : undefined,
|
|
125
|
+
}),
|
|
126
|
+
sessionMeta: (pkg) => ({
|
|
127
|
+
systemPrompt: pkg.instructions,
|
|
128
|
+
// No built-in tools, no member settings or project files, only the MCP server we pass.
|
|
129
|
+
claudeCode: { options: { tools: [], settingSources: [], strictMcpConfig: true } },
|
|
130
|
+
}),
|
|
131
|
+
// The member's own default mode could be "bypass permissions"; force the asking mode so our handler decides.
|
|
132
|
+
mode: "default",
|
|
133
|
+
acpMcp: true,
|
|
134
|
+
allowTool: allowClaudeTool,
|
|
135
|
+
},
|
|
136
|
+
codex: {
|
|
137
|
+
label: "Codex",
|
|
138
|
+
// The adapter takes Codex config as one JSON object. NO_BROWSER hides the browser login method, so a signed-out
|
|
139
|
+
// app fails instead of opening a login page.
|
|
140
|
+
launch: (pkg) => ({
|
|
141
|
+
command: process.execPath,
|
|
142
|
+
args: [adapterFile("@agentclientprotocol/codex-acp", "dist/index.js")],
|
|
143
|
+
env: {
|
|
144
|
+
CODEX_CONFIG: JSON.stringify(codexConfig(pkg, listCodexMcp())),
|
|
145
|
+
[CODEX_TOKEN_ENV]: pkg.token,
|
|
146
|
+
INITIAL_AGENT_MODE: "read-only",
|
|
147
|
+
NO_BROWSER: "1",
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
sessionMeta: () => undefined,
|
|
151
|
+
mode: null,
|
|
152
|
+
acpMcp: false,
|
|
153
|
+
allowTool: allowCodexTool,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
type Option = acp.RequestPermissionRequest["options"][number];
|
|
158
|
+
|
|
159
|
+
// Pure policy: the option id to select, or null to cancel the request.
|
|
160
|
+
export function decide(
|
|
161
|
+
spec: Pick<AgentSpec, "allowTool">,
|
|
162
|
+
p: acp.RequestPermissionRequest,
|
|
163
|
+
calls: ReadonlyMap<string, SeenCall> = new Map(),
|
|
164
|
+
): { allowed: boolean; optionId: string | null } {
|
|
165
|
+
const allowed = spec.allowTool(p, calls);
|
|
166
|
+
const pick = (kinds: string[], ids: string[]): Option | undefined =>
|
|
167
|
+
p.options.find((o) => kinds.includes(o.kind)) ?? p.options.find((o) => ids.includes(o.optionId));
|
|
168
|
+
// Always the one-shot option, never "always": the next call is judged on its own.
|
|
169
|
+
const o = allowed ? pick(["allow_once"], ["approved", "allow", "allow_once"]) : pick(["reject_once", "reject_always"], ["cancel", "reject", "denied", "abort"]);
|
|
170
|
+
return { allowed, optionId: o?.optionId ?? null };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type RunAcpOptions = {
|
|
174
|
+
timeoutMs: number;
|
|
175
|
+
// Override how the adapter is started (tests). Defaults to the registry.
|
|
176
|
+
launch?: Launch;
|
|
177
|
+
// The member's own CLI path (CREW_CLAUDE_BIN), handed to the claude adapter.
|
|
178
|
+
bin?: string;
|
|
179
|
+
// Abort = run cancelled: session/cancel first, kill after a short grace.
|
|
180
|
+
signal?: AbortSignal;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
type Sink = (events: RunEvent[]) => Promise<void>;
|
|
184
|
+
|
|
185
|
+
const failed = (error: string, inputTokens = 0, outputTokens = 0): Outcome => ({ status: "failed", text: "", inputTokens, outputTokens, error });
|
|
186
|
+
|
|
187
|
+
export async function runAcp(agent: AgentName, pkg: Claimed, sink: Sink, opts: RunAcpOptions): Promise<Outcome> {
|
|
188
|
+
const spec = AGENTS[agent];
|
|
189
|
+
// Empty temp directory: even if a built-in tool were somehow on, there is nothing of the member's to touch.
|
|
190
|
+
const cwd = await mkdtemp(join(tmpdir(), "crew-run-"));
|
|
191
|
+
let child: ReturnType<typeof spawn> | undefined;
|
|
192
|
+
let timer: NodeJS.Timeout | undefined;
|
|
193
|
+
let flushTimer: NodeJS.Timeout | undefined;
|
|
194
|
+
let graceTimer: NodeJS.Timeout | undefined;
|
|
195
|
+
let onAbort: (() => void) | undefined;
|
|
196
|
+
try {
|
|
197
|
+
const launch = opts.launch ?? spec.launch(pkg, { bin: opts.bin });
|
|
198
|
+
// POSIX: detached makes the adapter lead a process group, so killTree ends it and the app it started. Windows uses taskkill.
|
|
199
|
+
child = spawn(launch.command, launch.args, { cwd, stdio: ["pipe", "pipe", "ignore"], detached: process.platform !== "win32", windowsHide: true, env: { ...process.env, ...launch.env } });
|
|
200
|
+
const proc = child;
|
|
201
|
+
const exited = new Promise<never>((_, reject) => {
|
|
202
|
+
proc.on("error", () => reject(new Error("runner_failed")));
|
|
203
|
+
proc.on("close", () => reject(new Error("runner_failed")));
|
|
204
|
+
});
|
|
205
|
+
exited.catch(() => undefined);
|
|
206
|
+
const timedOut = new Promise<never>((_, reject) => {
|
|
207
|
+
timer = setTimeout(() => reject(new Error("runner_timeout")), opts.timeoutMs);
|
|
208
|
+
});
|
|
209
|
+
timedOut.catch(() => undefined);
|
|
210
|
+
|
|
211
|
+
// Serialised sink, text coalesced so a token stream is not one HTTP call per chunk.
|
|
212
|
+
let chain: Promise<void> = Promise.resolve();
|
|
213
|
+
const send = (events: RunEvent[]) => {
|
|
214
|
+
if (events.length) chain = chain.then(() => sink(events).catch(() => undefined));
|
|
215
|
+
};
|
|
216
|
+
const calls = new Map<string, SeenCall>();
|
|
217
|
+
let buffered = "";
|
|
218
|
+
let final = "";
|
|
219
|
+
const flush = () => {
|
|
220
|
+
clearTimeout(flushTimer);
|
|
221
|
+
flushTimer = undefined;
|
|
222
|
+
while (buffered) {
|
|
223
|
+
send([{ type: "text", text: buffered.slice(0, MAX_EVENT_TEXT) }]);
|
|
224
|
+
buffered = buffered.slice(MAX_EVENT_TEXT);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
const onText = (t: string) => {
|
|
228
|
+
buffered += t;
|
|
229
|
+
final += t;
|
|
230
|
+
if (buffered.length >= MAX_EVENT_TEXT) flush();
|
|
231
|
+
else flushTimer ??= setTimeout(flush, FLUSH_MS);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const handleUpdate = (u: acp.SessionUpdate) => {
|
|
235
|
+
switch (u.sessionUpdate) {
|
|
236
|
+
case "agent_message_chunk":
|
|
237
|
+
if (u.content.type === "text") onText(u.content.text);
|
|
238
|
+
break;
|
|
239
|
+
case "tool_call":
|
|
240
|
+
calls.set(u.toolCallId, { title: u.title, rawInput: u.rawInput });
|
|
241
|
+
flush();
|
|
242
|
+
final = ""; // the answer is the text after the last tool call
|
|
243
|
+
send([{ type: "step", kind: "tool", name: u.title.slice(0, 200), input: u.rawInput }]);
|
|
244
|
+
break;
|
|
245
|
+
case "tool_call_update":
|
|
246
|
+
if (u.status === "completed" || u.status === "failed") {
|
|
247
|
+
flush();
|
|
248
|
+
send([{ type: "step", kind: "tool", output: u.rawOutput ?? u.content }]);
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
case "plan":
|
|
252
|
+
send([{ type: "step", kind: "model", name: "plan", output: u.entries.map((e) => e.content) }]);
|
|
253
|
+
break;
|
|
254
|
+
default:
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const stream = acp.ndJsonStream(Writable.toWeb(proc.stdin!) as WritableStream<Uint8Array>, Readable.toWeb(proc.stdout!) as ReadableStream<Uint8Array>);
|
|
260
|
+
|
|
261
|
+
const session = acp
|
|
262
|
+
.client({ name: "crew-runner" })
|
|
263
|
+
.onRequest(acp.methods.client.session.requestPermission, (ctx) => {
|
|
264
|
+
const d = decide(spec, ctx.params, calls);
|
|
265
|
+
return { outcome: d.optionId ? { outcome: "selected" as const, optionId: d.optionId } : { outcome: "cancelled" as const } };
|
|
266
|
+
})
|
|
267
|
+
.connectWith(stream, async (ctx) => {
|
|
268
|
+
await ctx.request(acp.methods.agent.initialize, {
|
|
269
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
270
|
+
// No file system or terminal for the agent: it can only use the MCP tools we hand it.
|
|
271
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
|
|
272
|
+
});
|
|
273
|
+
const request: acp.NewSessionRequest = {
|
|
274
|
+
cwd,
|
|
275
|
+
mcpServers: spec.acpMcp ? [{ type: "http", name: MCP_NAME, url: pkg.mcp_url, headers: [{ name: "Authorization", value: `Bearer ${pkg.token}` }] }] : [],
|
|
276
|
+
_meta: spec.sessionMeta(pkg),
|
|
277
|
+
};
|
|
278
|
+
const active = await ctx.buildSession(request).start();
|
|
279
|
+
try {
|
|
280
|
+
if (spec.mode) await ctx.request(acp.methods.agent.session.setMode, { sessionId: active.sessionId, modeId: spec.mode });
|
|
281
|
+
onAbort = () => {
|
|
282
|
+
void ctx.notify(acp.methods.agent.session.cancel, { sessionId: active.sessionId }).catch(() => undefined);
|
|
283
|
+
graceTimer = setTimeout(() => killTree(proc), CANCEL_GRACE_MS);
|
|
284
|
+
};
|
|
285
|
+
if (opts.signal?.aborted) onAbort();
|
|
286
|
+
else opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
287
|
+
void active.prompt(promptFrom(pkg.messages)).catch(() => undefined);
|
|
288
|
+
for (;;) {
|
|
289
|
+
const m = await active.nextUpdate();
|
|
290
|
+
if (m.kind === "stop") return m.response;
|
|
291
|
+
handleUpdate(m.update);
|
|
292
|
+
}
|
|
293
|
+
} finally {
|
|
294
|
+
active.dispose();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
session.catch(() => undefined);
|
|
298
|
+
|
|
299
|
+
let response: acp.PromptResponse;
|
|
300
|
+
try {
|
|
301
|
+
response = await Promise.race([session, exited, timedOut]);
|
|
302
|
+
} catch (e) {
|
|
303
|
+
flush();
|
|
304
|
+
await chain;
|
|
305
|
+
return failed(e instanceof Error && e.message === "runner_timeout" ? "runner_timeout" : opts.signal?.aborted ? "runner_cancelled" : "runner_failed");
|
|
306
|
+
}
|
|
307
|
+
flush();
|
|
308
|
+
await chain;
|
|
309
|
+
const inputTokens = response.usage?.inputTokens ?? 0;
|
|
310
|
+
const outputTokens = response.usage?.outputTokens ?? 0;
|
|
311
|
+
if (response.stopReason === "cancelled") return failed("runner_cancelled", inputTokens, outputTokens);
|
|
312
|
+
if (response.stopReason !== "end_turn" && response.stopReason !== "max_tokens") return failed("runner_failed", inputTokens, outputTokens);
|
|
313
|
+
return { status: "completed", text: final.slice(0, MAX_TEXT), inputTokens, outputTokens };
|
|
314
|
+
} catch {
|
|
315
|
+
return failed("runner_failed");
|
|
316
|
+
} finally {
|
|
317
|
+
clearTimeout(timer);
|
|
318
|
+
clearTimeout(flushTimer);
|
|
319
|
+
clearTimeout(graceTimer);
|
|
320
|
+
if (onAbort) opts.signal?.removeEventListener("abort", onAbort);
|
|
321
|
+
if (child) killTree(child);
|
|
322
|
+
await rm(cwd, { recursive: true, force: true });
|
|
323
|
+
}
|
|
324
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Stored } from "./store.ts";
|
|
2
|
+
|
|
3
|
+
export type Claimed = {
|
|
4
|
+
run_id: string;
|
|
5
|
+
instructions: string;
|
|
6
|
+
messages: { role: string; content: unknown }[];
|
|
7
|
+
mcp_url: string;
|
|
8
|
+
token: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type RunEvent =
|
|
12
|
+
| { type: "text"; text: string }
|
|
13
|
+
| { type: "step"; kind: "model" | "tool"; name?: string; input?: unknown; output?: unknown };
|
|
14
|
+
|
|
15
|
+
export type Complete = {
|
|
16
|
+
run_id: string;
|
|
17
|
+
status: "completed" | "failed";
|
|
18
|
+
text: string;
|
|
19
|
+
input_tokens?: number;
|
|
20
|
+
output_tokens?: number;
|
|
21
|
+
error?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function createApi(cfg: Stored, fetchImpl: typeof fetch = fetch) {
|
|
25
|
+
const call = (path: string, body: unknown, signal?: AbortSignal) =>
|
|
26
|
+
fetchImpl(`${cfg.appUrl}/api/runner/${path}`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${cfg.token}` },
|
|
29
|
+
body: JSON.stringify(body),
|
|
30
|
+
signal,
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
// true = this device token is accepted by the server.
|
|
34
|
+
async ping(): Promise<boolean> {
|
|
35
|
+
return (await call("ping", {})).ok;
|
|
36
|
+
},
|
|
37
|
+
// 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);
|
|
40
|
+
if (res.status === 204) return null;
|
|
41
|
+
if (!res.ok) throw new Error(`claim failed (${res.status})`);
|
|
42
|
+
return (await res.json()) as Claimed;
|
|
43
|
+
},
|
|
44
|
+
async events(runId: string, events: RunEvent[]): Promise<void> {
|
|
45
|
+
if (events.length === 0) return;
|
|
46
|
+
await call("events", { run_id: runId, events });
|
|
47
|
+
},
|
|
48
|
+
async complete(body: Complete): Promise<void> {
|
|
49
|
+
await call("complete", body);
|
|
50
|
+
},
|
|
51
|
+
async revoke(): Promise<boolean> {
|
|
52
|
+
return (await call("revoke", {})).ok;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export type RunnerApi = ReturnType<typeof createApi>;
|
package/src/claude.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import type { Claimed, RunEvent } from "./api.ts";
|
|
7
|
+
import { invocation, spawnBin } from "./spawn.ts";
|
|
8
|
+
|
|
9
|
+
// Drives the member's own `claude` app in print mode. We only run its public commands (--version, auth status)
|
|
10
|
+
// and read its exit code. We never open its files or environment: whatever login it has stays inside it.
|
|
11
|
+
const SUPPORTED_MAJOR = 2;
|
|
12
|
+
const MIN_MINOR = 1;
|
|
13
|
+
const MAX_TEXT = 100_000;
|
|
14
|
+
|
|
15
|
+
export type Check = { ok: true; version: string } | { ok: false; message: string };
|
|
16
|
+
|
|
17
|
+
// On Windows `bin` may be a .cmd shim; invocation() starts it through cmd.exe, never through a general shell.
|
|
18
|
+
export const run = (bin: string, args: string[]): Promise<{ code: number; out: string }> =>
|
|
19
|
+
new Promise((resolve) => {
|
|
20
|
+
let inv;
|
|
21
|
+
try {
|
|
22
|
+
inv = invocation(bin, args);
|
|
23
|
+
} catch {
|
|
24
|
+
resolve({ code: 1, out: "" });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
execFile(inv.command, inv.args, { timeout: 20_000, windowsHide: true, windowsVerbatimArguments: inv.windowsVerbatimArguments }, (err, stdout) => {
|
|
28
|
+
const code = err ? (typeof err.code === "number" ? err.code : 1) : 0;
|
|
29
|
+
resolve({ code, out: String(stdout ?? "") });
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export function parseVersion(out: string): { major: number; minor: number; text: string } | null {
|
|
34
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(out);
|
|
35
|
+
if (!m) return null;
|
|
36
|
+
return { major: Number(m[1]), minor: Number(m[2]), text: `${m[1]}.${m[2]}.${m[3]}` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function checkClaude(bin = "claude"): Promise<Check> {
|
|
40
|
+
const v = await run(bin, ["--version"]);
|
|
41
|
+
if (v.code !== 0) return { ok: false, message: "Claude Code is not installed. Install it, sign in, then run this again." };
|
|
42
|
+
const ver = parseVersion(v.out);
|
|
43
|
+
if (!ver) return { ok: false, message: "Could not read the Claude Code version." };
|
|
44
|
+
if (ver.major !== SUPPORTED_MAJOR || ver.minor < MIN_MINOR) {
|
|
45
|
+
return { ok: false, message: `Claude Code ${ver.text} is outside the supported range (${SUPPORTED_MAJOR}.${MIN_MINOR}.x and later ${SUPPORTED_MAJOR}.x). Update it.` };
|
|
46
|
+
}
|
|
47
|
+
const s = await run(bin, ["auth", "status"]);
|
|
48
|
+
let signedIn = s.code === 0;
|
|
49
|
+
try {
|
|
50
|
+
const json = JSON.parse(s.out) as { loggedIn?: unknown };
|
|
51
|
+
if (json.loggedIn === false) signedIn = false;
|
|
52
|
+
} catch {
|
|
53
|
+
// Not JSON: trust the exit code.
|
|
54
|
+
}
|
|
55
|
+
if (!signedIn) return { ok: false, message: "Claude Code is not signed in. Open it, sign in, then run this again." };
|
|
56
|
+
return { ok: true, version: ver.text };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Only our MCP server is configured and every built-in file and shell tool is off, so the only tools are ours.
|
|
60
|
+
export function buildArgs(pkg: Pick<Claimed, "instructions" | "mcp_url" | "token">): string[] {
|
|
61
|
+
const mcp = JSON.stringify({ mcpServers: { crew: { type: "http", url: pkg.mcp_url, headers: { Authorization: `Bearer ${pkg.token}` } } } });
|
|
62
|
+
return [
|
|
63
|
+
"-p",
|
|
64
|
+
"--output-format", "stream-json",
|
|
65
|
+
"--verbose",
|
|
66
|
+
"--mcp-config", mcp,
|
|
67
|
+
"--strict-mcp-config",
|
|
68
|
+
"--tools", "",
|
|
69
|
+
"--permission-mode", "dontAsk",
|
|
70
|
+
"--allowedTools", "mcp__crew",
|
|
71
|
+
"--system-prompt", pkg.instructions,
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function promptFrom(messages: Claimed["messages"]): string {
|
|
76
|
+
return messages.map((m) => `${m.role}: ${typeof m.content === "string" ? m.content : JSON.stringify(m.content)}`).join("\n\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type Outcome = { status: "completed" | "failed"; text: string; inputTokens: number; outputTokens: number; costMicroUsd?: number; error?: string };
|
|
80
|
+
|
|
81
|
+
type Block = { type?: string; text?: string; name?: string; input?: unknown; content?: unknown };
|
|
82
|
+
type Line = {
|
|
83
|
+
type?: string;
|
|
84
|
+
message?: { content?: Block[] };
|
|
85
|
+
result?: string;
|
|
86
|
+
is_error?: boolean;
|
|
87
|
+
total_cost_usd?: number;
|
|
88
|
+
usage?: { input_tokens?: number; output_tokens?: number };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// One stream-json line -> zero or more events for the server.
|
|
92
|
+
export function eventsFromLine(line: Line): RunEvent[] {
|
|
93
|
+
const out: RunEvent[] = [];
|
|
94
|
+
const blocks = line.message?.content;
|
|
95
|
+
if (!Array.isArray(blocks)) return out;
|
|
96
|
+
for (const b of blocks) {
|
|
97
|
+
if (line.type === "assistant" && b.type === "tool_use") out.push({ type: "step", kind: "tool", name: b.name, input: b.input });
|
|
98
|
+
if (line.type === "user" && b.type === "tool_result") out.push({ type: "step", kind: "tool", output: b.content });
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function runClaude(
|
|
104
|
+
pkg: Claimed,
|
|
105
|
+
sink: (events: RunEvent[]) => Promise<void>,
|
|
106
|
+
opts: { bin?: string; timeoutMs: number },
|
|
107
|
+
): Promise<Outcome> {
|
|
108
|
+
// Empty temp directory: even if a built-in tool were somehow on, there is nothing of the member's to touch.
|
|
109
|
+
const cwd = await mkdtemp(join(tmpdir(), "crew-run-"));
|
|
110
|
+
let child;
|
|
111
|
+
try {
|
|
112
|
+
child = spawnBin(opts.bin ?? "claude", buildArgs(pkg), { cwd, stdio: ["pipe", "pipe", "ignore"] });
|
|
113
|
+
} catch {
|
|
114
|
+
// Windows .cmd shim given an argument it cannot carry (a line break in the instructions).
|
|
115
|
+
await rm(cwd, { recursive: true, force: true });
|
|
116
|
+
return { status: "failed", text: "", inputTokens: 0, outputTokens: 0, error: "runner_failed" };
|
|
117
|
+
}
|
|
118
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs);
|
|
119
|
+
child.stdin?.on("error", () => undefined);
|
|
120
|
+
child.stdin?.end(promptFrom(pkg.messages));
|
|
121
|
+
|
|
122
|
+
let result: Line | null = null;
|
|
123
|
+
const pending: Promise<void>[] = [];
|
|
124
|
+
const rl = createInterface({ input: child.stdout! });
|
|
125
|
+
rl.on("line", (raw) => {
|
|
126
|
+
let line: Line;
|
|
127
|
+
try {
|
|
128
|
+
line = JSON.parse(raw) as Line;
|
|
129
|
+
} catch {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (line.type === "result") result = line;
|
|
133
|
+
const events = eventsFromLine(line);
|
|
134
|
+
if (events.length) pending.push(sink(events).catch(() => undefined));
|
|
135
|
+
});
|
|
136
|
+
const code = await new Promise<number>((resolve) => {
|
|
137
|
+
child.on("error", () => resolve(127));
|
|
138
|
+
child.on("close", (c) => resolve(c ?? 1));
|
|
139
|
+
});
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
await Promise.all(pending);
|
|
142
|
+
await rm(cwd, { recursive: true, force: true });
|
|
143
|
+
|
|
144
|
+
const r = result as Line | null;
|
|
145
|
+
if (!r || r.is_error || code !== 0) {
|
|
146
|
+
return { status: "failed", text: "", inputTokens: 0, outputTokens: 0, error: "runner_failed" };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
status: "completed",
|
|
150
|
+
text: (r.result ?? "").slice(0, MAX_TEXT),
|
|
151
|
+
inputTokens: r.usage?.input_tokens ?? 0,
|
|
152
|
+
outputTokens: r.usage?.output_tokens ?? 0,
|
|
153
|
+
costMicroUsd: undefined,
|
|
154
|
+
};
|
|
155
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createApi } from "./api.ts";
|
|
4
|
+
import { runAcp } from "./acp.ts";
|
|
5
|
+
import { checkClaude, runClaude, type Check } from "./claude.ts";
|
|
6
|
+
import { checkCodex, runCodex } from "./codex.ts";
|
|
7
|
+
import { loop, RUN_TIMEOUT_MS, type Runner } from "./loop.ts";
|
|
8
|
+
import * as store from "./store.ts";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_APP_URL = "https://app.crewcortex.com";
|
|
11
|
+
const LINK_WAIT_MS = 5 * 60 * 1000;
|
|
12
|
+
const LINK_POLL_MS = 2_000;
|
|
13
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
14
|
+
|
|
15
|
+
function openBrowser(url: string): void {
|
|
16
|
+
// Not `cmd /c start`: cmd treats the & in a URL as the end of the command. rundll32 takes the URL as one argument.
|
|
17
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
|
|
18
|
+
const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
19
|
+
try {
|
|
20
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => undefined).unref();
|
|
21
|
+
} catch {
|
|
22
|
+
// The URL is printed too.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The token is made here and never sent. The server only gets its SHA-256 (the "code") through the signed-in member.
|
|
27
|
+
async function login(appUrlArg: string | undefined): Promise<number> {
|
|
28
|
+
const appUrl = (appUrlArg || process.env.CREW_APP_URL || DEFAULT_APP_URL).replace(/\/+$/, "");
|
|
29
|
+
if (!/^https?:\/\//.test(appUrl)) {
|
|
30
|
+
console.error("Usage: crew-runner login [app url]");
|
|
31
|
+
return 2;
|
|
32
|
+
}
|
|
33
|
+
// 32 bytes base64url (43 chars): the only shape the server's bearerToken() accepts.
|
|
34
|
+
const token = randomBytes(32).toString("base64url");
|
|
35
|
+
const code = createHash("sha256").update(token).digest("hex");
|
|
36
|
+
const url = `${appUrl}/runner/link?code=${code}`;
|
|
37
|
+
console.log(`Open this page, sign in and confirm this computer:\n${url}`);
|
|
38
|
+
openBrowser(url);
|
|
39
|
+
const api = createApi({ appUrl, token });
|
|
40
|
+
const until = Date.now() + LINK_WAIT_MS;
|
|
41
|
+
while (Date.now() < until) {
|
|
42
|
+
if (await api.ping().catch(() => false)) {
|
|
43
|
+
await store.save({ appUrl, token });
|
|
44
|
+
console.log("Linked. Now run `npx crew-runner@latest start` (or `crew-runner start` if you installed it) and leave it running.");
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
await sleep(LINK_POLL_MS);
|
|
48
|
+
}
|
|
49
|
+
console.error("Timed out waiting for the link to be confirmed.");
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type CliName = "claude" | "codex";
|
|
54
|
+
|
|
55
|
+
// Default is the ACP path. CREW_RUNNER_PROTOCOL=legacy keeps the old direct adapters for one release.
|
|
56
|
+
const legacy = () => process.env.CREW_RUNNER_PROTOCOL === "legacy";
|
|
57
|
+
|
|
58
|
+
const CLIS: Record<CliName, { label: string; bin: () => string; check: (bin: string) => Promise<Check>; runner: (bin: string) => Runner }> = {
|
|
59
|
+
claude: {
|
|
60
|
+
label: "Claude Code",
|
|
61
|
+
bin: () => process.env.CREW_CLAUDE_BIN || "claude",
|
|
62
|
+
check: checkClaude,
|
|
63
|
+
runner: (bin) => (pkg, sink, signal) =>
|
|
64
|
+
legacy() ? runClaude(pkg, sink, { bin, timeoutMs: RUN_TIMEOUT_MS }) : runAcp("claude", pkg, sink, { bin, timeoutMs: RUN_TIMEOUT_MS, signal }),
|
|
65
|
+
},
|
|
66
|
+
codex: {
|
|
67
|
+
label: "Codex",
|
|
68
|
+
bin: () => process.env.CREW_CODEX_BIN || "codex",
|
|
69
|
+
check: checkCodex,
|
|
70
|
+
runner: (bin) => (pkg, sink, signal) =>
|
|
71
|
+
legacy() ? runCodex(pkg, sink, { bin, timeoutMs: RUN_TIMEOUT_MS }) : runAcp("codex", pkg, sink, { timeoutMs: RUN_TIMEOUT_MS, signal }),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
const CLI_NAMES = Object.keys(CLIS) as CliName[];
|
|
75
|
+
|
|
76
|
+
function flagValue(args: string[], flag: string): string | undefined {
|
|
77
|
+
const i = args.indexOf(flag);
|
|
78
|
+
if (i >= 0) return args[i + 1];
|
|
79
|
+
return args.find((a) => a.startsWith(`${flag}=`))?.slice(flag.length + 1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Picks which app runs the agents: --cli, then CREW_RUNNER_CLI, else the first one that is installed and signed in.
|
|
83
|
+
async function chooseCli(args: string[]): Promise<{ name: CliName; version: string } | { error: string }> {
|
|
84
|
+
const asked = flagValue(args, "--cli") ?? process.env.CREW_RUNNER_CLI;
|
|
85
|
+
if (asked) {
|
|
86
|
+
if (!CLI_NAMES.includes(asked as CliName)) return { error: `Unknown --cli "${asked}". Use claude or codex.` };
|
|
87
|
+
const name = asked as CliName;
|
|
88
|
+
const check = await CLIS[name].check(CLIS[name].bin());
|
|
89
|
+
return check.ok ? { name, version: check.version } : { error: check.message };
|
|
90
|
+
}
|
|
91
|
+
const messages: string[] = [];
|
|
92
|
+
for (const name of CLI_NAMES) {
|
|
93
|
+
const check = await CLIS[name].check(CLIS[name].bin());
|
|
94
|
+
if (check.ok) return { name, version: check.version };
|
|
95
|
+
messages.push(check.message);
|
|
96
|
+
}
|
|
97
|
+
return { error: messages.join("\n") };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function start(args: string[]): Promise<number> {
|
|
101
|
+
const cfg = await store.load();
|
|
102
|
+
if (!cfg) {
|
|
103
|
+
console.error("Not linked. Run `npx crew-runner@latest login` first.");
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
const chosen = await chooseCli(args);
|
|
107
|
+
if ("error" in chosen) {
|
|
108
|
+
console.error(chosen.error);
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
const api = createApi(cfg);
|
|
112
|
+
if (!(await api.ping().catch(() => false))) {
|
|
113
|
+
console.error("The server did not accept this device. Run `npx crew-runner@latest login` again.");
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
const cli = CLIS[chosen.name];
|
|
117
|
+
console.log(`Ready. ${cli.label} ${chosen.version}. Waiting for runs. Press Ctrl+C to stop.`);
|
|
118
|
+
const ac = new AbortController();
|
|
119
|
+
process.once("SIGINT", () => ac.abort());
|
|
120
|
+
process.once("SIGTERM", () => ac.abort());
|
|
121
|
+
// Windows sends SIGBREAK (Ctrl+Break, closing the console window) instead of SIGTERM.
|
|
122
|
+
if (process.platform === "win32") process.once("SIGBREAK", () => ac.abort());
|
|
123
|
+
await loop(api, cli.runner(cli.bin()), ac.signal, (m) => console.log(m));
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function status(): Promise<number> {
|
|
128
|
+
const cfg = await store.load();
|
|
129
|
+
if (!cfg) {
|
|
130
|
+
console.log("Not linked.");
|
|
131
|
+
return 1;
|
|
132
|
+
}
|
|
133
|
+
const linked = await createApi(cfg).ping().catch(() => false);
|
|
134
|
+
console.log(`Server: ${cfg.appUrl}`);
|
|
135
|
+
console.log(`Device: ${linked ? "linked" : "not accepted (run login again)"}`);
|
|
136
|
+
let anyReady = false;
|
|
137
|
+
for (const name of CLI_NAMES) {
|
|
138
|
+
const check = await CLIS[name].check(CLIS[name].bin());
|
|
139
|
+
anyReady ||= check.ok;
|
|
140
|
+
console.log(`${CLIS[name].label}: ${check.ok ? `ready (${check.version})` : check.message}`);
|
|
141
|
+
}
|
|
142
|
+
return linked && anyReady ? 0 : 1;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function logout(): Promise<number> {
|
|
146
|
+
const cfg = await store.load();
|
|
147
|
+
if (cfg) await createApi(cfg).revoke().catch(() => false);
|
|
148
|
+
await store.clear();
|
|
149
|
+
console.log("Logged out. This device can no longer receive runs.");
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function main(argv: string[]): Promise<number> {
|
|
154
|
+
const [cmd, arg] = argv;
|
|
155
|
+
if (cmd === "login") return login(arg);
|
|
156
|
+
if (cmd === "start") return start(argv.slice(1));
|
|
157
|
+
if (cmd === "status") return status();
|
|
158
|
+
if (cmd === "logout") return logout();
|
|
159
|
+
console.log("Usage: crew-runner <login [app url] | start [--cli claude|codex] | status | logout>");
|
|
160
|
+
return cmd ? 2 : 0;
|
|
161
|
+
}
|
package/src/codex.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import type { Claimed, RunEvent } from "./api.ts";
|
|
6
|
+
import { spawnBin } from "./spawn.ts";
|
|
7
|
+
import { parseVersion, promptFrom, run, type Check, type Outcome } from "./claude.ts";
|
|
8
|
+
|
|
9
|
+
// Drives the member's own `codex` app in exec mode. Like claude.ts we only run its public commands
|
|
10
|
+
// (--version, login status) and read the exit code. We never open its files or environment.
|
|
11
|
+
const MIN_CODEX_MAJOR = 0;
|
|
12
|
+
const MIN_CODEX_MINOR = 142;
|
|
13
|
+
const MAX_TEXT = 100_000;
|
|
14
|
+
const TOKEN_ENV = "CREW_RUN_TOKEN";
|
|
15
|
+
|
|
16
|
+
// Every enabled feature in `codex features list` that adds tool surface (checked on 0.142.5). Our MCP server is the only
|
|
17
|
+
// intended tool, so the shell, browser, computer, image, apps, plugin and sub-agent tools are all switched off.
|
|
18
|
+
export const DISABLED_FEATURES = [
|
|
19
|
+
"shell_tool",
|
|
20
|
+
"unified_exec",
|
|
21
|
+
"apps",
|
|
22
|
+
"browser_use",
|
|
23
|
+
"browser_use_external",
|
|
24
|
+
"browser_use_full_cdp_access",
|
|
25
|
+
"computer_use",
|
|
26
|
+
"in_app_browser",
|
|
27
|
+
"image_generation",
|
|
28
|
+
"multi_agent",
|
|
29
|
+
"plugins",
|
|
30
|
+
"tool_suggest",
|
|
31
|
+
"goals",
|
|
32
|
+
"hooks",
|
|
33
|
+
"skill_mcp_dependency_install",
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
export async function checkCodex(bin = "codex"): Promise<Check> {
|
|
37
|
+
const v = await run(bin, ["--version"]);
|
|
38
|
+
if (v.code !== 0) return { ok: false, message: "Codex is not installed. Install it, sign in with `codex login`, then run this again." };
|
|
39
|
+
const ver = parseVersion(v.out);
|
|
40
|
+
if (!ver) return { ok: false, message: "Could not read the Codex version." };
|
|
41
|
+
if (ver.major < MIN_CODEX_MAJOR || (ver.major === MIN_CODEX_MAJOR && ver.minor < MIN_CODEX_MINOR)) {
|
|
42
|
+
return { ok: false, message: `Codex ${ver.text} is too old (${MIN_CODEX_MAJOR}.${MIN_CODEX_MINOR}.x or later is needed). Update it.` };
|
|
43
|
+
}
|
|
44
|
+
const s = await run(bin, ["login", "status"]);
|
|
45
|
+
if (s.code !== 0) return { ok: false, message: "Codex is not signed in. Run `codex login`, then run this again." };
|
|
46
|
+
return { ok: true, version: ver.text };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The run token is NOT in these args: Codex reads it from the child env var named by bearer_token_env_var.
|
|
50
|
+
// Values go through JSON.stringify, which is a valid TOML basic string. The prompt is sent on stdin ("-").
|
|
51
|
+
export function buildCodexArgs(pkg: Pick<Claimed, "instructions" | "mcp_url">, cwd: string): string[] {
|
|
52
|
+
const args = [
|
|
53
|
+
"exec",
|
|
54
|
+
"--json",
|
|
55
|
+
"-s", "read-only",
|
|
56
|
+
"--ephemeral",
|
|
57
|
+
"--skip-git-repo-check",
|
|
58
|
+
"--ignore-user-config",
|
|
59
|
+
"--ignore-rules",
|
|
60
|
+
"-C", cwd,
|
|
61
|
+
];
|
|
62
|
+
for (const f of DISABLED_FEATURES) args.push("--disable", f);
|
|
63
|
+
const cfg: [string, string][] = [
|
|
64
|
+
["web_search", JSON.stringify("disabled")],
|
|
65
|
+
["approval_policy", JSON.stringify("never")],
|
|
66
|
+
["developer_instructions", JSON.stringify(pkg.instructions)],
|
|
67
|
+
["mcp_servers.crew.url", JSON.stringify(pkg.mcp_url)],
|
|
68
|
+
["mcp_servers.crew.bearer_token_env_var", JSON.stringify(TOKEN_ENV)],
|
|
69
|
+
["mcp_servers.crew.default_tools_approval_mode", JSON.stringify("approve")],
|
|
70
|
+
["mcp_servers.crew.required", "true"],
|
|
71
|
+
];
|
|
72
|
+
for (const [k, v] of cfg) args.push("-c", `${k}=${v}`);
|
|
73
|
+
args.push("-");
|
|
74
|
+
return args;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type Item = {
|
|
78
|
+
type?: string;
|
|
79
|
+
text?: string;
|
|
80
|
+
server?: string;
|
|
81
|
+
tool?: string;
|
|
82
|
+
arguments?: unknown;
|
|
83
|
+
result?: { content?: unknown; structured_content?: unknown } | null;
|
|
84
|
+
error?: { message?: string } | null;
|
|
85
|
+
};
|
|
86
|
+
type Line = {
|
|
87
|
+
type?: string;
|
|
88
|
+
item?: Item;
|
|
89
|
+
usage?: { input_tokens?: number; output_tokens?: number };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// One `codex exec --json` line -> zero or more events for the server.
|
|
93
|
+
export function codexEventsFromLine(line: Line): RunEvent[] {
|
|
94
|
+
const item = line.item;
|
|
95
|
+
if (!item || item.type !== "mcp_tool_call") return [];
|
|
96
|
+
if (line.type === "item.started") {
|
|
97
|
+
return [{ type: "step", kind: "tool", name: `mcp__${item.server}__${item.tool}`, input: item.arguments }];
|
|
98
|
+
}
|
|
99
|
+
if (line.type === "item.completed") {
|
|
100
|
+
const output = item.error ? item.error.message : (item.result?.structured_content ?? item.result?.content);
|
|
101
|
+
return [{ type: "step", kind: "tool", output }];
|
|
102
|
+
}
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function runCodex(
|
|
107
|
+
pkg: Claimed,
|
|
108
|
+
sink: (events: RunEvent[]) => Promise<void>,
|
|
109
|
+
opts: { bin?: string; timeoutMs: number },
|
|
110
|
+
): Promise<Outcome> {
|
|
111
|
+
// Empty temp directory, as in claude.ts: there is nothing of the member's to touch.
|
|
112
|
+
const cwd = await mkdtemp(join(tmpdir(), "crew-run-"));
|
|
113
|
+
const env = { ...process.env, [TOKEN_ENV]: pkg.token };
|
|
114
|
+
const child = spawnBin(opts.bin ?? "codex", buildCodexArgs(pkg, cwd), { cwd, env, stdio: ["pipe", "pipe", "ignore"] });
|
|
115
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs);
|
|
116
|
+
child.stdin!.on("error", () => undefined);
|
|
117
|
+
child.stdin!.end(promptFrom(pkg.messages));
|
|
118
|
+
|
|
119
|
+
let text = "";
|
|
120
|
+
let usage: Line["usage"];
|
|
121
|
+
let completed = false;
|
|
122
|
+
let failed = false;
|
|
123
|
+
const pending: Promise<void>[] = [];
|
|
124
|
+
const rl = createInterface({ input: child.stdout! });
|
|
125
|
+
rl.on("line", (raw) => {
|
|
126
|
+
let line: Line;
|
|
127
|
+
try {
|
|
128
|
+
line = JSON.parse(raw) as Line;
|
|
129
|
+
} catch {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (line.type === "turn.completed") {
|
|
133
|
+
completed = true;
|
|
134
|
+
usage = line.usage;
|
|
135
|
+
}
|
|
136
|
+
if (line.type === "turn.failed") failed = true;
|
|
137
|
+
if (line.type === "item.completed" && line.item?.type === "agent_message") text = line.item.text ?? "";
|
|
138
|
+
const events = codexEventsFromLine(line);
|
|
139
|
+
if (events.length) pending.push(sink(events).catch(() => undefined));
|
|
140
|
+
});
|
|
141
|
+
const code = await new Promise<number>((resolve) => {
|
|
142
|
+
child.on("error", () => resolve(127));
|
|
143
|
+
child.on("close", (c) => resolve(c ?? 1));
|
|
144
|
+
});
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
await Promise.all(pending);
|
|
147
|
+
await rm(cwd, { recursive: true, force: true });
|
|
148
|
+
|
|
149
|
+
if (!completed || failed || code !== 0) {
|
|
150
|
+
return { status: "failed", text: "", inputTokens: 0, outputTokens: 0, error: "runner_failed" };
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
status: "completed",
|
|
154
|
+
text: text.slice(0, MAX_TEXT),
|
|
155
|
+
inputTokens: usage?.input_tokens ?? 0,
|
|
156
|
+
outputTokens: usage?.output_tokens ?? 0,
|
|
157
|
+
};
|
|
158
|
+
}
|
package/src/loop.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Claimed, RunEvent, RunnerApi } from "./api.ts";
|
|
2
|
+
import type { Outcome } from "./claude.ts";
|
|
3
|
+
|
|
4
|
+
export const RUN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
5
|
+
const BACKOFF_MS = 5_000;
|
|
6
|
+
|
|
7
|
+
// `signal` aborts when the runner is stopped (Ctrl+C): the ACP path cancels the agent turn, the legacy path ignores it.
|
|
8
|
+
export type Runner = (pkg: Claimed, sink: (events: RunEvent[]) => Promise<void>, signal?: AbortSignal) => Promise<Outcome>;
|
|
9
|
+
|
|
10
|
+
export async function handleRun(api: RunnerApi, pkg: Claimed, runner: Runner, signal?: AbortSignal): Promise<void> {
|
|
11
|
+
const outcome = await runner(pkg, (events) => api.events(pkg.run_id, events), signal);
|
|
12
|
+
await api.complete({
|
|
13
|
+
run_id: pkg.run_id,
|
|
14
|
+
status: outcome.status,
|
|
15
|
+
text: outcome.text,
|
|
16
|
+
input_tokens: outcome.inputTokens,
|
|
17
|
+
output_tokens: outcome.outputTokens,
|
|
18
|
+
error: outcome.error,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Claim, run, complete, repeat. A failed claim (network, 401) waits and tries again until stopped.
|
|
23
|
+
export async function loop(api: RunnerApi, runner: Runner, signal: AbortSignal, log: (m: string) => void): Promise<void> {
|
|
24
|
+
while (!signal.aborted) {
|
|
25
|
+
try {
|
|
26
|
+
const pkg = await api.claim(signal);
|
|
27
|
+
if (pkg) {
|
|
28
|
+
log(`run ${pkg.run_id} started`);
|
|
29
|
+
await handleRun(api, pkg, runner, signal);
|
|
30
|
+
log(`run ${pkg.run_id} finished`);
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
if (signal.aborted) return;
|
|
34
|
+
log("could not reach the server, retrying");
|
|
35
|
+
await new Promise((r) => setTimeout(r, BACKOFF_MS));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/spawn.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { win32 } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Windows launch helpers. On Windows the apps installed with npm are .cmd shims, and a spawn without a shell cannot
|
|
6
|
+
// start them (ENOENT, or EINVAL on current Node). We never use `shell: true`. For a shim we start cmd.exe ourselves
|
|
7
|
+
// with every argument quoted for it. Everything here is a no-op on Mac and Linux. The run token is never put in
|
|
8
|
+
// these arguments: it travels in an environment variable or a request header.
|
|
9
|
+
type Platform = NodeJS.Platform;
|
|
10
|
+
type Exists = (path: string) => boolean;
|
|
11
|
+
|
|
12
|
+
const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
|
|
13
|
+
const SHIM = /\.(cmd|bat)$/i;
|
|
14
|
+
// Characters cmd.exe treats as syntax.
|
|
15
|
+
const META = /([()\][%!^"`<>&|;, *?])/g;
|
|
16
|
+
|
|
17
|
+
export const isShim = (path: string): boolean => SHIM.test(path);
|
|
18
|
+
|
|
19
|
+
// "claude" -> "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd" by walking PATH with PATHEXT, as cmd.exe would.
|
|
20
|
+
// A path with a folder in it is only tried with the extensions. Anything not found is returned unchanged.
|
|
21
|
+
export function resolveBin(bin: string, platform: Platform = process.platform, env: NodeJS.ProcessEnv = process.env, exists: Exists = existsSync): string {
|
|
22
|
+
if (platform !== "win32") return bin;
|
|
23
|
+
// Lower case: Windows file names are not case sensitive, and this keeps the result stable.
|
|
24
|
+
const exts = (env.PATHEXT ?? DEFAULT_PATHEXT).toLowerCase().split(";").filter(Boolean);
|
|
25
|
+
const names = win32.extname(bin) ? [bin, ...exts.map((e) => bin + e)] : exts.map((e) => bin + e);
|
|
26
|
+
const dirs = /[\\/]/.test(bin) ? [""] : (env.PATH ?? env.Path ?? "").split(";").filter(Boolean);
|
|
27
|
+
for (const dir of dirs) {
|
|
28
|
+
for (const name of names) {
|
|
29
|
+
const candidate = dir ? win32.join(dir, name) : name;
|
|
30
|
+
if (exists(candidate)) return candidate;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return bin;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Quoting for cmd.exe /c, in the same way as the cross-spawn package. Shim arguments are escaped twice because the
|
|
37
|
+
// shim hands them to a second cmd.exe level.
|
|
38
|
+
const quoteArg = (arg: string): string => {
|
|
39
|
+
let a = arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1");
|
|
40
|
+
a = `"${a}"`.replace(META, "^$1");
|
|
41
|
+
return a.replace(META, "^$1");
|
|
42
|
+
};
|
|
43
|
+
const quoteCommand = (cmd: string): string => cmd.replace(META, "^$1");
|
|
44
|
+
|
|
45
|
+
export type Invocation = { command: string; args: string[]; windowsVerbatimArguments?: boolean };
|
|
46
|
+
|
|
47
|
+
export function invocation(
|
|
48
|
+
bin: string,
|
|
49
|
+
args: readonly string[],
|
|
50
|
+
platform: Platform = process.platform,
|
|
51
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
52
|
+
exists: Exists = existsSync,
|
|
53
|
+
): Invocation {
|
|
54
|
+
const resolved = resolveBin(bin, platform, env, exists);
|
|
55
|
+
if (platform !== "win32" || !isShim(resolved)) return { command: resolved, args: [...args] };
|
|
56
|
+
// cmd.exe ends a command line at a line break, so such an argument would be silently cut short.
|
|
57
|
+
if (args.some((a) => /[\r\n]/.test(a))) throw new Error("an argument has a line break, which cmd.exe cannot pass to a .cmd shim");
|
|
58
|
+
const line = [quoteCommand(resolved), ...args.map(quoteArg)].join(" ");
|
|
59
|
+
return { command: env.ComSpec || "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], windowsVerbatimArguments: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function spawnBin(bin: string, args: readonly string[], options: SpawnOptions): ChildProcess {
|
|
63
|
+
const inv = invocation(bin, args);
|
|
64
|
+
return spawn(inv.command, inv.args, { ...options, windowsHide: true, ...(inv.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}) });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The npm shim for an app is a few lines that run a script next to it, for example
|
|
68
|
+
// "%_prog%" "%dp0%\node_modules\@anthropic-ai\claude-code\cli.js" %*
|
|
69
|
+
// Returns that script (or .exe) so the Claude adapter, which cannot start a shim, can be pointed at the real file.
|
|
70
|
+
export function shimTarget(shim: string, read: (p: string) => string = (p) => readFileSync(p, "utf8"), exists: Exists = existsSync): string | null {
|
|
71
|
+
try {
|
|
72
|
+
const m = /"%(?:~dp0|dp0)%?\\([^"\r\n]+\.(?:m?js|cjs|exe))"/i.exec(read(shim));
|
|
73
|
+
if (!m) return null;
|
|
74
|
+
const target = win32.join(win32.dirname(shim), m[1]!);
|
|
75
|
+
return exists(target) ? target : null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// What to hand the Claude adapter as CLAUDE_CODE_EXECUTABLE: the real app file, not a .cmd shim.
|
|
82
|
+
export function claudeExecutable(bin: string, platform: Platform = process.platform, env: NodeJS.ProcessEnv = process.env, exists: Exists = existsSync): string {
|
|
83
|
+
const resolved = resolveBin(bin, platform, env, exists);
|
|
84
|
+
if (platform !== "win32" || !isShim(resolved)) return resolved;
|
|
85
|
+
return shimTarget(resolved, undefined, exists) ?? resolved;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Ends the adapter and the app it started. POSIX: the adapter leads a process group, so one negative-pid kill ends
|
|
89
|
+
// both. Windows has no groups, so `taskkill /T` walks the child tree; if that cannot run we kill the one process.
|
|
90
|
+
type KillDeps = {
|
|
91
|
+
kill: (pid: number, signal: NodeJS.Signals) => void;
|
|
92
|
+
run: (cmd: string, args: string[], onFail: () => void) => void;
|
|
93
|
+
};
|
|
94
|
+
const realKillDeps: KillDeps = {
|
|
95
|
+
kill: (pid, signal) => process.kill(pid, signal),
|
|
96
|
+
run: (cmd, args, onFail) => {
|
|
97
|
+
const p = spawn(cmd, args, { stdio: "ignore", windowsHide: true });
|
|
98
|
+
p.on("error", onFail);
|
|
99
|
+
p.on("exit", (code) => {
|
|
100
|
+
if (code !== 0) onFail();
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export function killTree(proc: Pick<ChildProcess, "pid" | "kill">, platform: Platform = process.platform, deps: KillDeps = realKillDeps): void {
|
|
106
|
+
const own = () => {
|
|
107
|
+
try {
|
|
108
|
+
proc.kill("SIGKILL");
|
|
109
|
+
} catch {
|
|
110
|
+
// Already gone.
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
if (platform === "win32" && proc.pid) deps.run("taskkill", ["/pid", String(proc.pid), "/T", "/F"], own);
|
|
115
|
+
else if (platform !== "win32" && proc.pid) deps.kill(-proc.pid, "SIGKILL");
|
|
116
|
+
else own();
|
|
117
|
+
} catch {
|
|
118
|
+
own();
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// The only secret this program keeps is the device token the CrewCortex server gave it. It lives in a 0600 file.
|
|
6
|
+
// On Windows the mode bits and chmod do nothing; the file is protected by the ACL of the user's own profile folder instead.
|
|
7
|
+
// (An OS keychain is a later upgrade; the file is the supported path and the one tests use.)
|
|
8
|
+
export type Stored = { appUrl: string; token: string };
|
|
9
|
+
|
|
10
|
+
const dir = () => process.env.CREW_RUNNER_HOME || join(homedir(), ".crew-runner");
|
|
11
|
+
const file = () => join(dir(), "device.json");
|
|
12
|
+
|
|
13
|
+
export async function save(value: Stored): Promise<void> {
|
|
14
|
+
await mkdir(dir(), { recursive: true, mode: 0o700 });
|
|
15
|
+
await writeFile(file(), JSON.stringify(value), { mode: 0o600 });
|
|
16
|
+
await chmod(file(), 0o600);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function load(): Promise<Stored | null> {
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(await readFile(file(), "utf8")) as Partial<Stored>;
|
|
22
|
+
return typeof parsed.appUrl === "string" && typeof parsed.token === "string" ? { appUrl: parsed.appUrl, token: parsed.token } : null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function clear(): Promise<void> {
|
|
29
|
+
await rm(file(), { force: true });
|
|
30
|
+
}
|