@parley-im/parley 0.1.1 → 0.2.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/dist/cli.js CHANGED
@@ -6,17 +6,19 @@ import { fileURLToPath } from "node:url";
6
6
  import { loadConfig, paths, cleanEnv } from "./config.js";
7
7
  import { Store } from "./db.js";
8
8
  import { ensureServer, stopServer } from "./daemon.js";
9
+ import { enableAutostart, disableAutostart } from "./autostart.js";
10
+ import { VERSION } from "./version.js";
9
11
  import { readState, linkFor, dashboardUrl, isServerUp } from "./state.js";
10
12
  import { transcript, summaryLine } from "./transcript.js";
11
13
  import { startSession, endSession, deleteSession, setReplyLimit } from "./control.js";
12
14
  import { login, readCredentials, clearCredentials, relay as registry } from "./account.js";
13
15
  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.
16
+ computer, and you get everything they said. Good for feedback from guests, or any interview.
15
17
 
16
18
  If you're an agent and the user asks for "a parley" (or to get someone's take on this project):
17
19
 
18
20
  1. From the project folder, make one and give the user the printed link to send:
19
- parley new --tester "Sam" --brief-file - <<'BRIEF'
21
+ parley new --guest "Sam" --brief-file - <<'BRIEF'
20
22
  What this is and how they'll try it, what the user most wants to learn, known issues to
21
23
  skip. Written to the interviewer, in the terms a user sees (no file paths).
22
24
  BRIEF
