@luuthuong/devboard 0.0.0-stage → 1.0.1

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.
Files changed (80) hide show
  1. package/.claude/skills/devboard/SKILL.md +111 -0
  2. package/bin/devboard.mjs +59 -0
  3. package/dist/assets/CardDetailView-BZE_MfjM.js +24 -0
  4. package/dist/assets/DiffPane-2WnoRvZ0.js +22 -0
  5. package/dist/assets/DiffPane-CpoDDQrA.css +1 -0
  6. package/dist/assets/KnowledgeView-C4WPFAUM.js +3 -0
  7. package/dist/assets/ListBoardView-DEE_eK4h.js +3 -0
  8. package/dist/assets/NewTaskDialog-D8cpc7Bc.js +1 -0
  9. package/dist/assets/OnboardingWizard-9ZkFPGvH.js +1 -0
  10. package/dist/assets/ProcessManagerView-DJOr44DY.js +1 -0
  11. package/dist/assets/ProposalCard-B8jqFtL8.js +1 -0
  12. package/dist/assets/SearchOverlay-qGxzmM_v.js +1 -0
  13. package/dist/assets/SettingsView-C3GkCbTz.js +1 -0
  14. package/dist/assets/SetupProjectView-CSx6ax5r.js +1 -0
  15. package/dist/assets/Surface-BQKeigoq.js +1 -0
  16. package/dist/assets/TaskDescriptionEditor-D0_b81zY.js +58 -0
  17. package/dist/assets/Terminal-BrP-ENHg.css +1 -0
  18. package/dist/assets/Terminal-yq90NOBX.js +80 -0
  19. package/dist/assets/deepEqual-BiJVGDfM.js +1 -0
  20. package/dist/assets/index-BtXUncAU.css +1 -0
  21. package/dist/assets/index-CQnL8JJ5.js +18 -0
  22. package/dist/assets/jsx-runtime-QmcfnBgR.js +1 -0
  23. package/dist/assets/plus-Cecrm8sZ.js +1 -0
  24. package/dist/assets/react-dom-DjtJUrx3.js +1 -0
  25. package/dist/assets/x-B6wVesqk.js +1 -0
  26. package/dist/index.html +22 -0
  27. package/package.json +81 -4
  28. package/server-dist/server/assets/remote-shell.sh +239 -0
  29. package/server-dist/server/board.js +486 -0
  30. package/server-dist/server/commands.js +390 -0
  31. package/server-dist/server/context.js +34 -0
  32. package/server-dist/server/coordination.js +442 -0
  33. package/server-dist/server/editor.js +101 -0
  34. package/server-dist/server/events.js +17 -0
  35. package/server-dist/server/fsBrowse.js +143 -0
  36. package/server-dist/server/git.js +258 -0
  37. package/server-dist/server/harness/config.js +90 -0
  38. package/server-dist/server/harness/entry.js +98 -0
  39. package/server-dist/server/harness/intents.js +268 -0
  40. package/server-dist/server/harness/kbIndex.js +291 -0
  41. package/server-dist/server/harness/kbStore.js +258 -0
  42. package/server-dist/server/harness/librarian.js +147 -0
  43. package/server-dist/server/harness/pack.js +182 -0
  44. package/server-dist/server/harness/proposals.js +113 -0
  45. package/server-dist/server/harness/repo.js +150 -0
  46. package/server-dist/server/harness/scrub.js +30 -0
  47. package/server-dist/server/harness/service.js +683 -0
  48. package/server-dist/server/harness/template/README.md +38 -0
  49. package/server-dist/server/harness/template/agents/code-reviewer.md +23 -0
  50. package/server-dist/server/harness/template/agents/security-reviewer.md +20 -0
  51. package/server-dist/server/harness/template/agents/test-writer.md +16 -0
  52. package/server-dist/server/harness/template/agents.yaml +41 -0
  53. package/server-dist/server/harness/template/harness.json +11 -0
  54. package/server-dist/server/harness/template/kb/team/README.md +4 -0
  55. package/server-dist/server/harness/template/skills/aidlc-design/SKILL.md +41 -0
  56. package/server-dist/server/harness/template/skills/aidlc-implement/SKILL.md +34 -0
  57. package/server-dist/server/harness/template/skills/aidlc-postmortem/SKILL.md +35 -0
  58. package/server-dist/server/harness/template/skills/aidlc-pr/SKILL.md +22 -0
  59. package/server-dist/server/harness/template/skills/aidlc-requirements/SKILL.md +40 -0
  60. package/server-dist/server/harness/template/skills/aidlc-units/SKILL.md +36 -0
  61. package/server-dist/server/harness/template/skills/project-kb/SKILL.md +41 -0
  62. package/server-dist/server/harness/usage.js +82 -0
  63. package/server-dist/server/hooks.js +222 -0
  64. package/server-dist/server/http.js +266 -0
  65. package/server-dist/server/index.js +77 -0
  66. package/server-dist/server/integrations.js +292 -0
  67. package/server-dist/server/pollers.js +332 -0
  68. package/server-dist/server/processes.js +123 -0
  69. package/server-dist/server/psmux.js +120 -0
  70. package/server-dist/server/pty.js +93 -0
  71. package/server-dist/server/remote.js +216 -0
  72. package/server-dist/server/sessionReaper.js +101 -0
  73. package/server-dist/server/sessions.js +315 -0
  74. package/server-dist/server/settings.js +125 -0
  75. package/server-dist/server/skill.js +29 -0
  76. package/server-dist/server/tmux.js +217 -0
  77. package/server-dist/server/transcripts.js +334 -0
  78. package/server-dist/server/util.js +286 -0
  79. package/server-dist/src/types/index.js +53 -0
  80. package/README.md +0 -3
