parawatch 0.3.14 → 0.3.16

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
@@ -46,6 +46,8 @@ parawatch sync setup ~/agent-sync/parawatch --device pc --adapter syncthing
46
46
 
47
47
  Cards, answers and the list of running sessions then show on every machine, each marked with where it came from. Agent logs stay on the machine that wrote them. The details are in [More than one machine](docs/sync.md).
48
48
 
49
+ Or run a [relay](docs/relay.md) of your own, a small server that carries the folder sealed end to end, so the server cannot read your cards or sessions: `parawatch relay setup <url> <folder>` on the first machine, `parawatch relay join <code> <folder>` on the others.
50
+
49
51
  ## How an answer gets back
50
52
 
51
53
  <picture>
@@ -133,6 +135,8 @@ This writes a short block that tells the agents in that project to say what they
133
135
  | [Pull requests](docs/pull-requests.md) | What is read from GitHub, and its settings |
134
136
  | [Observed agents](docs/observed-agents.md) | Which logs are read, run states, adding a runtime |
135
137
  | [More than one machine](docs/sync.md) | Cards and answers across machines through a synced folder |
138
+ | [The relay](docs/relay.md) | Your own server that carries the shared folder, sealed end to end |
139
+ | [The shared folder](docs/protocol.md) | The files machines exchange, for anyone writing a client |
136
140
  | [Storage](docs/storage.md) | Where everything is kept, notifications |
137
141
  | [Installing, updating, removing](docs/install.md) | The three parts in detail, updates, `parawatch doctor`, uninstalling |
138
142
  | [Building on it](docs/integrations.md) | `status --json`, `watch`, the panel package |
package/dist/bin/cli.js CHANGED
@@ -7,10 +7,12 @@ import { githubConfig, readCache, resolvePrRef, syncGithub, prViews } from "../c
7
7
  import { recordPrompt } from "../core/prompts.js";
8
8
  import { callingSubagent } from "../adapters/claudeCode.js";
9
9
  import { digest, digestText, localDate } from "../core/digest.js";
10
- import { everywhere, readEvents } from "../core/store.js";
10
+ import { everywhere, readConfig, readEvents, sharedDir, writeConfig } from "../core/store.js";
11
11
  import { followEvents } from "../core/follow.js";
12
12
  import { adapterNames, setRoot, setupSync, syncAdapter, syncStatus, touchDevice } from "../core/sync.js";
13
13
  import { syncText } from "../core/format.js";
14
+ import { RelayClient, RelayMirror, joinCode, newKey, readJoinCode, relayConfig, takeMirrorLock } from "../core/relay.js";
15
+ import { createRelayServer } from "../relay/server.js";
14
16
  import { collectRuns, shareRunsDue } from "../core/runs.js";
15
17
  import { askRows, cardDetail, cardLine, cardRows, inboxLine, prRows, runsTree, view } from "../core/format.js";
16
18
  import { hookContext, notify, removeInstructions, writeInstructions } from "../core/integrate.js";
@@ -148,7 +150,8 @@ program
148
150
  .option("--copy", "also put a line about the card on the clipboard, to paste into that session")
149
151
  .option("--print", "print the link instead of opening it")
150
152
  .action((target, o) => {
151
- const g = resolveGoto(target, readState());
153
+ const state = readState();
154
+ const g = resolveGoto(target, state, collectRuns(state, { sinceHours: 24, includeEnded: true }));
152
155
  if (o.copy && g.card) {
153
156
  const line = cardContextLine(g.card);
154
157
  try {
@@ -158,13 +161,19 @@ program
158
161
  process.stderr.write(`parawatch: could not copy to the clipboard; the line is: ${line}\n`);
159
162
  }
160
163
  }
164
+ if (!g.url && g.device)
165
+ throw new UserError(`${g.name} runs on ${g.device}. Open it there, or turn on Remote Control for it in the Claude app to open it from here.`);
161
166
  if (!g.url)
162
167
  throw new UserError(`${g.name} is not a Claude desktop session. In a terminal: claude --resume ${g.session}`);
163
168
  if (o.print) {
164
169
  process.stdout.write(g.url + "\n");
165
170
  return;
166
171
  }
167
- execFileSync("open", [g.url]);
172
+ // cmd start would cut the link at its &
173
+ if (process.platform === "win32")
174
+ execFileSync("rundll32", ["url.dll,FileProtocolHandler", g.url]);
175
+ else
176
+ execFileSync(process.platform === "darwin" ? "open" : "xdg-open", [g.url]);
168
177
  process.stdout.write(`Opened ${g.name}${g.desktop?.archived ? " (archived)" : ""}\n`);
169
178
  });
