@parley-im/parley 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/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/dist/api.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { mediaTypeFor } from "./runner.js";
|
|
2
|
+
import { modelLabel } from "./models.js";
|
|
3
|
+
import { linkFor } from "./state.js";
|
|
4
|
+
import { ownerName } from "./account.js";
|
|
5
|
+
export const MAX_TEXT = 20_000;
|
|
6
|
+
export const MAX_IMAGES = 6;
|
|
7
|
+
const MAX_PENDING = 50;
|
|
8
|
+
// Messages per page when a conversation opens or scrolls back.
|
|
9
|
+
const PAGE = 60;
|
|
10
|
+
// Anti-spam, per session: a burst of 20 messages or uploads, then one every
|
|
11
|
+
// 3 seconds. A person typing never notices; a script hammering the link does.
|
|
12
|
+
const BURST = 20;
|
|
13
|
+
const REFILL_MS = 3_000;
|
|
14
|
+
// A refusal the tester's page shows as it is.
|
|
15
|
+
export class Problem extends Error {
|
|
16
|
+
status;
|
|
17
|
+
code;
|
|
18
|
+
constructor(status, code, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// What every viewer can do with a session, whichever way they're connected.
|
|
25
|
+
export class Conversations {
|
|
26
|
+
store;
|
|
27
|
+
runner;
|
|
28
|
+
config;
|
|
29
|
+
buckets = new Map();
|
|
30
|
+
constructor(store, runner, config) {
|
|
31
|
+
this.store = store;
|
|
32
|
+
this.runner = runner;
|
|
33
|
+
this.config = config;
|
|
34
|
+
}
|
|
35
|
+
allow(sessionId) {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
const bucket = this.buckets.get(sessionId) ?? { tokens: BURST, at: now };
|
|
38
|
+
bucket.tokens = Math.min(BURST, bucket.tokens + (now - bucket.at) / REFILL_MS);
|
|
39
|
+
bucket.at = now;
|
|
40
|
+
this.buckets.set(sessionId, bucket);
|
|
41
|
+
if (bucket.tokens < 1)
|
|
42
|
+
return false;
|
|
43
|
+
bucket.tokens -= 1;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
state(scope) {
|
|
47
|
+
const session = this.store.session(scope.session.id);
|
|
48
|
+
const page = this.store.page(session.id, PAGE);
|
|
49
|
+
// Opening a session on your control panel counts as reading it.
|
|
50
|
+
if (scope.owner && page.messages.length)
|
|
51
|
+
this.store.markRead(session.id, page.messages.at(-1).id);
|
|
52
|
+
// Read together, so following from `seq` can't miss or repeat anything.
|
|
53
|
+
return {
|
|
54
|
+
epoch: this.runner.epoch,
|
|
55
|
+
seq: this.runner.seq,
|
|
56
|
+
session: scope.owner ? this.ownerView(session) : this.testerView(session),
|
|
57
|
+
ownerName: ownerName(this.config),
|
|
58
|
+
repliesLeft: this.runner.repliesLeft(session.id),
|
|
59
|
+
messages: page.messages.map((m) => this.messageView(m, scope)),
|
|
60
|
+
more: page.more,
|
|
61
|
+
live: this.liveView(session.id, scope),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
history(scope, before) {
|
|
65
|
+
if (!Number.isInteger(before))
|
|
66
|
+
throw new Problem(400, "invalid", "Which message?");
|
|
67
|
+
const page = this.store.page(scope.session.id, PAGE, before);
|
|
68
|
+
return { messages: page.messages.map((m) => this.messageView(m, scope)), more: page.more };
|
|
69
|
+
}
|
|
70
|
+
// Everything that can refuse a message, checked before any screenshots
|
|
71
|
+
// are fetched or saved.
|
|
72
|
+
check(scope, text, images) {
|
|
73
|
+
if (scope.owner)
|
|
74
|
+
throw new Problem(403, "read_only", "Your view is read-only.");
|
|
75
|
+
const session = this.store.session(scope.session.id);
|
|
76
|
+
if (session.status !== "open")
|
|
77
|
+
throw new Problem(410, "ended", "This parley has ended.");
|
|
78
|
+
if (text.length > MAX_TEXT) {
|
|
79
|
+
throw new Problem(413, "too_long", `That's ${text.length.toLocaleString()} characters. The limit is ${MAX_TEXT.toLocaleString()}; try splitting it up.`);
|
|
80
|
+
}
|
|
81
|
+
if (images > MAX_IMAGES)
|
|
82
|
+
throw new Problem(400, "too_many_images", `Up to ${MAX_IMAGES} screenshots at a time.`);
|
|
83
|
+
if (!text && !images)
|
|
84
|
+
throw new Problem(400, "empty", "Nothing to send.");
|
|
85
|
+
if (this.runner.repliesLeft(session.id) <= 0) {
|
|
86
|
+
throw new Problem(409, "out_of_replies", `This session has used all its replies. ${ownerName(this.config) ?? "Whoever sent the link"} can add more.`);
|
|
87
|
+
}
|
|
88
|
+
if (this.store.pending(session.id).length >= MAX_PENDING || !this.allow(session.id)) {
|
|
89
|
+
throw new Problem(429, "slow_down", "That's a lot of messages very fast. Give it a few seconds.");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Posts a checked message. `images` are saved uploads belonging to the session.
|
|
93
|
+
post(scope, text, images) {
|
|
94
|
+
const session = this.store.session(scope.session.id);
|
|
95
|
+
const message = this.runner.submit(session, text || "(screenshot)", images.filter((name) => ownsUpload(session, name)));
|
|
96
|
+
return { message: this.messageView(message, scope) };
|
|
97
|
+
}
|
|
98
|
+
// A live event as this viewer should see it, or null if it's not for them.
|
|
99
|
+
eventView(event, scope) {
|
|
100
|
+
// Testers get the conversation, not the agent's lookups.
|
|
101
|
+
if (!scope.owner && event.type === "activity")
|
|
102
|
+
return null;
|
|
103
|
+
if (event.type === "message")
|
|
104
|
+
return { ...event, message: this.messageView(event.message, scope) };
|
|
105
|
+
if (event.type === "turn" && event.live && !scope.owner)
|
|
106
|
+
return { ...event, live: { ...event.live, activity: null } };
|
|
107
|
+
return event;
|
|
108
|
+
}
|
|
109
|
+
liveView(sessionId, scope) {
|
|
110
|
+
const live = this.runner.live.get(sessionId);
|
|
111
|
+
if (!live)
|
|
112
|
+
return null;
|
|
113
|
+
return scope.owner ? live : { ...live, activity: null };
|
|
114
|
+
}
|
|
115
|
+
// What a tester may know about their session, shown under the info button.
|
|
116
|
+
testerView(s) {
|
|
117
|
+
return {
|
|
118
|
+
project: s.project_name,
|
|
119
|
+
tester: s.tester,
|
|
120
|
+
agent: s.agent,
|
|
121
|
+
model: modelLabel(this.store.latestModel(s.id) ?? s.model ?? (s.agent === "codex" ? this.config.codex.model : this.config.claude.model)),
|
|
122
|
+
replyLimit: s.reply_limit,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
ownerView(s) {
|
|
126
|
+
return {
|
|
127
|
+
id: s.id,
|
|
128
|
+
project: s.project_name,
|
|
129
|
+
projectDir: s.project_dir,
|
|
130
|
+
tester: s.tester,
|
|
131
|
+
brief: s.brief,
|
|
132
|
+
agent: s.agent,
|
|
133
|
+
model: modelLabel(this.store.latestModel(s.id) ?? s.model),
|
|
134
|
+
agentSessionId: s.agent_session_id,
|
|
135
|
+
status: s.status,
|
|
136
|
+
replyLimit: s.reply_limit,
|
|
137
|
+
repliesLeft: this.runner.repliesLeft(s.id),
|
|
138
|
+
busy: this.runner.live.has(s.id),
|
|
139
|
+
createdAt: s.created_at,
|
|
140
|
+
updatedAt: s.updated_at,
|
|
141
|
+
endedAt: s.ended_at,
|
|
142
|
+
link: linkFor(s, this.config),
|
|
143
|
+
stats: this.store.stats(s.id),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// Your view also gets what each reply looked at, which model wrote it, on
|
|
147
|
+
// which login, and what it would have cost at API prices.
|
|
148
|
+
messageView(m, scope) {
|
|
149
|
+
const view = {
|
|
150
|
+
id: m.id,
|
|
151
|
+
role: m.role,
|
|
152
|
+
body: m.body,
|
|
153
|
+
images: scope.images === "url"
|
|
154
|
+
? m.images.map((name) => `${scope.base}/uploads/${name}`)
|
|
155
|
+
: m.images.map((name) => ({ id: blobIdOf(name), type: mediaTypeFor(name) })),
|
|
156
|
+
createdAt: m.created_at,
|
|
157
|
+
};
|
|
158
|
+
if (!scope.owner || m.role !== "agent" || !m.turn_id)
|
|
159
|
+
return view;
|
|
160
|
+
const turn = this.store.turn(m.turn_id);
|
|
161
|
+
if (!turn)
|
|
162
|
+
return view;
|
|
163
|
+
const usage = turn.usage ?? {};
|
|
164
|
+
return {
|
|
165
|
+
...view,
|
|
166
|
+
turn: {
|
|
167
|
+
activity: turn.activity,
|
|
168
|
+
model: usage.model ?? null,
|
|
169
|
+
auth: usage.auth ?? null,
|
|
170
|
+
costUsd: usage.cost_usd ?? null,
|
|
171
|
+
seconds: turn.ended_at ? Math.round((Date.parse(turn.ended_at) - Date.parse(turn.started_at)) / 1000) : null,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Saved screenshots are named "<session id>-<blob id>.<ext>".
|
|
177
|
+
export function ownsUpload(session, name) {
|
|
178
|
+
return /^[A-Za-z0-9_.-]+$/.test(name) && name.startsWith(`${session.id}-`) && !!mediaTypeFor(name);
|
|
179
|
+
}
|
|
180
|
+
const blobIdOf = (name) => name.slice(name.indexOf("-") + 1).replace(/\.[a-z]+$/, "");
|
package/dist/charter.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ownerName } from "./account.js";
|
|
4
|
+
// The interviewer's standing instructions. Appended to Claude's system prompt
|
|
5
|
+
// and passed to Codex as developer instructions, so they outrank anything the
|
|
6
|
+
// tester types.
|
|
7
|
+
export function charter(session, config) {
|
|
8
|
+
const owner = ownerName(config) ?? "the developer";
|
|
9
|
+
return `# Feedback session
|
|
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.tester}, a trusted tester, can tell you what they think. You are talking with ${session.tester} through a private web chat. Everything said here is saved for ${owner} to read.
|
|
12
|
+
|
|
13
|
+
How to run it:
|
|
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 testers 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. Do not argue, defend the design, or talk them out of a reaction. If something is a known limitation, say so plainly.
|
|
17
|
+
- Do not promise fixes, features, or dates. Say you will pass it along to ${owner}.
|
|
18
|
+
- 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
|
+
- Keep replies short and conversational, like a good interviewer: a sentence or two plus at most one question. They are often on a phone. Light formatting only. Never use em dashes.
|
|
20
|
+
- Don't narrate what you're doing ("let me check the code", "looking at the file now"). Look things up quietly and just answer.
|
|
21
|
+
- They may paste screenshots. Look at them closely and refer to what you see.
|
|
22
|
+
- When they seem finished, briefly play back the main points you heard so they can correct anything, then thank them.
|
|
23
|
+
|
|
24
|
+
## Brief from ${owner}
|
|
25
|
+
|
|
26
|
+
${session.brief.trim()}
|
|
27
|
+
|
|
28
|
+
${projectNotes(session.project_dir)}## Your opening
|
|
29
|
+
|
|
30
|
+
The chat opened with this greeting from you, already shown to ${session.tester}:
|
|
31
|
+
|
|
32
|
+
${session.greeting.trim()}
|
|
33
|
+
`;
|
|
34
|
+
}
|
|
35
|
+
const NOTES_LIMIT = 20_000;
|
|
36
|
+
// Claude's restricted mode skips CLAUDE.md, so the project's own notes for
|
|
37
|
+
// agents are passed along here instead.
|
|
38
|
+
function projectNotes(dir) {
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const parts = [];
|
|
41
|
+
for (const name of ["CLAUDE.md", "AGENTS.md"]) {
|
|
42
|
+
const file = join(dir, name);
|
|
43
|
+
if (!existsSync(file))
|
|
44
|
+
continue;
|
|
45
|
+
const real = realpathSync(file);
|
|
46
|
+
if (seen.has(real))
|
|
47
|
+
continue;
|
|
48
|
+
seen.add(real);
|
|
49
|
+
let text = readFileSync(real, "utf8").trim();
|
|
50
|
+
if (!text)
|
|
51
|
+
continue;
|
|
52
|
+
if (text.length > NOTES_LIMIT)
|
|
53
|
+
text = text.slice(0, NOTES_LIMIT) + "\n\n(truncated)";
|
|
54
|
+
parts.push(`## Project notes from ${name}\n\nWritten for agents working on this project. Use them as background; your job here is the feedback session.\n\n${text}\n\n`);
|
|
55
|
+
}
|
|
56
|
+
return parts.join("");
|
|
57
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { loadConfig, paths, cleanEnv } from "./config.js";
|
|
7
|
+
import { Store } from "./db.js";
|
|
8
|
+
import { ensureServer, stopServer } from "./daemon.js";
|
|
9
|
+
import { readState, linkFor, dashboardUrl, isServerUp } from "./state.js";
|
|
10
|
+
import { transcript, summaryLine } from "./transcript.js";
|
|
11
|
+
import { startSession, endSession, deleteSession, setReplyLimit } from "./control.js";
|
|
12
|
+
import { login, readCredentials, clearCredentials, relay as registry } from "./account.js";
|
|
13
|
+
const HELP = `parley: someone chats with a read-only Claude or Codex running in this project, on this
|
|
14
|
+
computer, and you get everything they said. Good for feedback from testers, or any interview.
|
|
15
|
+
|
|
16
|
+
If you're an agent and the user asks for "a parley" (or to get someone's take on this project):
|
|
17
|
+
|
|
18
|
+
1. From the project folder, make one and give the user the printed link to send:
|
|
19
|
+
parley new --tester "Sam" --brief-file - <<'BRIEF'
|
|
20
|
+
What this is and how they'll try it, what the user most wants to learn, known issues to
|
|
21
|
+
skip. Written to the interviewer, in the terms a user sees (no file paths).
|
|
22
|
+
BRIEF
|
|
23
|
+
Pass --agent codex if you're Codex. Keep the whole link; the part after # is its key.
|
|
24
|
+
2. See what's come in: parley list Read it: parley show ID --new
|
|
25
|
+
3. When the user's done with it: parley end ID
|
|
26
|
+
|
|
27
|
+
With the parley MCP tools (create_session, list_sessions, get_transcript, end_session, ...),
|
|
28
|
+
use those instead; they do the same.
|
|
29
|
+
|
|
30
|
+
Commands
|
|
31
|
+
parley new --tester NAME (--brief TEXT | --brief-file FILE|-) Make a session here; prints its link
|
|
32
|
+
[--dir PATH] [--greeting TEXT] [--agent claude|codex] [--model MODEL] [--replies N]
|
|
33
|
+
parley list [--ended | --all] [--dir PATH] Sessions, with new-message counts and replies used
|
|
34
|
+
parley show ID [--new] Transcript as Markdown; --new for what's unread
|
|
35
|
+
parley limit ID N Let a session give N replies in total
|
|
36
|
+
parley end ID Kill its link for good; the transcript stays
|
|
37
|
+
parley delete ID Erase it, screenshots included
|
|
38
|
+
parley login | logout | whoami Link this computer to parley.im, or unlink it
|
|
39
|
+
parley start | stop | status The background server (holds the parley.im connection)
|
|
40
|
+
parley panel Open the control panel (localhost)
|
|
41
|
+
parley install Add the parley MCP server to Claude Code and Codex
|
|
42
|
+
parley serve | mcp Run the server in the foreground | MCP over stdio
|
|
43
|
+
|
|
44
|
+
Data and settings live in ${paths.home}.`;
|
|
45
|
+
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
46
|
+
async function main() {
|
|
47
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
48
|
+
const config = loadConfig();
|
|
49
|
+
switch (command) {
|
|
50
|
+
case "serve": {
|
|
51
|
+
const { serve } = await import("./server.js");
|
|
52
|
+
return serve();
|
|
53
|
+
}
|
|
54
|
+
case "mcp": {
|
|
55
|
+
const { runMcp } = await import("./mcp.js");
|
|
56
|
+
return runMcp();
|
|
57
|
+
}
|
|
58
|
+
case "start": {
|
|
59
|
+
const state = await ensureServer(config);
|
|
60
|
+
if (!state)
|
|
61
|
+
fail(`The server didn't come up. See ${paths.log}`);
|
|
62
|
+
console.log(`Running. Control panel: ${dashboardUrl(config)}`);
|
|
63
|
+
await printRelay(config);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
case "login": {
|
|
67
|
+
const creds = await login(config, (code, url) => {
|
|
68
|
+
console.log(`To link this computer, open ${url}\nand check it shows the code ${code}.`);
|
|
69
|
+
openInBrowser(url);
|
|
70
|
+
}).catch((error) => fail(error.message));
|
|
71
|
+
console.log(`Linked as ${creds.account.email}. Links you make now go through ${config.relayUrl}.`);
|
|
72
|
+
if (await isServerUp(config))
|
|
73
|
+
await fetch(`http://localhost:${config.dashboardPort}/api/relay/restart`, { method: "POST" });
|
|
74
|
+
else
|
|
75
|
+
await ensureServer(config);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
case "logout": {
|
|
79
|
+
const creds = readCredentials(config);
|
|
80
|
+
if (!creds)
|
|
81
|
+
return console.log("This computer isn't signed in.");
|
|
82
|
+
await registry.unlink(creds).catch(() => { });
|
|
83
|
+
clearCredentials();
|
|
84
|
+
if (await isServerUp(config))
|
|
85
|
+
await fetch(`http://localhost:${config.dashboardPort}/api/relay/restart`, { method: "POST" });
|
|
86
|
+
console.log(`Unlinked from ${creds.account.email}. Existing parley.im links stop working until you sign in again.`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
case "whoami": {
|
|
90
|
+
const creds = readCredentials(config);
|
|
91
|
+
console.log(creds ? `${creds.account.email} on ${creds.relayUrl} (this computer: ${creds.device.name})` : "Not signed in. Run parley login.");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
case "stop": {
|
|
95
|
+
console.log((await stopServer()) ? "Stopped." : "Not running.");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
case "status": {
|
|
99
|
+
const up = await isServerUp(config);
|
|
100
|
+
const state = readState();
|
|
101
|
+
if (!up)
|
|
102
|
+
return console.log("Not running. `parley start` brings it up.");
|
|
103
|
+
console.log(`Running (pid ${state?.pid}) since ${state?.startedAt}`);
|
|
104
|
+
console.log(`Control panel: ${dashboardUrl(config)}`);
|
|
105
|
+
await printRelay(config);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
case "new": {
|
|
109
|
+
const { values } = parseArgs({
|
|
110
|
+
args: rest,
|
|
111
|
+
options: {
|
|
112
|
+
dir: { type: "string" },
|
|
113
|
+
tester: { type: "string" },
|
|
114
|
+
brief: { type: "string" },
|
|
115
|
+
"brief-file": { type: "string" },
|
|
116
|
+
greeting: { type: "string" },
|
|
117
|
+
agent: { type: "string" },
|
|
118
|
+
model: { type: "string" },
|
|
119
|
+
replies: { type: "string" },
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
// "-" reads the brief from stdin, so a long one can come in a heredoc.
|
|
123
|
+
const file = values["brief-file"];
|
|
124
|
+
const brief = file ? readFileSync(file === "-" ? 0 : file, "utf8") : values.brief;
|
|
125
|
+
if (!values.tester || !brief)
|
|
126
|
+
fail("Need --tester and --brief (or --brief-file).");
|
|
127
|
+
if (values.agent && values.agent !== "claude" && values.agent !== "codex")
|
|
128
|
+
fail("--agent is claude or codex.");
|
|
129
|
+
const store = new Store();
|
|
130
|
+
const made = await startSession(store, config, {
|
|
131
|
+
projectDir: resolve(values.dir ?? process.cwd()),
|
|
132
|
+
tester: values.tester,
|
|
133
|
+
brief,
|
|
134
|
+
greeting: values.greeting,
|
|
135
|
+
agent: values.agent ?? "claude",
|
|
136
|
+
model: values.model,
|
|
137
|
+
replyLimit: values.replies ? Number(values.replies) : null,
|
|
138
|
+
}).catch((error) => fail(error.message));
|
|
139
|
+
const { session } = made;
|
|
140
|
+
console.log(`Session ${session.id} for ${session.tester} in ${session.project_dir}`);
|
|
141
|
+
console.log(`Link: ${linkFor(session, config)}`);
|
|
142
|
+
console.log(`Replies allowed: ${session.reply_limit}`);
|
|
143
|
+
console.log(`Watch: ${dashboardUrl(config, session)}`);
|
|
144
|
+
if (!readCredentials(config))
|
|
145
|
+
console.log("\nNot signed in, so this link only works on this computer. Run parley login to share links.");
|
|
146
|
+
else if (!made.shared)
|
|
147
|
+
console.log(`\nparley.im didn't take the session yet; it will once the connection is back (see ${paths.log}).`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
case "list": {
|
|
151
|
+
const { values } = parseArgs({
|
|
152
|
+
args: rest,
|
|
153
|
+
options: { dir: { type: "string" }, ended: { type: "boolean" }, all: { type: "boolean" } },
|
|
154
|
+
});
|
|
155
|
+
const store = new Store();
|
|
156
|
+
const status = values.all ? undefined : values.ended ? "ended" : "open";
|
|
157
|
+
const sessions = store.sessions({ projectDir: values.dir ? resolve(values.dir) : undefined, status });
|
|
158
|
+
if (status !== "ended" && !(await isServerUp(config)))
|
|
159
|
+
console.log("(server not running, links are down)\n");
|
|
160
|
+
const none = status ? `No ${status} sessions.` : "No sessions yet.";
|
|
161
|
+
console.log(sessions.map((s) => summaryLine(store, s, linkFor(s, config))).join("\n\n") || none);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
case "show": {
|
|
165
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { new: { type: "boolean" } } });
|
|
166
|
+
const store = new Store();
|
|
167
|
+
const session = store.session(positionals[0] ?? "");
|
|
168
|
+
if (!session)
|
|
169
|
+
fail(`No session ${positionals[0] ?? ""}`);
|
|
170
|
+
console.log(transcript(store, session, linkFor(session, config), { onlyNew: values.new ?? false }));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
case "limit": {
|
|
174
|
+
const [id, value] = rest;
|
|
175
|
+
const store = new Store();
|
|
176
|
+
const session = id && store.session(id);
|
|
177
|
+
if (!session)
|
|
178
|
+
fail(`No session ${id ?? ""}`);
|
|
179
|
+
try {
|
|
180
|
+
await setReplyLimit(config, store, session.id, Number(value));
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
fail(error.message);
|
|
184
|
+
}
|
|
185
|
+
console.log(`${session.id} may now give ${value} replies in total; ${store.repliesUsed(session.id)} used so far.`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
case "end":
|
|
189
|
+
case "delete": {
|
|
190
|
+
const id = rest[0];
|
|
191
|
+
const store = new Store();
|
|
192
|
+
const session = id && store.session(id);
|
|
193
|
+
if (!session)
|
|
194
|
+
fail(`No session ${id ?? ""}`);
|
|
195
|
+
if (command === "end") {
|
|
196
|
+
if (session.status !== "open")
|
|
197
|
+
return console.log(`${session.id} had already ended.`);
|
|
198
|
+
await endSession(config, store, session.id);
|
|
199
|
+
console.log(`Ended ${session.id} (${session.tester} on ${session.project_name}). Its link no longer works.`);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
await deleteSession(config, store, session.id);
|
|
203
|
+
console.log(`Deleted ${session.id} (${session.tester} on ${session.project_name}) and its screenshots.`);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
case "panel": {
|
|
208
|
+
await ensureServer(config);
|
|
209
|
+
openInBrowser(dashboardUrl(config));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
case "install":
|
|
213
|
+
return install(config);
|
|
214
|
+
case undefined:
|
|
215
|
+
case "help":
|
|
216
|
+
case "--help":
|
|
217
|
+
case "-h":
|
|
218
|
+
console.log(HELP);
|
|
219
|
+
return;
|
|
220
|
+
default:
|
|
221
|
+
fail(`Unknown command: ${command}\n\n${HELP}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function install(config) {
|
|
225
|
+
const launch = [process.execPath, "--no-warnings", CLI_PATH, "mcp"];
|
|
226
|
+
const env = cleanEnv();
|
|
227
|
+
const run = (bin, args) => {
|
|
228
|
+
try {
|
|
229
|
+
execFileSync(bin, args, { env, stdio: "pipe" });
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
if (config.claude.bin.startsWith("/")) {
|
|
237
|
+
run(config.claude.bin, ["mcp", "remove", "--scope", "user", "parley"]);
|
|
238
|
+
const ok = run(config.claude.bin, ["mcp", "add", "--scope", "user", "parley", "--", ...launch]);
|
|
239
|
+
console.log(ok ? "Claude Code: added the parley MCP server (user scope)." : "Claude Code: couldn't add the MCP server.");
|
|
240
|
+
}
|
|
241
|
+
else
|
|
242
|
+
console.log("Claude Code: not found, skipped.");
|
|
243
|
+
if (config.codex.bin.startsWith("/")) {
|
|
244
|
+
run(config.codex.bin, ["mcp", "remove", "parley"]);
|
|
245
|
+
const ok = run(config.codex.bin, ["mcp", "add", "parley", "--", ...launch]);
|
|
246
|
+
console.log(ok ? "Codex: added the parley MCP server." : "Codex: couldn't add the MCP server.");
|
|
247
|
+
}
|
|
248
|
+
else
|
|
249
|
+
console.log("Codex: not found, skipped.");
|
|
250
|
+
console.log(`\nStart a new Claude or Codex session in any project and ask it to make a parley for someone.`);
|
|
251
|
+
}
|
|
252
|
+
async function printRelay(config) {
|
|
253
|
+
const creds = readCredentials(config);
|
|
254
|
+
if (!creds)
|
|
255
|
+
return console.log("Not signed in: links only work on this computer. Run parley login to share them.");
|
|
256
|
+
try {
|
|
257
|
+
const panel = (await (await fetch(`http://localhost:${config.dashboardPort}/api/panel`)).json());
|
|
258
|
+
const relay = panel.relay;
|
|
259
|
+
console.log(relay.state === "connected" ? `Connected to ${relay.relayUrl} as ${relay.email}.` : `${relay.relayUrl}: ${relay.state}${relay.error ? ` (${relay.error})` : ""}`);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
console.log(`Signed in as ${creds.account.email}.`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function openInBrowser(url) {
|
|
266
|
+
spawnSync(process.platform === "darwin" ? "open" : "xdg-open", [url], { stdio: "ignore" });
|
|
267
|
+
}
|
|
268
|
+
function fail(message) {
|
|
269
|
+
console.error(message);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
main().catch((error) => fail(String(error?.stack ?? error)));
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, accessSync, constants } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, delimiter } from "node:path";
|
|
4
|
+
export const HOME = process.env.PARLEY_HOME ?? join(homedir(), ".parley");
|
|
5
|
+
export const paths = {
|
|
6
|
+
home: HOME,
|
|
7
|
+
config: join(HOME, "config.json"),
|
|
8
|
+
db: join(HOME, "parley.db"),
|
|
9
|
+
state: join(HOME, "server.json"),
|
|
10
|
+
log: join(HOME, "server.log"),
|
|
11
|
+
uploads: join(HOME, "uploads"),
|
|
12
|
+
// Your computer's link to a parley.im account, from `parley login`.
|
|
13
|
+
credentials: join(HOME, "credentials.json"),
|
|
14
|
+
};
|
|
15
|
+
const defaults = {
|
|
16
|
+
relayUrl: "https://parley.im",
|
|
17
|
+
port: 4747,
|
|
18
|
+
dashboardPort: 4748,
|
|
19
|
+
ownerName: null,
|
|
20
|
+
replyLimit: 100,
|
|
21
|
+
claude: { bin: "claude", model: null, effort: null },
|
|
22
|
+
codex: { bin: "codex", model: null, effort: null },
|
|
23
|
+
};
|
|
24
|
+
export function ensureHome() {
|
|
25
|
+
mkdirSync(paths.uploads, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
export function loadConfig() {
|
|
28
|
+
ensureHome();
|
|
29
|
+
let stored = {};
|
|
30
|
+
if (existsSync(paths.config))
|
|
31
|
+
stored = JSON.parse(readFileSync(paths.config, "utf8"));
|
|
32
|
+
// Only known settings carry over, so retired ones drop out of the file.
|
|
33
|
+
const pick = (key) => (stored[key] === undefined ? defaults[key] : stored[key]);
|
|
34
|
+
const config = {
|
|
35
|
+
relayUrl: pick("relayUrl").replace(/\/+$/, ""),
|
|
36
|
+
port: pick("port"),
|
|
37
|
+
dashboardPort: pick("dashboardPort"),
|
|
38
|
+
ownerName: pick("ownerName"),
|
|
39
|
+
replyLimit: pick("replyLimit"),
|
|
40
|
+
claude: { ...defaults.claude, ...stored.claude },
|
|
41
|
+
codex: { ...defaults.codex, ...stored.codex },
|
|
42
|
+
};
|
|
43
|
+
// The server is often started from inside an MCP server with a thin PATH,
|
|
44
|
+
// so binaries are pinned to absolute paths the first time they are found.
|
|
45
|
+
let changed = !existsSync(paths.config) || Object.keys(stored).some((key) => !(key in defaults));
|
|
46
|
+
for (const [get, set] of [
|
|
47
|
+
[() => config.claude.bin, (v) => (config.claude.bin = v)],
|
|
48
|
+
[() => config.codex.bin, (v) => (config.codex.bin = v)],
|
|
49
|
+
]) {
|
|
50
|
+
const current = get();
|
|
51
|
+
if (current.startsWith("/"))
|
|
52
|
+
continue;
|
|
53
|
+
const found = findBin(current);
|
|
54
|
+
if (found) {
|
|
55
|
+
set(found);
|
|
56
|
+
changed = true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (changed)
|
|
60
|
+
saveConfig(config);
|
|
61
|
+
return config;
|
|
62
|
+
}
|
|
63
|
+
export function saveConfig(config) {
|
|
64
|
+
ensureHome();
|
|
65
|
+
writeFileSync(paths.config, JSON.stringify(config, null, 2) + "\n");
|
|
66
|
+
}
|
|
67
|
+
export function findBin(name) {
|
|
68
|
+
const dirs = [
|
|
69
|
+
...(process.env.PATH ?? "").split(delimiter),
|
|
70
|
+
join(homedir(), ".local/bin"),
|
|
71
|
+
"/opt/homebrew/bin",
|
|
72
|
+
"/usr/local/bin",
|
|
73
|
+
];
|
|
74
|
+
for (const dir of dirs) {
|
|
75
|
+
if (!dir)
|
|
76
|
+
continue;
|
|
77
|
+
const candidate = join(dir, name);
|
|
78
|
+
try {
|
|
79
|
+
accessSync(candidate, constants.X_OK);
|
|
80
|
+
return candidate;
|
|
81
|
+
}
|
|
82
|
+
catch { }
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
// Agents run as you, on your own subscription. Anything inherited from a
|
|
87
|
+
// host session (Claude Code's own env, API keys, proxies) is dropped so the
|
|
88
|
+
// CLIs fall back to your normal login.
|
|
89
|
+
export function cleanEnv() {
|
|
90
|
+
const env = {};
|
|
91
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
92
|
+
if (/^(CLAUDE|ANTHROPIC_|MCP_)/.test(key))
|
|
93
|
+
continue;
|
|
94
|
+
env[key] = value;
|
|
95
|
+
}
|
|
96
|
+
return env;
|
|
97
|
+
}
|
package/dist/control.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { isServerUp } from "./state.js";
|
|
4
|
+
import { readCredentials, relay as registry } from "./account.js";
|
|
5
|
+
import { ensureServer } from "./daemon.js";
|
|
6
|
+
// The one way sessions get made, from the CLI, MCP tools or your control panel.
|
|
7
|
+
export function createSession(store, config, input) {
|
|
8
|
+
if (!isAbsolute(input.projectDir))
|
|
9
|
+
throw new Error(`Use the full path of the project folder, not ${input.projectDir}.`);
|
|
10
|
+
const projectDir = resolve(input.projectDir);
|
|
11
|
+
if (!existsSync(projectDir) || !statSync(projectDir).isDirectory())
|
|
12
|
+
throw new Error(`Not a folder: ${projectDir}`);
|
|
13
|
+
const tester = input.tester.trim();
|
|
14
|
+
const brief = input.brief.trim();
|
|
15
|
+
if (!tester)
|
|
16
|
+
throw new Error("Say who the session is for.");
|
|
17
|
+
if (!brief)
|
|
18
|
+
throw new Error("Write a brief for the interviewer.");
|
|
19
|
+
const replyLimit = input.replyLimit ?? config.replyLimit;
|
|
20
|
+
if (!Number.isInteger(replyLimit) || replyLimit < 1)
|
|
21
|
+
throw new Error("The reply limit has to be a whole number above zero.");
|
|
22
|
+
return store.createSession({ ...input, projectDir, tester, brief, replyLimit });
|
|
23
|
+
}
|
|
24
|
+
// Changes go through the server when it's running, so a reply in progress
|
|
25
|
+
// stops, any open tester page updates at once, and parley.im hears about it.
|
|
26
|
+
async function viaServer(config, path, body) {
|
|
27
|
+
const res = await fetch(`http://localhost:${config.dashboardPort}${path}`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "content-type": "application/json" },
|
|
30
|
+
body: JSON.stringify(body ?? {}),
|
|
31
|
+
});
|
|
32
|
+
const json = (await res.json().catch(() => ({})));
|
|
33
|
+
if (!res.ok)
|
|
34
|
+
throw new Error(json.message ?? `The server refused (${res.status}).`);
|
|
35
|
+
return json;
|
|
36
|
+
}
|
|
37
|
+
// Makes a session and shares it on parley.im, starting the server if needed
|
|
38
|
+
// (it holds the connection the session's link depends on).
|
|
39
|
+
export async function startSession(store, config, input) {
|
|
40
|
+
if (await ensureServer(config)) {
|
|
41
|
+
const made = await viaServer(config, "/api/sessions", input);
|
|
42
|
+
return { session: store.session(made.id), shared: made.shared, serverUp: true };
|
|
43
|
+
}
|
|
44
|
+
const session = createSession(store, config, input);
|
|
45
|
+
return { session, shared: false, serverUp: false };
|
|
46
|
+
}
|
|
47
|
+
export async function endSession(config, store, id) {
|
|
48
|
+
if (await isServerUp(config))
|
|
49
|
+
return viaServer(config, `/api/sessions/${id}/end`);
|
|
50
|
+
const session = store.session(id);
|
|
51
|
+
store.end(id);
|
|
52
|
+
await tellRelay(config, session, "end");
|
|
53
|
+
}
|
|
54
|
+
export async function deleteSession(config, store, id) {
|
|
55
|
+
if (await isServerUp(config))
|
|
56
|
+
return viaServer(config, `/api/sessions/${id}/delete`);
|
|
57
|
+
const session = store.session(id);
|
|
58
|
+
store.deleteSession(id);
|
|
59
|
+
await tellRelay(config, session, "remove");
|
|
60
|
+
}
|
|
61
|
+
async function tellRelay(config, session, what) {
|
|
62
|
+
const creds = readCredentials(config);
|
|
63
|
+
if (creds && session)
|
|
64
|
+
await registry[what](creds, session).catch(() => { });
|
|
65
|
+
}
|
|
66
|
+
export async function setReplyLimit(config, store, id, limit) {
|
|
67
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
68
|
+
throw new Error("The reply limit has to be a whole number above zero.");
|
|
69
|
+
if (await isServerUp(config))
|
|
70
|
+
return viaServer(config, `/api/sessions/${id}/limit`, { limit });
|
|
71
|
+
store.setReplyLimit(id, limit);
|
|
72
|
+
}
|