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,261 @@
1
+ export function ago(iso, now = Date.now()) {
2
+ if (!iso)
3
+ return "?";
4
+ const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
5
+ if (s < 60)
6
+ return `${s}s`;
7
+ const m = Math.round(s / 60);
8
+ if (m < 60)
9
+ return `${m}m`;
10
+ const h = Math.round(m / 60);
11
+ if (h < 48)
12
+ return `${h}h`;
13
+ return `${Math.round(h / 24)}d`;
14
+ }
15
+ export function view(opts = {}) {
16
+ const out = process.stdout;
17
+ const color = process.env.NO_COLOR ? false : process.env.FORCE_COLOR ? true : Boolean(out.isTTY);
18
+ const width = out.columns || Number(process.env.COLUMNS) || 120;
19
+ return { color, width: Math.max(50, width), wide: Boolean(opts.wide), all: Boolean(opts.all) };
20
+ }
21
+ const PLAIN = { color: false, width: 120, wide: false, all: false };
22
+ const SGR = { bold: "1", dim: "2", red: "31", green: "32", yellow: "33", blue: "34", magenta: "35", cyan: "36" };
23
+ function paint(v, s, ...codes) {
24
+ if (!v.color || !s || !codes.length)
25
+ return s;
26
+ return `\x1b[${codes.map((c) => SGR[c]).join(";")}m${s}\x1b[0m`;
27
+ }
28
+ function charWidth(cp) {
29
+ if (cp < 32 || (cp >= 0x7f && cp < 0xa0))
30
+ return 0;
31
+ if ((cp >= 0x300 && cp <= 0x36f) || (cp >= 0x200b && cp <= 0x200f) || (cp >= 0xfe00 && cp <= 0xfe0f))
32
+ return 0;
33
+ if ((cp >= 0x1100 && cp <= 0x115f) ||
34
+ (cp >= 0x2e80 && cp <= 0xa4cf) ||
35
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
36
+ (cp >= 0xf900 && cp <= 0xfaff) ||
37
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
38
+ (cp >= 0xff00 && cp <= 0xff60) ||
39
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
40
+ (cp >= 0x1f300 && cp <= 0x1faff) ||
41
+ (cp >= 0x20000 && cp <= 0x3fffd))
42
+ return 2;
43
+ return 1;
44
+ }
45
+ /** Columns a string takes in a terminal (CJK and emoji count as two). */
46
+ export function textWidth(s) {
47
+ let w = 0;
48
+ for (const ch of s)
49
+ w += charWidth(ch.codePointAt(0));
50
+ return w;
51
+ }
52
+ /** Cut to at most `max` columns, ending with … when cut. */
53
+ export function fit(s, max) {
54
+ if (max <= 0)
55
+ return "";
56
+ if (textWidth(s) <= max)
57
+ return s;
58
+ let w = 0;
59
+ let out = "";
60
+ for (const ch of s) {
61
+ const c = charWidth(ch.codePointAt(0));
62
+ if (w + c > max - 1)
63
+ break;
64
+ out += ch;
65
+ w += c;
66
+ }
67
+ return out + "…";
68
+ }
69
+ function pad(s, n) {
70
+ return s + " ".repeat(Math.max(0, n - textWidth(s)));
71
+ }
72
+ /** One line of plain text from an agent message: no markdown, no line breaks. */
73
+ export function clean(s) {
74
+ return s
75
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
76
+ .replace(/\*\*|`/g, "")
77
+ .replace(/^\s*(?:[-*>]|#+)\s+/gm, "")
78
+ .replace(/\s+/g, " ")
79
+ .trim();
80
+ }
81
+ // ------------------------------------------------------------------ cards
82
+ const KIND_LABEL = { decide: "decide", try: "try", block: "blocked", todo: "to do" };
83
+ const KIND_PAINT = { decide: "yellow", try: "cyan", block: "red", todo: "blue" };
84
+ /** When the matter came up: `since` when it is older than the card, else when the card opened. */
85
+ export function cardStart(c) {
86
+ return c.since && c.since < c.openedAt ? c.since : c.openedAt;
87
+ }
88
+ /** Cards as aligned rows: id, kind, project, title, age. */
89
+ export function cardRows(cards, v = PLAIN, now = Date.now(), indent = "") {
90
+ const idW = Math.max(3, ...cards.map((c) => c.id.length));
91
+ const projW = Math.min(16, Math.max(0, ...cards.map((c) => textWidth(c.project + (c.task ? `/${c.task}` : "")))));
92
+ const noteOf = (c) => (c.status === "answered" ? " · answered" : c.status === "closed" ? " · closed" : "");
93
+ const fixed = textWidth(indent) + idW + 2 + 7 + 2 + projW + 2 + 2 + 4;
94
+ const titleW = Math.min(v.width - fixed, Math.max(0, ...cards.map((c) => textWidth(c.title + noteOf(c)))));
95
+ return cards.map((c) => {
96
+ const proj = c.project + (c.task ? `/${c.task}` : "");
97
+ const age = ago(cardStart(c), now).padStart(4);
98
+ const note = noteOf(c);
99
+ const title = fit(c.title, titleW - textWidth(note));
100
+ const dim = c.status !== "open";
101
+ return [
102
+ indent,
103
+ paint(v, pad(c.id, idW), "bold"),
104
+ " ",
105
+ paint(v, pad(KIND_LABEL[c.kind], 7), dim ? "dim" : KIND_PAINT[c.kind]),
106
+ " ",
107
+ pad(fit(proj, projW), projW),
108
+ " ",
109
+ paint(v, pad(title + note, titleW), ...(dim ? ["dim"] : [])),
110
+ " ",
111
+ paint(v, age, "dim"),
112
+ ].join("");
113
+ });
114
+ }
115
+ export function cardLine(c, now = Date.now()) {
116
+ return cardRows([c], PLAIN, now)[0].trimEnd();
117
+ }
118
+ export function cardDetail(c) {
119
+ const out = [];
120
+ out.push(`${c.id} · ${KIND_LABEL[c.kind]} · ${c.project}${c.task ? ` [${c.task}]` : ""} · ${c.status}`);
121
+ out.push(c.title);
122
+ if (c.body)
123
+ out.push("", c.body);
124
+ if (c.options?.length) {
125
+ out.push("");
126
+ c.options.forEach((o, i) => out.push(` ${i + 1}. ${o}${c.recommend === i + 1 ? " (recommended)" : ""}`));
127
+ }
128
+ if (c.look)
129
+ out.push("", `Look at: ${c.look}`);
130
+ for (const o of c.open ?? [])
131
+ out.push(`Open: ${o}`);
132
+ for (const a of c.attach ?? [])
133
+ out.push(`Attached: ${a}`);
134
+ if (c.from || c.session)
135
+ out.push(`From: ${[c.from, c.session ? `session ${c.session.slice(0, 8)}` : ""].filter(Boolean).join(", ")}`);
136
+ out.push(`Opened: ${new Date(c.openedAt).toLocaleString()}${c.since ? `, first came up ${new Date(c.since).toLocaleDateString()}` : ""}`);
137
+ for (const a of c.answers) {
138
+ const choice = a.choice !== undefined ? ` chose ${a.choice}${c.options?.[a.choice - 1] ? ` (${c.options[a.choice - 1]})` : ""}` : "";
139
+ out.push("", `${a.by}${choice}${a.comment ? `: ${a.comment}` : ""}`);
140
+ for (const f of a.attach ?? [])
141
+ out.push(` attached ${f}`);
142
+ }
143
+ for (const m of c.comments)
144
+ out.push(`${m.by}: ${m.text}`);
145
+ if (c.closeNote)
146
+ out.push("", `Closed: ${c.closeNote}`);
147
+ return out.join("\n");
148
+ }
149
+ export function inboxLine(i) {
150
+ const head = `[${i.card.project}] ${i.card.id} "${i.card.title}"`;
151
+ if (i.kind === "comment")
152
+ return `${head} comment: ${i.comment}`;
153
+ const parts = [];
154
+ if (i.choice !== undefined)
155
+ parts.push(`chose ${i.choice}${i.choiceText ? ` (${i.choiceText})` : ""}`);
156
+ if (i.comment)
157
+ parts.push(`said: ${i.comment}`);
158
+ if (i.attach?.length)
159
+ parts.push(`attached: ${i.attach.join(", ")}`);
160
+ return `${head} ${parts.join("; ")}`;
161
+ }
162
+ const STATE_MARK = {
163
+ working: "●",
164
+ waiting: "◐",
165
+ idle: "○",
166
+ done: "✓",
167
+ stalled: "!",
168
+ failed: "✗",
169
+ unknown: "?",
170
+ };
171
+ const STATE_PAINT = {
172
+ working: ["green"],
173
+ waiting: ["yellow"],
174
+ idle: ["dim"],
175
+ done: ["dim"],
176
+ stalled: ["red"],
177
+ failed: ["red"],
178
+ unknown: ["dim"],
179
+ };
180
+ const ENDED = new Set(["done", "idle"]);
181
+ /** Finished subagents older than this fold into one "N finished" line. */
182
+ const FOLD_AFTER_MS = 5 * 60_000;
183
+ export const OTHER = "other";
184
+ function whatText(r) {
185
+ if (r.error)
186
+ return `unreadable: ${r.error}`;
187
+ const a = clean(r.action ?? "");
188
+ if (r.state === "stalled")
189
+ return a ? `stalled · ${a}` : "stalled";
190
+ return a;
191
+ }
192
+ /** Runs grouped by project, children drawn as a tree under their parent, one terminal line each. */
193
+ export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
194
+ const groups = new Map();
195
+ for (const r of runs) {
196
+ const k = r.project ?? OTHER;
197
+ if (!groups.has(k))
198
+ groups.set(k, []);
199
+ groups.get(k).push(r);
200
+ }
201
+ const names = [...groups.keys()].sort((a, b) => (a === OTHER ? 1 : b === OTHER ? -1 : a.localeCompare(b)));
202
+ // rows first, so every group shares one set of columns
203
+ const blocks = [];
204
+ for (const name of names) {
205
+ const rs = groups.get(name);
206
+ const ids = new Set(rs.map((r) => r.id));
207
+ const rows = [];
208
+ const walk = (r, prefix, cont) => {
209
+ rows.push({ prefix, run: r });
210
+ const kids = rs.filter((x) => x.parent === r.id);
211
+ const shown = v.all ? kids : kids.filter((k) => !(ENDED.has(k.state) && now - Date.parse(k.lastActivity ?? "") > FOLD_AFTER_MS));
212
+ const folded = kids.filter((k) => !shown.includes(k));
213
+ const items = [...shown];
214
+ if (folded.length)
215
+ items.push({ count: folded.length, newest: folded.map((k) => k.lastActivity ?? "").sort().pop() });
216
+ items.forEach((it, i) => {
217
+ const last = i === items.length - 1;
218
+ const p = cont + (last ? "└ " : "├ ");
219
+ const c = cont + (last ? " " : "│ ");
220
+ if (it && "id" in it)
221
+ walk(it, p, c);
222
+ else
223
+ rows.push({ prefix: p, folded: it });
224
+ });
225
+ };
226
+ for (const r of rs.filter((r) => !r.parent || !ids.has(r.parent)))
227
+ walk(r, indent + " ", indent + " ");
228
+ blocks.push({ name, rows });
229
+ }
230
+ const all = blocks.flatMap((b) => b.rows);
231
+ const nameW = Math.min(Math.max(24, Math.floor(v.width * 0.4)), Math.max(12, ...all.map((row) => textWidth(row.prefix) + 2 + textWidth(row.run?.title ?? ""))));
232
+ const wideW = v.wide ? 24 : 0;
233
+ const out = [];
234
+ for (const { name, rows } of blocks) {
235
+ if (name === OTHER)
236
+ out.push(indent + paint(v, "other", "bold") + paint(v, " not in a registered project", "dim"));
237
+ else
238
+ out.push(indent + paint(v, name, "bold"));
239
+ for (const row of rows) {
240
+ const lead = paint(v, row.prefix, "dim");
241
+ const room = nameW - textWidth(row.prefix) - 2;
242
+ if (row.folded) {
243
+ const label = `✓ ${row.folded.count} finished`;
244
+ out.push(lead + paint(v, pad(fit(label, room + 2), room + 2), "dim") + " " + paint(v, ago(row.folded.newest, now).padStart(4), "dim"));
245
+ continue;
246
+ }
247
+ const r = row.run;
248
+ const ended = ENDED.has(r.state);
249
+ const titlePaint = r.role === "lead" ? ["bold"] : ended ? ["dim"] : [];
250
+ const mark = paint(v, STATE_MARK[r.state], ...STATE_PAINT[r.state]);
251
+ const title = paint(v, pad(fit(r.title, room), room), ...titlePaint);
252
+ const age = paint(v, ago(r.lastActivity, now).padStart(4), "dim");
253
+ const wide = v.wide ? " " + paint(v, pad(`${r.kind === "subagent" ? r.model ?? "subagent" : r.runtime} ${r.id.slice(0, 8)}`, wideW - 2), "dim") : "";
254
+ const restW = v.width - nameW - 2 - 4 - wideW - 2;
255
+ const what = restW >= 8 ? " " + paint(v, fit(whatText(r), restW), ...(r.error || r.state === "stalled" || r.state === "failed" ? ["red"] : ended ? ["dim"] : [])) : "";
256
+ out.push(`${lead}${mark} ${title} ${age}${wide}${what}`.trimEnd());
257
+ }
258
+ }
259
+ return out.join("\n");
260
+ }
261
+ //# sourceMappingURL=format.js.map
@@ -0,0 +1,114 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { readInbox, inboxFrom } from "./ops.js";
5
+ import { homeDir, readCursor, readEvents } from "./store.js";
6
+ import { fold } from "./state.js";
7
+ import { inboxLine } from "./format.js";
8
+ export const BEGIN = "<!-- parawatch:begin -->";
9
+ export const END = "<!-- parawatch:end -->";
10
+ export function instructions(project) {
11
+ const p = project ?? "<project>";
12
+ return `${BEGIN}
13
+ ## Parawatch
14
+
15
+ The human follows this work through \`parawatch\`. Use it instead of asking in chat. If a \`parawatch\` skill is available, follow it.
16
+
17
+ - At the start of a session: \`parawatch claim --project ${p}\` (add \`--role lead\` if you lead this project).
18
+
19
+ - Need a decision: \`parawatch ask --project ${p} "question" --option "..." --option "..." --recommend N --body "two or three lines"\`
20
+ - Need the human to try something (a build, a video, a screen): \`parawatch try --project ${p} "title" --open <app or file> --attach <video or image> --look "what to look at"\`
21
+ - Stuck until the human does something only they can do (install, sudo, sign in): \`parawatch block --project ${p} "what to do"\`
22
+ - Something only the human can do, but nothing waits on it now: \`parawatch todo --project ${p} "what to do"\`
23
+ - After opening a card, keep a background wait armed: \`parawatch wait --project ${p}\`. It prints the human's answers and exits; act on them, \`parawatch close <card> --note "what you did"\`, then arm it again.
24
+ - A long job with no agent log (a bake, a benchmark): \`parawatch run start --project ${p} "title"\`, \`parawatch run note <id> "..."\`, \`parawatch run end <id>\`.
25
+ - Never answer a card yourself. \`parawatch status\` shows everything that is open.
26
+ ${END}`;
27
+ }
28
+ /** Insert or replace the parawatch block in an instruction file. Returns true when the file changed. */
29
+ export function writeInstructions(file, project) {
30
+ let text = "";
31
+ try {
32
+ text = fs.readFileSync(file, "utf8");
33
+ }
34
+ catch (err) {
35
+ if (err.code !== "ENOENT")
36
+ throw err;
37
+ }
38
+ const block = instructions(project);
39
+ let next;
40
+ const a = text.indexOf(BEGIN);
41
+ const b = text.indexOf(END);
42
+ if (a >= 0 && b > a)
43
+ next = text.slice(0, a) + block + text.slice(b + END.length);
44
+ else
45
+ next = text + (text && !text.endsWith("\n") ? "\n" : "") + (text ? "\n" : "") + block + "\n";
46
+ if (next === text)
47
+ return false;
48
+ fs.writeFileSync(file, next);
49
+ return true;
50
+ }
51
+ /**
52
+ * Claude Code hook: given the hook's JSON on stdin, return text to add to the turn
53
+ * when the human has answered something for this session's project. Peeks only;
54
+ * the agent's own `wait` still advances its cursor.
55
+ */
56
+ export function hookContext(input, project) {
57
+ const events = readEvents();
58
+ const state = fold(events);
59
+ let name = project;
60
+ if (!name && input.session_id)
61
+ name = state.claims.find((c) => input.session_id.startsWith(c.session))?.project;
62
+ if (!name && input.cwd) {
63
+ for (const p of state.projects.values())
64
+ if (p.paths.some((x) => input.cwd === x || input.cwd.startsWith(x + "/")))
65
+ name = p.name;
66
+ }
67
+ if (!name)
68
+ return undefined;
69
+ const items = inboxFrom(events, readCursor(name), { project: name });
70
+ if (!items.length)
71
+ return undefined;
72
+ return [`Parawatch: the human answered ${items.length} card(s) for ${name} that you have not handled yet.`, ...items.map(inboxLine), `Act on them, then \`parawatch close <card>\`.`].join("\n");
73
+ }
74
+ /** Best-effort desktop notification. Never throws, never blocks. */
75
+ /** The desktop app writes its pid here while it runs, so the CLI leaves notifications to it. */
76
+ export function desktopPidPath() {
77
+ return path.join(homeDir(), "desktop.pid");
78
+ }
79
+ function desktopAppRunning() {
80
+ try {
81
+ const pid = Number(fs.readFileSync(desktopPidPath(), "utf8").trim());
82
+ if (!Number.isInteger(pid) || pid <= 0)
83
+ return false;
84
+ process.kill(pid, 0);
85
+ return true;
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ export function notify(card) {
92
+ if (process.env.PARAWATCH_NOTIFY === "0")
93
+ return;
94
+ if (card.kind === "todo")
95
+ return; // a to-do waits in the panel
96
+ if (desktopAppRunning())
97
+ return; // the app notifies, with its own badge
98
+ const title = `parawatch · ${card.project}`;
99
+ const body = `${card.kind === "decide" ? "Decide" : card.kind === "try" ? "Try" : "Blocked"}: ${card.title}`;
100
+ try {
101
+ if (process.platform === "darwin") {
102
+ const esc = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
103
+ spawn("osascript", ["-e", `display notification "${esc(body)}" with title "${esc(title)}"`], { stdio: "ignore", detached: true }).on("error", () => { }).unref();
104
+ }
105
+ else if (process.platform === "linux") {
106
+ spawn("notify-send", [title, body], { stdio: "ignore", detached: true }).on("error", () => { }).unref();
107
+ }
108
+ }
109
+ catch {
110
+ // no notifier available
111
+ }
112
+ }
113
+ export { readInbox };
114
+ //# sourceMappingURL=integrate.js.map
@@ -0,0 +1,271 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { appendEvent, eventsPath, readCursor, readEvents, writeCursor } from "./store.js";
4
+ import { fold, nextId } from "./state.js";
5
+ import { CARD_KINDS, HUMAN } from "./types.js";
6
+ export class UserError extends Error {
7
+ }
8
+ function cleanList(xs) {
9
+ const out = (xs ?? []).map((x) => x.trim()).filter(Boolean);
10
+ return out.length ? out : undefined;
11
+ }
12
+ function absPaths(xs) {
13
+ // attachments and things to open are kept as given when they look like URLs
14
+ return cleanList(xs)?.map((x) => (/^[a-z]+:\/\//i.test(x) ? x : path.resolve(x)));
15
+ }
16
+ export function addProject(name, paths) {
17
+ if (!/^[A-Za-z0-9._-]+$/.test(name))
18
+ throw new UserError(`project name "${name}" may only use letters, digits, . _ -`);
19
+ appendEvent(() => ({
20
+ type: "project.add",
21
+ project: { name, paths: paths.map((p) => path.resolve(p)), addedAt: new Date().toISOString() },
22
+ }));
23
+ }
24
+ export function removeProject(name) {
25
+ const s = fold(readEvents());
26
+ if (!s.projects.has(name))
27
+ throw new UserError(`no project named "${name}"`);
28
+ appendEvent(() => ({ type: "project.remove", name }));
29
+ }
30
+ /** A date or timestamp as ISO text; a bare date counts as local midnight. */
31
+ export function parseWhen(s) {
32
+ const t = s.trim();
33
+ const d = /^\d{4}-\d{2}-\d{2}$/.test(t) ? new Date(`${t}T00:00:00`) : new Date(t);
34
+ if (Number.isNaN(d.getTime()))
35
+ throw new UserError(`"${s}" is not a date (use YYYY-MM-DD)`);
36
+ return d.toISOString();
37
+ }
38
+ /** The claimed name of a session (claims may hold an id prefix). */
39
+ export function sessionTitle(claims, session) {
40
+ if (!session)
41
+ return undefined;
42
+ const c = claims.find((x) => session.startsWith(x.session) || x.session.startsWith(session));
43
+ return c?.title;
44
+ }
45
+ function checkKind(kind) {
46
+ if (!CARD_KINDS.includes(kind))
47
+ throw new UserError(`kind must be one of ${CARD_KINDS.join(", ")}`);
48
+ return kind;
49
+ }
50
+ export function openCard(input) {
51
+ if (!input.title.trim())
52
+ throw new UserError("a card needs a title");
53
+ if (!input.project.trim())
54
+ throw new UserError("a card needs --project");
55
+ const options = cleanList(input.options);
56
+ if (input.recommend !== undefined) {
57
+ if (!options)
58
+ throw new UserError("--recommend needs --option");
59
+ if (!Number.isInteger(input.recommend) || input.recommend < 1 || input.recommend > options.length) {
60
+ throw new UserError(`--recommend must be between 1 and ${options.length}`);
61
+ }
62
+ }
63
+ checkKind(input.kind);
64
+ const since = input.since?.trim() ? parseWhen(input.since) : undefined;
65
+ let id = "";
66
+ appendEvent((events) => {
67
+ id = nextId(events, "c");
68
+ const session = input.session?.trim() || undefined;
69
+ const from = input.from?.trim() || sessionTitle(fold(events).claims, session);
70
+ return {
71
+ type: "card.open",
72
+ card: {
73
+ id,
74
+ kind: input.kind,
75
+ project: input.project,
76
+ task: input.task || undefined,
77
+ title: input.title.trim(),
78
+ body: input.body?.trim() || undefined,
79
+ options,
80
+ recommend: input.recommend,
81
+ attach: absPaths(input.attach),
82
+ open: absPaths(input.open),
83
+ look: input.look?.trim() || undefined,
84
+ from,
85
+ session,
86
+ since,
87
+ },
88
+ };
89
+ });
90
+ const card = fold(readEvents()).cards.get(id);
91
+ if (!card)
92
+ throw new Error("card vanished right after it was written");
93
+ return card;
94
+ }
95
+ function requireCard(id) {
96
+ const card = fold(readEvents()).cards.get(id);
97
+ if (!card)
98
+ throw new UserError(`no card "${id}"`);
99
+ return card;
100
+ }
101
+ export function answerCard(id, a) {
102
+ const card = requireCard(id);
103
+ if (card.status === "closed")
104
+ throw new UserError(`card ${id} is already closed`);
105
+ if (a.choice === undefined && !a.comment?.trim() && !cleanList(a.attach)) {
106
+ throw new UserError("an answer needs --choice, --comment or --attach");
107
+ }
108
+ if (a.choice !== undefined) {
109
+ const n = card.options?.length ?? 0;
110
+ if (!Number.isInteger(a.choice) || a.choice < 1 || a.choice > n) {
111
+ throw new UserError(n ? `--choice must be between 1 and ${n}` : `card ${id} has no options`);
112
+ }
113
+ }
114
+ appendEvent(() => ({
115
+ type: "card.answer",
116
+ card: id,
117
+ by: a.by || HUMAN,
118
+ choice: a.choice,
119
+ comment: a.comment?.trim() || undefined,
120
+ attach: absPaths(a.attach),
121
+ }));
122
+ }
123
+ export function commentCard(id, text, by) {
124
+ requireCard(id);
125
+ if (!text.trim())
126
+ throw new UserError("a comment needs text");
127
+ appendEvent(() => ({ type: "card.comment", card: id, by, text: text.trim() }));
128
+ }
129
+ export function editCard(id, set) {
130
+ requireCard(id);
131
+ const clean = {};
132
+ if (set.kind !== undefined)
133
+ clean.kind = checkKind(set.kind);
134
+ if (set.title !== undefined) {
135
+ if (!set.title.trim())
136
+ throw new UserError("a card needs a title");
137
+ clean.title = set.title.trim();
138
+ }
139
+ // an empty value clears the field; null survives JSON where undefined would vanish
140
+ const cleared = null;
141
+ if (set.body !== undefined)
142
+ clean.body = set.body.trim() || cleared;
143
+ if (set.task !== undefined)
144
+ clean.task = set.task.trim() || cleared;
145
+ if (set.from !== undefined)
146
+ clean.from = set.from.trim() || cleared;
147
+ if (set.session !== undefined)
148
+ clean.session = set.session.trim() || cleared;
149
+ if (set.since !== undefined)
150
+ clean.since = set.since.trim() ? parseWhen(set.since) : cleared;
151
+ if (!Object.keys(clean).length)
152
+ throw new UserError("nothing to change");
153
+ appendEvent(() => ({ type: "card.edit", card: id, set: clean }));
154
+ return requireCard(id);
155
+ }
156
+ export function closeCard(id, note) {
157
+ const card = requireCard(id);
158
+ if (card.status === "closed")
159
+ return;
160
+ appendEvent(() => ({ type: "card.close", card: id, note: note?.trim() || undefined }));
161
+ }
162
+ export function startRun(r) {
163
+ let id = "";
164
+ appendEvent((events) => {
165
+ id = nextId(events, "r");
166
+ return { type: "run.start", run: { id, project: r.project, task: r.task, title: r.title, parent: r.parent } };
167
+ });
168
+ return id;
169
+ }
170
+ export function noteRun(id, text) {
171
+ if (!fold(readEvents()).runs.has(id))
172
+ throw new UserError(`no declared run "${id}"`);
173
+ appendEvent(() => ({ type: "run.note", run: id, text }));
174
+ }
175
+ export function endRun(id, status, note) {
176
+ if (!fold(readEvents()).runs.has(id))
177
+ throw new UserError(`no declared run "${id}"`);
178
+ appendEvent(() => ({ type: "run.end", run: id, status, note }));
179
+ }
180
+ export function claimSession(claim) {
181
+ if (claim.session.length < 6)
182
+ throw new UserError("give at least 6 characters of the session id");
183
+ appendEvent(() => ({ type: "run.claim", claim }));
184
+ }
185
+ /** Human answers and comments newer than `afterSeq`, on cards that are not closed. */
186
+ export function inboxFrom(events, afterSeq, filter) {
187
+ const state = fold(events);
188
+ const out = [];
189
+ for (const ev of events) {
190
+ if (ev.seq <= afterSeq)
191
+ continue;
192
+ if (ev.type !== "card.answer" && ev.type !== "card.comment")
193
+ continue;
194
+ if (ev.by !== HUMAN)
195
+ continue;
196
+ const card = state.cards.get(ev.card);
197
+ if (!card || card.status === "closed")
198
+ continue;
199
+ if (filter.project && card.project !== filter.project)
200
+ continue;
201
+ if (filter.card && card.id !== filter.card)
202
+ continue;
203
+ if (ev.type === "card.answer") {
204
+ out.push({
205
+ seq: ev.seq,
206
+ at: ev.ts,
207
+ kind: "answer",
208
+ card,
209
+ by: ev.by,
210
+ choice: ev.choice,
211
+ choiceText: ev.choice !== undefined ? card.options?.[ev.choice - 1] : undefined,
212
+ comment: ev.comment,
213
+ attach: ev.attach,
214
+ });
215
+ }
216
+ else {
217
+ out.push({ seq: ev.seq, at: ev.ts, kind: "comment", card, by: ev.by, comment: ev.text });
218
+ }
219
+ }
220
+ return out;
221
+ }
222
+ export function consumerName(opts) {
223
+ return opts.as || opts.project || (opts.card ? `card-${opts.card}` : "default");
224
+ }
225
+ export function readInbox(consumer, filter, advance) {
226
+ const events = readEvents();
227
+ const items = inboxFrom(events, readCursor(consumer), filter);
228
+ const last = items[items.length - 1];
229
+ if (advance && last)
230
+ writeCursor(consumer, last.seq);
231
+ return items;
232
+ }
233
+ /**
234
+ * Block until the consumer has something in its inbox, or the timeout passes.
235
+ * Returns the items (cursor advanced) or [] on timeout.
236
+ */
237
+ export async function waitInbox(consumer, filter, timeoutMs, pollMs = 1000) {
238
+ const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : Infinity;
239
+ let watcher;
240
+ let wake;
241
+ try {
242
+ const dir = path.dirname(eventsPath());
243
+ fs.mkdirSync(dir, { recursive: true });
244
+ watcher = fs.watch(dir, () => wake?.());
245
+ }
246
+ catch {
247
+ watcher = undefined; // polling alone still works
248
+ }
249
+ try {
250
+ for (;;) {
251
+ const items = readInbox(consumer, filter, true);
252
+ if (items.length)
253
+ return items;
254
+ const left = deadline - Date.now();
255
+ if (left <= 0)
256
+ return [];
257
+ await new Promise((resolve) => {
258
+ const t = setTimeout(resolve, Math.min(pollMs, left));
259
+ wake = () => {
260
+ clearTimeout(t);
261
+ resolve();
262
+ };
263
+ });
264
+ wake = undefined;
265
+ }
266
+ }
267
+ finally {
268
+ watcher?.close();
269
+ }
270
+ }
271
+ //# sourceMappingURL=ops.js.map