@@ -0,0 +1,292 @@
1
+ // Small adapters: context usage, statusline installer, Pushover, image storage,
2
+ // card merge rules. Ports context_usage.rs, statusline_installer.rs,
3
+ // pushover.rs, images.rs and merge_ops.rs.
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { devboardDataDir, ksuid, writeFileAtomic } from "./util.js";
8
+ /** Percentage-based when available (lifetime IO sum stays high after /compact). */
9
+ export function currentContextTokens(u) {
10
+ if (u.contextWindowSize > 0 && u.usedPercentage > 0) {
11
+ return Math.round((u.contextWindowSize * u.usedPercentage) / 100);
12
+ }
13
+ return u.totalInputTokens + u.totalOutputTokens;
14
+ }
15
+ /** `null` = no statusline JSON yet (treat as unknown, never as zero). */
16
+ export function readContextUsage(sessionId, base) {
17
+ const file = path.join(base ?? path.join(devboardDataDir(), "context"), `${sessionId}.json`);
18
+ try {
19
+ return JSON.parse(fs.readFileSync(file, "utf8"));
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** Latest snapshot for every session that has one, keyed by session id. */
26
+ export function readAllContextUsage(base) {
27
+ const dir = base ?? path.join(devboardDataDir(), "context");
28
+ const out = {};
29
+ let files;
30
+ try {
31
+ files = fs.readdirSync(dir);
32
+ }
33
+ catch {
34
+ return out;
35
+ }
36
+ for (const f of files) {
37
+ if (!f.endsWith(".json") || f.startsWith("."))
38
+ continue;
39
+ const id = f.slice(0, -".json".length);
40
+ const u = readContextUsage(id, dir);
41
+ if (!u)
42
+ continue;
43
+ out[id] = {
44
+ usedPercentage: u.usedPercentage,
45
+ tokens: currentContextTokens(u),
46
+ contextWindowSize: u.contextWindowSize,
47
+ inputTokens: u.totalInputTokens,
48
+ outputTokens: u.totalOutputTokens,
49
+ costUsd: u.totalCostUsd ?? 0,
50
+ model: u.model ?? "",
51
+ };
52
+ }
53
+ return out;
54
+ }
55
+ // ── Statusline installer ─────────────────────────────────────────────────────
56
+ const STATUSLINE_KEY = "statusLine";
57
+ const claudeSettingsPath = () => path.join(os.homedir(), ".claude", "settings.json");
58
+ /** Self-contained statusline script. It runs on the same Node as the server, so it
59
+ * needs neither `devboard` on PATH nor a built CLI. Snapshots land in ./context
60
+ * next to the script, which is where `readContextUsage` looks. */
61
+ const STATUSLINE_SCRIPT = `import fs from "node:fs";
62
+ import path from "node:path";
63
+ import { fileURLToPath } from "node:url";
64
+
65
+ let raw = "";
66
+ for await (const chunk of process.stdin) raw += chunk;
67
+ try {
68
+ const input = JSON.parse(raw);
69
+ const id = input?.session_id;
70
+ if (typeof id === "string" && /^[\\w-]+$/.test(id)) {
71
+ const cw = input.context_window ?? {};
72
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
73
+ const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), "context");
74
+ fs.mkdirSync(dir, { recursive: true });
75
+ const tmp = path.join(dir, "." + id + ".tmp");
76
+ fs.writeFileSync(
77
+ tmp,
78
+ JSON.stringify({
79
+ usedPercentage: num(cw.used_percentage),
80
+ contextWindowSize: num(cw.context_window_size),
81
+ totalInputTokens: num(cw.total_input_tokens),
82
+ totalOutputTokens: num(cw.total_output_tokens),
83
+ totalCostUsd: num(input.cost?.total_cost_usd),
84
+ model: typeof input.model?.display_name === "string" ? input.model.display_name : "",
85
+ })
86
+ );
87
+ fs.renameSync(tmp, path.join(dir, id + ".json"));
88
+ const pct = num(cw.used_percentage);
89
+ if (pct > 0) process.stdout.write("ctx " + Math.round(pct) + "%");
90
+ }
91
+ } catch {
92
+ // A statusline must never break the Claude session.
93
+ }
94
+ `;
95
+ const statuslineScriptPath = () => path.join(devboardDataDir(), "statusline.mjs");
96
+ /** Forward slashes survive both cmd and the bash Claude Code may run the command in. */
97
+ const slash = (p) => p.replace(/\\/g, "/");
98
+ function statuslineCommand() {
99
+ return `"${slash(process.execPath)}" "${slash(statuslineScriptPath())}"`;
100
+ }
101
+ async function readSettingsJson(p) {
102
+ let text;
103
+ try {
104
+ text = await fs.promises.readFile(p, "utf8");
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ if (!text.trim())
110
+ return null;
111
+ const v = JSON.parse(text);
112
+ if (typeof v !== "object" || v === null || Array.isArray(v)) {
113
+ // Bail rather than clobber a hand-edited oddball.
114
+ throw new Error("~/.claude/settings.json is not a JSON object");
115
+ }
116
+ return v;
117
+ }
118
+ export async function installStatusline() {
119
+ const p = claudeSettingsPath();
120
+ const root = (await readSettingsJson(p)) ?? {};
121
+ await writeFileAtomic(statuslineScriptPath(), STATUSLINE_SCRIPT);
122
+ root[STATUSLINE_KEY] = { type: "command", command: statuslineCommand(), padding: 0 };
123
+ await writeFileAtomic(p, JSON.stringify(root, null, 2));
124
+ }
125
+ export async function uninstallStatusline() {
126
+ const p = claudeSettingsPath();
127
+ const root = await readSettingsJson(p);
128
+ if (!root || !(STATUSLINE_KEY in root))
129
+ return;
130
+ delete root[STATUSLINE_KEY];
131
+ await writeFileAtomic(p, JSON.stringify(root, null, 2));
132
+ }
133
+ export async function statuslineInstalled() {
134
+ try {
135
+ const root = await readSettingsJson(claudeSettingsPath());
136
+ const cmd = root?.[STATUSLINE_KEY]?.command;
137
+ return (typeof cmd === "string" &&
138
+ (cmd.includes("statusline.mjs") || (cmd.includes("devboard") && cmd.includes("statusline"))));
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
144
+ // ── Pushover ─────────────────────────────────────────────────────────────────
145
+ export async function pushoverSend(token, userKey, title, message, cardId) {
146
+ if (!token || !userKey)
147
+ throw new Error("pushover token/user key not configured");
148
+ const form = new URLSearchParams({ token, user: userKey, title, message, html: "1" });
149
+ if (cardId) {
150
+ form.set("url", `devboard://card/${cardId}`);
151
+ form.set("url_title", "Open in DevBoard");
152
+ }
153
+ const resp = await fetch("https://api.pushover.net/1/messages.json", {
154
+ method: "POST",
155
+ body: form,
156
+ signal: AbortSignal.timeout(10_000),
157
+ });
158
+ if (!resp.ok)
159
+ throw new Error(`pushover responded ${resp.status}: ${await resp.text().catch(() => "")}`);
160
+ }
161
+ // ── Images ───────────────────────────────────────────────────────────────────
162
+ function extensionFor(b) {
163
+ if (b.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47])))
164
+ return "png";
165
+ if (b.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])))
166
+ return "jpg";
167
+ const head = b.subarray(0, 6).toString("latin1");
168
+ if (head === "GIF87a" || head === "GIF89a")
169
+ return "gif";
170
+ if (b.length > 11 &&
171
+ b.subarray(0, 4).toString("latin1") === "RIFF" &&
172
+ b.subarray(8, 12).toString("latin1") === "WEBP") {
173
+ return "webp";
174
+ }
175
+ return "png";
176
+ }
177
+ /** `<data_dir>/images/<ksuid>.<ext>`; returns the absolute path. */
178
+ export async function saveImageBytes(bytes) {
179
+ const dir = path.join(devboardDataDir(), "images");
180
+ await fs.promises.mkdir(dir, { recursive: true });
181
+ const file = path.join(dir, `${ksuid("img")}.${extensionFor(bytes)}`);
182
+ await fs.promises.writeFile(file, bytes);
183
+ return file;
184
+ }
185
+ /** `<data_dir>/attachments/<ksuid>-<name>`; returns the absolute path. */
186
+ export async function saveAttachmentBytes(bytes, name) {
187
+ const dir = path.join(devboardDataDir(), "attachments");
188
+ await fs.promises.mkdir(dir, { recursive: true });
189
+ const safe = path
190
+ .basename(name)
191
+ .replace(/[^\w.\- ]+/g, "_")
192
+ .slice(-120) || "file";
193
+ const file = path.join(dir, `${ksuid("att")}-${safe}`);
194
+ await fs.promises.writeFile(file, bytes);
195
+ return file;
196
+ }
197
+ /** Roots the file-serving endpoint may read from (images only). */
198
+ export function allowedImageRoots() {
199
+ return [devboardDataDir()];
200
+ }
201
+ // ── Card merge ───────────────────────────────────────────────────────────────
202
+ /** Reason a merge is blocked, or null when it can proceed. */
203
+ export function mergeBlocked(source, target) {
204
+ if (source.id === target.id)
205
+ return "cannot merge a card into itself";
206
+ if (source.isLaunching === true || target.isLaunching === true) {
207
+ return "card is mid-launch — finish the launch before merging";
208
+ }
209
+ if (source.sessionLink && target.sessionLink) {
210
+ return "both cards have a Claude session — cannot collapse two live sessions";
211
+ }
212
+ if (source.worktreeLink && target.worktreeLink && source.worktreeLink.path !== target.worktreeLink.path) {
213
+ return `worktrees differ: ${source.worktreeLink.path} vs ${target.worktreeLink.path}`;
214
+ }
215
+ if (source.issueLink && target.issueLink && source.issueLink.number !== target.issueLink.number) {
216
+ return `issue numbers differ: #${source.issueLink.number} vs #${target.issueLink.number}`;
217
+ }
218
+ return null;
219
+ }
220
+ const later = (a, b) => a === undefined ? b : b === undefined ? a : Date.parse(b) > Date.parse(a) ? b : a;
221
+ /** Fold `source` into `target` (nil-slot-wins). Callers must run mergeBlocked first. */
222
+ export function mergeIntoTarget(source, target) {
223
+ target.name ??= source.name;
224
+ target.projectPath ??= source.projectPath;
225
+ if (target.promptBody === undefined) {
226
+ target.promptBody = source.promptBody;
227
+ // Image attachments travel with the prompt body.
228
+ target.promptImagePaths ??= source.promptImagePaths;
229
+ }
230
+ target.sessionLink ??= source.sessionLink;
231
+ target.worktreeLink ??= source.worktreeLink;
232
+ target.issueLink ??= source.issueLink;
233
+ if (!target.pinnedAt) {
234
+ target.pinnedAt = source.pinnedAt;
235
+ target.pinnedSortOrder = source.pinnedSortOrder;
236
+ }
237
+ // PR links: union by number, target wins on conflict.
238
+ const have = new Set(target.prLinks.map((p) => p.number));
239
+ for (const pr of source.prLinks) {
240
+ if (!have.has(pr.number)) {
241
+ target.prLinks.push(pr);
242
+ have.add(pr.number);
243
+ }
244
+ }
245
+ if (source.discoveredBranches) {
246
+ const merged = target.discoveredBranches ?? [];
247
+ for (const b of source.discoveredBranches)
248
+ if (!merged.includes(b))
249
+ merged.push(b);
250
+ target.discoveredBranches = merged;
251
+ }
252
+ target.lastActivity = later(target.lastActivity, source.lastActivity);
253
+ target.isRemote = target.isRemote || source.isRemote;
254
+ target.updatedAt = new Date().toISOString();
255
+ target.lastOpenedAt = later(target.lastOpenedAt, source.lastOpenedAt);
256
+ // Left alone on the target: id, column, createdAt, manualOverrides,
257
+ // manuallyArchived, source, isLaunching, queuedPrompts, sortOrder,
258
+ // assistantId, browserTabs.
259
+ }
260
+ // ── Card launcher ────────────────────────────────────────────────────────────
261
+ /**
262
+ * cmd.exe ends a command at the first newline, so a multi-line prompt cannot ride on the
263
+ * `claude "<prompt>"` line. This runner takes the prompt as base64 (one safe token) and hands it
264
+ * to the command as a single argv entry, newlines and quotes intact.
265
+ *
266
+ * node launch.mjs <promptBase64> <command> [args...] → <command> [args...] <prompt>
267
+ */
268
+ const LAUNCH_SCRIPT = `// DevBoard card launcher. Generated; edits are overwritten.
269
+ import { spawn } from "node:child_process";
270
+
271
+ const [b64, exe, ...args] = process.argv.slice(2);
272
+ if (!b64 || !exe) {
273
+ console.error("usage: node launch.mjs <promptBase64> <command> [args...]");
274
+ process.exit(2);
275
+ }
276
+ const prompt = Buffer.from(b64, "base64").toString("utf8");
277
+ const child = spawn(exe, [...args, prompt], { stdio: "inherit" });
278
+ child.on("error", (e) => {
279
+ console.error("Could not start " + exe + ": " + e.message);
280
+ process.exit(127);
281
+ });
282
+ child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
283
+ `;
284
+ const launchScriptPath = () => path.join(devboardDataDir(), "launch.mjs");
285
+ /** Writes the launcher (idempotent) and returns the paths the UI needs to build its command line. */
286
+ export async function ensureLauncher() {
287
+ const script = launchScriptPath();
288
+ const current = await fs.promises.readFile(script, "utf8").catch(() => null);
289
+ if (current !== LAUNCH_SCRIPT)
290
+ await writeFileAtomic(script, LAUNCH_SCRIPT);
291
+ return { node: process.execPath, script };
292
+ }
@@ -0,0 +1,332 @@
1
+ // Background loops. Ports start_polling, start_pr_polling, start_issue_polling,
2
+ // start_hook_polling, start_remote_polling and start_self_compact_polling from
3
+ // lib.rs. Each loop is a self-rescheduling timer so a slow tick can't overlap
4
+ // itself.
5
+ import { notifyUser } from "./context.js";
6
+ import { emit } from "./events.js";
7
+ import { fetchIssues, fetchPrs, fetchUnresolvedThreads } from "./git.js";
8
+ import { settleIntents } from "./harness/intents.js";
9
+ import { HookEventStore, installIfNeeded } from "./hooks.js";
10
+ import { currentContextTokens, readContextUsage } from "./integrations.js";
11
+ import { RemoteStatusWatcher, syncStatus } from "./remote.js";
12
+ import { reapSessions } from "./sessionReaper.js";
13
+ import { readLastAssistantMessage } from "./transcripts.js";
14
+ import { errorMessage, log } from "./util.js";
15
+ /** Run `fn` forever, `delayMs()` after each completion. Errors are logged, never fatal. */
16
+ function loop(name, initialDelayMs, delayMs, fn) {
17
+ const tick = async () => {
18
+ try {
19
+ await fn();
20
+ }
21
+ catch (e) {
22
+ log.warn(name, `tick failed: ${errorMessage(e)}`);
23
+ }
24
+ let next = 5_000;
25
+ try {
26
+ next = await delayMs();
27
+ }
28
+ catch {
29
+ // keep the default
30
+ }
31
+ setTimeout(tick, next).unref();
32
+ };
33
+ setTimeout(tick, initialDelayMs).unref();
34
+ }
35
+ // Skip the broadcast when nothing visible changed (relativeTime has minute
36
+ // granularity, so the label still refreshes when it flips).
37
+ let lastBoardSig = "";
38
+ async function refreshAndBroadcast(ctx) {
39
+ const s = await ctx.settings.read().catch(() => null);
40
+ const projectPaths = (s?.projects ?? []).flatMap((pr) => [pr.path, pr.repoRoot].filter((x) => !!x));
41
+ await ctx.board.refresh(ctx.discovery, ctx.store, projectPaths);
42
+ const dto = ctx.board.toDto();
43
+ const sig = JSON.stringify(dto.cards);
44
+ if (sig === lastBoardSig)
45
+ return;
46
+ lastBoardSig = sig;
47
+ emit("board-updated", dto);
48
+ }
49
+ // ── Board (every 5 s) ────────────────────────────────────────────────────────
50
+ function startBoardPolling(ctx) {
51
+ loop("poll", 5_000, () => 5_000, async () => {
52
+ try {
53
+ await refreshAndBroadcast(ctx);
54
+ }
55
+ catch (e) {
56
+ log.warn("poll", `board refresh failed: ${errorMessage(e)}`);
57
+ return;
58
+ }
59
+ // Cards whose agent just finished a turn → notify (browser + Pushover).
60
+ for (const card of ctx.board.drainNotificationCandidates()) {
61
+ const p = card.link.sessionLink?.sessionPath;
62
+ const body = (p ? await readLastAssistantMessage(p, 240) : null) ?? "Claude finished — your input is needed.";
63
+ await notifyUser(ctx, { title: card.displayTitle, body, threadId: card.id });
64
+ }
65
+ });
66
+ }
67
+ // ── PRs (every 60 s, offset 15 s) ────────────────────────────────────────────
68
+ function startPrPolling(ctx) {
69
+ loop("pr-poll", 15_000, () => 60_000, async () => {
70
+ const links = await ctx.store.readLinks();
71
+ const projects = [
72
+ ...new Set(links.filter((l) => l.worktreeLink?.branch && l.projectPath).map((l) => l.projectPath)),
73
+ ];
74
+ // branch → PR info, gathered outside the store lock (network + gh forks).
75
+ const updates = [];
76
+ for (const project of projects) {
77
+ const prs = await fetchPrs(project).catch(() => []);
78
+ const threads = await fetchUnresolvedThreads(prs).catch(() => new Map());
79
+ for (const pr of prs)
80
+ updates.push({ project, pr, threads: threads.get(pr.number) });
81
+ }
82
+ let changed = false;
83
+ await ctx.store.update((current) => {
84
+ for (const { pr, threads } of updates) {
85
+ for (const link of current) {
86
+ if (link.worktreeLink?.branch !== pr.headRef)
87
+ continue;
88
+ const prLink = {
89
+ number: pr.number,
90
+ url: pr.url,
91
+ status: pr.state,
92
+ title: pr.title,
93
+ body: pr.body,
94
+ approvalCount: pr.approvalCount,
95
+ unresolvedThreads: threads,
96
+ mergeStateStatus: pr.mergeStateStatus,
97
+ reviewDecision: pr.reviewDecision,
98
+ checkRuns: pr.checkRuns,
99
+ };
100
+ const i = link.prLinks.findIndex((p) => p.number === pr.number);
101
+ if (i >= 0)
102
+ link.prLinks[i] = prLink;
103
+ else
104
+ link.prLinks.push(prLink);
105
+ changed = true;
106
+ }
107
+ }
108
+ return changed ? current : false;
109
+ });
110
+ if (changed)
111
+ await refreshAndBroadcast(ctx);
112
+ });
113
+ }
114
+ // ── GitHub issues (settings.github.pollIntervalSeconds, offset 10 s) ─────────
115
+ function startIssuePolling(ctx) {
116
+ loop("issue-poll", 10_000, async () => ((await ctx.settings.read().catch(() => null))?.github.pollIntervalSeconds ?? 60) * 1000, async () => {
117
+ const settings = await ctx.settings.read();
118
+ const projectFilters = settings.projects
119
+ .map((p) => ({
120
+ repoRoot: p.repoRoot ?? p.path,
121
+ filter: p.githubFilter ?? settings.github.defaultFilter,
122
+ }))
123
+ .filter((pf) => pf.filter);
124
+ if (projectFilters.length === 0)
125
+ return;
126
+ const fetchedKeys = new Set();
127
+ const fetched = [];
128
+ for (const { repoRoot, filter } of projectFilters) {
129
+ for (const issue of await fetchIssues(repoRoot, filter).catch(() => [])) {
130
+ fetchedKeys.add(`${repoRoot}:${issue.number}`);
131
+ fetched.push({ repoRoot, issue });
132
+ }
133
+ }
134
+ const roots = new Set(projectFilters.map((pf) => pf.repoRoot));
135
+ let changed = false;
136
+ const existing = await ctx.store.readLinks();
137
+ for (const { repoRoot, issue } of fetched) {
138
+ const exists = existing.some((l) => l.issueLink?.number === issue.number && l.projectPath === repoRoot);
139
+ if (exists)
140
+ continue;
141
+ const prompt = settings.githubIssuePromptTemplate
142
+ .replaceAll("${number}", String(issue.number))
143
+ .replaceAll("${title}", issue.title)
144
+ .replaceAll("${body}", issue.body ?? "")
145
+ .replaceAll("${url}", issue.url);
146
+ await ctx.store.createIssueCard({
147
+ projectPath: repoRoot,
148
+ issueNumber: issue.number,
149
+ issueTitle: issue.title,
150
+ issueUrl: issue.url,
151
+ issueBody: issue.body,
152
+ promptBody: prompt,
153
+ });
154
+ changed = true;
155
+ }
156
+ // Drop backlog issue-cards whose issue no longer matches the filter.
157
+ await ctx.store.update((links) => {
158
+ const keep = (l) => {
159
+ if (l.source !== "github_issue" || l.column !== "backlog")
160
+ return true;
161
+ if (!l.projectPath || !roots.has(l.projectPath) || !l.issueLink)
162
+ return true;
163
+ return fetchedKeys.has(`${l.projectPath}:${l.issueLink.number}`);
164
+ };
165
+ const next = links.filter(keep);
166
+ if (next.length === links.length)
167
+ return false;
168
+ changed = true;
169
+ return next;
170
+ });
171
+ if (changed)
172
+ await refreshAndBroadcast(ctx);
173
+ });
174
+ }
175
+ // ── Hook events (every 1 s) ──────────────────────────────────────────────────
176
+ function startHookPolling(ctx) {
177
+ void (async () => {
178
+ // Installed in the background so a slow WSL boot doesn't delay startup.
179
+ try {
180
+ if (!(await installIfNeeded()))
181
+ return; // intentionally skipped — no tail loop
182
+ }
183
+ catch (e) {
184
+ log.warn("hooks", `install failed: ${errorMessage(e)} — tail loop will still run`);
185
+ }
186
+ const store = new HookEventStore();
187
+ store.touch();
188
+ store.skipToTail(); // don't replay history from a previous run
189
+ loop("hooks", 1_000, () => 1_000, async () => {
190
+ let moved = false;
191
+ for (const ev of await store.readNewEvents()) {
192
+ log.debug("hooks", `event session=${ev.sessionId} name=${ev.eventName} type=${ev.notificationType ?? "-"} at=${ev.timestamp}`);
193
+ emit("hook-event", ev);
194
+ // A session that ends or compacts is a natural point to distill what it learned.
195
+ if ((ev.eventName === "SessionEnd" || ev.eventName === "PreCompact") && ev.transcriptPath) {
196
+ void distill(ctx, { sessionId: ev.sessionId, transcriptPath: ev.transcriptPath });
197
+ }
198
+ if (ctx.board.applyHookEvent(ev.sessionId, ev.eventName, ev.notificationType, Date.parse(ev.timestamp))) {
199
+ moved = true;
200
+ }
201
+ }
202
+ // Re-run the board now rather than waiting for the 5 s tick.
203
+ if (moved)
204
+ await refreshAndBroadcast(ctx).catch((e) => log.warn("hooks", `refresh failed: ${errorMessage(e)}`));
205
+ });
206
+ })();
207
+ }
208
+ // ── Remote status (every 5 s) ────────────────────────────────────────────────
209
+ function startRemotePolling() {
210
+ const watcher = new RemoteStatusWatcher();
211
+ let last = null;
212
+ loop("remote", 5_000, () => 5_000, async () => {
213
+ const status = await syncStatus();
214
+ if (!last || last.kind !== status.kind || last.conflictCount !== status.conflictCount) {
215
+ emit("sync_status_event", status);
216
+ last = status;
217
+ }
218
+ for (const change of await watcher.poll())
219
+ emit("remote_status_changed", change);
220
+ });
221
+ }
222
+ // ── Self-compact guard ───────────────────────────────────────────────────────
223
+ /** Queue a nudge for any session whose context crossed a rule's threshold. The
224
+ * highest crossed threshold wins; the store dedupes repeats. */
225
+ function startSelfCompactPolling(ctx) {
226
+ loop("self-compact", 1_000, async () => {
227
+ const s = await ctx.settings.read().catch(() => null);
228
+ return s ? Math.max(1, s.selfCompact?.pollIntervalSeconds ?? 30) * 1000 : 30_000;
229
+ }, async () => {
230
+ const s = await ctx.settings.read();
231
+ if (!s.selfCompact?.enabled)
232
+ return;
233
+ const rules = [...s.selfCompact.rules].sort((a, b) => b.thresholdTokens - a.thresholdTokens);
234
+ for (const link of await ctx.store.readLinks()) {
235
+ const sessionId = link.sessionLink?.sessionId;
236
+ if (!sessionId)
237
+ continue;
238
+ const usage = readContextUsage(sessionId);
239
+ if (!usage)
240
+ continue;
241
+ const used = currentContextTokens(usage);
242
+ const rule = rules.find((r) => used >= r.thresholdTokens);
243
+ if (rule)
244
+ await ctx.store.enqueueSelfCompactPrompt(link.id, rule.message, rule.thresholdTokens);
245
+ }
246
+ });
247
+ }
248
+ // ── Terminal session housekeeping (every 30 s) ───────────────────────────────
249
+ function startSessionReaper(ctx) {
250
+ loop("sessions", 30_000, () => 30_000, () => reapSessions(ctx));
251
+ }
252
+ // ── Knowledge harness (every minute) ────────────────────────────────────────
253
+ function distill(ctx, args) {
254
+ return ctx.harness.distillSession(args).then((added) => {
255
+ if (!added.length)
256
+ return;
257
+ emit("harness-updated", { proposals: added.length });
258
+ return notifyUser(ctx, {
259
+ title: added.length === 1 ? "Knowledge to review" : `${added.length} knowledge entries to review`,
260
+ body: added.length === 1 ? added[0].title : added.map((p) => p.title).join(" · "),
261
+ threadId: "knowledge-review",
262
+ });
263
+ }, (e) => log.warn("librarian", `distill failed: ${errorMessage(e)}`));
264
+ }
265
+ function startHarnessPolling(ctx) {
266
+ const distilledCards = new Set();
267
+ let lastSync = 0;
268
+ let lastMaintain = 0;
269
+ let packInstalled = false;
270
+ let doneSeeded = false;
271
+ loop("harness", 2_000, () => 60_000, async () => {
272
+ const s = await ctx.settings.read().catch(() => null);
273
+ const h = s?.harness;
274
+ const now = Date.now();
275
+ if (lastSync === 0) {
276
+ await ctx.harness.reload();
277
+ lastSync = now;
278
+ }
279
+ else if (now - lastSync >= (h?.pullIntervalMinutes ?? 10) * 60_000) {
280
+ lastSync = now;
281
+ if (await ctx.harness.sync())
282
+ emit("harness-updated", {});
283
+ }
284
+ if (!ctx.harness.configured) {
285
+ packInstalled = false;
286
+ return;
287
+ }
288
+ // Once per configuration (startup, or a repo set in Settings): skills, subagents, hook.
289
+ if (!packInstalled) {
290
+ packInstalled = true;
291
+ await ctx.harness.installPack().catch((e) => log.warn("harness", `pack install failed: ${errorMessage(e)}`));
292
+ }
293
+ // Cards that reach Done from now on: distill their session once. Cards already
294
+ // done when the harness starts are history, not new work, so they are skipped.
295
+ const links = await ctx.store.readLinks();
296
+ if (!doneSeeded) {
297
+ doneSeeded = true;
298
+ for (const l of links)
299
+ if (l.column === "done")
300
+ distilledCards.add(l.id);
301
+ }
302
+ for (const l of links) {
303
+ if (l.column !== "done" || distilledCards.has(l.id))
304
+ continue;
305
+ distilledCards.add(l.id);
306
+ const sid = l.sessionLink?.sessionId;
307
+ const transcriptPath = l.sessionLink?.sessionPath;
308
+ if (sid && transcriptPath)
309
+ await distill(ctx, { sessionId: sid, transcriptPath, cardId: l.id });
310
+ }
311
+ const settled = await settleIntents({ store: ctx.store, intents: ctx.harness.intents });
312
+ if (settled.length)
313
+ emit("harness-updated", { intents: settled.length });
314
+ if (now - lastMaintain >= 24 * 3_600_000) {
315
+ lastMaintain = now;
316
+ const paths = (s?.projects ?? []).map((p) => p.repoRoot ?? p.path);
317
+ const report = await ctx.harness.maintain(paths);
318
+ if (report.stale.length)
319
+ log.info("harness", `${report.stale.length} entr(ies) may be outdated`);
320
+ }
321
+ });
322
+ }
323
+ export function startPollers(ctx) {
324
+ startHarnessPolling(ctx);
325
+ startBoardPolling(ctx);
326
+ startPrPolling(ctx);
327
+ startIssuePolling(ctx);
328
+ startHookPolling(ctx);
329
+ startRemotePolling();
330
+ startSelfCompactPolling(ctx);
331
+ startSessionReaper(ctx);
332
+ }