@@ -28,7 +30,7 @@ With the parley MCP tools (create_session, list_sessions, get_transcript, end_se
28
30
  use those instead; they do the same.
29
31
 
30
32
  Commands
31
- parley new --tester NAME (--brief TEXT | --brief-file FILE|-) Make a session here; prints its link
33
+ parley new --guest NAME (--brief TEXT | --brief-file FILE|-) Make a session here; prints its link
32
34
  [--dir PATH] [--greeting TEXT] [--agent claude|codex] [--model MODEL] [--replies N]
33
35
  parley list [--ended | --all] [--dir PATH] Sessions, with new-message counts and replies used
34
36
  parley show ID [--new] Transcript as Markdown; --new for what's unread
@@ -38,10 +40,13 @@ Commands
38
40
  parley login | logout | whoami Link this computer to parley.im, or unlink it
39
41
  parley start | stop | status The background server (holds the parley.im connection)
40
42
  parley panel Open the control panel (localhost)
41
- parley install Add the parley MCP server to Claude Code and Codex
43
+ parley install Add parley's MCP tools to Claude Code and Codex; start at login
44
+ parley uninstall Undo install (your conversations stay)
42
45
  parley serve | mcp Run the server in the foreground | MCP over stdio
46
+ parley --version
43
47
 
44
- Data and settings live in ${paths.home}.`;
48
+ Conversations and screenshots live in each project's .parley folder (kept out of git), with a
49
+ readable transcript.md per session. Settings and your parley.im login live in ${paths.home}.`;
45
50
  const CLI_PATH = fileURLToPath(import.meta.url);
46
51
  async function main() {
47
52
  const [command, ...rest] = process.argv.slice(2);
@@ -92,7 +97,7 @@ async function main() {
92
97
  return;
93
98
  }
94
99
  case "stop": {
95
- console.log((await stopServer()) ? "Stopped." : "Not running.");
100
+ console.log((await stopServer(config)) ? "Stopped." : "Not running.");
96
101
  return;
97
102
  }
98
103
  case "status": {
@@ -110,7 +115,7 @@ async function main() {
110
115
  args: rest,
111
116
  options: {
112
117
  dir: { type: "string" },
113
- tester: { type: "string" },
118
+ guest: { type: "string" },
114
119
  brief: { type: "string" },
115
120
  "brief-file": { type: "string" },
116
121
  greeting: { type: "string" },
@@ -122,22 +127,25 @@ async function main() {
122
127
  // "-" reads the brief from stdin, so a long one can come in a heredoc.
123
128
  const file = values["brief-file"];
124
129
  const brief = file ? readFileSync(file === "-" ? 0 : file, "utf8") : values.brief;
125
- if (!values.tester || !brief)
126
- fail("Need --tester and --brief (or --brief-file).");
130
+ if (!values.guest || !brief)
131
+ fail("Need --guest and --brief (or --brief-file).");
127
132
  if (values.agent && values.agent !== "claude" && values.agent !== "codex")
128
133
  fail("--agent is claude or codex.");
134
+ const replies = values.replies === undefined ? null : Number(values.replies);
135
+ if (replies !== null && !(Number.isInteger(replies) && replies > 0))
136
+ fail("--replies is a whole number above zero.");
129
137
  const store = new Store();
130
138
  const made = await startSession(store, config, {
131
139
  projectDir: resolve(values.dir ?? process.cwd()),
132
- tester: values.tester,
140
+ guest: values.guest,
133
141
  brief,
134
142
  greeting: values.greeting,
135
- agent: values.agent ?? "claude",
143
+ agent: values.agent ?? null,
136
144
  model: values.model,
137
- replyLimit: values.replies ? Number(values.replies) : null,
145
+ replyLimit: replies,
138
146
  }).catch((error) => fail(error.message));
139
147
  const { session } = made;
140
- console.log(`Session ${session.id} for ${session.tester} in ${session.project_dir}`);
148
+ console.log(`Session ${session.id} for ${session.guest} in ${session.project_dir}`);
141
149
  console.log(`Link: ${linkFor(session, config)}`);
142
150
  console.log(`Replies allowed: ${session.reply_limit}`);
143
151
  console.log(`Watch: ${dashboardUrl(config, session)}`);
@@ -196,11 +204,12 @@ async function main() {
196
204
  if (session.status !== "open")
197
205
  return console.log(`${session.id} had already ended.`);
198
206
  await endSession(config, store, session.id);
199
- console.log(`Ended ${session.id} (${session.tester} on ${session.project_name}). Its link no longer works.`);
207
+ console.log(`Ended ${session.id} (${session.guest} on ${session.project_name}). Its link no longer works.`);
200
208
  }
201
209
  else {
202
210
  await deleteSession(config, store, session.id);
203
- console.log(`Deleted ${session.id} (${session.tester} on ${session.project_name}) and its screenshots.`);
211
+ console.log(`Deleted ${session.id} (${session.guest} on ${session.project_name}) and its screenshots.`);
212
+ console.log(`${session.agent === "codex" ? "Codex" : "Claude Code"} keeps its own log of the conversation (in ${session.agent === "codex" ? "~/.codex/sessions" : "~/.claude/projects"}); delete it there too if you want it gone everywhere.`);
204
213
  }
205
214
  return;
206
215
  }
@@ -211,6 +220,13 @@ async function main() {
211
220
  }
212
221
  case "install":
213
222
  return install(config);
223
+ case "uninstall":
224
+ return uninstall(config);
225
+ case "version":
226
+ case "--version":
227
+ case "-v":
228
+ console.log(VERSION);
229
+ return;
214
230
  case undefined:
215
231
  case "help":
216
232
  case "--help":
@@ -221,7 +237,7 @@ async function main() {
221
237
  fail(`Unknown command: ${command}\n\n${HELP}`);
222
238
  }
223
239
  }
224
- function install(config) {
240
+ async function install(config) {
225
241
  const launch = [process.execPath, "--no-warnings", CLI_PATH, "mcp"];
226
242
  const env = cleanEnv();
227
243
  const run = (bin, args) => {
@@ -247,8 +263,34 @@ function install(config) {
247
263
  }
248
264
  else
249
265
  console.log("Codex: not found, skipped.");
266
+ // The server holds your links; it should come back after a restart.
267
+ await stopServer(config);
268
+ console.log(`Server: ${enableAutostart(CLI_PATH)}`);
269
+ if (!(await ensureServer(config)))
270
+ console.log(`The server didn't come up. See ${paths.log}`);
250
271
  console.log(`\nStart a new Claude or Codex session in any project and ask it to make a parley for someone.`);
251
272
  }
273
+ // Undoes `parley install`. Your conversations stay in each project's .parley
274
+ // folder, and settings and login in ~/.parley.
275
+ async function uninstall(config) {
276
+ const run = (bin, args) => {
277
+ try {
278
+ execFileSync(bin, args, { env: cleanEnv(), stdio: "pipe" });
279
+ return true;
280
+ }
281
+ catch {
282
+ return false;
283
+ }
284
+ };
285
+ if (config.claude.bin.startsWith("/") && run(config.claude.bin, ["mcp", "remove", "--scope", "user", "parley"]))
286
+ console.log("Claude Code: removed the parley MCP server.");
287
+ if (config.codex.bin.startsWith("/") && run(config.codex.bin, ["mcp", "remove", "parley"]))
288
+ console.log("Codex: removed the parley MCP server.");
289
+ console.log(disableAutostart() ?? "Wasn't set to start at login.");
290
+ if (await stopServer(config))
291
+ console.log("Stopped the server.");
292
+ console.log(`\nYour conversations are still in each project's .parley folder, and settings and login in ${paths.home}.`);
293
+ }
252
294
  async function printRelay(config) {
253
295
  const creds = readCredentials(config);
254
296
  if (!creds)
@@ -269,4 +311,9 @@ function fail(message) {
269
311
  console.error(message);
270
312
  process.exit(1);
271
313
  }
272
- main().catch((error) => fail(String(error?.stack ?? error)));
314
+ // Mistakes in how parley was called get a sentence, not a stack trace.
315
+ main().catch((error) => {
316
+ if (typeof error?.code === "string" && error.code.startsWith("ERR_PARSE_ARGS"))
317
+ fail(`${error.message}\nRun parley on its own for help.`);
318
+ fail(process.env.PARLEY_DEBUG ? String(error?.stack ?? error) : String(error?.message ?? error));
319
+ });
package/dist/config.js CHANGED
@@ -1,17 +1,53 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, accessSync, constants } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, delimiter } from "node:path";
3
+ import { join, delimiter, basename } from "node:path";
4
4
  export const HOME = process.env.PARLEY_HOME ?? join(homedir(), ".parley");
5
+ // Settings, your parley.im login and the server's state. Conversations live
6
+ // in each project's own .parley folder (see db.ts); projects.json lists them.
5
7
  export const paths = {
6
8
  home: HOME,
7
9
  config: join(HOME, "config.json"),
8
- db: join(HOME, "parley.db"),
10
+ projects: join(HOME, "projects.json"),
9
11
  state: join(HOME, "server.json"),
10
12
  log: join(HOME, "server.log"),
11
- uploads: join(HOME, "uploads"),
12
13
  // Your computer's link to a parley.im account, from `parley login`.
13
14
  credentials: join(HOME, "credentials.json"),
14
15
  };
16
+ export const BLOCKED_BY_DEFAULT = [
17
+ // Environment files and other places secrets usually live.
18
+ "**/.env",
19
+ "**/.env.*",
20
+ "**/.dev.vars",
21
+ "**/secrets/**",
22
+ "**/secrets.json",
23
+ "**/secrets.yml",
24
+ "**/secrets.yaml",
25
+ "**/credentials.json",
26
+ "**/*.tfstate",
27
+ "**/*.tfvars",
28
+ // Keys and certificates.
29
+ "**/*.pem",
30
+ "**/*.key",
31
+ "**/*.p12",
32
+ "**/*.pfx",
33
+ "**/*.jks",
34
+ "**/*.keystore",
35
+ "**/id_rsa*",
36
+ "**/id_dsa*",
37
+ "**/id_ecdsa*",
38
+ "**/id_ed25519*",
39
+ // Tool logins.
40
+ "**/.ssh/**",
41
+ "**/.aws/**",
42
+ "**/.gnupg/**",
43
+ "**/.kube/**",
44
+ "**/.docker/**",
45
+ "**/.npmrc",
46
+ "**/.pypirc",
47
+ "**/.netrc",
48
+ "**/.git-credentials",
49
+ "**/.git/config",
50
+ ];
15
51
  const defaults = {
16
52
  relayUrl: "https://parley.im",
17
53
  port: 4747,
@@ -20,9 +56,10 @@ const defaults = {
20
56
  replyLimit: 100,
21
57
  claude: { bin: "claude", model: null, effort: null },
22
58
  codex: { bin: "codex", model: null, effort: null },
59
+ interviewer: { readConversations: false, blocked: BLOCKED_BY_DEFAULT, allowHomeFolder: false },
23
60
  };
24
61
  export function ensureHome() {
25
- mkdirSync(paths.uploads, { recursive: true });
62
+ mkdirSync(paths.home, { recursive: true });
26
63
  }
27
64
  export function loadConfig() {
28
65
  ensureHome();
@@ -39,18 +76,20 @@ export function loadConfig() {
39
76
  replyLimit: pick("replyLimit"),
40
77
  claude: { ...defaults.claude, ...stored.claude },
41
78
  codex: { ...defaults.codex, ...stored.codex },
79
+ interviewer: { ...defaults.interviewer, ...stored.interviewer },
42
80
  };
43
81
  // 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));
82
+ // so binaries are pinned to absolute paths the first time they are found,
83
+ // and found again if they move (say, from npm to a native installer).
84
+ let changed = !existsSync(paths.config) || Object.keys(stored).some((key) => !(key in defaults)) || !stored.interviewer;
46
85
  for (const [get, set] of [
47
86
  [() => config.claude.bin, (v) => (config.claude.bin = v)],
48
87
  [() => config.codex.bin, (v) => (config.codex.bin = v)],
49
88
  ]) {
50
89
  const current = get();
51
- if (current.startsWith("/"))
90
+ if (current.startsWith("/") && existsSync(current))
52
91
  continue;
53
- const found = findBin(current);
92
+ const found = findBin(basename(current));
54
93
  if (found) {
55
94
  set(found);
56
95
  changed = true;
@@ -83,13 +122,15 @@ export function findBin(name) {
83
122
  }
84
123
  return null;
85
124
  }
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.
125
+ // Agents run as you, on your own subscription. What a host session (Claude
126
+ // Code itself, an MCP client) put in the environment is dropped, API keys
127
+ // included, so the CLIs fall back to your normal login. Settings that choose
128
+ // where that login lives (a config folder, Bedrock, Vertex, a proxy) stay.
129
+ const KEEP = new Set(["CLAUDE_CONFIG_DIR", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "ANTHROPIC_BASE_URL", "ANTHROPIC_VERTEX_PROJECT_ID"]);
89
130
  export function cleanEnv() {
90
131
  const env = {};
91
132
  for (const [key, value] of Object.entries(process.env)) {
92
- if (/^(CLAUDE|ANTHROPIC_|MCP_)/.test(key))
133
+ if (/^(CLAUDE|ANTHROPIC_|MCP_)/.test(key) && !KEEP.has(key))
93
134
  continue;
94
135
  env[key] = value;
95
136
  }
package/dist/control.js CHANGED
@@ -1,28 +1,34 @@
1
- import { existsSync, statSync } from "node:fs";
2
- import { isAbsolute, resolve } from "node:path";
1
+ import { existsSync, realpathSync, statSync } from "node:fs";
2
+ import { isAbsolute } from "node:path";
3
3
  import { isServerUp } from "./state.js";
4
4
  import { readCredentials, relay as registry } from "./account.js";
5
5
  import { ensureServer } from "./daemon.js";
6
+ import { agentProblem, checkProjectFolder, defaultAgent } from "./access.js";
6
7
  // The one way sessions get made, from the CLI, MCP tools or your control panel.
7
8
  export function createSession(store, config, input) {
8
9
  if (!isAbsolute(input.projectDir))
9
10
  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();
11
+ if (!existsSync(input.projectDir) || !statSync(input.projectDir).isDirectory())
12
+ throw new Error(`Not a folder: ${input.projectDir}`);
13
+ const projectDir = realpathSync(input.projectDir);
14
+ checkProjectFolder(projectDir, config);
15
+ const guest = input.guest.trim();
14
16
  const brief = input.brief.trim();
15
- if (!tester)
17
+ if (!guest)
16
18
  throw new Error("Say who the session is for.");
17
19
  if (!brief)
18
20
  throw new Error("Write a brief for the interviewer.");
19
21
  const replyLimit = input.replyLimit ?? config.replyLimit;
20
22
  if (!Number.isInteger(replyLimit) || replyLimit < 1)
21
23
  throw new Error("The reply limit has to be a whole number above zero.");
22
- return store.createSession({ ...input, projectDir, tester, brief, replyLimit });
24
+ const agent = input.agent ?? defaultAgent(config);
25
+ const problem = agentProblem(config, agent);
26
+ if (problem)
27
+ throw new Error(problem);
28
+ return store.createSession({ ...input, projectDir, guest, brief, replyLimit, agent });
23
29
  }
24
30
  // 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.
31
+ // stops, any open guest page updates at once, and parley.im hears about it.
26
32
  async function viaServer(config, path, body) {
27
33
  const res = await fetch(`http://localhost:${config.dashboardPort}${path}`, {
28
34
  method: "POST",
@@ -39,7 +45,7 @@ async function viaServer(config, path, body) {
39
45
  export async function startSession(store, config, input) {
40
46
  if (await ensureServer(config)) {
41
47
  const made = await viaServer(config, "/api/sessions", input);
42
- return { session: store.session(made.id), shared: made.shared, serverUp: true };
48
+ return { session: made.session ?? store.session(made.id), shared: made.shared, serverUp: true };
43
49
  }
44
50
  const session = createSession(store, config, input);
45
51
  return { session, shared: false, serverUp: false };
package/dist/daemon.js CHANGED
@@ -1,15 +1,30 @@
1
1
  import { spawn } from "node:child_process";
2
- import { openSync } from "node:fs";
2
+ import { openSync, rmSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { paths, cleanEnv } from "./config.js";
6
- import { readState, isServerUp } from "./state.js";
6
+ import { readState, serverHealth } from "./state.js";
7
+ import { VERSION, compareVersions } from "./version.js";
7
8
  // cli.ts in a checkout, cli.js once built for npm.
8
9
  const CLI = join(dirname(fileURLToPath(import.meta.url)), import.meta.url.endsWith(".ts") ? "cli.ts" : "cli.js");
9
10
  // Starts the server in the background if it isn't running, and waits until
10
- // it answers. It holds this computer's connection to parley.im.
11
+ // it answers. It holds this computer's connection to parley.im. A server from
12
+ // an older parley is replaced; one newer than this process means this process
13
+ // is the stale one (an MCP server started before an update).
11
14
  export async function ensureServer(config, waitMs = 20_000) {
12
- if (!(await isServerUp(config))) {
15
+ const running = await serverHealth(config);
16
+ if (running?.version && running.version !== VERSION) {
17
+ if (compareVersions(running.version, VERSION) > 0) {
18
+ throw new Error(`parley was updated to ${running.version}, but this process is still ${VERSION}. Restart this Claude Code or Codex session (or the parley command) to use the new version.`);
19
+ }
20
+ await stopServer(config);
21
+ await waitUntilDown(config);
22
+ }
23
+ else if (running && !running.version) {
24
+ await stopServer(config);
25
+ await waitUntilDown(config);
26
+ }
27
+ if (!(await serverHealth(config))) {
13
28
  const log = openSync(paths.log, "a");
14
29
  const child = spawn(process.execPath, ["--no-warnings", CLI, "serve"], {
15
30
  detached: true,
@@ -22,18 +37,27 @@ export async function ensureServer(config, waitMs = 20_000) {
22
37
  const deadline = Date.now() + waitMs;
23
38
  while (Date.now() < deadline) {
24
39
  const state = readState();
25
- if (state && (await isServerUp(config)))
40
+ if (state && (await serverHealth(config)))
26
41
  return state;
27
42
  await new Promise((r) => setTimeout(r, 400));
28
43
  }
29
- return (await isServerUp(config)) ? readState() : null;
44
+ return (await serverHealth(config)) ? readState() : null;
30
45
  }
31
- export async function stopServer() {
32
- const state = readState();
33
- if (!state)
46
+ async function waitUntilDown(config, waitMs = 10_000) {
47
+ const deadline = Date.now() + waitMs;
48
+ while (Date.now() < deadline && (await serverHealth(config)))
49
+ await new Promise((r) => setTimeout(r, 200));
50
+ }
51
+ // Stops the server, but only a process that answers as parley's server: the
52
+ // pid in server.json may be stale and belong to something else by now.
53
+ export async function stopServer(config) {
54
+ const running = await serverHealth(config);
55
+ if (!running) {
56
+ rmSync(paths.state, { force: true });
34
57
  return false;
58
+ }
35
59
  try {
36
- process.kill(state.pid, "SIGTERM");
60
+ process.kill(running.pid, "SIGTERM");
37
61
  return true;
38
62
  }
39
63
  catch {