@parley-im/parley 0.1.2 → 0.2.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/README.md +19 -17
- package/bin/parley +7 -0
- package/dist/access.js +57 -0
- package/dist/account.js +5 -1
- package/dist/agents/claude.js +20 -11
- package/dist/agents/codex.js +10 -5
- package/dist/agents/process.js +35 -4
- package/dist/api.js +32 -10
- package/dist/autostart.js +86 -0
- package/dist/charter.js +6 -5
- package/dist/cli.js +62 -16
- package/dist/config.js +49 -9
- package/dist/control.js +16 -10
- package/dist/daemon.js +34 -10
- package/dist/db.js +111 -27
- package/dist/mcp.js +20 -20
- package/dist/models.js +8 -2
- package/dist/relay.js +35 -10
- package/dist/runner.js +9 -7
- package/dist/seal.js +7 -5
- package/dist/server.js +43 -17
- package/dist/state.js +7 -4
- package/dist/transcript.js +10 -6
- package/dist/version.js +15 -0
- package/package.json +2 -2
- package/web/app.css +29 -13
- package/web/chat.html +6 -6
- package/web/chat.js +198 -62
- package/web/dashboard.js +41 -0
- package/web/ended.html +1 -1
- package/web/missing.html +1 -1
- package/web/panel.html +4 -4
- package/web/panel.js +6 -6
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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
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
|
|
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 guest's browser and your computer **end-to-end encrypted**: it can't read what's said.
|
|
6
6
|
|
|
7
7
|
## Using it
|
|
8
8
|
|
|
@@ -18,14 +18,14 @@ Then, from any Claude Code or Codex session:
|
|
|
18
18
|
|
|
19
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
20
|
|
|
21
|
-
> Anything new from
|
|
21
|
+
> Anything new from guests? Summarize it and file the bugs.
|
|
22
22
|
|
|
23
23
|
> We're done with Sam's session.
|
|
24
24
|
|
|
25
25
|
Or from a terminal:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
parley new --
|
|
28
|
+
parley new --guest Sam --brief "Sam is trying the new onboarding on their phone. Find out where they got stuck."
|
|
29
29
|
parley list # open sessions: new messages, replies used, spend (--ended, --all)
|
|
30
30
|
parley show <id> --new # what came in since you last read it
|
|
31
31
|
parley limit <id> 150 # let a session give 150 replies in total
|
|
@@ -38,24 +38,24 @@ parley whoami | logout
|
|
|
38
38
|
## How it fits together
|
|
39
39
|
|
|
40
40
|
```
|
|
41
|
-
|
|
41
|
+
guest's browser ──wss──▶ parley.im (Cloudflare Worker) ◀──wss── your computer (parley server)
|
|
42
42
|
key from # sender accounts, open sessions, agent runs here, transcripts
|
|
43
43
|
seals everything relays ciphertext, stores none of it and screenshots stay here,
|
|
44
44
|
holds each session's key
|
|
45
45
|
```
|
|
46
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.
|
|
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. Guests never sign in: the link is their access.
|
|
48
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
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
|
|
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 guest's browser and your computer; screenshots cross in pieces over the same connection, and your computer serves them back to the page.
|
|
51
51
|
|
|
52
52
|
### End-to-end encryption
|
|
53
53
|
|
|
54
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
55
|
|
|
56
|
-
What it doesn't hide, said plainly on the
|
|
56
|
+
What it doesn't hide, said plainly on the guest'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
57
|
|
|
58
|
-
## What the
|
|
58
|
+
## What the guest gets
|
|
59
59
|
|
|
60
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
61
|
|
|
@@ -63,10 +63,12 @@ A chat page built for phones first, in a plain technical style (a transcript, no
|
|
|
63
63
|
|
|
64
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
65
|
|
|
66
|
-
It can read the project folder and nothing else on your computer,
|
|
66
|
+
It can read the project folder and nothing else on your computer. Inside it, a blocked list keeps it from secrets (`.env` files, keys and certificates, `.ssh`, cloud and package-manager logins, `secrets/`), from other guests' conversations in any `.parley` folder, and always from parley's own database. Each agent's own sandbox enforces that, not just the charter (which asks too). `src/access.ts` builds the rules; sessions won't start in your home folder. Both are settings (see Config).
|
|
67
67
|
|
|
68
|
-
- **Claude:** `claude -p --restricted --tools Read,Grep,Glob --strict-mcp-config`, plus
|
|
69
|
-
- **Codex:** `codex exec --ignore-user-config` under a permission profile that can read the project and the system files tools need (`:minimal`), with
|
|
68
|
+
- **Claude:** `claude -p --restricted --tools Read,Grep,Glob --strict-mcp-config`, plus `Read(...)` deny rules from the blocked list. No command, write or web tools; `--restricted` confines the file tools to the project; your MCP servers are left out.
|
|
69
|
+
- **Codex:** `codex exec --ignore-user-config` under a permission profile that can read the project and the system files tools need (`:minimal`), with the blocked list denied, no writes and no network. Apps, browser, computer use, plugins, hooks and web search are off; your model and reasoning effort carry over. Codex re-runs itself inside that sandbox, so parley starts it by its real path and lets it read that one file.
|
|
70
|
+
|
|
71
|
+
Before a session is made, parley checks the agent is installed and new enough for these flags, and says what to update if not.
|
|
70
72
|
|
|
71
73
|
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.
|
|
72
74
|
|
|
@@ -77,7 +79,7 @@ Each reply records which model answered, on which login (`auth: "none"` means yo
|
|
|
77
79
|
## Keeping it in hand
|
|
78
80
|
|
|
79
81
|
- **Reply limit:** 100 per session by default (`replyLimit`, or per session); then it pauses until you add more.
|
|
80
|
-
- **Anti-spam:** per session, a burst of 20 messages, then one every 3 seconds; parley.im also rate-limits each
|
|
82
|
+
- **Anti-spam:** per session, a burst of 20 messages, then one every 3 seconds; parley.im also rate-limits each guest connection.
|
|
81
83
|
- **Message size:** up to 20,000 characters, refused with the count if longer.
|
|
82
84
|
- **End** kills the link at once and parley.im forgets the session; **delete** also erases the transcript and screenshots on your computer.
|
|
83
85
|
|
|
@@ -93,16 +95,16 @@ Each reply records which model answered, on which login (`auth: "none"` means yo
|
|
|
93
95
|
| `src/runner.ts`, `src/agents/` | One reply at a time per session; Claude and Codex adapters |
|
|
94
96
|
| `src/mcp.ts` | `create_session`, `list_sessions`, `get_transcript`, `set_reply_limit`, `end_session` |
|
|
95
97
|
| `src/db.ts` | Each project's `.parley/parley.db` (sessions with keys, messages, turns), a folder per session with screenshots and `transcript.md`, and `~/.parley/projects.json` listing the projects |
|
|
96
|
-
| `web/` | Chat page, session view, control panel; parley.im serves the
|
|
98
|
+
| `web/` | Chat page, session view, control panel; parley.im serves the guest page's files from here too |
|
|
97
99
|
| `relay/` | parley.im: Worker, a Durable Object per linked computer, D1; the front page's animation is `web/home.js` |
|
|
98
100
|
|
|
99
101
|
## Setup
|
|
100
102
|
|
|
101
|
-
Requires Node
|
|
103
|
+
Requires Node 22.13+ on macOS or Linux, and Claude Code and/or Codex logged in.
|
|
102
104
|
|
|
103
105
|
```bash
|
|
104
106
|
npm install -g @parley-im/parley
|
|
105
|
-
parley install # registers the MCP server with Claude Code and Codex
|
|
107
|
+
parley install # registers the MCP server with Claude Code and Codex, and starts parley at login
|
|
106
108
|
parley login
|
|
107
109
|
```
|
|
108
110
|
|
|
@@ -116,11 +118,11 @@ The npm package is `@parley-im/parley` (the parley-im org; plain `parley` is tak
|
|
|
116
118
|
|
|
117
119
|
Conversations live with their project, in `<project>/.parley/`: a database, and a folder per session holding its screenshots and a `transcript.md` your agent can read. The folder carries its own `.gitignore`, so it stays out of git. `~/.parley` keeps only settings, your parley.im login, the server's state and log, and the list of projects.
|
|
118
120
|
|
|
119
|
-
`~/.parley/config.json`: `relayUrl` (default `https://parley.im`), `ownerName` (defaults to your account's first name), `replyLimit`, ports,
|
|
121
|
+
`~/.parley/config.json`: `relayUrl` (default `https://parley.im`), `ownerName` (defaults to your account's first name), `replyLimit`, ports, `claude`/`codex` model and effort overrides, and `interviewer`: `readConversations` (default false), `blocked` (globs from the project folder, starting with the defaults in `src/config.ts`) and `allowHomeFolder` (default false). Without `parley login`, links are local-only (`http://localhost:4747/...`), for development.
|
|
120
122
|
|
|
121
123
|
## Running parley.im
|
|
122
124
|
|
|
123
|
-
`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`).
|
|
125
|
+
`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; `/admin` is for admins (`ADMIN_EMAILS`): invites, the waitlist and who's in. Email goes through Cloudflare Email Service (the `EMAIL` binding, from `hello@parley.im`): a note when someone joins the waitlist (to them and the admins), when they're let in, and invites. Rate limits use the `LIMITS` binding. 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`).
|
|
124
126
|
|
|
125
127
|
```bash
|
|
126
128
|
cd relay
|
package/bin/parley
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
// npm package ships only the compiled dist/.
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
|
|
6
|
+
// node:sqlite arrived in Node 22.13; anything older can't run parley at all.
|
|
7
|
+
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
8
|
+
if (major < 22 || (major === 22 && minor < 13)) {
|
|
9
|
+
console.error(`parley needs Node 22.13 or later, and this is Node ${process.versions.node}. Update Node (https://nodejs.org), then try again.`);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
// node:sqlite still announces itself as experimental; keep that out of
|
|
7
14
|
// parley's output.
|
|
8
15
|
const emit = process.emitWarning;
|
package/dist/access.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, sep } from "node:path";
|
|
5
|
+
import { cleanEnv, paths } from "./config.js";
|
|
6
|
+
// What the interviewer may not read, as globs from the project folder: your
|
|
7
|
+
// blocked list, other sessions' conversations unless you allow them, and
|
|
8
|
+
// parley's own database (it holds every session's key) always.
|
|
9
|
+
export function blockedPaths(config) {
|
|
10
|
+
const blocked = [...config.interviewer.blocked, "**/.parley/parley.db*"];
|
|
11
|
+
if (!config.interviewer.readConversations)
|
|
12
|
+
blocked.push("**/.parley/**");
|
|
13
|
+
return [...new Set(blocked)];
|
|
14
|
+
}
|
|
15
|
+
// A session in your home folder (or /) would put everything in it within
|
|
16
|
+
// reach, so that takes an explicit setting.
|
|
17
|
+
export function checkProjectFolder(dir, config) {
|
|
18
|
+
if (config.interviewer.allowHomeFolder)
|
|
19
|
+
return;
|
|
20
|
+
const real = realpathSync(dir);
|
|
21
|
+
const home = realpathSync(homedir());
|
|
22
|
+
if (real === sep || real === home || home.startsWith(real + sep)) {
|
|
23
|
+
throw new Error(`${real} is your home folder or contains it, so the interviewer could read everything in it. Run parley from a project folder instead, or allow it with "allowHomeFolder": true under "interviewer" in ${paths.config}.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const NAMES = { claude: "Claude Code", codex: "Codex" };
|
|
27
|
+
const UPDATE = { claude: "claude update", codex: "brew upgrade codex, or npm install -g @openai/codex" };
|
|
28
|
+
// The flags parley's sandboxing depends on, as each CLI's help lists them.
|
|
29
|
+
const NEEDS = {
|
|
30
|
+
claude: { args: ["--help"], flags: ["--restricted", "--permission-prompts", "--settings"] },
|
|
31
|
+
codex: { args: ["sandbox", "--help"], flags: ["--permission-profile"] },
|
|
32
|
+
};
|
|
33
|
+
const checked = new Map();
|
|
34
|
+
// Why this computer's Claude Code or Codex can't run an interviewer, or null
|
|
35
|
+
// if it can: it has to be installed and new enough for the sandbox.
|
|
36
|
+
export function agentProblem(config, agent) {
|
|
37
|
+
const bin = config[agent].bin;
|
|
38
|
+
if (!bin.startsWith("/") || !existsSync(bin))
|
|
39
|
+
return `${NAMES[agent]} isn't installed on this computer (no ${basename(bin)} found).`;
|
|
40
|
+
const key = `${bin}@${statSync(bin).mtimeMs}`;
|
|
41
|
+
if (checked.has(key))
|
|
42
|
+
return checked.get(key);
|
|
43
|
+
let problem = null;
|
|
44
|
+
try {
|
|
45
|
+
const help = execFileSync(bin, NEEDS[agent].args, { encoding: "utf8", timeout: 20_000, env: cleanEnv(), stdio: ["ignore", "pipe", "pipe"] });
|
|
46
|
+
if (NEEDS[agent].flags.some((flag) => !help.includes(flag))) {
|
|
47
|
+
problem = `This ${NAMES[agent]} is too old for parley's sandbox. Update it (${UPDATE[agent]}) and try again.`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
problem = `${NAMES[agent]} didn't run (${bin}): ${String(error.message).split("\n")[0]}`;
|
|
52
|
+
}
|
|
53
|
+
checked.set(key, problem);
|
|
54
|
+
return problem;
|
|
55
|
+
}
|
|
56
|
+
// Claude Code if it can run an interviewer, otherwise Codex.
|
|
57
|
+
export const defaultAgent = (config) => (agentProblem(config, "claude") && !agentProblem(config, "codex") ? "codex" : "claude");
|
package/dist/account.js
CHANGED
|
@@ -20,7 +20,7 @@ export function writeCredentials(creds) {
|
|
|
20
20
|
export function clearCredentials() {
|
|
21
21
|
rmSync(paths.credentials, { force: true });
|
|
22
22
|
}
|
|
23
|
-
// How the interviewer and
|
|
23
|
+
// How the interviewer and guest pages refer to you.
|
|
24
24
|
export function ownerName(config) {
|
|
25
25
|
const creds = readCredentials(config);
|
|
26
26
|
return config.ownerName ?? creds?.account.givenName ?? creds?.account.name ?? null;
|
|
@@ -72,6 +72,10 @@ export async function login(config, show) {
|
|
|
72
72
|
writeCredentials(creds);
|
|
73
73
|
return creds;
|
|
74
74
|
}
|
|
75
|
+
// Signed in on parley.im, but not let in yet: no computer links until then.
|
|
76
|
+
if (poll.status === "waitlisted") {
|
|
77
|
+
throw new Error(`You're on the parley waitlist as ${poll.email}. You'll get an email when you're let in; then run parley login again.`);
|
|
78
|
+
}
|
|
75
79
|
if (poll.status === "expired")
|
|
76
80
|
break;
|
|
77
81
|
}
|
package/dist/agents/claude.js
CHANGED
|
@@ -2,13 +2,15 @@ import { readFileSync, realpathSync } from "node:fs";
|
|
|
2
2
|
import { sep } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { runJsonLines } from "./process.js";
|
|
5
|
+
import { blockedPaths } from "../access.js";
|
|
6
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
5
7
|
// Claude Code in print mode, one process per turn, resumed by session id.
|
|
6
8
|
// --restricted drops every tool that runs code, ignores your settings files
|
|
7
9
|
// and confines the file tools to the project folder; --tools narrows it to
|
|
8
|
-
// reading
|
|
9
|
-
// people's conversations)
|
|
10
|
-
// (mail, calendar, ...) out of reach. The project's CLAUDE.md and
|
|
11
|
-
// reach it through the charter.
|
|
10
|
+
// reading; deny rules keep it from your blocked files (secrets, keys, other
|
|
11
|
+
// people's conversations; see access.ts); and --strict-mcp-config keeps your
|
|
12
|
+
// MCP servers (mail, calendar, ...) out of reach. The project's CLAUDE.md and
|
|
13
|
+
// AGENTS.md reach it through the charter.
|
|
12
14
|
export function runClaudeTurn(input, events) {
|
|
13
15
|
const { session, config } = input;
|
|
14
16
|
const agentSessionId = session.agent_session_id ?? randomUUID();
|
|
@@ -23,9 +25,9 @@ export function runClaudeTurn(input, events) {
|
|
|
23
25
|
"--strict-mcp-config",
|
|
24
26
|
"--permission-prompts", "none",
|
|
25
27
|
"--disable-slash-commands",
|
|
26
|
-
"--settings", JSON.stringify({ permissions: { deny:
|
|
28
|
+
"--settings", JSON.stringify({ permissions: { deny: blockedPaths(config).map((glob) => `Read(./${glob})`) } }),
|
|
27
29
|
"--append-system-prompt", input.charter,
|
|
28
|
-
"--name", `parley: ${session.
|
|
30
|
+
"--name", `parley: ${session.guest}`,
|
|
29
31
|
];
|
|
30
32
|
const model = session.model ?? config.claude.model;
|
|
31
33
|
if (model)
|
|
@@ -36,14 +38,21 @@ export function runClaudeTurn(input, events) {
|
|
|
36
38
|
args.push("--resume", agentSessionId);
|
|
37
39
|
else
|
|
38
40
|
args.push("--session-id", agentSessionId);
|
|
41
|
+
// Claude takes images up to 5 MB; the page shrinks screenshots before
|
|
42
|
+
// sending, so a bigger one is rare, and is described instead of failing
|
|
43
|
+
// the reply.
|
|
39
44
|
const content = [];
|
|
45
|
+
let skipped = 0;
|
|
40
46
|
for (const image of input.images) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
const data = readFileSync(image.path);
|
|
48
|
+
if (data.length > MAX_IMAGE_BYTES) {
|
|
49
|
+
skipped++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
content.push({ type: "image", source: { type: "base64", media_type: image.mediaType, data: data.toString("base64") } });
|
|
45
53
|
}
|
|
46
|
-
|
|
54
|
+
const note = skipped ? `\n\n(${skipped === 1 ? "A screenshot" : `${skipped} screenshots`} came with this but ${skipped === 1 ? "was" : "were"} too large for you to see. Say so if it matters.)` : "";
|
|
55
|
+
content.push({ type: "text", text: input.text + note });
|
|
47
56
|
const stdin = JSON.stringify({ type: "user", message: { role: "user", content } }) + "\n";
|
|
48
57
|
const texts = [];
|
|
49
58
|
let streamedAny = false;
|
package/dist/agents/codex.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { runJsonLines } from "./process.js";
|
|
5
|
+
import { blockedPaths } from "../access.js";
|
|
5
6
|
// Features that reach beyond reading the project. Off for every turn.
|
|
6
7
|
const DISABLED_FEATURES = [
|
|
7
8
|
"apps",
|
|
@@ -20,10 +21,14 @@ const DISABLED_FEATURES = [
|
|
|
20
21
|
// model and reasoning effort are carried over. Its commands run under a
|
|
21
22
|
// permission profile that can read the project and the system files tools
|
|
22
23
|
// need, and nothing else: not the rest of your home folder, not other
|
|
23
|
-
// projects, and
|
|
24
|
-
// conversations
|
|
25
|
-
// sandbox, so it's started by its real path and may read that
|
|
26
|
-
|
|
24
|
+
// projects, and none of your blocked files (secrets, keys, other people's
|
|
25
|
+
// conversations; see access.ts). No writes, no network. Codex re-runs itself
|
|
26
|
+
// inside that sandbox, so it's started by its real path and may read that
|
|
27
|
+
// one file.
|
|
28
|
+
const profile = (bin, blocked) => {
|
|
29
|
+
const project = [`"."="read"`, ...blocked.map((glob) => `${JSON.stringify(glob)}="deny"`)].join(", ");
|
|
30
|
+
return `permissions.parley={filesystem={":minimal"="read", ${JSON.stringify(bin)}="read", ":workspace_roots"={${project}}}}`;
|
|
31
|
+
};
|
|
27
32
|
export function runCodexTurn(input, events) {
|
|
28
33
|
const { session, config } = input;
|
|
29
34
|
const defaults = readCodexDefaults();
|
|
@@ -37,7 +42,7 @@ export function runCodexTurn(input, events) {
|
|
|
37
42
|
"--skip-git-repo-check",
|
|
38
43
|
"--cd", session.project_dir,
|
|
39
44
|
"-c", `default_permissions="parley"`,
|
|
40
|
-
"-c", profile(bin),
|
|
45
|
+
"-c", profile(bin, blockedPaths(config)),
|
|
41
46
|
"-c", `approval_policy="never"`,
|
|
42
47
|
"-c", `web_search="disabled"`,
|
|
43
48
|
"-c", `developer_instructions=${JSON.stringify(input.charter)}`,
|
package/dist/agents/process.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { cleanEnv } from "../config.js";
|
|
3
3
|
const TURN_LIMIT_MS = 15 * 60 * 1000;
|
|
4
|
+
// How long a stopped agent gets to exit before it's killed outright.
|
|
5
|
+
const GRACE_MS = 5000;
|
|
4
6
|
// Runs one agent turn as a child process that prints JSON lines.
|
|
5
7
|
export function runJsonLines(opts) {
|
|
6
8
|
const child = spawn(opts.bin, opts.args, {
|
|
@@ -10,7 +12,19 @@ export function runJsonLines(opts) {
|
|
|
10
12
|
});
|
|
11
13
|
let stderr = "";
|
|
12
14
|
let buffer = "";
|
|
13
|
-
|
|
15
|
+
// Stop politely, then for good: an agent that ignores SIGTERM, or leaves a
|
|
16
|
+
// process holding its output open, must not block the session's queue.
|
|
17
|
+
let killer = null;
|
|
18
|
+
const stop = () => {
|
|
19
|
+
if (killer)
|
|
20
|
+
return;
|
|
21
|
+
child.kill("SIGTERM");
|
|
22
|
+
killer = setTimeout(() => {
|
|
23
|
+
child.kill("SIGKILL");
|
|
24
|
+
finish(null);
|
|
25
|
+
}, GRACE_MS);
|
|
26
|
+
};
|
|
27
|
+
const timer = setTimeout(stop, TURN_LIMIT_MS);
|
|
14
28
|
child.stdout.setEncoding("utf8");
|
|
15
29
|
child.stdout.on("data", (chunk) => {
|
|
16
30
|
buffer += chunk;
|
|
@@ -36,12 +50,29 @@ export function runJsonLines(opts) {
|
|
|
36
50
|
});
|
|
37
51
|
child.stdin.on("error", () => { });
|
|
38
52
|
child.stdin.end(opts.stdin);
|
|
53
|
+
// Done when its output closes; or, if something it started keeps the output
|
|
54
|
+
// open, shortly after it exits.
|
|
55
|
+
let finish = () => { };
|
|
39
56
|
const exited = new Promise((resolve, reject) => {
|
|
40
|
-
|
|
41
|
-
|
|
57
|
+
let settled = false;
|
|
58
|
+
finish = (code) => {
|
|
59
|
+
if (settled)
|
|
60
|
+
return;
|
|
61
|
+
settled = true;
|
|
42
62
|
clearTimeout(timer);
|
|
63
|
+
if (killer)
|
|
64
|
+
clearTimeout(killer);
|
|
43
65
|
resolve({ code, stderr });
|
|
66
|
+
};
|
|
67
|
+
child.on("error", (error) => {
|
|
68
|
+
if (settled)
|
|
69
|
+
return;
|
|
70
|
+
settled = true;
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
reject(error);
|
|
44
73
|
});
|
|
74
|
+
child.on("close", (code) => finish(code));
|
|
75
|
+
child.on("exit", (code) => setTimeout(() => finish(code), GRACE_MS));
|
|
45
76
|
});
|
|
46
|
-
return { exited, cancel:
|
|
77
|
+
return { exited, cancel: stop };
|
|
47
78
|
}
|
package/dist/api.js
CHANGED
|
@@ -11,7 +11,7 @@ const PAGE = 60;
|
|
|
11
11
|
// 3 seconds. A person typing never notices; a script hammering the link does.
|
|
12
12
|
const BURST = 20;
|
|
13
13
|
const REFILL_MS = 3_000;
|
|
14
|
-
// A refusal the
|
|
14
|
+
// A refusal the guest's page shows as it is.
|
|
15
15
|
export class Problem extends Error {
|
|
16
16
|
status;
|
|
17
17
|
code;
|
|
@@ -53,7 +53,7 @@ export class Conversations {
|
|
|
53
53
|
return {
|
|
54
54
|
epoch: this.runner.epoch,
|
|
55
55
|
seq: this.runner.seq,
|
|
56
|
-
session: scope.owner ? this.ownerView(session) : this.
|
|
56
|
+
session: scope.owner ? this.ownerView(session) : this.guestView(session),
|
|
57
57
|
ownerName: ownerName(this.config),
|
|
58
58
|
repliesLeft: this.runner.repliesLeft(session.id),
|
|
59
59
|
messages: page.messages.map((m) => this.messageView(m, scope)),
|
|
@@ -90,14 +90,31 @@ export class Conversations {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
// Posts a checked message. `images` are saved uploads belonging to the session.
|
|
93
|
-
post(scope, text, images) {
|
|
93
|
+
post(scope, text, images, clientId = null) {
|
|
94
94
|
const session = this.store.session(scope.session.id);
|
|
95
|
-
const message = this.runner.submit(session, text || "(screenshot)", images.filter((name) => ownsUpload(session, name)));
|
|
95
|
+
const message = this.runner.submit(session, text || "(screenshot)", images.filter((name) => ownsUpload(session, name)), clientId);
|
|
96
96
|
return { message: this.messageView(message, scope) };
|
|
97
97
|
}
|
|
98
|
+
// The guest is done. A note goes in the transcript for you, the session
|
|
99
|
+
// ends, and its link stops working. The caller tells parley.im.
|
|
100
|
+
endByGuest(scope) {
|
|
101
|
+
const session = this.store.session(scope.session.id);
|
|
102
|
+
if (session.status !== "open")
|
|
103
|
+
return session;
|
|
104
|
+
const note = this.store.addMessage(session.id, "guest", "(ended the conversation)", [], null);
|
|
105
|
+
this.runner.publish(session.id, { type: "message", message: note });
|
|
106
|
+
this.runner.end(session.id);
|
|
107
|
+
return session;
|
|
108
|
+
}
|
|
109
|
+
// The same message sent again (its answer got lost on the way back): the
|
|
110
|
+
// first one, rather than a second copy.
|
|
111
|
+
resent(scope, clientId) {
|
|
112
|
+
const message = clientId ? this.store.messageByClient(scope.session.id, clientId) : undefined;
|
|
113
|
+
return message ? { message: this.messageView(message, scope) } : null;
|
|
114
|
+
}
|
|
98
115
|
// A live event as this viewer should see it, or null if it's not for them.
|
|
99
116
|
eventView(event, scope) {
|
|
100
|
-
//
|
|
117
|
+
// Guests get the conversation, not the agent's lookups.
|
|
101
118
|
if (!scope.owner && event.type === "activity")
|
|
102
119
|
return null;
|
|
103
120
|
if (event.type === "message")
|
|
@@ -112,11 +129,11 @@ export class Conversations {
|
|
|
112
129
|
return null;
|
|
113
130
|
return scope.owner ? live : { ...live, activity: null };
|
|
114
131
|
}
|
|
115
|
-
// What a
|
|
116
|
-
|
|
132
|
+
// What a guest may know about their session, shown under the info button.
|
|
133
|
+
guestView(s) {
|
|
117
134
|
return {
|
|
118
135
|
project: s.project_name,
|
|
119
|
-
|
|
136
|
+
guest: s.guest,
|
|
120
137
|
agent: s.agent,
|
|
121
138
|
model: modelLabel(this.store.latestModel(s.id) ?? s.model ?? (s.agent === "codex" ? this.config.codex.model : this.config.claude.model)),
|
|
122
139
|
replyLimit: s.reply_limit,
|
|
@@ -127,7 +144,7 @@ export class Conversations {
|
|
|
127
144
|
id: s.id,
|
|
128
145
|
project: s.project_name,
|
|
129
146
|
projectDir: s.project_dir,
|
|
130
|
-
|
|
147
|
+
guest: s.guest,
|
|
131
148
|
brief: s.brief,
|
|
132
149
|
agent: s.agent,
|
|
133
150
|
model: modelLabel(this.store.latestModel(s.id) ?? s.model),
|
|
@@ -155,11 +172,16 @@ export class Conversations {
|
|
|
155
172
|
: m.images.map((name) => ({ id: blobIdOf(name), type: mediaTypeFor(name) })),
|
|
156
173
|
createdAt: m.created_at,
|
|
157
174
|
};
|
|
158
|
-
if (!scope.owner ||
|
|
175
|
+
if (!scope.owner || !m.turn_id)
|
|
159
176
|
return view;
|
|
160
177
|
const turn = this.store.turn(m.session_id, m.turn_id);
|
|
161
178
|
if (!turn)
|
|
162
179
|
return view;
|
|
180
|
+
// Only you see why a reply failed.
|
|
181
|
+
if (m.role === "error")
|
|
182
|
+
return { ...view, detail: turn.error };
|
|
183
|
+
if (m.role !== "agent")
|
|
184
|
+
return view;
|
|
163
185
|
const usage = turn.usage ?? {};
|
|
164
186
|
return {
|
|
165
187
|
...view,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { paths } from "./config.js";
|
|
6
|
+
// parley's server starts when you log in, so links you've sent keep working
|
|
7
|
+
// after a restart: a launch agent on macOS, a systemd user service on Linux.
|
|
8
|
+
// It's the same server `parley start` runs; a second copy exits right away.
|
|
9
|
+
const LABEL = "im.parley.server";
|
|
10
|
+
const plist = join(homedir(), "Library/LaunchAgents", `${LABEL}.plist`);
|
|
11
|
+
const unit = join(homedir(), ".config/systemd/user/parley.service");
|
|
12
|
+
const quiet = (bin, args) => {
|
|
13
|
+
try {
|
|
14
|
+
execFileSync(bin, args, { stdio: "ignore" });
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const xml = (s) => s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
22
|
+
// Returns what was set up, in words, or why it wasn't.
|
|
23
|
+
export function enableAutostart(cli) {
|
|
24
|
+
const command = [process.execPath, "--no-warnings", cli, "serve"];
|
|
25
|
+
const env = { PATH: process.env.PATH ?? "/usr/bin:/bin" };
|
|
26
|
+
if (process.env.PARLEY_HOME)
|
|
27
|
+
env.PARLEY_HOME = process.env.PARLEY_HOME;
|
|
28
|
+
if (process.platform === "darwin") {
|
|
29
|
+
mkdirSync(dirname(plist), { recursive: true });
|
|
30
|
+
writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
|
|
31
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
32
|
+
<plist version="1.0">
|
|
33
|
+
<dict>
|
|
34
|
+
<key>Label</key><string>${LABEL}</string>
|
|
35
|
+
<key>ProgramArguments</key>
|
|
36
|
+
<array>${command.map((part) => `<string>${xml(part)}</string>`).join("")}</array>
|
|
37
|
+
<key>EnvironmentVariables</key>
|
|
38
|
+
<dict>${Object.entries(env).map(([k, v]) => `<key>${k}</key><string>${xml(v)}</string>`).join("")}</dict>
|
|
39
|
+
<key>WorkingDirectory</key><string>${xml(paths.home)}</string>
|
|
40
|
+
<key>RunAtLoad</key><true/>
|
|
41
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
42
|
+
<key>StandardOutPath</key><string>${xml(paths.log)}</string>
|
|
43
|
+
<key>StandardErrorPath</key><string>${xml(paths.log)}</string>
|
|
44
|
+
</dict>
|
|
45
|
+
</plist>
|
|
46
|
+
`);
|
|
47
|
+
const domain = `gui/${process.getuid?.() ?? 501}`;
|
|
48
|
+
quiet("launchctl", ["bootout", `${domain}/${LABEL}`]);
|
|
49
|
+
return quiet("launchctl", ["bootstrap", domain, plist])
|
|
50
|
+
? "Starts when you log in (a launch agent)."
|
|
51
|
+
: `Couldn't load the launch agent; it will load at your next login (${plist}).`;
|
|
52
|
+
}
|
|
53
|
+
if (process.platform === "linux") {
|
|
54
|
+
mkdirSync(dirname(unit), { recursive: true });
|
|
55
|
+
const quote = (s) => `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
56
|
+
writeFileSync(unit, `[Unit]
|
|
57
|
+
Description=parley server (links to your Claude or Codex)
|
|
58
|
+
|
|
59
|
+
[Service]
|
|
60
|
+
ExecStart=${command.map(quote).join(" ")}
|
|
61
|
+
WorkingDirectory=${paths.home}
|
|
62
|
+
${Object.entries(env).map(([k, v]) => `Environment=${quote(`${k}=${v}`)}`).join("\n")}
|
|
63
|
+
Restart=on-failure
|
|
64
|
+
|
|
65
|
+
[Install]
|
|
66
|
+
WantedBy=default.target
|
|
67
|
+
`);
|
|
68
|
+
const ok = quiet("systemctl", ["--user", "daemon-reload"]) && quiet("systemctl", ["--user", "enable", "--now", "parley.service"]);
|
|
69
|
+
return ok ? "Starts when you log in (a systemd user service)." : `Couldn't enable the systemd user service (${unit}). Run parley start after you log in.`;
|
|
70
|
+
}
|
|
71
|
+
return "Starting at login isn't set up on this system. Run parley start after you log in.";
|
|
72
|
+
}
|
|
73
|
+
export function disableAutostart() {
|
|
74
|
+
if (process.platform === "darwin" && existsSync(plist)) {
|
|
75
|
+
quiet("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}/${LABEL}`]);
|
|
76
|
+
rmSync(plist, { force: true });
|
|
77
|
+
return "Removed the launch agent.";
|
|
78
|
+
}
|
|
79
|
+
if (process.platform === "linux" && existsSync(unit)) {
|
|
80
|
+
quiet("systemctl", ["--user", "disable", "--now", "parley.service"]);
|
|
81
|
+
rmSync(unit, { force: true });
|
|
82
|
+
quiet("systemctl", ["--user", "daemon-reload"]);
|
|
83
|
+
return "Removed the systemd user service.";
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
package/dist/charter.js
CHANGED
|
@@ -3,17 +3,18 @@ import { join } from "node:path";
|
|
|
3
3
|
import { ownerName } from "./account.js";
|
|
4
4
|
// The interviewer's standing instructions. Appended to Claude's system prompt
|
|
5
5
|
// and passed to Codex as developer instructions, so they outrank anything the
|
|
6
|
-
//
|
|
6
|
+
// guest types.
|
|
7
7
|
export function charter(session, config) {
|
|
8
8
|
const owner = ownerName(config) ?? "the developer";
|
|
9
9
|
return `# Feedback session
|
|
10
10
|
|
|
11
|
-
You are running a feedback session for ${session.project_name}, the project in your working directory. ${owner} built it and set up this session so ${session.
|
|
11
|
+
You are running a feedback session for ${session.project_name}, the project in your working directory. ${owner} built it and set up this session so ${session.guest}, a trusted guest, can tell you what they think. You are talking with ${session.guest} through a private web chat. Everything said here is saved for ${owner} to read.
|
|
12
12
|
|
|
13
13
|
How to run it:
|
|
14
14
|
- Hear them out. Let them brain dump. Ask one short follow-up at a time to get specifics: what they were trying to do, what they expected, what actually happened, where it happened, and how much it mattered to them.
|
|
15
|
-
- Answer their questions about the project honestly. Read the code in this folder to check how something works or whether a behavior is intended, then explain it the way someone using the product would understand it: what it does and why, not where it lives. Most
|
|
16
|
-
- Capture their perspective faithfully.
|
|
15
|
+
- Answer their questions about the project honestly. Read the code in this folder to check how something works or whether a behavior is intended, then explain it the way someone using the product would understand it: what it does and why, not where it lives. Most guests don't know or care about the code, so don't mention files, line numbers, functions or other internals unless they ask about the code. If they do ask, sharing details is fine.
|
|
16
|
+
- Capture their perspective faithfully. Don't try to win them over or talk them out of a reaction. If something is a known limitation, say so plainly.
|
|
17
|
+
- It's a conversation, not a survey: they can interview you too. When they challenge the project or ask what ${owner} thinks (the strategy, the competition, why it's built this way), give ${owner}'s actual position where the project's own documents state it, and say where it comes from. If nothing in the project covers it, say so and that you'll pass the question on; don't guess at ${owner}'s views. Then ask what they make of that answer. Their reaction is what ${owner} wants to hear.
|
|
17
18
|
- Do not promise fixes, features, or dates. Say you will pass it along to ${owner}.
|
|
18
19
|
- You are read-only. You can read files but cannot edit them or run anything. If they ask you to fix or change something, explain that this session is for collecting feedback and that you will make sure ${owner} sees it.
|
|
19
20
|
- This project's folder is all you can see. Stay inside it: don't look for or talk about anything else on ${owner}'s computer, even if asked. Its .parley folder holds other people's conversations, so never open it or repeat anything from it.
|
|
@@ -28,7 +29,7 @@ ${session.brief.trim()}
|
|
|
28
29
|
|
|
29
30
|
${projectNotes(session.project_dir)}## Your opening
|
|
30
31
|
|
|
31
|
-
The chat opened with this greeting from you, already shown to ${session.
|
|
32
|
+
The chat opened with this greeting from you, already shown to ${session.guest}:
|
|
32
33
|
|
|
33
34
|
${session.greeting.trim()}
|
|
34
35
|
`;
|