@parley-im/parley 0.1.1 → 0.1.2

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 CHANGED
@@ -63,8 +63,10 @@ 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
- - **Claude:** `claude -p --restricted --tools Read,Grep,Glob --strict-mcp-config`. No command, write or web tools, reads confined to the project, your MCP servers left out.
67
- - **Codex:** `codex exec --sandbox read-only --ignore-user-config`, with apps, browser, computer use, plugins, hooks and web search off; your model and reasoning effort carry over.
66
+ It can read the project folder and nothing else on your computer, and not the project's `.parley` folder, where other people's conversations are. That's enforced by each agent's own sandbox, not just asked of it (the charter asks too):
67
+
68
+ - **Claude:** `claude -p --restricted --tools Read,Grep,Glob --strict-mcp-config`, plus a `Read(./.parley/**)` deny rule. 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 `.parley` 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.
68
70
 
69
71
  Each reply records which model answered, on which login (`auth: "none"` means your subscription, not an API key), and what it would cost at API prices.
70
72
 
@@ -90,7 +92,7 @@ Each reply records which model answered, on which login (`auth: "none"` means yo
90
92
  | `src/account.ts` | `parley login`, credentials, and what parley.im is told |
91
93
  | `src/runner.ts`, `src/agents/` | One reply at a time per session; Claude and Codex adapters |
92
94
  | `src/mcp.ts` | `create_session`, `list_sessions`, `get_transcript`, `set_reply_limit`, `end_session` |
93
- | `src/db.ts` | SQLite in `~/.parley`: sessions (with keys), messages, turns |
95
+ | `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 |
94
96
  | `web/` | Chat page, session view, control panel; parley.im serves the tester page's files from here too |
95
97
  | `relay/` | parley.im: Worker, a Durable Object per linked computer, D1; the front page's animation is `web/home.js` |
96
98
 
@@ -112,6 +114,8 @@ The npm package is `@parley-im/parley` (the parley-im org; plain `parley` is tak
112
114
 
113
115
  ## Config
114
116
 
117
+ 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
+
115
119
  `~/.parley/config.json`: `relayUrl` (default `https://parley.im`), `ownerName` (defaults to your account's first name), `replyLimit`, ports, and `claude`/`codex` model and effort overrides. Without `parley login`, links are local-only (`http://localhost:4747/...`), for development.
116
120
 
117
121
  ## Running parley.im
@@ -3,10 +3,12 @@ import { sep } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { runJsonLines } from "./process.js";
5
5
  // Claude Code in print mode, one process per turn, resumed by session id.
6
- // --restricted drops every tool that runs code and ignores settings files,
7
- // --tools narrows it further to reading, and --strict-mcp-config keeps your
8
- // MCP servers (mail, calendar, ...) out of reach. CLAUDE.md still loads, so
9
- // the interviewer knows the project the way your own sessions do.
6
+ // --restricted drops every tool that runs code, ignores your settings files
7
+ // and confines the file tools to the project folder; --tools narrows it to
8
+ // reading, a deny rule keeps it out of the project's .parley folder (other
9
+ // people's conversations), and --strict-mcp-config keeps your MCP servers
10
+ // (mail, calendar, ...) out of reach. The project's CLAUDE.md and AGENTS.md
11
+ // reach it through the charter.
10
12
  export function runClaudeTurn(input, events) {
11
13
  const { session, config } = input;
12
14
  const agentSessionId = session.agent_session_id ?? randomUUID();
@@ -21,6 +23,7 @@ export function runClaudeTurn(input, events) {
21
23
  "--strict-mcp-config",
22
24
  "--permission-prompts", "none",
23
25
  "--disable-slash-commands",
26
+ "--settings", JSON.stringify({ permissions: { deny: ["Read(./.parley/**)", `Read(/${session.project_dir}/.parley/**)`] } }),
24
27
  "--append-system-prompt", input.charter,
25
28
  "--name", `parley: ${session.tester}`,
26
29
  ];
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ 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";
@@ -17,21 +17,27 @@ const DISABLED_FEATURES = [
17
17
  ];
18
18
  // Codex exec, one process per turn, resumed by thread id. Your config.toml is
19
19
  // ignored (it carries your full-access sandbox and MCP servers); only its
20
- // model and reasoning effort are carried over. The sandbox is read-only, so
21
- // Codex can run commands like rg and cat but cannot write or reach the network.
20
+ // model and reasoning effort are carried over. Its commands run under a
21
+ // permission profile that can read the project and the system files tools
22
+ // need, and nothing else: not the rest of your home folder, not other
23
+ // projects, and not the project's .parley folder, where other people's
24
+ // conversations are. No writes, no network. Codex re-runs itself inside that
25
+ // sandbox, so it's started by its real path and may read that one file.
26
+ const profile = (bin) => `permissions.parley={filesystem={":minimal"="read", ${JSON.stringify(bin)}="read", ":workspace_roots"={"."="read", ".parley"="deny"}}}`;
22
27
  export function runCodexTurn(input, events) {
23
28
  const { session, config } = input;
24
29
  const defaults = readCodexDefaults();
25
30
  const model = session.model ?? config.codex.model ?? defaults.model;
26
31
  const effort = config.codex.effort ?? defaults.effort;
32
+ const bin = existsSync(config.codex.bin) ? realpathSync(config.codex.bin) : config.codex.bin;
27
33
  const args = [
28
34
  "exec",
29
35
  "--json",
30
36
  "--ignore-user-config",
31
37
  "--skip-git-repo-check",
32
- "--sandbox", "read-only",
33
38
  "--cd", session.project_dir,
34
- "-c", `sandbox_mode="read-only"`,
39
+ "-c", `default_permissions="parley"`,
40
+ "-c", profile(bin),
35
41
  "-c", `approval_policy="never"`,
36
42
  "-c", `web_search="disabled"`,
37
43
  "-c", `developer_instructions=${JSON.stringify(input.charter)}`,
@@ -54,7 +60,7 @@ export function runCodexTurn(input, events) {
54
60
  let threadId = null;
55
61
  const commands = new Set();
56
62
  const proc = runJsonLines({
57
- bin: config.codex.bin,
63
+ bin,
58
64
  args,
59
65
  cwd: session.project_dir,
60
66
  stdin: input.text,
package/dist/api.js CHANGED
@@ -157,7 +157,7 @@ export class Conversations {
157
157
  };
158
158
  if (!scope.owner || m.role !== "agent" || !m.turn_id)
159
159
  return view;
160
- const turn = this.store.turn(m.turn_id);
160
+ const turn = this.store.turn(m.session_id, m.turn_id);
161
161
  if (!turn)
162
162
  return view;
163
163
  const usage = turn.usage ?? {};
package/dist/charter.js CHANGED
@@ -16,6 +16,7 @@ How to run it:
16
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
17
  - Do not promise fixes, features, or dates. Say you will pass it along to ${owner}.
18
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
+ - 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.
19
20
  - 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
21
  - 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
22
  - They may paste screenshots. Look at them closely and refer to what you see.
package/dist/cli.js CHANGED
@@ -41,7 +41,8 @@ Commands
41
41
  parley install Add the parley MCP server to Claude Code and Codex
42
42
  parley serve | mcp Run the server in the foreground | MCP over stdio
43
43
 
44
- Data and settings live in ${paths.home}.`;
44
+ Conversations and screenshots live in each project's .parley folder (kept out of git), with a
45
+ readable transcript.md per session. Settings and your parley.im login live in ${paths.home}.`;
45
46
  const CLI_PATH = fileURLToPath(import.meta.url);
46
47
  async function main() {
47
48
  const [command, ...rest] = process.argv.slice(2);
package/dist/config.js CHANGED
@@ -2,13 +2,14 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, accessSync, constan
2
2
  import { homedir } from "node:os";
3
3
  import { join, delimiter } 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
  };
@@ -22,7 +23,7 @@ const defaults = {
22
23
  codex: { bin: "codex", model: null, effort: null },
23
24
  };
24
25
  export function ensureHome() {
25
- mkdirSync(paths.uploads, { recursive: true });
26
+ mkdirSync(paths.home, { recursive: true });
26
27
  }
27
28
  export function loadConfig() {
28
29
  ensureHome();
package/dist/db.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
2
  import { randomBytes } from "node:crypto";
3
- import { readdirSync, rmSync } from "node:fs";
4
- import { basename, join } from "node:path";
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { basename, join, resolve } from "node:path";
5
5
  import { paths, ensureHome } from "./config.js";
6
6
  import { newKey } from "./seal.js";
7
+ import { record } from "./transcript.js";
7
8
  const schema = `
8
9
  create table if not exists sessions (
9
10
  id text primary key,
@@ -50,67 +51,216 @@ create index if not exists messages_session on messages(session_id, id);
50
51
  create index if not exists turns_session on turns(session_id, id);
51
52
  `;
52
53
  const now = () => new Date().toISOString();
54
+ // A project's own parley folder: its sessions' database, and a folder per
55
+ // session with the screenshots and a readable transcript. Kept out of git,
56
+ // and out of the interviewer's reach (see agents/).
57
+ export const parleyDir = (projectDir) => join(projectDir, ".parley");
58
+ export const sessionDir = (session) => join(parleyDir(session.project_dir), session.id);
59
+ // Every project you've made parleys in. Each keeps its conversations in its
60
+ // own .parley folder; ~/.parley/projects.json only lists where they are.
53
61
  export class Store {
54
- db;
55
- constructor(file = paths.db) {
62
+ projects = new Map();
63
+ // Which project each session lives in, as they're looked up.
64
+ where = new Map();
65
+ constructor() {
56
66
  ensureHome();
57
- this.db = new DatabaseSync(file);
58
- this.db.exec("pragma journal_mode = wal; pragma busy_timeout = 5000; pragma foreign_keys = on;");
59
- this.db.exec(schema);
60
- this.addMissingColumns();
61
- }
62
- // Columns added since a database was first created.
63
- addMissingColumns() {
64
- const have = new Set(this.db.prepare("pragma table_info(sessions)").all().map((c) => c.name));
65
- if (!have.has("read_through"))
66
- this.db.exec("alter table sessions add column read_through integer not null default 0");
67
- if (!have.has("ended_at"))
68
- this.db.exec("alter table sessions add column ended_at text");
69
- if (!have.has("reply_limit"))
70
- this.db.exec("alter table sessions add column reply_limit integer not null default 100");
71
- if (!have.has("e2e_key"))
72
- this.db.exec("alter table sessions add column e2e_key text");
73
- for (const row of this.db.prepare("select id from sessions where e2e_key is null").all()) {
74
- this.db.prepare("update sessions set e2e_key = ? where id = ?").run(newKey(), row.id);
67
+ this.refresh();
68
+ }
69
+ // Opens any project another parley process has added since.
70
+ refresh() {
71
+ for (const dir of readProjects()) {
72
+ if (!this.projects.has(dir) && existsSync(join(parleyDir(dir), "parley.db")))
73
+ this.projects.set(dir, new ProjectDb(dir));
75
74
  }
76
- this.db.exec("update sessions set status = 'ended', ended_at = coalesce(ended_at, updated_at) where status not in ('open', 'ended')");
75
+ }
76
+ project(dir) {
77
+ dir = resolve(dir);
78
+ let db = this.projects.get(dir);
79
+ if (!db) {
80
+ mkdirSync(parleyDir(dir), { recursive: true });
81
+ // Everything in here stays out of git without touching your .gitignore.
82
+ writeFileSync(join(parleyDir(dir), ".gitignore"), "# parley's conversations, kept out of git\n*\n");
83
+ db = new ProjectDb(dir);
84
+ this.projects.set(dir, db);
85
+ const listed = readProjects();
86
+ if (!listed.includes(dir))
87
+ writeFileSync(paths.projects, JSON.stringify([...listed, dir], null, 2) + "\n");
88
+ }
89
+ return db;
90
+ }
91
+ of(id) {
92
+ let db = this.where.get(id);
93
+ if (db)
94
+ return db;
95
+ for (const pass of [0, 1]) {
96
+ if (pass)
97
+ this.refresh();
98
+ for (const candidate of this.projects.values()) {
99
+ if (candidate.session(id)) {
100
+ this.where.set(id, candidate);
101
+ return candidate;
102
+ }
103
+ }
104
+ }
105
+ return undefined;
106
+ }
107
+ // Whichever project holds a session; for one that doesn't exist, nothing.
108
+ on(id, fn, fallback) {
109
+ const db = this.of(id);
110
+ return db ? fn(db) : fallback;
77
111
  }
78
112
  createSession(input) {
79
- const id = shortId();
113
+ let id = shortId();
114
+ while (this.of(id))
115
+ id = shortId();
116
+ const db = this.project(input.projectDir);
117
+ db.createSession(id, input);
118
+ this.where.set(id, db);
119
+ this.addMessage(id, "agent", db.session(id).greeting, [], null);
120
+ return this.session(id);
121
+ }
122
+ session(id) {
123
+ return this.on(id, (db) => db.session(id), undefined);
124
+ }
125
+ sessionByToken(token) {
126
+ for (const pass of [0, 1]) {
127
+ if (pass)
128
+ this.refresh();
129
+ for (const db of this.projects.values()) {
130
+ const session = db.sessionByToken(token);
131
+ if (session)
132
+ return session;
133
+ }
134
+ }
135
+ return undefined;
136
+ }
137
+ sessions(filter = {}) {
138
+ this.refresh();
139
+ const dbs = filter.projectDir ? [this.projects.get(resolve(filter.projectDir))].filter((db) => !!db) : [...this.projects.values()];
140
+ return dbs.flatMap((db) => db.sessions(filter.status)).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
141
+ }
142
+ // Erases the session: transcript, turns, screenshots and its folder.
143
+ deleteSession(id) {
144
+ const session = this.session(id);
145
+ if (!session)
146
+ return;
147
+ this.of(id).deleteSession(id);
148
+ this.where.delete(id);
149
+ rmSync(sessionDir(session), { recursive: true, force: true });
150
+ }
151
+ end(id) {
152
+ this.on(id, (db) => db.end(id), undefined);
153
+ this.write(id);
154
+ }
155
+ setReplyLimit(id, limit) {
156
+ this.on(id, (db) => db.setReplyLimit(id, limit), undefined);
157
+ }
158
+ repliesUsed(id) {
159
+ return this.on(id, (db) => db.repliesUsed(id), 0);
160
+ }
161
+ latestModel(id) {
162
+ return this.on(id, (db) => db.latestModel(id), null);
163
+ }
164
+ markRead(id, messageId) {
165
+ this.on(id, (db) => db.markRead(id, messageId), undefined);
166
+ }
167
+ setAgentSessionId(id, agentSessionId) {
168
+ this.on(id, (db) => db.setAgentSessionId(id, agentSessionId), undefined);
169
+ }
170
+ addMessage(sessionId, role, body, images, turnId) {
171
+ const message = this.of(sessionId).addMessage(sessionId, role, body, images, turnId);
172
+ this.write(sessionId);
173
+ return message;
174
+ }
175
+ messages(sessionId, afterId = 0) {
176
+ return this.on(sessionId, (db) => db.messages(sessionId, afterId), []);
177
+ }
178
+ page(sessionId, limit, beforeId) {
179
+ return this.on(sessionId, (db) => db.page(sessionId, limit, beforeId), { messages: [], more: false });
180
+ }
181
+ pending(sessionId) {
182
+ return this.on(sessionId, (db) => db.pending(sessionId), []);
183
+ }
184
+ startTurn(session, messageIds) {
185
+ return this.of(session.id).startTurn(session, messageIds);
186
+ }
187
+ turn(sessionId, id) {
188
+ return this.on(sessionId, (db) => db.turn(id), undefined);
189
+ }
190
+ turns(sessionId) {
191
+ return this.on(sessionId, (db) => db.turns(sessionId), []);
192
+ }
193
+ addActivity(sessionId, turnId, line) {
194
+ this.on(sessionId, (db) => db.addActivity(turnId, line), undefined);
195
+ }
196
+ finishTurn(sessionId, turnId, status, usage, error) {
197
+ this.on(sessionId, (db) => db.finishTurn(turnId, status, usage, error), undefined);
198
+ }
199
+ releaseTurn(sessionId, turnId) {
200
+ this.on(sessionId, (db) => db.releaseTurn(turnId), undefined);
201
+ }
202
+ // Turns that never finished (the server stopped mid-reply), everywhere.
203
+ recoverInterrupted() {
204
+ this.refresh();
205
+ return [...this.projects.values()].flatMap((db) => db.recoverInterrupted());
206
+ }
207
+ stats(sessionId) {
208
+ return this.of(sessionId).stats(sessionId);
209
+ }
210
+ // Keeps <session>/transcript.md in step, for you and your agent to read.
211
+ write(sessionId) {
212
+ const session = this.session(sessionId);
213
+ if (!session)
214
+ return;
215
+ mkdirSync(sessionDir(session), { recursive: true });
216
+ writeFileSync(join(sessionDir(session), "transcript.md"), record(this, session));
217
+ }
218
+ }
219
+ function readProjects() {
220
+ try {
221
+ const list = JSON.parse(readFileSync(paths.projects, "utf8"));
222
+ return Array.isArray(list) ? list.filter((dir) => typeof dir === "string") : [];
223
+ }
224
+ catch {
225
+ return [];
226
+ }
227
+ }
228
+ // One project's sessions, in <project>/.parley/parley.db.
229
+ class ProjectDb {
230
+ db;
231
+ dir;
232
+ constructor(dir) {
233
+ this.dir = dir;
234
+ this.db = new DatabaseSync(join(parleyDir(dir), "parley.db"));
235
+ this.db.exec("pragma journal_mode = wal; pragma busy_timeout = 5000; pragma foreign_keys = on;");
236
+ this.db.exec(schema);
237
+ }
238
+ // A project that moved still has its sessions; they're wherever it is now.
239
+ row = (row) => row && { ...row, project_dir: this.dir };
240
+ createSession(id, input) {
80
241
  const token = randomBytes(18).toString("base64url");
81
- const projectName = basename(input.projectDir);
242
+ const projectName = basename(this.dir);
82
243
  const greeting = input.greeting?.trim() ||
83
244
  `Hi ${input.tester}! Thanks for trying ${projectName}. Tell me anything: what you tried, what tripped you up, what you liked. Long rambling brain dumps are perfect.`;
84
245
  const t = now();
85
246
  this.db
86
247
  .prepare(`insert into sessions (id, token, project_dir, project_name, tester, brief, greeting, agent, model, reply_limit, e2e_key, status, created_at, updated_at)
87
248
  values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`)
88
- .run(id, token, input.projectDir, projectName, input.tester, input.brief, greeting, input.agent, input.model ?? null, input.replyLimit, newKey(), t, t);
89
- this.addMessage(id, "agent", greeting, [], null);
90
- return this.session(id);
249
+ .run(id, token, this.dir, projectName, input.tester, input.brief, greeting, input.agent, input.model ?? null, input.replyLimit, newKey(), t, t);
91
250
  }
92
251
  session(id) {
93
- return this.db.prepare("select * from sessions where id = ?").get(id);
252
+ return this.row(this.db.prepare("select * from sessions where id = ?").get(id));
94
253
  }
95
254
  sessionByToken(token) {
96
- return this.db.prepare("select * from sessions where token = ?").get(token);
255
+ return this.row(this.db.prepare("select * from sessions where token = ?").get(token));
97
256
  }
98
- sessions(filter = {}) {
99
- return this.db
100
- .prepare(`select * from sessions
101
- where (?1 is null or project_dir = ?1) and (?2 is null or status = ?2)
102
- order by updated_at desc`)
103
- .all(filter.projectDir ?? null, filter.status ?? null);
257
+ sessions(status) {
258
+ return this.db.prepare("select * from sessions where (?1 is null or status = ?1)").all(status ?? null).map((r) => this.row(r));
104
259
  }
105
- // Erases the session: transcript, turns, and screenshots.
106
260
  deleteSession(id) {
107
261
  this.db.prepare("delete from messages where session_id = ?").run(id);
108
262
  this.db.prepare("delete from turns where session_id = ?").run(id);
109
263
  this.db.prepare("delete from sessions where id = ?").run(id);
110
- for (const name of readdirSync(paths.uploads)) {
111
- if (name.startsWith(`${id}-`))
112
- rmSync(join(paths.uploads, name), { force: true });
113
- }
114
264
  }
115
265
  end(id) {
116
266
  const t = now();
package/dist/relay.js CHANGED
@@ -1,7 +1,7 @@
1
- import { writeFileSync, readFileSync, existsSync, readdirSync } from "node:fs";
1
+ import { writeFileSync, readFileSync, existsSync, readdirSync, mkdirSync } from "node:fs";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { join } from "node:path";
4
- import { paths } from "./config.js";
4
+ import { sessionDir } from "./db.js";
5
5
  import { Problem, MAX_IMAGES } from "./api.js";
6
6
  import { readCredentials, relay as registry } from "./account.js";
7
7
  import { seal, open, sealBytes, openBytes } from "./seal.js";
@@ -230,7 +230,8 @@ export class RelayLink {
230
230
  }
231
231
  if (plain.length > MAX_IMAGE_BYTES)
232
232
  throw new Problem(413, "too_big", "That image is too big. The limit is 10 MB.");
233
- const kept = readdirSync(paths.uploads).filter((name) => name.startsWith(`${session.id}-`)).length;
233
+ const dir = sessionDir(session);
234
+ const kept = existsSync(dir) ? readdirSync(dir).filter((name) => name.startsWith(`${session.id}-`)).length : 0;
234
235
  if (kept >= MAX_SHOTS)
235
236
  throw new Problem(429, "too_many", "This session can't take more screenshots.");
236
237
  const now = Date.now();
@@ -256,7 +257,8 @@ export class RelayLink {
256
257
  throw new Problem(400, "bad_image", "That screenshot didn't arrive. Try adding it again.");
257
258
  this.held.delete(image.id);
258
259
  const name = `${session.id}-${image.id}${ext}`;
259
- writeFileSync(join(paths.uploads, name), held.bytes);
260
+ mkdirSync(sessionDir(session), { recursive: true });
261
+ writeFileSync(join(sessionDir(session), name), held.bytes);
260
262
  return name;
261
263
  }
262
264
  // A screenshot for a tester's page, encrypted here and sent in pieces.
@@ -267,11 +269,11 @@ export class RelayLink {
267
269
  return answer({ error: "ended", status: 410, message: "This parley has ended." });
268
270
  const id = String(frame.id ?? "");
269
271
  const name = /^[A-Za-z0-9_-]{8,40}$/.test(id)
270
- ? Object.values(IMAGE_TYPES).map((ext) => `${session.id}-${id}${ext}`).find((n) => existsSync(join(paths.uploads, n)))
272
+ ? Object.values(IMAGE_TYPES).map((ext) => `${session.id}-${id}${ext}`).find((n) => existsSync(join(sessionDir(session), n)))
271
273
  : undefined;
272
274
  if (!name)
273
275
  return answer({ error: "missing", status: 404, message: "No such screenshot." });
274
- const sealed = await sealBytes(session.e2e_key, session.token, "blob", readFileSync(join(paths.uploads, name)));
276
+ const sealed = await sealBytes(session.e2e_key, session.token, "blob", readFileSync(join(sessionDir(session), name)));
275
277
  for (let at = 0; at < sealed.length; at += PIECE)
276
278
  this.send({ t: "piece", r: frame.r, d: Buffer.from(sealed.subarray(at, at + PIECE)).toString("base64url") });
277
279
  answer({});
package/dist/runner.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { join, extname } from "node:path";
2
- import { paths } from "./config.js";
3
2
  import { charter } from "./charter.js";
3
+ import { sessionDir } from "./db.js";
4
4
  import { runClaudeTurn } from "./agents/claude.js";
5
5
  import { runCodexTurn } from "./agents/codex.js";
6
6
  import { modelLabel } from "./models.js";
@@ -136,7 +136,7 @@ export class Runner {
136
136
  const text = pending.map((m) => m.body).join("\n\n");
137
137
  const images = pending
138
138
  .flatMap((m) => m.images)
139
- .map((file) => ({ path: join(paths.uploads, file), mediaType: mediaTypeFor(file) }))
139
+ .map((file) => ({ path: join(sessionDir(session), file), mediaType: mediaTypeFor(file) }))
140
140
  .filter((image) => image.mediaType);
141
141
  const input = { session, config: this.config, charter: charter(session, this.config), text, images };
142
142
  const events = {
@@ -151,7 +151,7 @@ export class Runner {
151
151
  },
152
152
  onActivity: (line) => {
153
153
  live.activity = line;
154
- this.store.addActivity(turn.id, line);
154
+ this.store.addActivity(sessionId, turn.id, line);
155
155
  this.publish(sessionId, { type: "activity", line });
156
156
  },
157
157
  };
@@ -166,22 +166,22 @@ export class Runner {
166
166
  let failed = false;
167
167
  run.done
168
168
  .then((result) => {
169
- this.store.finishTurn(turn.id, "done", result.usage, null);
169
+ this.store.finishTurn(sessionId, turn.id, "done", result.usage, null);
170
170
  const message = this.store.addMessage(sessionId, "agent", result.text, [], turn.id);
171
171
  this.publish(sessionId, { type: "message", message });
172
172
  })
173
173
  .catch((error) => {
174
174
  if (this.store.session(sessionId)?.status !== "open") {
175
- this.store.finishTurn(turn.id, "interrupted", null, "Session ended mid-reply.");
175
+ this.store.finishTurn(sessionId, turn.id, "interrupted", null, "Session ended mid-reply.");
176
176
  failed = true;
177
177
  return;
178
178
  }
179
179
  const reason = String(error?.message ?? error).slice(0, 2000);
180
180
  console.error(`[${sessionId}] turn ${turn.id} failed: ${reason}`);
181
- this.store.finishTurn(turn.id, "failed", null, reason);
181
+ this.store.finishTurn(sessionId, turn.id, "failed", null, reason);
182
182
  // Unanswered messages go back in the queue and ride along with the
183
183
  // tester's next message, rather than retrying in a loop.
184
- this.store.releaseTurn(turn.id);
184
+ this.store.releaseTurn(sessionId, turn.id);
185
185
  failed = true;
186
186
  const message = this.store.addMessage(sessionId, "error", "Something went wrong on my end and I couldn't reply. Your message is saved, and I'll see it along with the next one you send.", [], turn.id);
187
187
  this.publish(sessionId, { type: "message", message });
package/dist/server.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { createServer } from "node:http";
2
- import { readFileSync, writeFileSync, existsSync, createReadStream } from "node:fs";
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, createReadStream } from "node:fs";
3
3
  import { join, extname, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { randomBytes } from "node:crypto";
6
- import { loadConfig, paths } from "./config.js";
7
- import { Store } from "./db.js";
6
+ import { loadConfig } from "./config.js";
7
+ import { Store, sessionDir } from "./db.js";
8
8
  import { Runner, mediaTypeFor } from "./runner.js";
9
9
  import { Conversations, Problem, MAX_TEXT, ownsUpload } from "./api.js";
10
10
  import { RelayLink } from "./relay.js";
@@ -184,13 +184,14 @@ export async function serve() {
184
184
  throw new Problem(429, "slow_down", "That's a lot of uploads very fast. Give it a few seconds.");
185
185
  const data = await readBody(req, MAX_UPLOAD);
186
186
  const name = `${session.id}-${randomBytes(9).toString("base64url")}${ext}`;
187
- writeFileSync(join(paths.uploads, name), data);
187
+ mkdirSync(sessionDir(session), { recursive: true });
188
+ writeFileSync(join(sessionDir(session), name), data);
188
189
  return json(res, { name, url: `${scope.base}/uploads/${name}` });
189
190
  }
190
191
  const upload = rest.match(/^\/uploads\/([A-Za-z0-9_.-]+)$/);
191
192
  if (upload && req.method === "GET") {
192
193
  const name = upload[1];
193
- const file = join(paths.uploads, name);
194
+ const file = join(sessionDir(session), name);
194
195
  if (!ownsUpload(session, name) || !existsSync(file))
195
196
  return notFound(res);
196
197
  res.writeHead(200, { "content-type": mediaTypeFor(name) ?? "application/octet-stream", "cache-control": "private, max-age=86400" });
@@ -1,5 +1,5 @@
1
1
  import { join } from "node:path";
2
- import { paths } from "./config.js";
2
+ import { sessionDir } from "./db.js";
3
3
  const agentName = (s) => (s.agent === "codex" ? "Codex" : "Claude");
4
4
  const time = (iso) => new Date(iso).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
5
5
  // Which model(s) answered, and on what login, from the recorded turns.
@@ -20,9 +20,20 @@ export function interviewer(store, session) {
20
20
  // it marks everything shown as read.
21
21
  export function transcript(store, session, link, { onlyNew = false } = {}) {
22
22
  const messages = store.messages(session.id, onlyNew ? session.read_through : 0);
23
+ const text = render(store, session, messages, { link, onlyNew, image: (name) => `Screenshot: ${join(sessionDir(session), name)}` });
24
+ if (messages.length)
25
+ store.markRead(session.id, messages.at(-1).id);
26
+ return text;
27
+ }
28
+ // The whole session for <project>/.parley/<id>/transcript.md, beside its
29
+ // screenshots. Reading it doesn't mark anything read.
30
+ export function record(store, session) {
31
+ return render(store, session, store.messages(session.id), { link: null, onlyNew: false, image: (name) => `![Screenshot](${name})` });
32
+ }
33
+ function render(store, session, messages, { link, onlyNew, image }) {
23
34
  const stats = store.stats(session.id);
24
35
  const lines = [];
25
- lines.push(`# Feedback from ${session.tester} on ${session.project_name}${onlyNew ? " (new since you last read)" : ""}`, "", `- Session: ${session.id}, ${session.status === "open" ? "open" : `ended ${time(session.ended_at ?? session.updated_at)}, link no longer works`}`, `- Interviewer: ${interviewer(store, session)}, started ${time(session.created_at)}`, `- Project: ${session.project_dir}`, ...(session.status === "open" ? [`- Tester link: ${link}`] : []), `- Messages: ${stats.tester_messages} from ${session.tester}, ${stats.agent_messages} from ${agentName(session)}`, `- Replies: ${stats.replies} of ${session.reply_limit}${spend(stats.cost_usd)}`, "");
36
+ lines.push(`# Feedback from ${session.tester} on ${session.project_name}${onlyNew ? " (new since you last read)" : ""}`, "", `- Session: ${session.id}, ${session.status === "open" ? "open" : `ended ${time(session.ended_at ?? session.updated_at)}, link no longer works`}`, `- Interviewer: ${interviewer(store, session)}, started ${time(session.created_at)}`, `- Project: ${session.project_dir}`, `- Files: ${sessionDir(session)}`, ...(session.status === "open" && link ? [`- Tester link: ${link}`] : []), `- Messages: ${stats.tester_messages} from ${session.tester}, ${stats.agent_messages} from ${agentName(session)}`, `- Replies: ${stats.replies} of ${session.reply_limit}${spend(stats.cost_usd)}`, "");
26
37
  if (!onlyNew)
27
38
  lines.push("## Brief", "", session.brief.trim(), "", "## Conversation", "");
28
39
  if (!messages.length)
@@ -30,12 +41,10 @@ export function transcript(store, session, link, { onlyNew = false } = {}) {
30
41
  for (const m of messages) {
31
42
  const who = m.role === "tester" ? session.tester : m.role === "agent" ? agentName(session) : "Parley (error shown to tester)";
32
43
  lines.push(`### ${who} · ${time(m.created_at)} · #${m.id}`, "", m.body.trim());
33
- for (const image of m.images)
34
- lines.push("", `Screenshot: ${join(paths.uploads, image)}`);
44
+ for (const name of m.images)
45
+ lines.push("", image(name));
35
46
  lines.push("");
36
47
  }
37
- if (messages.length)
38
- store.markRead(session.id, messages.at(-1).id);
39
48
  return lines.join("\n");
40
49
  }
41
50
  const spend = (usd) => (usd ? `, about $${usd.toFixed(2)} at API prices` : "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parley-im/parley",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Send someone a link. They talk to a read-only Claude or Codex in your project, on your computer, and you get everything they said.",
5
5
  "keywords": [
6
6
  "claude",