parawatch 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,144 @@
1
+ import fs from "node:fs";
2
+ /** Read up to `bytes` from the start of a file, dropping a torn last line. */
3
+ export function readHeadLines(file, bytes) {
4
+ const fd = fs.openSync(file, "r");
5
+ try {
6
+ const buf = Buffer.alloc(bytes);
7
+ const n = fs.readSync(fd, buf, 0, bytes, 0);
8
+ const text = buf.subarray(0, n).toString("utf8");
9
+ const lines = text.split("\n");
10
+ if (n === bytes)
11
+ lines.pop();
12
+ return parseLines(lines);
13
+ }
14
+ finally {
15
+ fs.closeSync(fd);
16
+ }
17
+ }
18
+ /** Read up to `bytes` from the end of a file, dropping a torn first line. Agent logs can be hundreds of MB. */
19
+ export function readTailLines(file, bytes) {
20
+ const size = fs.statSync(file).size;
21
+ const start = Math.max(0, size - bytes);
22
+ const fd = fs.openSync(file, "r");
23
+ try {
24
+ const len = size - start;
25
+ const buf = Buffer.alloc(len);
26
+ const n = fs.readSync(fd, buf, 0, len, start);
27
+ const lines = buf.subarray(0, n).toString("utf8").split("\n");
28
+ if (start > 0)
29
+ lines.shift();
30
+ return parseLines(lines);
31
+ }
32
+ finally {
33
+ fs.closeSync(fd);
34
+ }
35
+ }
36
+ /** Raw text from the start of a file, for lines too long to parse whole (inline images). */
37
+ export function readHeadText(file, bytes) {
38
+ const fd = fs.openSync(file, "r");
39
+ try {
40
+ const buf = Buffer.alloc(bytes);
41
+ const n = fs.readSync(fd, buf, 0, bytes, 0);
42
+ return buf.subarray(0, n).toString("utf8");
43
+ }
44
+ finally {
45
+ fs.closeSync(fd);
46
+ }
47
+ }
48
+ /** Every JSON string value of a "text" key found in raw, possibly truncated, JSON text. */
49
+ export function textValues(raw) {
50
+ const out = [];
51
+ const re = /"text":"((?:[^"\\]|\\.)*)"/g;
52
+ for (let m = re.exec(raw); m; m = re.exec(raw)) {
53
+ try {
54
+ out.push(JSON.parse(`"${m[1]}"`));
55
+ }
56
+ catch {
57
+ // malformed escape; skip
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ function parseLines(lines) {
63
+ const out = [];
64
+ for (const line of lines) {
65
+ if (!line.trim())
66
+ continue;
67
+ try {
68
+ out.push(JSON.parse(line));
69
+ }
70
+ catch {
71
+ // skip partial or foreign lines
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+ export function oneLine(s, max = 100) {
77
+ const flat = s.replace(/\s+/g, " ").trim();
78
+ return flat.length > max ? flat.slice(0, max - 1) + "…" : flat;
79
+ }
80
+ export function isObj(x) {
81
+ return typeof x === "object" && x !== null && !Array.isArray(x);
82
+ }
83
+ export function str(x) {
84
+ return typeof x === "string" ? x : undefined;
85
+ }
86
+ export const ACTIVITY_MINUTES = 60;
87
+ /** Which of the last `window` minutes saw any of these times: minutes before now, ascending. */
88
+ export function activeMinutes(times, now, window = ACTIVITY_MINUTES) {
89
+ const set = new Set();
90
+ for (const t of times) {
91
+ if (!Number.isFinite(t))
92
+ continue;
93
+ const m = Math.floor((now - t) / 60_000);
94
+ if (m >= 0 && m < window)
95
+ set.add(m);
96
+ }
97
+ return [...set].sort((a, b) => a - b);
98
+ }
99
+ const STAMP = /"timestamp":"([^"]{10,40})"/g;
100
+ /** Every record timestamp in raw log text, in milliseconds. */
101
+ export function timestampsIn(text) {
102
+ const out = [];
103
+ for (let m = STAMP.exec(text); m; m = STAMP.exec(text)) {
104
+ const t = Date.parse(m[1]);
105
+ if (Number.isFinite(t))
106
+ out.push(t);
107
+ }
108
+ STAMP.lastIndex = 0;
109
+ return out;
110
+ }
111
+ // How far back the first look at a log goes. Later looks read only what was appended since.
112
+ const FIRST_LOOK_BYTES = 16 * 1024 * 1024;
113
+ const seen = new Map();
114
+ /**
115
+ * The active minutes of an append-only log. A long-running process (the desktop app) keeps what
116
+ * it has read, so each refresh reads only the new end of the file.
117
+ */
118
+ export function logActivity(file, now) {
119
+ const size = fs.statSync(file).size;
120
+ let entry = seen.get(file);
121
+ if (!entry || size < entry.offset)
122
+ entry = { offset: Math.max(0, size - FIRST_LOOK_BYTES), times: [] };
123
+ if (size > entry.offset) {
124
+ const fd = fs.openSync(file, "r");
125
+ try {
126
+ const len = size - entry.offset;
127
+ const buf = Buffer.alloc(len);
128
+ const n = fs.readSync(fd, buf, 0, len, entry.offset);
129
+ const text = buf.subarray(0, n).toString("utf8");
130
+ // stop at the last full line so a record being written is read next time
131
+ const end = text.lastIndexOf("\n") + 1;
132
+ entry.times.push(...timestampsIn(text.slice(0, end)));
133
+ entry.offset += Buffer.byteLength(text.slice(0, end), "utf8");
134
+ }
135
+ finally {
136
+ fs.closeSync(fd);
137
+ }
138
+ }
139
+ const horizon = now - ACTIVITY_MINUTES * 60_000;
140
+ entry.times = entry.times.filter((t) => t >= horizon);
141
+ seen.set(file, entry);
142
+ return activeMinutes(entry.times, now);
143
+ }
144
+ //# sourceMappingURL=jsonl.js.map
@@ -0,0 +1,360 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import { Command } from "commander";
5
+ import { UserError, addProject, answerCard, claimSession, closeCard, commentCard, consumerName, editCard, endRun, noteRun, openCard, readInbox, removeProject, startRun, waitInbox, } from "../core/ops.js";
6
+ import { readEvents } from "../core/store.js";
7
+ import { followEvents } from "../core/follow.js";
8
+ import { fold } from "../core/state.js";
9
+ import { collectRuns } from "../core/runs.js";
10
+ import { cardDetail, cardLine, cardRows, inboxLine, runsTree, view } from "../core/format.js";
11
+ import { hookContext, notify, writeInstructions } from "../core/integrate.js";
12
+ import { installSkill, skillSource, skillTargets } from "../core/skill.js";
13
+ import { cardContextLine, resolveGoto } from "../core/desktop.js";
14
+ import { snapshot } from "../core/snapshot.js";
15
+ import { SCHEMA_VERSION } from "../core/types.js";
16
+ const collect = (v, prev = []) => [...prev, v];
17
+ const int = (v) => {
18
+ const n = Number(v);
19
+ if (!Number.isInteger(n))
20
+ throw new UserError(`"${v}" is not a whole number`);
21
+ return n;
22
+ };
23
+ const print = (x) => process.stdout.write(JSON.stringify(x) + "\n");
24
+ /** The agent session running this command, when its runtime says so. */
25
+ function currentSession() {
26
+ return process.env.CLAUDE_CODE_SESSION_ID || process.env.CODEX_THREAD_ID || undefined;
27
+ }
28
+ const program = new Command();
29
+ program
30
+ .name("parawatch")
31
+ .description("See what your agents are doing, what needs you, and wake the right agent when you answer.")
32
+ .version("0.1.0");
33
+ // ------------------------------------------------------------------ cards
34
+ function cardCommand(kind, name, summary) {
35
+ return program
36
+ .command(`${name} <title>`)
37
+ .description(summary)
38
+ .requiredOption("-p, --project <name>", "project this card belongs to")
39
+ .option("-t, --task <task>", "task within the project")
40
+ .option("-b, --body <text>", "two or three lines of context")
41
+ .option("--attach <file>", "image, video or file to look at (repeatable)", collect)
42
+ .option("--from <who>", "who is asking (defaults to the calling session's claimed title)")
43
+ .option("--session <id>", "the agent session asking (defaults to the calling session)")
44
+ .option("--since <date>", "when the matter first came up, if earlier (YYYY-MM-DD)")
45
+ .option("--json", "print the card as JSON");
46
+ }
47
+ /** Options every card kind shares. */
48
+ function common(o) {
49
+ return {
50
+ project: o.project,
51
+ task: o.task,
52
+ body: o.body,
53
+ attach: o.attach,
54
+ from: o.from,
55
+ session: o.session ?? currentSession(),
56
+ since: o.since,
57
+ };
58
+ }
59
+ function finishCard(input, json) {
60
+ const card = openCard(input);
61
+ notify(card);
62
+ if (json)
63
+ print(card);
64
+ else
65
+ process.stdout.write(`${card.id}\n`);
66
+ }
67
+ cardCommand("decide", "ask", "ask the human to decide")
68
+ .option("-o, --option <text>", "a choice (repeatable)", collect)
69
+ .option("-r, --recommend <n>", "the recommended choice, 1-based", int)
70
+ .action((title, o) => finishCard({ kind: "decide", title, ...common(o), options: o.option, recommend: o.recommend }, o.json));
71
+ cardCommand("try", "try", "ask the human to try a build, watch a video or look at a screen")
72
+ .option("--open <path>", "app, file or URL to open (repeatable)", collect)
73
+ .option("-l, --look <text>", "what to look at")
74
+ .action((title, o) => finishCard({ kind: "try", title, ...common(o), open: o.open, look: o.look }, o.json));
75
+ cardCommand("block", "block", "something only the human can do, and you are stuck until they do it")
76
+ .action((title, o) => finishCard({ kind: "block", title, ...common(o) }, o.json));
77
+ cardCommand("todo", "todo", "something only the human can do, but nothing waits on it now")
78
+ .action((title, o) => finishCard({ kind: "todo", title, ...common(o) }, o.json));
79
+ program
80
+ .command("edit <card>")
81
+ .description("change a card: its kind, wording, task, who asked or when it came up (an empty value clears)")
82
+ .option("-k, --kind <kind>", "decide, try, block or todo")
83
+ .option("--title <text>", "new title")
84
+ .option("-b, --body <text>", "new body")
85
+ .option("-t, --task <task>", "task within the project")
86
+ .option("--from <who>", "who asked")
87
+ .option("--session <id>", "the agent session that asked")
88
+ .option("--since <date>", "when the matter first came up (YYYY-MM-DD)")
89
+ .option("--json", "print the card as JSON")
90
+ .action((id, o) => {
91
+ const card = editCard(id, { kind: o.kind, title: o.title, body: o.body, task: o.task, from: o.from, session: o.session, since: o.since });
92
+ if (o.json)
93
+ print(card);
94
+ else
95
+ process.stdout.write(cardLine(card) + "\n");
96
+ });
97
+ program
98
+ .command("goto <target>")
99
+ .description("open the agent session behind a card (or a session id) in the Claude desktop app")
100
+ .option("--copy", "also put a line about the card on the clipboard, to paste into that session")
101
+ .option("--print", "print the link instead of opening it")
102
+ .action((target, o) => {
103
+ const g = resolveGoto(target, fold(readEvents()));
104
+ if (o.copy && g.card) {
105
+ const line = cardContextLine(g.card);
106
+ try {
107
+ execFileSync("pbcopy", { input: line });
108
+ }
109
+ catch {
110
+ process.stderr.write(`parawatch: could not copy to the clipboard; the line is: ${line}\n`);
111
+ }
112
+ }
113
+ if (!g.url)
114
+ throw new UserError(`${g.name} is not a Claude desktop session. In a terminal: claude --resume ${g.session}`);
115
+ if (o.print) {
116
+ process.stdout.write(g.url + "\n");
117
+ return;
118
+ }
119
+ execFileSync("open", [g.url]);
120
+ process.stdout.write(`Opened ${g.name}${g.desktop?.archived ? " (archived)" : ""}\n`);
121
+ });
122
+ program
123
+ .command("answer <card>")
124
+ .description("answer a card (this is what wakes the waiting agent)")
125
+ .option("-c, --choice <n>", "chosen option, 1-based", int)
126
+ .option("-m, --comment <text>", "free text")
127
+ .option("--attach <file>", "a screenshot or file (repeatable)", collect)
128
+ .option("--by <who>", "who answers", "human")
129
+ .action((id, o) => answerCard(id, { choice: o.choice, comment: o.comment, attach: o.attach, by: o.by }));
130
+ program
131
+ .command("comment <card> <text>")
132
+ .description("add a comment to a card")
133
+ .option("--by <who>", "who comments (use 'human' to wake the agent)", "agent")
134
+ .action((id, text, o) => commentCard(id, text, o.by));
135
+ program
136
+ .command("close <card>")
137
+ .description("close a card once it has been acted on")
138
+ .option("-n, --note <text>", "what was done")
139
+ .action((id, o) => closeCard(id, o.note));
140
+ program
141
+ .command("show <card>")
142
+ .description("show one card with its answers")
143
+ .option("--json", "JSON output")
144
+ .action((id, o) => {
145
+ const card = fold(readEvents()).cards.get(id);
146
+ if (!card)
147
+ throw new UserError(`no card "${id}"`);
148
+ if (o.json)
149
+ print(card);
150
+ else
151
+ process.stdout.write(cardDetail(card) + "\n");
152
+ });
153
+ program
154
+ .command("cards")
155
+ .description("list cards (open and answered by default)")
156
+ .option("-p, --project <name>", "only this project")
157
+ .option("-a, --all", "include closed cards")
158
+ .option("--json", "JSON output")
159
+ .action((o) => {
160
+ const cards = [...fold(readEvents()).cards.values()].filter((c) => (o.all || c.status !== "closed") && (!o.project || c.project === o.project));
161
+ if (o.json)
162
+ print({ v: SCHEMA_VERSION, cards });
163
+ else
164
+ process.stdout.write(cards.length ? cardRows(cards, view()).join("\n") + "\n" : "No cards.\n");
165
+ });
166
+ // ------------------------------------------------------------------ inbox
167
+ function inboxOptions(c) {
168
+ return c
169
+ .option("-p, --project <name>", "only answers for this project")
170
+ .option("--card <id>", "only answers to this card")
171
+ .option("--as <consumer>", "cursor name (defaults to the project)")
172
+ .option("--json", "JSON output");
173
+ }
174
+ inboxOptions(program.command("wait").description("block until the human answers, print the answers and exit"))
175
+ .option("--timeout <seconds>", "give up after this long (exit code 2); 0 waits forever", int, 0)
176
+ .action(async (o) => {
177
+ const items = await waitInbox(consumerName(o), { project: o.project, card: o.card }, o.timeout * 1000);
178
+ if (!items.length) {
179
+ process.exitCode = 2;
180
+ return;
181
+ }
182
+ if (o.json)
183
+ print({ v: SCHEMA_VERSION, items });
184
+ else
185
+ process.stdout.write(items.map(inboxLine).join("\n") + "\n");
186
+ });
187
+ inboxOptions(program.command("inbox").description("print unhandled answers without waiting"))
188
+ .option("--peek", "do not mark them as seen")
189
+ .action((o) => {
190
+ const items = readInbox(consumerName(o), { project: o.project, card: o.card }, !o.peek);
191
+ if (o.json)
192
+ print({ v: SCHEMA_VERSION, items });
193
+ else if (items.length)
194
+ process.stdout.write(items.map(inboxLine).join("\n") + "\n");
195
+ });
196
+ program
197
+ .command("watch")
198
+ .description("stream every new event as one JSON line (for plugins and monitors)")
199
+ .option("--from <seq>", "replay events after this sequence number first", int)
200
+ .action(async (o) => {
201
+ followEvents(print, o.from);
202
+ await new Promise(() => { });
203
+ });
204
+ // ------------------------------------------------------------------ runs
205
+ program
206
+ .command("runs")
207
+ .description("agents seen in their logs, grouped by project")
208
+ .option("-p, --project <name>", "only this project")
209
+ .option("--since <hours>", "look at logs touched in the last N hours", int, 6)
210
+ .option("-a, --all", "include runs that ended a while ago")
211
+ .option("--runtime <name>", "only this adapter (repeatable)", collect)
212
+ .option("-w, --wide", "show runtime and ids")
213
+ .option("--json", "JSON output")
214
+ .action((o) => {
215
+ const runs = collectRuns(fold(readEvents()), { sinceHours: o.since, project: o.project, includeEnded: o.all, runtimes: o.runtime });
216
+ if (o.json)
217
+ print({ v: SCHEMA_VERSION, runs });
218
+ else
219
+ process.stdout.write((runs.length ? runsTree(runs, view({ wide: o.wide, all: o.all })) : "Nothing running.") + "\n");
220
+ });
221
+ const run = program.command("run").description("report a job that leaves no agent log");
222
+ run
223
+ .command("start <title>")
224
+ .requiredOption("-p, --project <name>", "project")
225
+ .option("-t, --task <task>", "task")
226
+ .option("--parent <id>", "the run that started this one (defaults to the calling session)")
227
+ .action((title, o) => {
228
+ process.stdout.write(startRun({ title, project: o.project, task: o.task, parent: o.parent ?? currentSession() }) + "\n");
229
+ });
230
+ run.command("note <id> <text>").action((id, text) => noteRun(id, text));
231
+ run
232
+ .command("end <id>")
233
+ .option("--failed", "it failed")
234
+ .option("-n, --note <text>", "how it ended")
235
+ .action((id, o) => endRun(id, o.failed ? "failed" : "done", o.note));
236
+ program
237
+ .command("claim [session]")
238
+ .description("say which project an agent session belongs to (id prefix, 6+ characters; defaults to the calling session)")
239
+ .requiredOption("-p, --project <name>", "project")
240
+ .option("-t, --task <task>", "task")
241
+ .option("--role <role>", "for example lead or worker")
242
+ .option("--title <title>", "name to show")
243
+ .action((session, o) => {
244
+ const id = session ?? currentSession();
245
+ if (!id)
246
+ throw new UserError("no session id given and none found in the environment (CLAUDE_CODE_SESSION_ID, CODEX_THREAD_ID)");
247
+ claimSession({ session: id, project: o.project, task: o.task, role: o.role, title: o.title });
248
+ process.stdout.write(`${id.slice(0, 8)} → ${o.project}${o.role ? ` (${o.role})` : ""}\n`);
249
+ });
250
+ // ------------------------------------------------------------------ status
251
+ program
252
+ .command("status")
253
+ .description("everything at a glance: what needs you, then what is running")
254
+ .option("-p, --project <name>", "only this project")
255
+ .option("--since <hours>", "look at logs touched in the last N hours", int, 6)
256
+ .option("-w, --wide", "show runtime and ids")
257
+ .option("--json", "JSON output")
258
+ .action((o) => {
259
+ const snap = snapshot({ sinceHours: o.since, project: o.project });
260
+ if (o.json) {
261
+ print(snap);
262
+ return;
263
+ }
264
+ const { cards, runs } = snap;
265
+ const v = view({ wide: o.wide });
266
+ const head = (title, n, ...extra) => v.color ? `\x1b[1m${title}\x1b[0m \x1b[2m${n}${extra.join("")}\x1b[0m` : `${title} ${n}${extra.join("")}`;
267
+ const open = cards.filter((c) => c.status === "open" && c.kind !== "todo");
268
+ const todo = cards.filter((c) => c.status === "open" && c.kind === "todo");
269
+ const answered = cards.filter((c) => c.status === "answered");
270
+ const out = [];
271
+ out.push(head("NEEDS YOU", open.length || "nothing"));
272
+ out.push(...cardRows(open, v, Date.now(), " "));
273
+ if (todo.length) {
274
+ out.push("", head("TO DO, NOTHING WAITS ON IT", todo.length));
275
+ out.push(...cardRows(todo, v, Date.now(), " "));
276
+ }
277
+ if (answered.length) {
278
+ out.push("", head("ANSWERED, WAITING FOR THE AGENT", answered.length));
279
+ out.push(...cardRows(answered, v, Date.now(), " "));
280
+ }
281
+ const working = runs.filter((r) => r.state === "working" || r.state === "waiting").length;
282
+ out.push("", head("AGENTS", runs.length ? `${working} working` : "nothing running"));
283
+ if (runs.length)
284
+ out.push(runsTree(runs, v));
285
+ process.stdout.write(out.join("\n") + "\n");
286
+ });
287
+ // ------------------------------------------------------------------ skill
288
+ const skill = program.command("skill").description("the agent skill that teaches agents to use parawatch");
289
+ skill
290
+ .command("path")
291
+ .description("print where the skill lives")
292
+ .action(() => {
293
+ process.stdout.write(skillSource() + "\n");
294
+ });
295
+ skill
296
+ .command("install")
297
+ .description("link the skill into Claude Code (~/.claude/skills) and Codex (~/.codex/skills)")
298
+ .option("--runtime <name>", "only this runtime: claude-code or codex (repeatable)", collect)
299
+ .option("--force", "replace a folder that is already there")
300
+ .action((o) => {
301
+ for (const r of installSkill(skillTargets(), { only: o.runtime, force: o.force })) {
302
+ process.stdout.write(`${r.runtime.padEnd(12)} ${r.result.padEnd(15)} ${r.path}${r.note ? ` (${r.note})` : ""}\n`);
303
+ }
304
+ });
305
+ // ------------------------------------------------------------------ setup
306
+ const project = program.command("project").description("projects you work on");
307
+ project
308
+ .command("add <name> [paths...]")
309
+ .description("register a project and the checkouts or folders it lives in")
310
+ .action((name, paths) => addProject(name, paths.length ? paths : [process.cwd()]));
311
+ project.command("remove <name>").action((name) => removeProject(name));
312
+ project
313
+ .command("list")
314
+ .option("--json", "JSON output")
315
+ .action((o) => {
316
+ const ps = [...fold(readEvents()).projects.values()];
317
+ if (o.json)
318
+ print({ v: SCHEMA_VERSION, projects: ps });
319
+ else
320
+ process.stdout.write(ps.map((p) => `${p.name} ${p.paths.join(", ")}`).join("\n") + (ps.length ? "\n" : "No projects.\n"));
321
+ });
322
+ program
323
+ .command("init")
324
+ .description("add parawatch instructions for agents to an instruction file")
325
+ .option("-f, --file <path>", "file to write", "AGENTS.md")
326
+ .option("-p, --project <name>", "project name to put in the examples")
327
+ .action((o) => {
328
+ const changed = writeInstructions(o.file, o.project);
329
+ process.stdout.write(changed ? `Updated ${o.file}\n` : `${o.file} already up to date\n`);
330
+ });
331
+ program
332
+ .command("hook <runtime>")
333
+ .description("hook entry point; 'claude-code' reads the hook JSON on stdin")
334
+ .option("-p, --project <name>", "project, when it cannot be told from the session")
335
+ .action((runtime, o) => {
336
+ if (runtime !== "claude-code")
337
+ throw new UserError(`no hook for "${runtime}"`);
338
+ let input = {};
339
+ try {
340
+ input = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
341
+ }
342
+ catch {
343
+ input = {};
344
+ }
345
+ const text = hookContext(input, o.project);
346
+ if (!text)
347
+ return;
348
+ if (input.hook_event_name)
349
+ print({ hookSpecificOutput: { hookEventName: input.hook_event_name, additionalContext: text } });
350
+ else
351
+ process.stdout.write(text + "\n");
352
+ });
353
+ program.parseAsync().catch((err) => {
354
+ if (err instanceof UserError) {
355
+ process.stderr.write(`parawatch: ${err.message}\n`);
356
+ process.exit(1);
357
+ }
358
+ throw err;
359
+ });
360
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,78 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { sessionTitle, UserError } from "./ops.js";
5
+ /**
6
+ * Where the Claude desktop app keeps its Code sessions: one JSON file per session, named
7
+ * `local_<id>.json`, holding the CLI session id the app runs (`cliSessionId`).
8
+ */
9
+ export function desktopSessionsDir() {
10
+ return (process.env.PARAWATCH_CLAUDE_DESKTOP_DIR ||
11
+ path.join(os.homedir(), "Library", "Application Support", "Claude", "claude-code-sessions"));
12
+ }
13
+ function* sessionFiles(dir, depth = 0) {
14
+ let entries;
15
+ try {
16
+ entries = fs.readdirSync(dir, { withFileTypes: true });
17
+ }
18
+ catch {
19
+ return;
20
+ }
21
+ for (const e of entries) {
22
+ const p = path.join(dir, e.name);
23
+ if (e.isDirectory() && depth < 3)
24
+ yield* sessionFiles(p, depth + 1);
25
+ else if (e.isFile() && /^local_[A-Za-z0-9-]+\.json$/.test(e.name))
26
+ yield p;
27
+ }
28
+ }
29
+ /** The desktop app session running a CLI session (full id or a prefix of 6+ characters). */
30
+ export function findDesktopSession(cliSession, dir = desktopSessionsDir()) {
31
+ if (cliSession.length < 6)
32
+ return undefined;
33
+ let best;
34
+ for (const file of sessionFiles(dir)) {
35
+ let d;
36
+ try {
37
+ d = JSON.parse(fs.readFileSync(file, "utf8"));
38
+ }
39
+ catch {
40
+ continue;
41
+ }
42
+ const ids = [d.cliSessionId, d.lastAssistantCliSessionId].filter((x) => typeof x === "string");
43
+ if (!ids.some((x) => x.startsWith(cliSession)))
44
+ continue;
45
+ const s = {
46
+ id: typeof d.sessionId === "string" ? d.sessionId : path.basename(file, ".json"),
47
+ cliSessionId: ids[0],
48
+ title: typeof d.title === "string" ? d.title : undefined,
49
+ cwd: typeof d.cwd === "string" ? d.cwd : undefined,
50
+ archived: d.isArchived === true,
51
+ lastActivityAt: typeof d.lastActivityAt === "number" ? d.lastActivityAt : 0,
52
+ };
53
+ // prefer a live session, then the most recent one
54
+ if (!best || (best.archived && !s.archived) || (best.archived === s.archived && s.lastActivityAt > best.lastActivityAt))
55
+ best = s;
56
+ }
57
+ return best;
58
+ }
59
+ /** A link that opens the session in the Claude desktop app. */
60
+ export function desktopSessionUrl(s) {
61
+ return `claude://code/continue?session=${encodeURIComponent(s.id)}&source=parawatch`;
62
+ }
63
+ /** Where `goto` leads for a card id or a session id (prefix). */
64
+ export function resolveGoto(target, state) {
65
+ const card = state.cards.get(target);
66
+ const session = card ? card.session : target;
67
+ if (!session) {
68
+ throw new UserError(`card ${target} does not say which session opened it (set one with: parawatch edit ${target} --session <id>)`);
69
+ }
70
+ const desktop = findDesktopSession(session);
71
+ const name = sessionTitle(state.claims, session) ?? desktop?.title ?? session.slice(0, 8);
72
+ return { name, session, card, desktop, url: desktop ? desktopSessionUrl(desktop) : undefined };
73
+ }
74
+ /** A line to paste into the session so it knows which card the human means. */
75
+ export function cardContextLine(card) {
76
+ return `About parawatch card ${card.id} ("${card.title}"): `;
77
+ }
78
+ //# sourceMappingURL=desktop.js.map
@@ -0,0 +1,70 @@
1
+ import fs from "node:fs";
2
+ import { eventsPath, homeDir, parseEventLines } from "./store.js";
3
+ /**
4
+ * Call `onEvent` for every event appended after `afterSeq` (or after the current end when it is
5
+ * undefined). Watches the store folder and also checks once a second. Returns a stop function.
6
+ */
7
+ export function followEvents(onEvent, afterSeq) {
8
+ const file = eventsPath();
9
+ let offset = 0;
10
+ let lastSeq = afterSeq ?? Infinity;
11
+ try {
12
+ const text = fs.readFileSync(file, "utf8");
13
+ offset = Buffer.byteLength(text);
14
+ const events = parseEventLines(text);
15
+ const end = events[events.length - 1]?.seq ?? 0;
16
+ if (afterSeq === undefined)
17
+ lastSeq = end;
18
+ else {
19
+ for (const ev of events)
20
+ if (ev.seq > afterSeq)
21
+ onEvent(ev);
22
+ lastSeq = Math.max(afterSeq, end);
23
+ }
24
+ }
25
+ catch {
26
+ lastSeq = afterSeq ?? 0;
27
+ }
28
+ const pump = () => {
29
+ let size = 0;
30
+ try {
31
+ size = fs.statSync(file).size;
32
+ }
33
+ catch {
34
+ return;
35
+ }
36
+ if (size < offset)
37
+ offset = 0; // file replaced
38
+ if (size === offset)
39
+ return;
40
+ const fd = fs.openSync(file, "r");
41
+ const buf = Buffer.alloc(size - offset);
42
+ fs.readSync(fd, buf, 0, buf.length, offset);
43
+ fs.closeSync(fd);
44
+ const text = buf.toString("utf8");
45
+ const cut = text.lastIndexOf("\n");
46
+ if (cut < 0)
47
+ return;
48
+ offset += Buffer.byteLength(text.slice(0, cut + 1));
49
+ for (const ev of parseEventLines(text.slice(0, cut + 1))) {
50
+ if (ev.seq > lastSeq) {
51
+ onEvent(ev);
52
+ lastSeq = ev.seq;
53
+ }
54
+ }
55
+ };
56
+ let watcher;
57
+ try {
58
+ fs.mkdirSync(homeDir(), { recursive: true });
59
+ watcher = fs.watch(homeDir(), () => pump());
60
+ }
61
+ catch {
62
+ watcher = undefined; // the timer alone still works
63
+ }
64
+ const timer = setInterval(pump, 1000);
65
+ return () => {
66
+ clearInterval(timer);
67
+ watcher?.close();
68
+ };
69
+ }
70
+ //# sourceMappingURL=follow.js.map