parawatch 0.3.14 → 0.3.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/cli.js +9 -2
- package/dist/core/chatTypes.js +3 -0
- package/dist/core/codexChat.js +310 -0
- package/dist/core/desktop.js +66 -13
- package/dist/core/runs.js +2 -2
- package/package.json +1 -1
package/dist/bin/cli.js
CHANGED
|
@@ -148,7 +148,8 @@ program
|
|
|
148
148
|
.option("--copy", "also put a line about the card on the clipboard, to paste into that session")
|
|
149
149
|
.option("--print", "print the link instead of opening it")
|
|
150
150
|
.action((target, o) => {
|
|
151
|
-
const
|
|
151
|
+
const state = readState();
|
|
152
|
+
const g = resolveGoto(target, state, collectRuns(state, { sinceHours: 24, includeEnded: true }));
|
|
152
153
|
if (o.copy && g.card) {
|
|
153
154
|
const line = cardContextLine(g.card);
|
|
154
155
|
try {
|
|
@@ -158,13 +159,19 @@ program
|
|
|
158
159
|
process.stderr.write(`parawatch: could not copy to the clipboard; the line is: ${line}\n`);
|
|
159
160
|
}
|
|
160
161
|
}
|
|
162
|
+
if (!g.url && g.device)
|
|
163
|
+
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
164
|
if (!g.url)
|
|
162
165
|
throw new UserError(`${g.name} is not a Claude desktop session. In a terminal: claude --resume ${g.session}`);
|
|
163
166
|
if (o.print) {
|
|
164
167
|
process.stdout.write(g.url + "\n");
|
|
165
168
|
return;
|
|
166
169
|
}
|
|
167
|
-
|
|
170
|
+
// cmd start would cut the link at its &
|
|
171
|
+
if (process.platform === "win32")
|
|
172
|
+
execFileSync("rundll32", ["url.dll,FileProtocolHandler", g.url]);
|
|
173
|
+
else
|
|
174
|
+
execFileSync(process.platform === "darwin" ? "open" : "xdg-open", [g.url]);
|
|
168
175
|
process.stdout.write(`Opened ${g.name}${g.desktop?.archived ? " (archived)" : ""}\n`);
|
|
169
176
|
});
|
|
170
177
|
program
|
|
@@ -0,0 +1,310 @@
|
|
|
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 structured = false;
|
|
136
|
+
const fallback = [];
|
|
137
|
+
tail.forEach((r, i) => {
|
|
138
|
+
if (!isObj(r) || !isObj(r.payload))
|
|
139
|
+
return;
|
|
140
|
+
const p = r.payload;
|
|
141
|
+
const at = str(r.timestamp);
|
|
142
|
+
if (r.type === "turn_context") {
|
|
143
|
+
settings.cwd = str(p.cwd) ?? settings.cwd;
|
|
144
|
+
settings.model = str(p.model) ?? settings.model;
|
|
145
|
+
settings.effort = str(p.effort) ?? (isObj(p.collaboration_mode) && isObj(p.collaboration_mode.settings) ? str(p.collaboration_mode.settings.reasoning_effort) : undefined) ?? settings.effort;
|
|
146
|
+
settings.approval = str(p.approval_policy) ?? settings.approval;
|
|
147
|
+
Object.assign(settings, sandboxOf(p, settings.cwd));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (r.type === "event_msg") {
|
|
151
|
+
if (p.type === "task_started")
|
|
152
|
+
started = i;
|
|
153
|
+
else if (p.type === "task_complete")
|
|
154
|
+
ended = i;
|
|
155
|
+
else if (p.type === "turn_aborted") {
|
|
156
|
+
ended = i;
|
|
157
|
+
items.set(`aborted-${i}`, { kind: "note", id: `aborted-${i}`, text: "Stopped", tone: "info", at });
|
|
158
|
+
}
|
|
159
|
+
else if (p.type === "error") {
|
|
160
|
+
items.set(`error-${i}`, { kind: "note", id: `error-${i}`, text: str(p.message) ?? "Codex reported an error", tone: "error", at });
|
|
161
|
+
}
|
|
162
|
+
else if (p.type === "item_completed" && isObj(p.item)) {
|
|
163
|
+
structured = true;
|
|
164
|
+
const item = chatItem(p.item, at);
|
|
165
|
+
if (item) {
|
|
166
|
+
items.delete(item.id); // a repeat moves to where it was seen last
|
|
167
|
+
items.set(item.id, item);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// logs from versions without item events: messages only
|
|
173
|
+
if (r.type === "response_item" && p.type === "message" && (p.role === "user" || p.role === "assistant")) {
|
|
174
|
+
const text = texts(p.content);
|
|
175
|
+
if (!text || (p.role === "user" && (text.startsWith("<") || text.startsWith("# AGENTS.md"))))
|
|
176
|
+
return;
|
|
177
|
+
fallback.push(p.role === "user" ? { kind: "user", id: `m${i}`, text, at } : { kind: "agent", id: `m${i}`, text, at });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
const all = structured ? [...items.values()] : [...fallback, ...[...items.values()].filter((x) => x.kind === "note")];
|
|
181
|
+
const firstUser = all.find((x) => x.kind === "user");
|
|
182
|
+
return {
|
|
183
|
+
id,
|
|
184
|
+
file,
|
|
185
|
+
...(origin ? { origin } : {}),
|
|
186
|
+
...(firstUser ? { title: firstUser.text.split("\n")[0].slice(0, 80) } : {}),
|
|
187
|
+
settings,
|
|
188
|
+
working: started > ended,
|
|
189
|
+
items: all.slice(-MAX_ITEMS),
|
|
190
|
+
earlier: Math.max(0, all.length - MAX_ITEMS),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
export function readChat(file) {
|
|
194
|
+
return readChatLines(readHeadLines(file, HEAD_BYTES), readTailLines(file, TAIL_BYTES), file);
|
|
195
|
+
}
|
|
196
|
+
const toml = (s) => JSON.stringify(s);
|
|
197
|
+
/**
|
|
198
|
+
* The arguments for `codex` to send one message to the session, read from stdin, with the
|
|
199
|
+
* sandbox, folders and model it last ran with. `codex exec resume` takes no sandbox flags and
|
|
200
|
+
* would otherwise fall back to the user's config, which may allow more than the session had.
|
|
201
|
+
*/
|
|
202
|
+
export function resumeArgs(id, s) {
|
|
203
|
+
const sandbox = s.sandbox;
|
|
204
|
+
if (sandbox !== "read-only" && sandbox !== "workspace-write" && sandbox !== "danger-full-access") {
|
|
205
|
+
throw new Error(`this session ran with a sandbox parawatch does not know (${sandbox ?? "none recorded"}); continue it in Codex`);
|
|
206
|
+
}
|
|
207
|
+
const args = ["exec", "resume", "--json", "--skip-git-repo-check"];
|
|
208
|
+
args.push("-c", `sandbox_mode=${toml(sandbox)}`);
|
|
209
|
+
// a message sent from here has no one to ask, so it never waits for an approval
|
|
210
|
+
args.push("-c", `approval_policy="never"`);
|
|
211
|
+
if (sandbox === "workspace-write") {
|
|
212
|
+
if (s.network !== undefined)
|
|
213
|
+
args.push("-c", `sandbox_workspace_write.network_access=${s.network}`);
|
|
214
|
+
if (s.roots.length)
|
|
215
|
+
args.push("-c", `sandbox_workspace_write.writable_roots=[${s.roots.map(toml).join(",")}]`);
|
|
216
|
+
}
|
|
217
|
+
if (s.model)
|
|
218
|
+
args.push("-m", s.model);
|
|
219
|
+
if (s.effort)
|
|
220
|
+
args.push("-c", `model_reasoning_effort=${toml(s.effort)}`);
|
|
221
|
+
args.push(id, "-");
|
|
222
|
+
return args;
|
|
223
|
+
}
|
|
224
|
+
/** Where the codex command is. An app started from the Dock does not get the shell's PATH. */
|
|
225
|
+
export function findCodex(env = process.env, platform = process.platform) {
|
|
226
|
+
if (env.PARAWATCH_CODEX_BIN)
|
|
227
|
+
return env.PARAWATCH_CODEX_BIN;
|
|
228
|
+
const names = platform === "win32" ? ["codex.cmd", "codex.exe", "codex"] : ["codex"];
|
|
229
|
+
const home = os.homedir();
|
|
230
|
+
const dirs = [
|
|
231
|
+
...(env.PATH ?? "").split(path.delimiter),
|
|
232
|
+
...(platform === "win32"
|
|
233
|
+
? [env.APPDATA && path.join(env.APPDATA, "npm"), env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs", "codex")]
|
|
234
|
+
: ["/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")]),
|
|
235
|
+
].filter((d) => !!d);
|
|
236
|
+
for (const d of dirs) {
|
|
237
|
+
for (const n of names) {
|
|
238
|
+
const p = path.join(d, n);
|
|
239
|
+
try {
|
|
240
|
+
if (fs.statSync(p).isFile())
|
|
241
|
+
return p;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// not here
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (platform === "win32")
|
|
249
|
+
return undefined;
|
|
250
|
+
try {
|
|
251
|
+
const shell = env.SHELL || "/bin/zsh";
|
|
252
|
+
const out = execFileSync(shell, ["-lc", "command -v codex"], { encoding: "utf8", timeout: 5000 }).trim().split("\n").pop();
|
|
253
|
+
return out && path.isAbsolute(out) ? out : undefined;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Processes that have the log open: a Codex app or terminal that has the session loaded. Unknown on Windows. */
|
|
260
|
+
export function logOpenBy(file, platform = process.platform) {
|
|
261
|
+
if (platform === "win32")
|
|
262
|
+
return undefined;
|
|
263
|
+
try {
|
|
264
|
+
const out = execFileSync("lsof", ["-t", "--", file], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] });
|
|
265
|
+
return out
|
|
266
|
+
.split("\n")
|
|
267
|
+
.map((l) => Number(l.trim()))
|
|
268
|
+
.filter((n) => n > 0);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
// lsof exits 1 when no process has it open
|
|
272
|
+
return err.status === 1 ? [] : undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** Why a message cannot be sent now, or undefined when it can. */
|
|
276
|
+
export function cannotSend(view, openBy, mine = []) {
|
|
277
|
+
if (view.working)
|
|
278
|
+
return "Codex is working on this session. Wait until it finishes.";
|
|
279
|
+
if (openBy?.some((p) => !mine.includes(p)))
|
|
280
|
+
return "This session is open in Codex (the app or a terminal). Continue it there, or close it there first.";
|
|
281
|
+
const s = view.settings.sandbox;
|
|
282
|
+
if (s !== "read-only" && s !== "workspace-write" && s !== "danger-full-access")
|
|
283
|
+
return `This session ran with a sandbox parawatch does not know (${s ?? "none recorded"}). Continue it in Codex.`;
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
/** A line of `codex exec --json` that says what Codex is doing now, if it is one. */
|
|
287
|
+
export function liveLine(line) {
|
|
288
|
+
let e;
|
|
289
|
+
try {
|
|
290
|
+
e = JSON.parse(line);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
if (!isObj(e) || e.type !== "item.started" || !isObj(e.item))
|
|
296
|
+
return undefined;
|
|
297
|
+
const it = e.item;
|
|
298
|
+
if (it.type === "command_execution")
|
|
299
|
+
return `Running ${commandLine(it.command)}`.slice(0, 200);
|
|
300
|
+
if (it.type === "file_change")
|
|
301
|
+
return "Editing files";
|
|
302
|
+
if (it.type === "mcp_tool_call")
|
|
303
|
+
return `Using ${[str(it.server), str(it.tool)].filter(Boolean).join(" ")}`;
|
|
304
|
+
if (it.type === "web_search")
|
|
305
|
+
return "Searching the web";
|
|
306
|
+
if (it.type === "reasoning")
|
|
307
|
+
return "Thinking";
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
//# 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) {
|
package/dist/core/runs.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { scanAll } from "../adapters/index.js";
|
|
5
5
|
import { ACTIVITY_MINUTES, activeMinutes } from "../adapters/jsonl.js";
|
|
6
|
-
import { archivedSessions } from "./desktop.js";
|
|
6
|
+
import { archivedSessions, withRemote } from "./desktop.js";
|
|
7
7
|
import { canonicalPath, insidePath, namesPath, samePath } from "./paths.js";
|
|
8
8
|
import { applyPrompts } from "./prompts.js";
|
|
9
9
|
import { deviceName, sharedDir } from "./store.js";
|
|
@@ -392,7 +392,7 @@ export function collectRuns(state, q, now = Date.now()) {
|
|
|
392
392
|
const sinceMs = now - q.sinceHours * 3_600_000;
|
|
393
393
|
// a log file can be touched (title records, snapshots) long after its last real activity
|
|
394
394
|
const recent = (r) => !r.lastActivity || Date.parse(r.lastActivity) >= sinceMs;
|
|
395
|
-
let runs = applyPrompts(endArchived(attribute(scanAll({ sinceMs, now }, q.runtimes), state), archivedSessions())).filter(recent);
|
|
395
|
+
let runs = withRemote(applyPrompts(endArchived(attribute(scanAll({ sinceMs, now }, q.runtimes), state), archivedSessions())).filter(recent));
|
|
396
396
|
// a narrower reading (one runtime) would hide the rest from the other machines
|
|
397
397
|
if (!q.runtimes)
|
|
398
398
|
shareRuns(runs, now);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parawatch",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.15",
|
|
4
4
|
"description": "A control tower for people running many AI coding agents in parallel: see what is running, what needs you, and wake the right agent when you answer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|