170
179
  program
@@ -641,6 +650,103 @@ sync
641
650
  const d = setRoot(name, folder, VERSION);
642
651
  process.stdout.write(Object.entries(d.roots ?? {}).map(([k, v]) => `${k} ${v}`).join("\n") + (d.roots ? "\n" : "No named folders.\n"));
643
652
  });
653
+ // ------------------------------------------------------------------ the relay
654
+ const relay = program.command("relay").description("keep the shared folder in step through a parawatch relay server instead of a sync tool");
655
+ /** Point this machine's shared folder at a relay, keeping what config.json already has. */
656
+ function useRelay(folder, device, c) {
657
+ const r = setupSync({ dir: folder, device, adapter: "relay", version: VERSION });
658
+ const cfg = readConfig();
659
+ writeConfig({ ...cfg, sync: { ...(cfg.sync ?? {}), adapter: "relay", relay: c } });
660
+ return r;
661
+ }
662
+ relay
663
+ .command("setup <url> <folder>")
664
+ .description("make a space on a relay and share this machine's folder through it; prints the code for your other machines")
665
+ .option("-d, --device <name>", "this machine's name in the folder (a-z, 0-9, -); defaults to the host name")
666
+ .option("--invite <invite>", "the relay's invite (or set PARAWATCH_RELAY_INVITE)")
667
+ .action(async (url, folder, o) => {
668
+ const invite = o.invite ?? process.env.PARAWATCH_RELAY_INVITE;
669
+ if (!invite)
670
+ throw new UserError("the relay needs its invite to make a space: pass --invite or set PARAWATCH_RELAY_INVITE");
671
+ const base = url.replace(/\/+$/, "");
672
+ const made = await RelayClient.createSpace(base, invite);
673
+ const c = { url: base, space: made.space, token: made.token, key: newKey() };
674
+ const r = useRelay(folder, o.device, c);
675
+ process.stdout.write([
676
+ `This machine shares ${r.dir} as "${r.device}" through ${base}.`,
677
+ "On each of your other machines, run:",
678
+ "",
679
+ ` parawatch relay join <code> <folder> --device <its name>`,
680
+ "",
681
+ "with this code. It opens your space and its key: keep it to your own machines.",
682
+ "",
683
+ joinCode(c),
684
+ "",
685
+ "The folder is kept in step while the app runs, or while `parawatch relay run` does.",
686
+ ].join("\n") + "\n");
687
+ });
688
+ relay
689
+ .command("join <code> <folder>")
690
+ .description("share this machine's folder through the relay another machine set up")
691
+ .option("-d, --device <name>", "this machine's name in the folder (a-z, 0-9, -); defaults to the host name")
692
+ .action(async (code, folder, o) => {
693
+ const c = readJoinCode(code);
694
+ await new RelayClient(c).changes(0);
695
+ const r = useRelay(folder, o.device, c);
696
+ process.stdout.write(`This machine shares ${r.dir} as "${r.device}" through ${c.url}. The folder is kept in step while the app runs, or while \`parawatch relay run\` does.\n`);
697
+ });
698
+ relay
699
+ .command("code")
700
+ .description("print the code that lets another of your machines join (it holds the space key)")
701
+ .action(() => {
702
+ const c = relayConfig();
703
+ if (!c)
704
+ throw new UserError("this machine does not use a relay");
705
+ process.stdout.write(joinCode(c) + "\n");
706
+ });
707
+ relay
708
+ .command("run")
709
+ .description("keep the shared folder in step with the relay until stopped (the app does this while it runs)")
710
+ .option("--once", "bring it up to date once and stop")
711
+ .action(async (o) => {
712
+ const c = relayConfig();
713
+ const dir = sharedDir();
714
+ if (!c || !dir)
715
+ throw new UserError("this machine does not use a relay: run `parawatch relay setup` or `parawatch relay join` first");
716
+ const unlock = takeMirrorLock();
717
+ if (!unlock)
718
+ throw new UserError("another parawatch (the app, or another `relay run`) already keeps this folder in step");
719
+ const m = new RelayMirror({ dir, config: c, log: (l) => process.stderr.write(l + "\n") });
720
+ try {
721
+ if (o.once) {
722
+ const r = await m.once();
723
+ process.stdout.write(`brought down ${r.pulled}, sent up ${r.pushed}\n`);
724
+ return;
725
+ }
726
+ const stop = () => m.stop();
727
+ process.once("SIGINT", stop);
728
+ process.once("SIGTERM", stop);
729
+ process.stderr.write(`keeping ${dir} in step with ${c.url}; stop with Ctrl-C\n`);
730
+ await m.run();
731
+ }
732
+ finally {
733
+ unlock();
734
+ }
735
+ });
736
+ relay
737
+ .command("serve")
738
+ .description("run a relay server (for your own server; machines reach it over HTTPS through a proxy in front)")
739
+ .option("--port <n>", "port to listen on", "8787")
740
+ .option("--host <host>", "address to listen on", "127.0.0.1")
741
+ .option("--data <folder>", "where spaces are kept", "~/.parawatch-relay")
742
+ .action((o) => {
743
+ const invite = process.env.PARAWATCH_RELAY_INVITE;
744
+ const data = o.data.startsWith("~") ? o.data.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? "") : o.data;
745
+ const server = createRelayServer({ dataDir: data, invite, log: (l) => process.stdout.write(`${new Date().toISOString()} ${l}\n`) });
746
+ server.listen(Number(o.port), o.host, () => {
747
+ process.stdout.write(`relay on http://${o.host}:${o.port}, spaces in ${data}${invite ? "" : " (no PARAWATCH_RELAY_INVITE: no new spaces can be made)"}\n`);
748
+ });
749
+ });
644
750
  program
