parawatch 0.3.13 → 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 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 g = resolveGoto(target, readState());
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
- execFileSync("open", [g.url]);
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,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,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
@@ -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) {
package/dist/core/ops.js CHANGED
@@ -423,10 +423,12 @@ export function leadOf(state, ended) {
423
423
  if (!project)
424
424
  return undefined;
425
425
  if (!memo.has(project)) {
426
+ // the newest claim leads; claims keep log order, so a tie in time goes to the later one
426
427
  const leads = state.claims
427
- .filter((c) => c.role === "lead" && c.project === project && !c.done && !ended(c.session))
428
- .sort((a, b) => (b.at ?? "").localeCompare(a.at ?? ""));
429
- memo.set(project, leads[0]?.session);
428
+ .map((c, i) => ({ c, i }))
429
+ .filter(({ c }) => c.role === "lead" && c.project === project && !c.done && !ended(c.session))
430
+ .sort((a, b) => (b.c.at ?? "").localeCompare(a.c.at ?? "") || b.i - a.i);
431
+ memo.set(project, leads[0]?.c.session);
430
432
  }
431
433
  return memo.get(project);
432
434
  };
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.13",
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",
@@ -48,6 +48,7 @@
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/node": "^22.10.1",
51
+ "happy-dom": "^21.0.4",
51
52
  "tsx": "^4.19.2",
52
53
  "typescript": "^5.7.2",
53
54
  "vitest": "^2.1.8"