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 +4 -0
- package/dist/bin/cli.js +109 -3
- package/dist/core/chatTypes.js +3 -0
- package/dist/core/codexChat.js +314 -0
- package/dist/core/desktop.js +66 -13
- package/dist/core/relay.js +474 -0
- package/dist/core/remoteChat.js +212 -0
- package/dist/core/runs.js +2 -2
- package/dist/core/sync.js +2 -0
- package/dist/relay/server.js +234 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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,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
|
package/dist/core/desktop.js
CHANGED
|
@@ -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
|
|
12
|
-
|
|
13
|
-
|
|
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(
|
|
56
|
+
function desktopSessions(dirs) {
|
|
35
57
|
const out = [];
|
|
36
|
-
for (const file of sessionFiles(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
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) {
|