645
751
  .command("hook <runtime>")
646
752
  .description("hook entry point; 'claude-code' reads the hook JSON on stdin")
@@ -0,0 +1,3 @@
1
+ /** A Codex conversation as the app shows it. Types only, so the panel can share them. */
2
+ export {};
3
+ //# sourceMappingURL=chatTypes.js.map
@@ -0,0 +1,314 @@
1
+ /**
2
+ * A Codex session as a conversation: read from its rollout log (what `codex exec` and the Codex
3
+ * app write under ~/.codex/sessions), and continued with `codex exec resume`, under the same
4
+ * sandbox and model the session last ran with.
5
+ */
6
+ import { execFileSync } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { isObj, readHeadLines, readTailLines, str } from "../adapters/jsonl.js";
11
+ const OUTPUT_CHARS = 6000;
12
+ const DIFF_CHARS = 12000;
13
+ const MAX_ITEMS = 400;
14
+ // a log with inline images can be hundreds of MB; the conversation is in its last part
15
+ const TAIL_BYTES = 48 * 1024 * 1024;
16
+ const HEAD_BYTES = 256 * 1024;
17
+ const clip = (s, max) => (s.length > max ? `…${s.slice(s.length - max)}` : s);
18
+ const clipHead = (s, max) => (s.length > max ? `${s.slice(0, max)}\n…` : s);
19
+ const fileUrl = (p) => (p.startsWith("file://") ? decodeURIComponent(p.slice(7)) : p);
20
+ function texts(content) {
21
+ if (!Array.isArray(content))
22
+ return "";
23
+ return content
24
+ .filter(isObj)
25
+ .map((b) => str(b.text) ?? "")
26
+ .filter(Boolean)
27
+ .join("\n")
28
+ .trim();
29
+ }
30
+ function commandLine(cmd) {
31
+ if (typeof cmd === "string")
32
+ return cmd;
33
+ if (!Array.isArray(cmd))
34
+ return "";
35
+ const parts = cmd.map(String);
36
+ // ["/bin/zsh", "-lc", "<the command>"]: show the command
37
+ if (parts.length === 3 && /^-[a-z]*c$/.test(parts[1]))
38
+ return parts[2];
39
+ return parts.join(" ");
40
+ }
41
+ /** One item of the log's `item_completed` events, as the conversation shows it. */
42
+ export function chatItem(it, at) {
43
+ const id = str(it.id) ?? `${str(it.type)}-${at}`;
44
+ switch (it.type) {
45
+ case "UserMessage": {
46
+ const content = Array.isArray(it.content) ? it.content.filter(isObj) : [];
47
+ const images = content.filter((b) => /image/i.test(str(b.type) ?? "")).length;
48
+ const text = texts(content);
49
+ if (!text && !images)
50
+ return undefined;
51
+ return { kind: "user", id, text, ...(images ? { images } : {}), at };
52
+ }
53
+ case "AgentMessage": {
54
+ const text = texts(it.content);
55
+ if (!text)
56
+ return undefined;
57
+ return { kind: "agent", id, text, ...(it.phase === "commentary" ? { commentary: true } : {}), at };
58
+ }
59
+ case "CommandExecution": {
60
+ const output = str(it.aggregated_output) ?? [str(it.stdout), str(it.stderr)].filter(Boolean).join("\n");
61
+ const d = isObj(it.duration) ? Number(it.duration.secs ?? 0) + Number(it.duration.nanos ?? 0) / 1e9 : undefined;
62
+ return {
63
+ kind: "command",
64
+ id,
65
+ command: commandLine(it.command),
66
+ ...(output ? { output: clip(output.trimEnd(), OUTPUT_CHARS) } : {}),
67
+ ...(typeof it.exit_code === "number" ? { exit: it.exit_code } : {}),
68
+ status: str(it.status),
69
+ ...(d !== undefined && Number.isFinite(d) ? { seconds: Math.round(d * 10) / 10 } : {}),
70
+ at,
71
+ };
72
+ }
73
+ case "FileChange": {
74
+ const changes = isObj(it.changes) ? it.changes : {};
75
+ const files = Object.entries(changes).map(([p, c]) => {
76
+ const ch = isObj(c) ? c : {};
77
+ const diff = str(ch.unified_diff);
78
+ return { path: p, change: str(ch.type) ?? "update", ...(diff ? { diff: clipHead(diff, DIFF_CHARS) } : {}) };
79
+ });
80
+ return files.length ? { kind: "files", id, files, at } : undefined;
81
+ }
82
+ case "McpToolCall": {
83
+ const name = [str(it.appName) ?? str(it.server), str(it.actionName) ?? str(it.tool)].filter(Boolean).join(" ");
84
+ let detail;
85
+ try {
86
+ detail = it.arguments === undefined ? undefined : JSON.stringify(it.arguments);
87
+ }
88
+ catch {
89
+ detail = undefined;
90
+ }
91
+ return { kind: "tool", id, name: name || "tool", ...(detail ? { detail: clipHead(detail, 400) } : {}), status: str(it.status), at };
92
+ }
93
+ case "ImageView": {
94
+ const p = str(it.path);
95
+ return p ? { kind: "image", id, path: fileUrl(p), at } : undefined;
96
+ }
97
+ case "ContextCompaction":
98
+ return { kind: "note", id, text: "Earlier conversation compacted", tone: "info", at };
99
+ case "Reasoning":
100
+ case "Extension":
101
+ return undefined;
102
+ default:
103
+ return typeof it.type === "string" ? { kind: "tool", id, name: it.type, at } : undefined;
104
+ }
105
+ }
106
+ function sandboxOf(tc, cwd) {
107
+ const sp = isObj(tc.sandbox_policy) ? tc.sandbox_policy : undefined;
108
+ const sandbox = typeof tc.sandbox_policy === "string" ? tc.sandbox_policy : str(sp?.type);
109
+ const network = typeof sp?.network_access === "boolean" ? sp.network_access : undefined;
110
+ const roots = new Set();
111
+ for (const r of Array.isArray(sp?.writable_roots) ? sp.writable_roots : [])
112
+ if (typeof r === "string")
113
+ roots.add(r);
114
+ const fsp = isObj(tc.file_system_sandbox_policy) ? tc.file_system_sandbox_policy : undefined;
115
+ for (const e of Array.isArray(fsp?.entries) ? fsp.entries.filter(isObj) : []) {
116
+ const p = isObj(e.path) && e.path.type === "path" ? str(e.path.path) : undefined;
117
+ if (p && e.access === "write")
118
+ roots.add(p);
119
+ }
120
+ if (cwd)
121
+ roots.delete(cwd);
122
+ return { sandbox, ...(network !== undefined ? { network } : {}), roots: [...roots] };
123
+ }
124
+ /** The conversation in a rollout log's lines. */
125
+ export function readChatLines(head, tail, file = "") {
126
+ const meta = head.find((r) => isObj(r) && r.type === "session_meta");
127
+ const m = isObj(meta?.payload) ? meta.payload : {};
128
+ const id = str(m.id) ?? str(m.session_id) ?? path.basename(file);
129
+ const source = str(m.source);
130
+ const origin = str(m.originator) === "codex_exec" || source === "exec" ? "exec" : source === "vscode" ? "app" : source;
131
+ const settings = { cwd: str(m.cwd), roots: [] };
132
+ const items = new Map();
133
+ let started = -1;
134
+ let ended = -1;
135
+ let turnAt;
136
+ let structured = false;
137
+ const fallback = [];
138
+ tail.forEach((r, i) => {
139
+ if (!isObj(r) || !isObj(r.payload))
140
+ return;
141
+ const p = r.payload;
142
+ const at = str(r.timestamp);
143
+ if (r.type === "turn_context") {
144
+ settings.cwd = str(p.cwd) ?? settings.cwd;
145
+ settings.model = str(p.model) ?? settings.model;
146
+ settings.effort = str(p.effort) ?? (isObj(p.collaboration_mode) && isObj(p.collaboration_mode.settings) ? str(p.collaboration_mode.settings.reasoning_effort) : undefined) ?? settings.effort;
147
+ settings.approval = str(p.approval_policy) ?? settings.approval;
148
+ Object.assign(settings, sandboxOf(p, settings.cwd));
149
+ return;
150
+ }
151
+ if (r.type === "event_msg") {
152
+ if (p.type === "task_started") {
153
+ started = i;
154
+ turnAt = at;
155
+ }
156
+ else if (p.type === "task_complete")
157
+ ended = i;
158
+ else if (p.type === "turn_aborted") {
159
+ ended = i;
160
+ items.set(`aborted-${i}`, { kind: "note", id: `aborted-${i}`, text: "Stopped", tone: "info", at });
161
+ }
162
+ else if (p.type === "error") {
163
+ items.set(`error-${i}`, { kind: "note", id: `error-${i}`, text: str(p.message) ?? "Codex reported an error", tone: "error", at });
164
+ }
165
+ else if (p.type === "item_completed" && isObj(p.item)) {
166
+ structured = true;
167
+ const item = chatItem(p.item, at);
168
+ if (item) {
169
+ items.delete(item.id); // a repeat moves to where it was seen last
170
+ items.set(item.id, item);
171
+ }
172
+ }
173
+ return;
174
+ }
175
+ // logs from versions without item events: messages only
176
+ if (r.type === "response_item" && p.type === "message" && (p.role === "user" || p.role === "assistant")) {
177
+ const text = texts(p.content);
178
+ if (!text || (p.role === "user" && (text.startsWith("<") || text.startsWith("# AGENTS.md"))))
179
+ return;
180
+ fallback.push(p.role === "user" ? { kind: "user", id: `m${i}`, text, at } : { kind: "agent", id: `m${i}`, text, at });
181
+ }
182
+ });
183
+ const all = structured ? [...items.values()] : [...fallback, ...[...items.values()].filter((x) => x.kind === "note")];
184
+ const firstUser = all.find((x) => x.kind === "user");
185
+ return {
186
+ id,
187
+ file,
188
+ ...(origin ? { origin } : {}),
189
+ ...(firstUser ? { title: firstUser.text.split("\n")[0].slice(0, 80) } : {}),
190
+ settings,
191
+ working: started > ended,
192
+ ...(started > ended && turnAt ? { turnStarted: turnAt } : {}),
193
+ items: all.slice(-MAX_ITEMS),
194
+ earlier: Math.max(0, all.length - MAX_ITEMS),
195
+ };
196
+ }
197
+ export function readChat(file) {
198
+ return readChatLines(readHeadLines(file, HEAD_BYTES), readTailLines(file, TAIL_BYTES), file);
199
+ }
200
+ const toml = (s) => JSON.stringify(s);
201
+ /**
202
+ * The arguments for `codex` to send one message to the session, read from stdin, with the
203
+ * sandbox, folders and model it last ran with. `codex exec resume` takes no sandbox flags and
204
+ * would otherwise fall back to the user's config, which may allow more than the session had.
205
+ */
206
+ export function resumeArgs(id, s) {
207
+ const sandbox = s.sandbox;
208
+ if (sandbox !== "read-only" && sandbox !== "workspace-write" && sandbox !== "danger-full-access") {
209
+ throw new Error(`this session ran with a sandbox parawatch does not know (${sandbox ?? "none recorded"}); continue it in Codex`);
210
+ }
211
+ const args = ["exec", "resume", "--json", "--skip-git-repo-check"];
212
+ args.push("-c", `sandbox_mode=${toml(sandbox)}`);
213
+ // a message sent from here has no one to ask, so it never waits for an approval
214
+ args.push("-c", `approval_policy="never"`);
215
+ if (sandbox === "workspace-write") {
216
+ if (s.network !== undefined)
217
+ args.push("-c", `sandbox_workspace_write.network_access=${s.network}`);
218
+ if (s.roots.length)
219
+ args.push("-c", `sandbox_workspace_write.writable_roots=[${s.roots.map(toml).join(",")}]`);
220
+ }
221
+ if (s.model)
222
+ args.push("-m", s.model);
223
+ if (s.effort)
224
+ args.push("-c", `model_reasoning_effort=${toml(s.effort)}`);
225
+ args.push(id, "-");
226
+ return args;
227
+ }
228
+ /** Where the codex command is. An app started from the Dock does not get the shell's PATH. */
229
+ export function findCodex(env = process.env, platform = process.platform) {
230
+ if (env.PARAWATCH_CODEX_BIN)
231
+ return env.PARAWATCH_CODEX_BIN;
232
+ const names = platform === "win32" ? ["codex.cmd", "codex.exe", "codex"] : ["codex"];
233
+ const home = os.homedir();
234
+ const dirs = [
235
+ ...(env.PATH ?? "").split(path.delimiter),
236
+ ...(platform === "win32"
237
+ ? [env.APPDATA && path.join(env.APPDATA, "npm"), env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs", "OpenAI", "Codex", "bin"), env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs", "codex")]
238
+ : ["/opt/homebrew/bin", "/usr/local/bin", path.join(home, ".local", "bin"), path.join(home, ".npm-global", "bin"), path.join(home, ".volta", "bin"), path.join(home, ".bun", "bin")]),
239
+ ].filter((d) => !!d);
240
+ for (const d of dirs) {
241
+ for (const n of names) {
242
+ const p = path.join(d, n);
243
+ try {
244
+ if (fs.statSync(p).isFile())
245
+ return p;
246
+ }
247
+ catch {
248
+ // not here
249
+ }
250
+ }
251
+ }
252
+ if (platform === "win32")
253
+ return undefined;
254
+ try {
255
+ const shell = env.SHELL || "/bin/zsh";
256
+ const out = execFileSync(shell, ["-lc", "command -v codex"], { encoding: "utf8", timeout: 5000 }).trim().split("\n").pop();
257
+ return out && path.isAbsolute(out) ? out : undefined;
258
+ }
259
+ catch {
260
+ return undefined;
261
+ }
262
+ }
263
+ /** Processes that have the log open: a Codex app or terminal that has the session loaded. Unknown on Windows. */
264
+ export function logOpenBy(file, platform = process.platform) {
265
+ if (platform === "win32")
266
+ return undefined;
267
+ try {
268
+ const out = execFileSync("lsof", ["-t", "--", file], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] });
269
+ return out
270
+ .split("\n")
271
+ .map((l) => Number(l.trim()))
272
+ .filter((n) => n > 0);
273
+ }
274
+ catch (err) {
275
+ // lsof exits 1 when no process has it open
276
+ return err.status === 1 ? [] : undefined;
277
+ }
278
+ }
279
+ /** Why a message cannot be sent now, or undefined when it can. */
280
+ export function cannotSend(view, openBy, mine = []) {
281
+ if (view.working)
282
+ return "Codex is working on this session. Wait until it finishes.";
283
+ if (openBy?.some((p) => !mine.includes(p)))
284
+ return "This session is open in Codex (the app or a terminal). Continue it there, or close it there first.";
285
+ const s = view.settings.sandbox;
286
+ if (s !== "read-only" && s !== "workspace-write" && s !== "danger-full-access")
287
+ return `This session ran with a sandbox parawatch does not know (${s ?? "none recorded"}). Continue it in Codex.`;
288
+ return undefined;
289
+ }
290
+ /** A line of `codex exec --json` that says what Codex is doing now, if it is one. */
291
+ export function liveLine(line) {
292
+ let e;
293
+ try {
294
+ e = JSON.parse(line);
295
+ }
296
+ catch {
297
+ return undefined;
298
+ }
299
+ if (!isObj(e) || e.type !== "item.started" || !isObj(e.item))
300
+ return undefined;
301
+ const it = e.item;
302
+ if (it.type === "command_execution")
303
+ return `Running ${commandLine(it.command)}`.slice(0, 200);
304
+ if (it.type === "file_change")
305
+ return "Editing files";
306
+ if (it.type === "mcp_tool_call")
307
+ return `Using ${[str(it.server), str(it.tool)].filter(Boolean).join(" ")}`;
308
+ if (it.type === "web_search")
309
+ return "Searching the web";
310
+ if (it.type === "reasoning")
311
+ return "Thinking";
312
+ return undefined;
313
+ }
314
+ //# sourceMappingURL=codexChat.js.map
@@ -6,11 +6,33 @@ import { UserError } from "./errors.js";
6
6
  import { sessionTitle } from "./state.js";
