@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.
- package/.claude/skills/devboard/SKILL.md +111 -0
- package/bin/devboard.mjs +59 -0
- package/dist/assets/CardDetailView-BZE_MfjM.js +24 -0
- package/dist/assets/DiffPane-2WnoRvZ0.js +22 -0
- package/dist/assets/DiffPane-CpoDDQrA.css +1 -0
- package/dist/assets/KnowledgeView-C4WPFAUM.js +3 -0
- package/dist/assets/ListBoardView-DEE_eK4h.js +3 -0
- package/dist/assets/NewTaskDialog-D8cpc7Bc.js +1 -0
- package/dist/assets/OnboardingWizard-9ZkFPGvH.js +1 -0
- package/dist/assets/ProcessManagerView-DJOr44DY.js +1 -0
- package/dist/assets/ProposalCard-B8jqFtL8.js +1 -0
- package/dist/assets/SearchOverlay-qGxzmM_v.js +1 -0
- package/dist/assets/SettingsView-C3GkCbTz.js +1 -0
- package/dist/assets/SetupProjectView-CSx6ax5r.js +1 -0
- package/dist/assets/Surface-BQKeigoq.js +1 -0
- package/dist/assets/TaskDescriptionEditor-D0_b81zY.js +58 -0
- package/dist/assets/Terminal-BrP-ENHg.css +1 -0
- package/dist/assets/Terminal-yq90NOBX.js +80 -0
- package/dist/assets/deepEqual-BiJVGDfM.js +1 -0
- package/dist/assets/index-BtXUncAU.css +1 -0
- package/dist/assets/index-CQnL8JJ5.js +18 -0
- package/dist/assets/jsx-runtime-QmcfnBgR.js +1 -0
- package/dist/assets/plus-Cecrm8sZ.js +1 -0
- package/dist/assets/react-dom-DjtJUrx3.js +1 -0
- package/dist/assets/x-B6wVesqk.js +1 -0
- package/dist/index.html +22 -0
- package/package.json +81 -4
- package/server-dist/server/assets/remote-shell.sh +239 -0
- package/server-dist/server/board.js +486 -0
- package/server-dist/server/commands.js +390 -0
- package/server-dist/server/context.js +34 -0
- package/server-dist/server/coordination.js +442 -0
- package/server-dist/server/editor.js +101 -0
- package/server-dist/server/events.js +17 -0
- package/server-dist/server/fsBrowse.js +143 -0
- package/server-dist/server/git.js +258 -0
- package/server-dist/server/harness/config.js +90 -0
- package/server-dist/server/harness/entry.js +98 -0
- package/server-dist/server/harness/intents.js +268 -0
- package/server-dist/server/harness/kbIndex.js +291 -0
- package/server-dist/server/harness/kbStore.js +258 -0
- package/server-dist/server/harness/librarian.js +147 -0
- package/server-dist/server/harness/pack.js +182 -0
- package/server-dist/server/harness/proposals.js +113 -0
- package/server-dist/server/harness/repo.js +150 -0
- package/server-dist/server/harness/scrub.js +30 -0
- package/server-dist/server/harness/service.js +683 -0
- package/server-dist/server/harness/template/README.md +38 -0
- package/server-dist/server/harness/template/agents/code-reviewer.md +23 -0
- package/server-dist/server/harness/template/agents/security-reviewer.md +20 -0
- package/server-dist/server/harness/template/agents/test-writer.md +16 -0
- package/server-dist/server/harness/template/agents.yaml +41 -0
- package/server-dist/server/harness/template/harness.json +11 -0
- package/server-dist/server/harness/template/kb/team/README.md +4 -0
- package/server-dist/server/harness/template/skills/aidlc-design/SKILL.md +41 -0
- package/server-dist/server/harness/template/skills/aidlc-implement/SKILL.md +34 -0
- package/server-dist/server/harness/template/skills/aidlc-postmortem/SKILL.md +35 -0
- package/server-dist/server/harness/template/skills/aidlc-pr/SKILL.md +22 -0
- package/server-dist/server/harness/template/skills/aidlc-requirements/SKILL.md +40 -0
- package/server-dist/server/harness/template/skills/aidlc-units/SKILL.md +36 -0
- package/server-dist/server/harness/template/skills/project-kb/SKILL.md +41 -0
- package/server-dist/server/harness/usage.js +82 -0
- package/server-dist/server/hooks.js +222 -0
- package/server-dist/server/http.js +266 -0
- package/server-dist/server/index.js +77 -0
- package/server-dist/server/integrations.js +292 -0
- package/server-dist/server/pollers.js +332 -0
- package/server-dist/server/processes.js +123 -0
- package/server-dist/server/psmux.js +120 -0
- package/server-dist/server/pty.js +93 -0
- package/server-dist/server/remote.js +216 -0
- package/server-dist/server/sessionReaper.js +101 -0
- package/server-dist/server/sessions.js +315 -0
- package/server-dist/server/settings.js +125 -0
- package/server-dist/server/skill.js +29 -0
- package/server-dist/server/tmux.js +217 -0
- package/server-dist/server/transcripts.js +334 -0
- package/server-dist/server/util.js +286 -0
- package/server-dist/src/types/index.js +53 -0
- package/README.md +0 -3
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
// The board pipeline: activity detection → reconcile → column assignment →
|
|
2
|
+
// CardDto. Ports activity_detector.rs, assign_column.rs, card_reconciler.rs and
|
|
3
|
+
// board_state.rs.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { displayTitle, newLinkBase } from "./coordination.js";
|
|
6
|
+
import { listWorktrees } from "./git.js";
|
|
7
|
+
import { sessionDisplayTitle } from "./sessions.js";
|
|
8
|
+
import { ksuid } from "./util.js";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
// ── Activity ─────────────────────────────────────────────────────────────────
|
|
11
|
+
/** Hook events that move a session's state; everything else is ignored. */
|
|
12
|
+
export function activityFromHook(eventName, notificationType) {
|
|
13
|
+
switch (eventName) {
|
|
14
|
+
case "UserPromptSubmit":
|
|
15
|
+
return "activelyWorking";
|
|
16
|
+
// A (re)opened session sits at its prompt until a UserPromptSubmit says otherwise.
|
|
17
|
+
case "SessionStart":
|
|
18
|
+
case "Stop":
|
|
19
|
+
return "needsAttention";
|
|
20
|
+
case "Notification":
|
|
21
|
+
// A permission/elicitation prompt blocks the turn; an idle prompt means it already ended.
|
|
22
|
+
return notificationType === "idle_prompt" ? "needsAttention" : "idleWaiting";
|
|
23
|
+
case "SessionEnd":
|
|
24
|
+
return "ended";
|
|
25
|
+
default:
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** A file write this long after a waiting hook means Claude resumed without a prompt hook. */
|
|
30
|
+
const HOOK_RESUME_MS = 5_000;
|
|
31
|
+
/**
|
|
32
|
+
* Per-session state: Claude Code hook events when we have them (exact), else
|
|
33
|
+
* JSONL mtimes compared across polls (changing = working, stable = waiting).
|
|
34
|
+
*/
|
|
35
|
+
export class ActivityTracker {
|
|
36
|
+
prev = new Map();
|
|
37
|
+
hooked = new Map();
|
|
38
|
+
applyHook(sessionId, state, at) {
|
|
39
|
+
const cur = this.hooked.get(sessionId);
|
|
40
|
+
if (!cur || at >= cur.at)
|
|
41
|
+
this.hooked.set(sessionId, { state, at });
|
|
42
|
+
}
|
|
43
|
+
detect(jsonlPath, sessionId) {
|
|
44
|
+
let mtime = 0;
|
|
45
|
+
try {
|
|
46
|
+
mtime = fs.statSync(jsonlPath).mtimeMs;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// missing file → epoch → stale
|
|
50
|
+
}
|
|
51
|
+
const elapsed = Date.now() - mtime;
|
|
52
|
+
const prev = this.prev.get(jsonlPath);
|
|
53
|
+
this.prev.set(jsonlPath, mtime);
|
|
54
|
+
if (elapsed > 86_400_000)
|
|
55
|
+
return "stale";
|
|
56
|
+
if (elapsed > 5 * 60_000)
|
|
57
|
+
return "ended";
|
|
58
|
+
const hook = sessionId ? this.hooked.get(sessionId) : undefined;
|
|
59
|
+
if (hook) {
|
|
60
|
+
// Working on the hook's word, unless the transcript went quiet (crash: no Stop ever came).
|
|
61
|
+
if (hook.state === "activelyWorking")
|
|
62
|
+
return "activelyWorking";
|
|
63
|
+
if (hook.state !== "ended" && mtime - hook.at > HOOK_RESUME_MS)
|
|
64
|
+
return "activelyWorking";
|
|
65
|
+
return hook.state;
|
|
66
|
+
}
|
|
67
|
+
if (prev !== undefined)
|
|
68
|
+
return mtime !== prev ? "activelyWorking" : "needsAttention";
|
|
69
|
+
return elapsed < 10_000 ? "activelyWorking" : "needsAttention";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// ── Column assignment ────────────────────────────────────────────────────────
|
|
73
|
+
export function assignColumn(link, activity, hasWorktree) {
|
|
74
|
+
const hasPr = link.prLinks.length > 0;
|
|
75
|
+
const allPrsDone = hasPr && link.prLinks.every((p) => p.status === "MERGED" || p.status === "CLOSED");
|
|
76
|
+
if (link.manuallyArchived)
|
|
77
|
+
return "all_sessions";
|
|
78
|
+
if (activity === "activelyWorking")
|
|
79
|
+
return "in_progress";
|
|
80
|
+
if (allPrsDone)
|
|
81
|
+
return "done";
|
|
82
|
+
if (link.manualOverrides.column)
|
|
83
|
+
return link.column;
|
|
84
|
+
if (hasPr && activity && ["needsAttention", "idleWaiting", "ended", "stale"].includes(activity)) {
|
|
85
|
+
return "in_review";
|
|
86
|
+
}
|
|
87
|
+
switch (activity) {
|
|
88
|
+
case "needsAttention":
|
|
89
|
+
case "idleWaiting":
|
|
90
|
+
return "in_progress";
|
|
91
|
+
case "ended":
|
|
92
|
+
if (hasWorktree)
|
|
93
|
+
return "in_progress";
|
|
94
|
+
break;
|
|
95
|
+
default:
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
if (link.source === "github_issue" && !link.sessionLink)
|
|
99
|
+
return "backlog";
|
|
100
|
+
if (link.source === "manual" && !link.sessionLink)
|
|
101
|
+
return "backlog";
|
|
102
|
+
if (hasWorktree)
|
|
103
|
+
return "in_progress";
|
|
104
|
+
if (link.lastActivity) {
|
|
105
|
+
const hours = (Date.now() - Date.parse(link.lastActivity)) / 3_600_000;
|
|
106
|
+
if (hours < 24)
|
|
107
|
+
return "in_progress";
|
|
108
|
+
}
|
|
109
|
+
return "all_sessions";
|
|
110
|
+
}
|
|
111
|
+
export function updateCardColumn(link, activity, hasWorktree) {
|
|
112
|
+
const col = assignColumn(link, activity, hasWorktree);
|
|
113
|
+
if (col !== link.column) {
|
|
114
|
+
link.column = col;
|
|
115
|
+
link.updatedAt = new Date().toISOString();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// ── Reconcile ────────────────────────────────────────────────────────────────
|
|
119
|
+
const stripRefsHeads = (b) => (b.startsWith("refs/heads/") ? b.slice("refs/heads/".length) : b);
|
|
120
|
+
const isTrunk = (b) => b === "main" || b === "master";
|
|
121
|
+
function projectMatches(session, link) {
|
|
122
|
+
if (!session.projectPath || !link.projectPath)
|
|
123
|
+
return true;
|
|
124
|
+
return session.projectPath === link.projectPath || session.projectPath.startsWith(`${link.projectPath}/`);
|
|
125
|
+
}
|
|
126
|
+
function findCardForSession(session, cardIdBySessionId, cardsByBranch, linksById) {
|
|
127
|
+
const exact = cardIdBySessionId.get(session.id);
|
|
128
|
+
if (exact)
|
|
129
|
+
return exact;
|
|
130
|
+
if (session.gitBranch) {
|
|
131
|
+
const base = stripRefsHeads(session.gitBranch);
|
|
132
|
+
if (!isTrunk(base)) {
|
|
133
|
+
const ids = cardsByBranch.get(base);
|
|
134
|
+
if (ids) {
|
|
135
|
+
const sameProject = ids.filter((id) => {
|
|
136
|
+
const l = linksById.get(id);
|
|
137
|
+
return l ? projectMatches(session, l) : false;
|
|
138
|
+
});
|
|
139
|
+
const pending = sameProject.find((id) => !linksById.get(id)?.sessionLink);
|
|
140
|
+
return pending ?? sameProject[0];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return adoptByPrompt(session, linksById);
|
|
145
|
+
}
|
|
146
|
+
const normPrompt = (s) => s.replace(/\s+/g, " ").trim();
|
|
147
|
+
/**
|
|
148
|
+
* A card launched fresh has no session id yet (Claude picks one) and often sits
|
|
149
|
+
* on a trunk branch, so neither id nor branch matches. Claim the session for the
|
|
150
|
+
* not-yet-linked card whose prompt it was started with, instead of spawning a
|
|
151
|
+
* duplicate "discovered" card.
|
|
152
|
+
*/
|
|
153
|
+
function adoptByPrompt(session, linksById) {
|
|
154
|
+
const first = session.firstPrompt ? normPrompt(session.firstPrompt) : "";
|
|
155
|
+
if (!first)
|
|
156
|
+
return undefined;
|
|
157
|
+
for (const link of linksById.values()) {
|
|
158
|
+
if (link.sessionLink || link.source === "discovered" || !link.promptBody)
|
|
159
|
+
continue;
|
|
160
|
+
if (!projectMatches(session, link))
|
|
161
|
+
continue;
|
|
162
|
+
const body = normPrompt(link.promptBody);
|
|
163
|
+
// firstPrompt is truncated, so accept a prefix match in either direction.
|
|
164
|
+
if (body && (body.startsWith(first) || first.startsWith(body)))
|
|
165
|
+
return link.id;
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
function newDiscoveredLink(session) {
|
|
170
|
+
const now = new Date().toISOString();
|
|
171
|
+
const link = newLinkBase();
|
|
172
|
+
link.id = ksuid("card");
|
|
173
|
+
link.name = session.name;
|
|
174
|
+
link.projectPath = session.projectPath;
|
|
175
|
+
link.column = "all_sessions";
|
|
176
|
+
link.createdAt = session.modifiedTime;
|
|
177
|
+
link.updatedAt = now;
|
|
178
|
+
link.lastActivity = session.modifiedTime;
|
|
179
|
+
link.source = "discovered";
|
|
180
|
+
link.promptBody = session.firstPrompt;
|
|
181
|
+
link.sessionLink = { sessionId: session.id, sessionPath: session.jsonlPath };
|
|
182
|
+
if (session.gitBranch) {
|
|
183
|
+
const base = stripRefsHeads(session.gitBranch);
|
|
184
|
+
if (!isTrunk(base))
|
|
185
|
+
link.worktreeLink = { path: "", branch: base };
|
|
186
|
+
}
|
|
187
|
+
return link;
|
|
188
|
+
}
|
|
189
|
+
/** Match discovered sessions to existing cards; create cards for new ones. */
|
|
190
|
+
export function reconcile(existing, sessions) {
|
|
191
|
+
const linksById = new Map(existing.map((l) => [l.id, l]));
|
|
192
|
+
const cardIdBySessionId = new Map();
|
|
193
|
+
const cardsByBranch = new Map();
|
|
194
|
+
const addBranch = (branch, id, dedupe) => {
|
|
195
|
+
const ids = cardsByBranch.get(branch) ?? [];
|
|
196
|
+
if (!dedupe || !ids.includes(id))
|
|
197
|
+
ids.push(id);
|
|
198
|
+
cardsByBranch.set(branch, ids);
|
|
199
|
+
};
|
|
200
|
+
for (const link of linksById.values()) {
|
|
201
|
+
if (link.sessionLink)
|
|
202
|
+
cardIdBySessionId.set(link.sessionLink.sessionId, link.id);
|
|
203
|
+
if (link.worktreeLink?.branch)
|
|
204
|
+
addBranch(link.worktreeLink.branch, link.id, false);
|
|
205
|
+
for (const b of link.discoveredBranches ?? [])
|
|
206
|
+
addBranch(b, link.id, true);
|
|
207
|
+
}
|
|
208
|
+
// A. match each session to a card, or create one
|
|
209
|
+
for (const session of sessions) {
|
|
210
|
+
const cardId = findCardForSession(session, cardIdBySessionId, cardsByBranch, linksById);
|
|
211
|
+
if (cardId) {
|
|
212
|
+
const link = linksById.get(cardId);
|
|
213
|
+
if (!link)
|
|
214
|
+
continue;
|
|
215
|
+
if (!link.sessionLink) {
|
|
216
|
+
link.sessionLink = { sessionId: session.id, sessionPath: session.jsonlPath };
|
|
217
|
+
cardIdBySessionId.set(session.id, cardId);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
link.sessionLink.sessionPath = session.jsonlPath;
|
|
221
|
+
}
|
|
222
|
+
link.lastActivity = session.modifiedTime;
|
|
223
|
+
if (!link.projectPath)
|
|
224
|
+
link.projectPath = session.projectPath;
|
|
225
|
+
if (!link.promptBody)
|
|
226
|
+
link.promptBody = session.firstPrompt;
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
const created = newDiscoveredLink(session);
|
|
230
|
+
cardIdBySessionId.set(session.id, created.id);
|
|
231
|
+
if (session.gitBranch) {
|
|
232
|
+
const base = stripRefsHeads(session.gitBranch);
|
|
233
|
+
if (!isTrunk(base))
|
|
234
|
+
addBranch(base, created.id, false);
|
|
235
|
+
}
|
|
236
|
+
linksById.set(created.id, created);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// A2. index session gitBranch → cards
|
|
240
|
+
for (const session of sessions) {
|
|
241
|
+
if (!session.gitBranch)
|
|
242
|
+
continue;
|
|
243
|
+
const base = stripRefsHeads(session.gitBranch);
|
|
244
|
+
if (isTrunk(base))
|
|
245
|
+
continue;
|
|
246
|
+
const cardId = cardIdBySessionId.get(session.id);
|
|
247
|
+
if (cardId)
|
|
248
|
+
addBranch(base, cardId, true);
|
|
249
|
+
}
|
|
250
|
+
// B2. absorb orphan worktree-only cards into real cards on the same branch
|
|
251
|
+
for (const [branch, cardIds] of [...cardsByBranch.entries()]) {
|
|
252
|
+
if (cardIds.length <= 1)
|
|
253
|
+
continue;
|
|
254
|
+
const orphanIds = cardIds.filter((id) => {
|
|
255
|
+
const l = linksById.get(id);
|
|
256
|
+
return l ? !l.sessionLink && l.source !== "manual" && !l.name : false;
|
|
257
|
+
});
|
|
258
|
+
if (orphanIds.length === 0)
|
|
259
|
+
continue;
|
|
260
|
+
const realIds = cardIds.filter((id) => !orphanIds.includes(id));
|
|
261
|
+
const keeperId = realIds[0] ?? orphanIds[0];
|
|
262
|
+
for (const orphanId of orphanIds) {
|
|
263
|
+
if (orphanId === keeperId)
|
|
264
|
+
continue;
|
|
265
|
+
const orphan = linksById.get(orphanId);
|
|
266
|
+
linksById.delete(orphanId);
|
|
267
|
+
const keeper = linksById.get(keeperId);
|
|
268
|
+
if (orphan && keeper && !keeper.worktreeLink)
|
|
269
|
+
keeper.worktreeLink = orphan.worktreeLink;
|
|
270
|
+
}
|
|
271
|
+
cardsByBranch.set(branch, cardIds.filter((id) => !orphanIds.includes(id) || id === keeperId));
|
|
272
|
+
}
|
|
273
|
+
const ts = (l) => Date.parse(l.lastActivity ?? l.updatedAt);
|
|
274
|
+
return [...linksById.values()].sort((a, b) => ts(b) - ts(a));
|
|
275
|
+
}
|
|
276
|
+
// ── Board state ──────────────────────────────────────────────────────────────
|
|
277
|
+
/** Suppress duplicate notifications for one card within this window (62 s). */
|
|
278
|
+
const NOTIFICATION_DEDUP_MS = 62_000;
|
|
279
|
+
/** Discovered sessions idle for longer than this stay off the board (pinned ones excepted). */
|
|
280
|
+
const RECENT_SESSION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
281
|
+
function formatRelativeTime(iso) {
|
|
282
|
+
const secs = Math.floor((Date.now() - Date.parse(iso)) / 1000);
|
|
283
|
+
if (secs < 60)
|
|
284
|
+
return "just now";
|
|
285
|
+
if (secs < 3600)
|
|
286
|
+
return `${Math.floor(secs / 60)}m ago`;
|
|
287
|
+
if (secs < 86400)
|
|
288
|
+
return `${Math.floor(secs / 3600)}h ago`;
|
|
289
|
+
const days = Math.floor(secs / 86400);
|
|
290
|
+
if (days === 1)
|
|
291
|
+
return "yesterday";
|
|
292
|
+
if (days < 30)
|
|
293
|
+
return `${days}d ago`;
|
|
294
|
+
return `${Math.floor(days / 30)}mo ago`;
|
|
295
|
+
}
|
|
296
|
+
/** Fill `worktreeLink.path` from `git worktree list` for cards that only know a branch. */
|
|
297
|
+
const WORKTREE_CACHE_MS = 30_000;
|
|
298
|
+
const worktreeCache = new Map();
|
|
299
|
+
async function backfillWorktreePaths(links) {
|
|
300
|
+
// Only roots with a card that still lacks a path need a `git worktree list`.
|
|
301
|
+
const roots = new Set(links
|
|
302
|
+
.filter((l) => l.projectPath && l.worktreeLink?.branch && !l.worktreeLink.path)
|
|
303
|
+
.map((l) => l.projectPath));
|
|
304
|
+
if (roots.size === 0)
|
|
305
|
+
return;
|
|
306
|
+
const byRoot = new Map();
|
|
307
|
+
for (const root of roots) {
|
|
308
|
+
const hit = worktreeCache.get(root);
|
|
309
|
+
if (hit && Date.now() - hit.at < WORKTREE_CACHE_MS) {
|
|
310
|
+
byRoot.set(root, hit.list);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
const list = await listWorktrees(root);
|
|
315
|
+
worktreeCache.set(root, { at: Date.now(), list });
|
|
316
|
+
byRoot.set(root, list);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
// not a git repo — nothing to backfill
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
for (const link of links) {
|
|
323
|
+
const wt = link.worktreeLink;
|
|
324
|
+
if (!link.projectPath || !wt?.branch || wt.path)
|
|
325
|
+
continue;
|
|
326
|
+
const found = byRoot.get(link.projectPath)?.find((w) => w.branch === wt.branch);
|
|
327
|
+
if (found)
|
|
328
|
+
wt.path = found.path;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
export class BoardState {
|
|
332
|
+
cards = [];
|
|
333
|
+
lastRefresh = null;
|
|
334
|
+
prevActivity = new Map();
|
|
335
|
+
lastNotifiedAt = new Map();
|
|
336
|
+
tracker = new ActivityTracker();
|
|
337
|
+
refreshing = null;
|
|
338
|
+
applyHookEvent(sessionId, eventName, notificationType, at) {
|
|
339
|
+
const state = activityFromHook(eventName, notificationType);
|
|
340
|
+
if (!state || !sessionId)
|
|
341
|
+
return false;
|
|
342
|
+
this.tracker.applyHook(sessionId, state, at);
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
/** Coalesces concurrent refreshes — a poll tick and a UI request share one pass. */
|
|
346
|
+
refresh(discovery, store, projectPaths = []) {
|
|
347
|
+
if (!this.refreshing) {
|
|
348
|
+
this.refreshing = this.doRefresh(discovery, store, projectPaths).finally(() => {
|
|
349
|
+
this.refreshing = null;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
return this.refreshing;
|
|
353
|
+
}
|
|
354
|
+
async doRefresh(discovery, store, projectPaths) {
|
|
355
|
+
const dismissed = await store.readDismissedSessions();
|
|
356
|
+
const sessions = (await discovery.discoverSessions()).filter((s) => !dismissed.has(s.id));
|
|
357
|
+
const existing = await store.readLinks();
|
|
358
|
+
const existingSnapshot = new Map(existing.map((l) => [l.id, { column: l.column, updatedAt: l.updatedAt }]));
|
|
359
|
+
const all = reconcile(existing, sessions);
|
|
360
|
+
await backfillWorktreePaths(all);
|
|
361
|
+
const sessionsById = new Map(sessions.map((s) => [s.id, s]));
|
|
362
|
+
const activity = new Map();
|
|
363
|
+
for (const link of all) {
|
|
364
|
+
const p = link.sessionLink?.sessionPath;
|
|
365
|
+
if (p && !activity.has(link.id))
|
|
366
|
+
activity.set(link.id, this.tracker.detect(p, link.sessionLink?.sessionId));
|
|
367
|
+
}
|
|
368
|
+
for (const link of all) {
|
|
369
|
+
updateCardColumn(link, activity.get(link.id), !!link.worktreeLink?.path);
|
|
370
|
+
}
|
|
371
|
+
const changed = all.length !== existing.length ||
|
|
372
|
+
all.some((l) => {
|
|
373
|
+
const old = existingSnapshot.get(l.id);
|
|
374
|
+
return !old || old.column !== l.column || old.updatedAt !== l.updatedAt;
|
|
375
|
+
});
|
|
376
|
+
if (changed) {
|
|
377
|
+
// Re-read inside the store lock so concurrent edits (renames, pins…)
|
|
378
|
+
// made while we were discovering aren't clobbered by our stale copy.
|
|
379
|
+
await store.update((current) => {
|
|
380
|
+
const currentById = new Map(current.map((l) => [l.id, l]));
|
|
381
|
+
const allIds = new Set(all.map((l) => l.id));
|
|
382
|
+
// Take our reconcile result, but: drop cards deleted since we read,
|
|
383
|
+
// let cards edited since then win on their user-owned fields, and keep
|
|
384
|
+
// cards created since then.
|
|
385
|
+
const merged = all
|
|
386
|
+
.filter((l) => currentById.has(l.id) || !existingSnapshot.has(l.id))
|
|
387
|
+
.map((l) => {
|
|
388
|
+
const cur = currentById.get(l.id);
|
|
389
|
+
if (!cur)
|
|
390
|
+
return l;
|
|
391
|
+
const edited = cur.updatedAt !== existingSnapshot.get(l.id)?.updatedAt;
|
|
392
|
+
return edited ? { ...l, ...pickUserFields(cur) } : l;
|
|
393
|
+
});
|
|
394
|
+
for (const cur of current) {
|
|
395
|
+
if (!allIds.has(cur.id) && !existingSnapshot.has(cur.id))
|
|
396
|
+
merged.push(cur);
|
|
397
|
+
}
|
|
398
|
+
return merged;
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
// Sessions found on disk only get a card once the user has added their
|
|
402
|
+
// project in Settings, and only while recent. Other cards (manual, issues, …) always show.
|
|
403
|
+
const recentCutoff = Date.now() - RECENT_SESSION_MS;
|
|
404
|
+
const visible = all.filter((link) => {
|
|
405
|
+
if (link.source !== "discovered")
|
|
406
|
+
return true;
|
|
407
|
+
if (!link.pinnedAt) {
|
|
408
|
+
const last = Date.parse(link.lastActivity ?? link.updatedAt);
|
|
409
|
+
if (Number.isFinite(last) && last < recentCutoff)
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
const projectPath = link.projectPath ?? (link.sessionLink && sessionsById.get(link.sessionLink.sessionId)?.projectPath);
|
|
413
|
+
return isInProject(projectPath, projectPaths);
|
|
414
|
+
});
|
|
415
|
+
const cards = visible.map((link) => {
|
|
416
|
+
const session = link.sessionLink ? sessionsById.get(link.sessionLink.sessionId) : undefined;
|
|
417
|
+
const act = activity.get(link.id);
|
|
418
|
+
const title = link.name ? link.name : session ? sessionDisplayTitle(session) : displayTitle(link);
|
|
419
|
+
const projectPath = link.projectPath ?? session?.projectPath;
|
|
420
|
+
const projectName = projectPath ? path.basename(projectPath.replace(/[\\/]+$/, "")) : undefined;
|
|
421
|
+
return {
|
|
422
|
+
id: link.id,
|
|
423
|
+
link,
|
|
424
|
+
session,
|
|
425
|
+
activityState: act,
|
|
426
|
+
displayTitle: title,
|
|
427
|
+
projectName: projectName || undefined,
|
|
428
|
+
relativeTime: formatRelativeTime(link.lastActivity ?? link.updatedAt),
|
|
429
|
+
showSpinner: act === "activelyWorking" || link.isLaunching === true,
|
|
430
|
+
};
|
|
431
|
+
});
|
|
432
|
+
this.cards = cards;
|
|
433
|
+
this.lastRefresh = new Date().toISOString();
|
|
434
|
+
}
|
|
435
|
+
toDto() {
|
|
436
|
+
return { cards: this.cards, lastRefresh: this.lastRefresh ?? undefined };
|
|
437
|
+
}
|
|
438
|
+
/** Cards that just went activelyWorking → needsAttention (turn finished). */
|
|
439
|
+
drainNotificationCandidates() {
|
|
440
|
+
const now = Date.now();
|
|
441
|
+
const notify = [];
|
|
442
|
+
const next = new Map();
|
|
443
|
+
for (const card of this.cards) {
|
|
444
|
+
const current = card.activityState ?? "";
|
|
445
|
+
next.set(card.id, current);
|
|
446
|
+
const prev = this.prevActivity.get(card.id) ?? "";
|
|
447
|
+
if ((current === "needsAttention" || current === "idleWaiting") && prev === "activelyWorking") {
|
|
448
|
+
const last = this.lastNotifiedAt.get(card.id);
|
|
449
|
+
if (last === undefined || now - last >= NOTIFICATION_DEDUP_MS) {
|
|
450
|
+
this.lastNotifiedAt.set(card.id, now);
|
|
451
|
+
notify.push(card);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
this.prevActivity = next;
|
|
456
|
+
return notify;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const normPath = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
460
|
+
/** True when `p` is one of the configured project folders or inside one. */
|
|
461
|
+
export function isInProject(p, projectPaths) {
|
|
462
|
+
if (!p)
|
|
463
|
+
return false;
|
|
464
|
+
const n = normPath(p);
|
|
465
|
+
return projectPaths.some((root) => {
|
|
466
|
+
const r = normPath(root);
|
|
467
|
+
return r !== "" && (n === r || n.startsWith(`${r}/`));
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
function pickUserFields(cur) {
|
|
471
|
+
return {
|
|
472
|
+
name: cur.name,
|
|
473
|
+
manualOverrides: cur.manualOverrides,
|
|
474
|
+
manuallyArchived: cur.manuallyArchived,
|
|
475
|
+
column: cur.column,
|
|
476
|
+
pinnedAt: cur.pinnedAt,
|
|
477
|
+
pinnedSortOrder: cur.pinnedSortOrder,
|
|
478
|
+
sortOrder: cur.sortOrder,
|
|
479
|
+
queuedPrompts: cur.queuedPrompts,
|
|
480
|
+
browserTabs: cur.browserTabs,
|
|
481
|
+
cardRuntime: cur.cardRuntime,
|
|
482
|
+
apiServiceId: cur.apiServiceId,
|
|
483
|
+
lastOpenedAt: cur.lastOpenedAt,
|
|
484
|
+
updatedAt: cur.updatedAt,
|
|
485
|
+
};
|
|
486
|
+
}
|