@parley-im/parley 0.0.0-stage → 0.1.1
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 +134 -2
- package/bin/parley +15 -0
- package/dist/account.js +79 -0
- package/dist/agents/claude.js +134 -0
- package/dist/agents/codex.js +128 -0
- package/dist/agents/process.js +47 -0
- package/dist/agents/types.js +1 -0
- package/dist/api.js +180 -0
- package/dist/charter.js +57 -0
- package/dist/cli.js +272 -0
- package/dist/config.js +97 -0
- package/dist/control.js +72 -0
- package/dist/daemon.js +42 -0
- package/dist/db.js +243 -0
- package/dist/mcp.js +153 -0
- package/dist/models.js +10 -0
- package/dist/relay.js +304 -0
- package/dist/runner.js +198 -0
- package/dist/seal.js +51 -0
- package/dist/server.js +308 -0
- package/dist/state.js +41 -0
- package/dist/transcript.js +53 -0
- package/package.json +46 -4
- package/web/app.css +404 -0
- package/web/chat.html +81 -0
- package/web/chat.js +901 -0
- package/web/ended.html +20 -0
- package/web/home.js +552 -0
- package/web/icon-180.png +0 -0
- package/web/icon.png +0 -0
- package/web/icon.svg +1 -0
- package/web/markdown.js +87 -0
- package/web/missing.html +20 -0
- package/web/panel.html +76 -0
- package/web/panel.js +224 -0
- package/web/seal.js +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James Turnshek
|
|
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,135 @@
|
|
|
1
|
-
#
|
|
1
|
+
# parley
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Send someone a link. They open a private chat with your own Claude or Codex, running read-only in your project folder, and tell it what they think. It asks follow-up questions, answers their questions by reading the code, and saves everything for you.
|
|
4
|
+
|
|
5
|
+
The agent runs on your computer, with your Claude or Codex login and plan. Links live on **parley.im**, which passes the conversation between the tester's browser and your computer **end-to-end encrypted**: it can't read what's said.
|
|
6
|
+
|
|
7
|
+
## Using it
|
|
8
|
+
|
|
9
|
+
Link your computer to your parley.im account once:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
parley login
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then, from any Claude Code or Codex session:
|
|
16
|
+
|
|
17
|
+
> Make a parley so Sam can give feedback on the new onboarding. Sam's testing on their phone.
|
|
18
|
+
|
|
19
|
+
Your agent writes a brief from what it knows and hands back a link like `https://parley.im/s/…#…`. Send Sam the whole link: the part after `#` is the conversation's key. Later:
|
|
20
|
+
|
|
21
|
+
> Anything new from testers? Summarize it and file the bugs.
|
|
22
|
+
|
|
23
|
+
> We're done with Sam's session.
|
|
24
|
+
|
|
25
|
+
Or from a terminal:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
parley new --tester Sam --brief "Sam is trying the new onboarding on their phone. Find out where they got stuck."
|
|
29
|
+
parley list # open sessions: new messages, replies used, spend (--ended, --all)
|
|
30
|
+
parley show <id> --new # what came in since you last read it
|
|
31
|
+
parley limit <id> 150 # let a session give 150 replies in total
|
|
32
|
+
parley end <id> # kill the link; keep the transcript
|
|
33
|
+
parley delete <id> # erase it
|
|
34
|
+
parley panel # open your control panel
|
|
35
|
+
parley whoami | logout
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## How it fits together
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
tester's browser ──wss──▶ parley.im (Cloudflare Worker) ◀──wss── your computer (parley server)
|
|
42
|
+
key from # sender accounts, open sessions, agent runs here, transcripts
|
|
43
|
+
seals everything relays ciphertext, stores none of it and screenshots stay here,
|
|
44
|
+
holds each session's key
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- **Accounts are for senders only**, signed in with Google and keyed on the verified email, so a later sign-in method with the same email joins the same account. Testers never sign in: the link is their access.
|
|
48
|
+
- **`parley login`** works like `gh auth login`: your computer shows a code, you approve it at parley.im while signed in, and the computer gets its own token. Unlink computers from your account page on parley.im.
|
|
49
|
+
- **Your computer connects out** to parley.im over a WebSocket; nothing listens for the internet on your machine. The server starts on demand (or `parley start`) and holds that connection.
|
|
50
|
+
- **parley.im stores** accounts, linked computers, and each open session's id and title (the project name, for link previews). It forgets a session the moment it ends. Messages and screenshots only pass through, encrypted, between the tester's browser and your computer; screenshots cross in pieces over the same connection, and your computer serves them back to the page.
|
|
51
|
+
|
|
52
|
+
### End-to-end encryption
|
|
53
|
+
|
|
54
|
+
Each session has a random 256-bit key, kept in your parley database and in the link after the `#`, which browsers never send to a server. Every request, reply, live update and screenshot is sealed with AES-256-GCM (fresh IV each time) and bound to its session and direction, so parley.im can't read or alter anything, or move it between sessions. Your computer refuses requests older than five minutes or seen before, so replays go nowhere. `src/seal.ts` and `web/seal.js` are the two halves; the tests check they read each other.
|
|
55
|
+
|
|
56
|
+
What it doesn't hide, said plainly on the tester's info sheet: parley.im knows who sent a link, the project name, and when messages pass; the page's code comes from parley.im, so the promise relies on parley.im serving it honestly (a strict Content-Security-Policy means the key can only ever go back to parley.im); and Claude or Codex reads the conversation to reply. Anyone with the whole link can read and join the chat.
|
|
57
|
+
|
|
58
|
+
## What the tester gets
|
|
59
|
+
|
|
60
|
+
A chat page built for phones first, in a plain technical style (a transcript, not chat bubbles). The header shows who's answering (the model, once the first reply names it) and how many replies are left; the ⓘ sheet explains who sent it (verified by parley.im), what the agent can and can't do, that everything is saved and encrypted, and when the link stops working. Replies stream in as plain conversation; the agent's lookups only show in your view. Markdown renders, tables and quotes included; HTML stays text. Screenshots can be pasted, dropped or picked. If they're at the bottom, the view stays there through streaming, images, resizing and the keyboard (on iOS the page pins itself above the keyboard); long conversations load older messages as you scroll up. If your computer is asleep, the page still loads and says so.
|
|
61
|
+
|
|
62
|
+
## What the interviewer can do
|
|
63
|
+
|
|
64
|
+
Each session is a fresh agent conversation in the project folder, guided by a charter (`src/charter.ts`), your brief, and the project's CLAUDE.md and AGENTS.md. It talks about the product as a user sees it and only gets into files and internals when asked.
|
|
65
|
+
|
|
66
|
+
- **Claude:** `claude -p --restricted --tools Read,Grep,Glob --strict-mcp-config`. No command, write or web tools, reads confined to the project, your MCP servers left out.
|
|
67
|
+
- **Codex:** `codex exec --sandbox read-only --ignore-user-config`, with apps, browser, computer use, plugins, hooks and web search off; your model and reasoning effort carry over.
|
|
68
|
+
|
|
69
|
+
Each reply records which model answered, on which login (`auth: "none"` means your subscription, not an API key), and what it would cost at API prices.
|
|
70
|
+
|
|
71
|
+
## Your control panel
|
|
72
|
+
|
|
73
|
+
`http://localhost:4748`, only reachable from your computer: your parley.im connection, totals (open sessions, new messages, replies, spend), every session with its replies against its limit, and buttons to copy a link, add replies, end or delete. Opening a session shows the conversation with what each reply looked at, plus the brief and controls. A **New session** form makes one without an agent.
|
|
74
|
+
|
|
75
|
+
## Keeping it in hand
|
|
76
|
+
|
|
77
|
+
- **Reply limit:** 100 per session by default (`replyLimit`, or per session); then it pauses until you add more.
|
|
78
|
+
- **Anti-spam:** per session, a burst of 20 messages, then one every 3 seconds; parley.im also rate-limits each tester connection.
|
|
79
|
+
- **Message size:** up to 20,000 characters, refused with the count if longer.
|
|
80
|
+
- **End** kills the link at once and parley.im forgets the session; **delete** also erases the transcript and screenshots on your computer.
|
|
81
|
+
|
|
82
|
+
## Pieces
|
|
83
|
+
|
|
84
|
+
| | |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `src/server.ts` | Your control panel (4748) and local development links (4747) |
|
|
87
|
+
| `src/relay.ts` | The connection to parley.im: answers sealed requests, pushes sealed updates |
|
|
88
|
+
| `src/api.ts` | What a viewer can do with a session, shared by both |
|
|
89
|
+
| `src/seal.ts`, `web/seal.js` | The encryption, Node and browser halves |
|
|
90
|
+
| `src/account.ts` | `parley login`, credentials, and what parley.im is told |
|
|
91
|
+
| `src/runner.ts`, `src/agents/` | One reply at a time per session; Claude and Codex adapters |
|
|
92
|
+
| `src/mcp.ts` | `create_session`, `list_sessions`, `get_transcript`, `set_reply_limit`, `end_session` |
|
|
93
|
+
| `src/db.ts` | SQLite in `~/.parley`: sessions (with keys), messages, turns |
|
|
94
|
+
| `web/` | Chat page, session view, control panel; parley.im serves the tester page's files from here too |
|
|
95
|
+
| `relay/` | parley.im: Worker, a Durable Object per linked computer, D1; the front page's animation is `web/home.js` |
|
|
96
|
+
|
|
97
|
+
## Setup
|
|
98
|
+
|
|
99
|
+
Requires Node 24+ on macOS or Linux, and Claude Code and/or Codex logged in.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npm install -g @parley-im/parley
|
|
103
|
+
parley install # registers the MCP server with Claude Code and Codex
|
|
104
|
+
parley login
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
From a checkout instead: `npm install`, then `ln -s "$PWD/bin/parley" ~/.local/bin/parley`. `bin/parley` runs `src/` directly when it's there and the compiled `dist/` otherwise.
|
|
108
|
+
|
|
109
|
+
### Publishing
|
|
110
|
+
|
|
111
|
+
The npm package is `@parley-im/parley` (the parley-im org; plain `parley` is taken) and ships `bin/`, `dist/` and `web/`. `npm publish` builds `dist/` first (`npm run build`). Bump `version` in package.json each time.
|
|
112
|
+
|
|
113
|
+
## Config
|
|
114
|
+
|
|
115
|
+
`~/.parley/config.json`: `relayUrl` (default `https://parley.im`), `ownerName` (defaults to your account's first name), `replyLimit`, ports, and `claude`/`codex` model and effort overrides. Without `parley login`, links are local-only (`http://localhost:4747/...`), for development.
|
|
116
|
+
|
|
117
|
+
## Running parley.im
|
|
118
|
+
|
|
119
|
+
`relay/` is a Cloudflare Worker (`wrangler.jsonc`) with a Durable Object per linked computer and a D1 database (`migrations/`). Signed out, `/` is a wordless animation; signed in, it's your dashboard (open sessions, linked computers and whether they're online). `/docs` is the install guide. Secrets: `SESSION_SECRET` (random), `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` (a Google OAuth web client with redirect URI `https://parley.im/auth/google/callback`).
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
cd relay
|
|
123
|
+
npx wrangler d1 migrations apply parley --remote
|
|
124
|
+
npx wrangler deploy
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npm test # server, store, renderer, encryption; no model calls
|
|
131
|
+
npm run test:relay # end to end through a local parley.im (wrangler dev): login, encrypted chat, the real page, screenshots, ending, unlinking
|
|
132
|
+
npm run check # types (and `npm run check` in relay/)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
For a local relay by hand: `cd relay && npx wrangler dev --local --port 8799 --local-upstream 127.0.0.1:8799` with `DEV_LOGIN=1` in `.dev.vars` (a sign-in form instead of Google), and `"relayUrl": "http://127.0.0.1:8799"` in a separate `PARLEY_HOME`.
|
package/bin/parley
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The parley command. From a checkout it runs the TypeScript in src/; the
|
|
3
|
+
// npm package ships only the compiled dist/.
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
// node:sqlite still announces itself as experimental; keep that out of
|
|
7
|
+
// parley's output.
|
|
8
|
+
const emit = process.emitWarning;
|
|
9
|
+
process.emitWarning = (warning, ...rest) => {
|
|
10
|
+
if (String(warning).includes("SQLite")) return;
|
|
11
|
+
return emit.call(process, warning, ...rest);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const source = new URL("../src/cli.ts", import.meta.url);
|
|
15
|
+
await import(existsSync(source) ? source.href : new URL("../dist/cli.js", import.meta.url).href);
|
package/dist/account.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, rmSync, chmodSync } from "node:fs";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { paths } from "./config.js";
|
|
4
|
+
export function readCredentials(config) {
|
|
5
|
+
if (!existsSync(paths.credentials))
|
|
6
|
+
return null;
|
|
7
|
+
try {
|
|
8
|
+
const creds = JSON.parse(readFileSync(paths.credentials, "utf8"));
|
|
9
|
+
// Linked to a different relay (say, a local one during development).
|
|
10
|
+
return creds.relayUrl === config.relayUrl ? creds : null;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function writeCredentials(creds) {
|
|
17
|
+
writeFileSync(paths.credentials, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
|
|
18
|
+
chmodSync(paths.credentials, 0o600);
|
|
19
|
+
}
|
|
20
|
+
export function clearCredentials() {
|
|
21
|
+
rmSync(paths.credentials, { force: true });
|
|
22
|
+
}
|
|
23
|
+
// How the interviewer and tester pages refer to you.
|
|
24
|
+
export function ownerName(config) {
|
|
25
|
+
const creds = readCredentials(config);
|
|
26
|
+
return config.ownerName ?? creds?.account.givenName ?? creds?.account.name ?? null;
|
|
27
|
+
}
|
|
28
|
+
async function call(creds, path, init = {}) {
|
|
29
|
+
const res = await fetch(`${creds.relayUrl}${path}`, {
|
|
30
|
+
...init,
|
|
31
|
+
headers: { authorization: `Bearer ${creds.token}`, "content-type": "application/json", ...init.headers },
|
|
32
|
+
signal: AbortSignal.timeout(15_000),
|
|
33
|
+
});
|
|
34
|
+
if (res.status === 401)
|
|
35
|
+
throw Object.assign(new Error("This computer isn't linked to parley.im anymore. Run parley login."), { unlinked: true });
|
|
36
|
+
if (!res.ok)
|
|
37
|
+
throw new Error((await res.json().catch(() => null))?.message ?? `parley.im answered ${res.status}`);
|
|
38
|
+
return res.json();
|
|
39
|
+
}
|
|
40
|
+
// What parley.im is told about a session: its id and a title for link
|
|
41
|
+
// previews. Never the conversation, and never the key.
|
|
42
|
+
export const relay = {
|
|
43
|
+
register: (creds, s) => call(creds, "/api/sessions", { method: "POST", body: JSON.stringify({ id: s.token, title: s.project_name }) }),
|
|
44
|
+
end: (creds, s) => call(creds, `/api/sessions/${s.token}/end`, { method: "POST" }),
|
|
45
|
+
remove: (creds, s) => call(creds, `/api/sessions/${s.token}`, { method: "DELETE" }),
|
|
46
|
+
sync: (creds, open) => call(creds, "/api/sessions/sync", { method: "POST", body: JSON.stringify({ open: open.map((s) => ({ id: s.token, title: s.project_name })) }) }),
|
|
47
|
+
unlink: (creds) => call(creds, "/api/device", { method: "DELETE" }),
|
|
48
|
+
};
|
|
49
|
+
// `parley login`: the same pattern as `gh auth login`. This computer asks
|
|
50
|
+
// for a code, you approve it in your browser while signed in, and it gets a
|
|
51
|
+
// token of its own.
|
|
52
|
+
export async function login(config, show) {
|
|
53
|
+
const start = await fetch(`${config.relayUrl}/api/device/start`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "content-type": "application/json" },
|
|
56
|
+
body: JSON.stringify({ name: hostname().replace(/\.local$/, "") }),
|
|
57
|
+
});
|
|
58
|
+
if (!start.ok)
|
|
59
|
+
throw new Error(`Couldn't reach ${config.relayUrl} (${start.status}).`);
|
|
60
|
+
const { device_code, user_code, verify_url, expires_in, interval } = (await start.json());
|
|
61
|
+
show(user_code, verify_url);
|
|
62
|
+
const deadline = Date.now() + expires_in * 1000;
|
|
63
|
+
while (Date.now() < deadline) {
|
|
64
|
+
await new Promise((r) => setTimeout(r, interval * 1000));
|
|
65
|
+
const poll = (await (await fetch(`${config.relayUrl}/api/device/poll`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "content-type": "application/json" },
|
|
68
|
+
body: JSON.stringify({ device_code }),
|
|
69
|
+
})).json());
|
|
70
|
+
if (poll.status === "approved") {
|
|
71
|
+
const creds = { relayUrl: config.relayUrl, token: poll.token, device: poll.device, account: poll.account };
|
|
72
|
+
writeCredentials(creds);
|
|
73
|
+
return creds;
|
|
74
|
+
}
|
|
75
|
+
if (poll.status === "expired")
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
throw new Error("The code expired before it was approved. Run parley login again.");
|
|
79
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { sep } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { runJsonLines } from "./process.js";
|
|
5
|
+
// Claude Code in print mode, one process per turn, resumed by session id.
|
|
6
|
+
// --restricted drops every tool that runs code and ignores settings files,
|
|
7
|
+
// --tools narrows it further to reading, and --strict-mcp-config keeps your
|
|
8
|
+
// MCP servers (mail, calendar, ...) out of reach. CLAUDE.md still loads, so
|
|
9
|
+
// the interviewer knows the project the way your own sessions do.
|
|
10
|
+
export function runClaudeTurn(input, events) {
|
|
11
|
+
const { session, config } = input;
|
|
12
|
+
const agentSessionId = session.agent_session_id ?? randomUUID();
|
|
13
|
+
const args = [
|
|
14
|
+
"-p",
|
|
15
|
+
"--output-format", "stream-json",
|
|
16
|
+
"--input-format", "stream-json",
|
|
17
|
+
"--verbose",
|
|
18
|
+
"--include-partial-messages",
|
|
19
|
+
"--restricted",
|
|
20
|
+
"--tools", "Read,Grep,Glob",
|
|
21
|
+
"--strict-mcp-config",
|
|
22
|
+
"--permission-prompts", "none",
|
|
23
|
+
"--disable-slash-commands",
|
|
24
|
+
"--append-system-prompt", input.charter,
|
|
25
|
+
"--name", `parley: ${session.tester}`,
|
|
26
|
+
];
|
|
27
|
+
const model = session.model ?? config.claude.model;
|
|
28
|
+
if (model)
|
|
29
|
+
args.push("--model", model);
|
|
30
|
+
if (config.claude.effort)
|
|
31
|
+
args.push("--effort", config.claude.effort);
|
|
32
|
+
if (session.agent_session_id)
|
|
33
|
+
args.push("--resume", agentSessionId);
|
|
34
|
+
else
|
|
35
|
+
args.push("--session-id", agentSessionId);
|
|
36
|
+
const content = [];
|
|
37
|
+
for (const image of input.images) {
|
|
38
|
+
content.push({
|
|
39
|
+
type: "image",
|
|
40
|
+
source: { type: "base64", media_type: image.mediaType, data: readFileSync(image.path).toString("base64") },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
content.push({ type: "text", text: input.text });
|
|
44
|
+
const stdin = JSON.stringify({ type: "user", message: { role: "user", content } }) + "\n";
|
|
45
|
+
const texts = [];
|
|
46
|
+
let streamedAny = false;
|
|
47
|
+
let result = null;
|
|
48
|
+
let init = null;
|
|
49
|
+
const proc = runJsonLines({
|
|
50
|
+
bin: config.claude.bin,
|
|
51
|
+
args,
|
|
52
|
+
cwd: session.project_dir,
|
|
53
|
+
stdin,
|
|
54
|
+
onLine(event) {
|
|
55
|
+
if (event.type === "system" && event.subtype === "init")
|
|
56
|
+
init = event;
|
|
57
|
+
else if (event.type === "stream_event") {
|
|
58
|
+
const e = event.event;
|
|
59
|
+
if (e?.type === "content_block_start" && e.content_block?.type === "text" && streamedAny)
|
|
60
|
+
events.onBreak();
|
|
61
|
+
if (e?.type === "content_block_delta" && e.delta?.type === "text_delta") {
|
|
62
|
+
streamedAny = true;
|
|
63
|
+
events.onDelta(e.delta.text);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else if (event.type === "assistant" && !event.parent_tool_use_id) {
|
|
67
|
+
for (const block of event.message?.content ?? []) {
|
|
68
|
+
if (block.type === "text" && block.text.trim())
|
|
69
|
+
texts.push(block.text.trim());
|
|
70
|
+
if (block.type === "tool_use")
|
|
71
|
+
events.onActivity(describeTool(block.name, block.input, session.project_dir));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (event.type === "result") {
|
|
75
|
+
result = event;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const done = proc.exited.then(({ code, stderr }) => {
|
|
80
|
+
// A result line means Claude recorded the conversation, so later turns
|
|
81
|
+
// can resume it even if this one ended in an error.
|
|
82
|
+
if (result && !session.agent_session_id)
|
|
83
|
+
events.onAgentSessionId(result.session_id ?? agentSessionId);
|
|
84
|
+
if (!result || result.is_error) {
|
|
85
|
+
const reason = result?.result || result?.errors?.join("; ") || stderr.trim() || `claude exited with code ${code}`;
|
|
86
|
+
throw new Error(reason);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
text: texts.join("\n\n") || String(result.result ?? "").trim(),
|
|
90
|
+
usage: {
|
|
91
|
+
// Which model answered and which login it ran on. "none" means no
|
|
92
|
+
// API key, so it ran on your claude.ai subscription.
|
|
93
|
+
model: Object.keys(result.modelUsage ?? {})[0] ?? init?.model ?? null,
|
|
94
|
+
auth: init?.apiKeySource ?? null,
|
|
95
|
+
// What the turn would cost at API prices; a subscription isn't billed this.
|
|
96
|
+
cost_usd: result.total_cost_usd ?? null,
|
|
97
|
+
duration_ms: result.duration_ms ?? null,
|
|
98
|
+
num_turns: result.num_turns ?? null,
|
|
99
|
+
...result.usage,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
return { done, cancel: proc.cancel };
|
|
104
|
+
}
|
|
105
|
+
function describeTool(name, input, root) {
|
|
106
|
+
const roots = [root, safeRealpath(root)];
|
|
107
|
+
const rel = (p) => {
|
|
108
|
+
for (const r of roots) {
|
|
109
|
+
if (p === r)
|
|
110
|
+
return ".";
|
|
111
|
+
if (p?.startsWith(r + sep))
|
|
112
|
+
return p.slice(r.length + 1);
|
|
113
|
+
}
|
|
114
|
+
return p;
|
|
115
|
+
};
|
|
116
|
+
switch (name) {
|
|
117
|
+
case "Read":
|
|
118
|
+
return `Reading ${rel(input?.file_path)}`;
|
|
119
|
+
case "Grep":
|
|
120
|
+
return `Searching the code for “${input?.pattern}”${input?.path ? ` in ${rel(input.path)}` : ""}`;
|
|
121
|
+
case "Glob":
|
|
122
|
+
return `Looking for files matching ${input?.pattern}`;
|
|
123
|
+
default:
|
|
124
|
+
return `Using ${name}`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function safeRealpath(p) {
|
|
128
|
+
try {
|
|
129
|
+
return realpathSync(p);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return p;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { runJsonLines } from "./process.js";
|
|
5
|
+
// Features that reach beyond reading the project. Off for every turn.
|
|
6
|
+
const DISABLED_FEATURES = [
|
|
7
|
+
"apps",
|
|
8
|
+
"browser_use",
|
|
9
|
+
"browser_use_external",
|
|
10
|
+
"computer_use",
|
|
11
|
+
"in_app_browser",
|
|
12
|
+
"image_generation",
|
|
13
|
+
"multi_agent",
|
|
14
|
+
"plugins",
|
|
15
|
+
"remote_plugin",
|
|
16
|
+
"hooks",
|
|
17
|
+
];
|
|
18
|
+
// Codex exec, one process per turn, resumed by thread id. Your config.toml is
|
|
19
|
+
// ignored (it carries your full-access sandbox and MCP servers); only its
|
|
20
|
+
// model and reasoning effort are carried over. The sandbox is read-only, so
|
|
21
|
+
// Codex can run commands like rg and cat but cannot write or reach the network.
|
|
22
|
+
export function runCodexTurn(input, events) {
|
|
23
|
+
const { session, config } = input;
|
|
24
|
+
const defaults = readCodexDefaults();
|
|
25
|
+
const model = session.model ?? config.codex.model ?? defaults.model;
|
|
26
|
+
const effort = config.codex.effort ?? defaults.effort;
|
|
27
|
+
const args = [
|
|
28
|
+
"exec",
|
|
29
|
+
"--json",
|
|
30
|
+
"--ignore-user-config",
|
|
31
|
+
"--skip-git-repo-check",
|
|
32
|
+
"--sandbox", "read-only",
|
|
33
|
+
"--cd", session.project_dir,
|
|
34
|
+
"-c", `sandbox_mode="read-only"`,
|
|
35
|
+
"-c", `approval_policy="never"`,
|
|
36
|
+
"-c", `web_search="disabled"`,
|
|
37
|
+
"-c", `developer_instructions=${JSON.stringify(input.charter)}`,
|
|
38
|
+
];
|
|
39
|
+
for (const feature of DISABLED_FEATURES)
|
|
40
|
+
args.push("--disable", feature);
|
|
41
|
+
if (model)
|
|
42
|
+
args.push("--model", model);
|
|
43
|
+
if (effort)
|
|
44
|
+
args.push("-c", `model_reasoning_effort=${JSON.stringify(effort)}`);
|
|
45
|
+
if (session.agent_session_id)
|
|
46
|
+
args.push("resume", session.agent_session_id);
|
|
47
|
+
for (const image of input.images)
|
|
48
|
+
args.push(`--image=${image.path}`);
|
|
49
|
+
args.push("-");
|
|
50
|
+
const texts = [];
|
|
51
|
+
let usage = null;
|
|
52
|
+
let failure = null;
|
|
53
|
+
let lastError = null;
|
|
54
|
+
let threadId = null;
|
|
55
|
+
const commands = new Set();
|
|
56
|
+
const proc = runJsonLines({
|
|
57
|
+
bin: config.codex.bin,
|
|
58
|
+
args,
|
|
59
|
+
cwd: session.project_dir,
|
|
60
|
+
stdin: input.text,
|
|
61
|
+
onLine(event) {
|
|
62
|
+
switch (event.type) {
|
|
63
|
+
case "thread.started":
|
|
64
|
+
threadId = event.thread_id;
|
|
65
|
+
break;
|
|
66
|
+
case "item.started":
|
|
67
|
+
case "item.completed":
|
|
68
|
+
// Commands the sandbox refuses can skip straight to completed.
|
|
69
|
+
if (event.item?.type === "command_execution" && !commands.has(event.item.id)) {
|
|
70
|
+
commands.add(event.item.id);
|
|
71
|
+
events.onActivity(describeCommand(event.item.command));
|
|
72
|
+
}
|
|
73
|
+
if (event.type === "item.completed" && event.item?.type === "agent_message" && event.item.text?.trim()) {
|
|
74
|
+
if (texts.length)
|
|
75
|
+
events.onBreak();
|
|
76
|
+
texts.push(event.item.text.trim());
|
|
77
|
+
events.onDelta(event.item.text.trim());
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
case "turn.completed":
|
|
81
|
+
usage = event.usage ?? null;
|
|
82
|
+
break;
|
|
83
|
+
case "turn.failed":
|
|
84
|
+
failure = event.error?.message ?? "Codex turn failed";
|
|
85
|
+
break;
|
|
86
|
+
case "error":
|
|
87
|
+
lastError = event.message ?? null;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const done = proc.exited.then(({ code, stderr }) => {
|
|
93
|
+
if (threadId && !session.agent_session_id)
|
|
94
|
+
events.onAgentSessionId(threadId);
|
|
95
|
+
if (failure || !texts.length) {
|
|
96
|
+
throw new Error(failure ?? lastError ?? (stderr.trim() || `codex exited with code ${code}`));
|
|
97
|
+
}
|
|
98
|
+
return { text: texts.join("\n\n"), usage: { model: model ?? null, effort: effort ?? null, ...(usage ?? {}) } };
|
|
99
|
+
});
|
|
100
|
+
return { done, cancel: proc.cancel };
|
|
101
|
+
}
|
|
102
|
+
function describeCommand(command) {
|
|
103
|
+
let text = String(command ?? "");
|
|
104
|
+
const wrapped = text.match(/^(?:\/bin\/)?(?:ba|z)?sh -l?c ['"]?([\s\S]*?)['"]?$/);
|
|
105
|
+
if (wrapped)
|
|
106
|
+
text = wrapped[1];
|
|
107
|
+
text = text.replace(/\s+/g, " ").trim();
|
|
108
|
+
if (text.length > 90)
|
|
109
|
+
text = text.slice(0, 87) + "...";
|
|
110
|
+
return `Looking through the code: ${text}`;
|
|
111
|
+
}
|
|
112
|
+
// Top-level `model` and `model_reasoning_effort` from ~/.codex/config.toml.
|
|
113
|
+
function readCodexDefaults() {
|
|
114
|
+
const file = join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "config.toml");
|
|
115
|
+
const found = { model: null, effort: null };
|
|
116
|
+
if (!existsSync(file))
|
|
117
|
+
return found;
|
|
118
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
119
|
+
if (/^\s*\[/.test(line))
|
|
120
|
+
break;
|
|
121
|
+
const m = line.match(/^\s*(model|model_reasoning_effort)\s*=\s*"([^"]*)"/);
|
|
122
|
+
if (m?.[1] === "model")
|
|
123
|
+
found.model = m[2];
|
|
124
|
+
if (m?.[1] === "model_reasoning_effort")
|
|
125
|
+
found.effort = m[2];
|
|
126
|
+
}
|
|
127
|
+
return found;
|
|
128
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { cleanEnv } from "../config.js";
|
|
3
|
+
const TURN_LIMIT_MS = 15 * 60 * 1000;
|
|
4
|
+
// Runs one agent turn as a child process that prints JSON lines.
|
|
5
|
+
export function runJsonLines(opts) {
|
|
6
|
+
const child = spawn(opts.bin, opts.args, {
|
|
7
|
+
cwd: opts.cwd,
|
|
8
|
+
env: cleanEnv(),
|
|
9
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
10
|
+
});
|
|
11
|
+
let stderr = "";
|
|
12
|
+
let buffer = "";
|
|
13
|
+
const timer = setTimeout(() => child.kill("SIGTERM"), TURN_LIMIT_MS);
|
|
14
|
+
child.stdout.setEncoding("utf8");
|
|
15
|
+
child.stdout.on("data", (chunk) => {
|
|
16
|
+
buffer += chunk;
|
|
17
|
+
let newline;
|
|
18
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
19
|
+
const line = buffer.slice(0, newline).trim();
|
|
20
|
+
buffer = buffer.slice(newline + 1);
|
|
21
|
+
if (!line)
|
|
22
|
+
continue;
|
|
23
|
+
let event;
|
|
24
|
+
try {
|
|
25
|
+
event = JSON.parse(line);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
opts.onLine(event);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
child.stderr.setEncoding("utf8");
|
|
34
|
+
child.stderr.on("data", (chunk) => {
|
|
35
|
+
stderr = (stderr + chunk).slice(-4000);
|
|
36
|
+
});
|
|
37
|
+
child.stdin.on("error", () => { });
|
|
38
|
+
child.stdin.end(opts.stdin);
|
|
39
|
+
const exited = new Promise((resolve, reject) => {
|
|
40
|
+
child.on("error", reject);
|
|
41
|
+
child.on("close", (code) => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
resolve({ code, stderr });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
return { exited, cancel: () => child.kill("SIGTERM") };
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|