7
7
  /**
8
8
  * Where the Claude desktop app keeps its Code sessions: one JSON file per session, named
9
- * `local_<id>.json`, holding the CLI session id the app runs (`cliSessionId`).
9
+ * `local_<id>.json`, holding the CLI session id the app runs (`cliSessionId`). On Windows the
10
+ * Store build keeps them under `%LOCALAPPDATA%\Claude-Data\claude_<package>`, the installer build
11
+ * under `%APPDATA%\Claude`.
10
12
  */
11
- export function desktopSessionsDir() {
12
- return (process.env.PARAWATCH_CLAUDE_DESKTOP_DIR ||
13
- path.join(os.homedir(), "Library", "Application Support", "Claude", "claude-code-sessions"));
13
+ export function desktopSessionsDirs() {
14
+ const env = process.env.PARAWATCH_CLAUDE_DESKTOP_DIR;
15
+ if (env)
16
+ return [env];
17
+ const home = os.homedir();
18
+ if (process.platform === "win32") {
19
+ const roaming = process.env.APPDATA || path.join(home, "AppData", "Roaming");
20
+ const local = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
21
+ const out = [path.join(roaming, "Claude", "claude-code-sessions")];
22
+ const data = path.join(local, "Claude-Data");
23
+ try {
24
+ for (const e of fs.readdirSync(data, { withFileTypes: true }))
25
+ if (e.isDirectory() && /^claude_/i.test(e.name))
26
+ out.push(path.join(data, e.name, "claude-code-sessions"));
27
+ }
28
+ catch {
29
+ // no Store build here
30
+ }
31
+ return out;
32
+ }
33
+ if (process.platform === "darwin")
34
+ return [path.join(home, "Library", "Application Support", "Claude", "claude-code-sessions")];
35
+ return [path.join(process.env.XDG_CONFIG_HOME || path.join(home, ".config"), "Claude", "claude-code-sessions")];
14
36
  }
15
37
  function* sessionFiles(dir, depth = 0) {
16
38
  let entries;
@@ -31,9 +53,9 @@ function* sessionFiles(dir, depth = 0) {
31
53
  /** Session files read before, by path; a file is read again only when it changes. */
32
54
  const readCache = new Map();
33
55
  /** Every desktop app session in the folder, read once per change of its file. */
34
- function desktopSessions(dir) {
56
+ function desktopSessions(dirs) {
35
57
  const out = [];
36
- for (const file of sessionFiles(dir)) {
58
+ for (const file of [dirs].flat().flatMap((d) => [...sessionFiles(d)])) {
37
59
  let mtimeMs;
38
60
  try {
39
61
  mtimeMs = fs.statSync(file).mtimeMs;
@@ -70,10 +92,12 @@ function readSessionFile(file) {
70
92
  cwd: typeof d.cwd === "string" ? d.cwd : undefined,
71
93
  archived: d.isArchived === true,
72
94
  lastActivityAt: typeof d.lastActivityAt === "number" ? d.lastActivityAt : 0,
95
+ remote: Array.isArray(d.bridgeSessionIds) ? d.bridgeSessionIds.find((x) => typeof x === "string" && REMOTE_ID.test(x)) : undefined,
73
96
  };
74
97
  }
75
98
  /** The desktop app session running a CLI session (full id or a prefix of 6+ characters). */
76
- export function findDesktopSession(cliSession, dir = desktopSessionsDir()) {
99
+ const REMOTE_ID = /^session_[A-Za-z0-9]+$/;
100
+ export function findDesktopSession(cliSession, dir = desktopSessionsDirs()) {
77
101
  if (cliSession.length < 6)
78
102
  return undefined;
79
103
  let best;
@@ -91,7 +115,7 @@ export function findDesktopSession(cliSession, dir = desktopSessionsDir()) {
91
115
  * (full or a prefix of 6+ characters). Such a session never takes another turn, however its log
92
116
  * ends. A CLI session that a live desktop session also runs is not archived.
93
117
  */
94
- export function archivedSessions(dir = desktopSessionsDir()) {
118
+ export function archivedSessions(dir = desktopSessionsDirs()) {
95
119
  const archived = new Set();
96
120
  const live = new Set();
97
121
  for (const s of desktopSessions(dir))
@@ -112,6 +136,27 @@ export function archivedSessions(dir = desktopSessionsDir()) {
112
136
  export function desktopSessionUrl(s) {
113
137
  return `claude://code/continue?session=${encodeURIComponent(s.id)}&source=parawatch`;
114
138
  }
139
+ /** A Remote Control session on claude.ai, which opens from any machine signed in to the account. */
140
+ export function remoteSessionUrl(id) {
141
+ return `https://claude.ai/code/${encodeURIComponent(id)}`;
142
+ }
143
+ /** Give each Claude Code session of this machine its Remote Control link, when it has one. */
144
+ export function withRemote(runs, dir = desktopSessionsDirs()) {
145
+ const sessions = runs.filter((r) => r.runtime === "claude-code" && r.kind === "session" && !r.device);
146
+ if (!sessions.length)
147
+ return runs;
148
+ const remote = new Map();
149
+ for (const s of desktopSessions(dir))
150
+ if (s.remote && !s.archived)
151
+ for (const id of s.ids)
152
+ remote.set(id, s.remote);
153
+ for (const r of sessions) {
154
+ const id = remote.get(r.id);
155
+ if (id)
156
+ r.remote = remoteSessionUrl(id);
157
+ }
158
+ return runs;
159
+ }
115
160
  /** The session that owns a pull request: by URL, `owner/repo#N`, or `#N` / `repo#N` when only one matches. */
116
161
  function prSession(target, state) {
117
162
  const ref = parsePrRef(target);
@@ -136,15 +181,23 @@ function prSession(target, state) {
136
181
  return hits[0].session;
137
182
  }
138
183
  /** Where `goto` leads for a card id, a pull request, or a session id (prefix). */
139
- export function resolveGoto(target, state) {
184
+ export function resolveGoto(target, state, runs = []) {
140
185
  const card = state.cards.get(target);
141
- const session = card ? card.session : (prSession(target, state) ?? target);
186
+ let session = card ? card.session : (prSession(target, state) ?? target);
142
187
  if (!session) {
143
188
  throw new UserError(`card ${target} does not say which session opened it (set one with: parawatch edit ${target} --session <id>)`);
144
189
  }
145
- const desktop = findDesktopSession(session);
146
- const name = sessionTitle(state.claims, session) ?? desktop?.title ?? session.slice(0, 8);
147
- return { name, session, card, desktop, url: desktop ? desktopSessionUrl(desktop) : undefined };
190
+ // a subagent has no window of its own: open the session it works under
191
+ const byId = new Map(runs.map((r) => [r.id, r]));
192
+ let run = runs.find((r) => r.id === session || (session.length >= 6 && r.id.startsWith(session)));
193
+ for (let i = 0; run?.parent && byId.has(run.parent) && i < 8; i++)
194
+ run = byId.get(run.parent);
195
+ if (run)
196
+ session = run.id;
197
+ const desktop = run?.device ? undefined : findDesktopSession(session);
198
+ const name = sessionTitle(state.claims, session) ?? run?.title ?? desktop?.title ?? session.slice(0, 8);
199
+ const url = desktop ? desktopSessionUrl(desktop) : run?.remote;
200
+ return { name, session, card, desktop, device: run?.device, url };
148
201
  }
149
202
  /** A line to paste into the session so it knows which card the human means. */
150
203
  export function cardContextLine(card) {