A panel in Claude Code Desktop that ranks your sessions by what they need from you

A panel for Claude Code Desktop that shows every Claude Code session on your machine, ranked by what it needs from you.
It sits beside the chat in each session. It reads the desktop app's own session records and transcripts, so it covers all of your local sessions, not only the one you're in.
The panel lists the sessions of the Desktop instance it runs in. If you run more than one instance, each with its own data folder, each panel shows only its own sessions.
Each stopped session gets one card with:
production or ci when they apply.mcp__ccd_session_mgmt__archive_session, it archives the session at once; otherwise it asks that session to archive itself, so the request goes through the session's own permission checks.The session you're in sits in a This session section at the top. Sessions in their last mile (ready to archive, a PR ready to merge, a last yes/no) gather in a Finish section, oldest first, so nearly-done work gets closed. Widen the panel past 100 columns and it switches to a board with one lane per label.
Clicking a card opens that session.
When a session finishes a turn, Sonnet reads it once on your Claude subscription. It sees a digest of the whole conversation, the last exchange, the session's open PRs, its ticket, and any sessions started from it. It answers what the session waits for, what you should do next, an urgency score from 0 to 100, which labels apply, and whether the session is in its last mile or finished. A session is read again only when something it was read with changes: a new message, a PR's state, its ticket's priority, or your ranking rules.
The order is plain arithmetic on that reading, recalculated on every refresh with no model call: urgency, lowered gently by age, times a small factor for what the session waits for. Age only orders sessions of similar urgency, so urgent work stays on top however old it is.
What counts as urgent is yours to set, in your own words. The first time the panel opens, a card offers a short setup in a new session; after that, tell any session what should rank higher or lower and the claudetower:ranking skill updates your ranking file, previewing the change on made-up examples before saving. docs/ranking.md explains the whole model.
claudetower doesn't assume any tool. Once per project, and again every 7 days while the project has active sessions, Sonnet profiles it from the repo: its git remote, recent branches, session titles, CI workflows, README and CLAUDE.md, plus the MCP tools your Claude Code can call. The profile names the project's ticket prefixes, the tool that looks up one of its tickets, the services it depends on, and what a P0 looks like there. Session reads use it to judge priority.
gh, signed in, for PR details. Without it the PR rows are skipped.claudetower reads Claude Code Desktop's own session records and transcripts and opens sessions through its claude:// links. None of these are a public API, so a Desktop update can change them and break parts of the panel until claudetower catches up.
In a terminal:
claude plugin marketplace add eshaham/claudetower
claude plugin install claudetower@claudetower
In Claude Code Desktop, the same is under + → Plugins → Add marketplace, then add the claudetower plugin. Once a session is running (not on Desktop's new-session page, which runs no plugins), the band above the chat input asks how to rank your sessions; after that, the panel opens in every session.
The panel is titled claudetower. Type /tower in any session to reopen it after closing it.
ticketPrefixes is optional. Leave it empty to use the prefixes claudetower learns for each project, or list prefixes to use everywhere instead, in ~/.claude/settings.json:
{
"pluginConfigs": {
"claudetower": { "options": { "ticketPrefixes": "ENG,OPS" } }
}
}
Clone the repo and load it from your clone in every session, so your edits apply without reinstalling. Uninstall the marketplace copy first, then add to ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/claudetower",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
}
CLAUDE_CODE_PLUGIN_DIR_WATCH makes running sessions reload the panel when its files change. Sessions read these settings when they start, so restart the Desktop app once after adding them.
Check a change with claude plugin validate ..
MIT
hooks/register.tsx 145 lines1import type { Register } from "claude-code";
2
3import type { CardHitMessage } from "../types";
4import {
5 checkInbox,
6 followOtherSessions,
7 isPaneOpen,
8 load,
9 loadOnChange,
10 markPanelAsked,
11 noteClickLayerFailure,
12 openPane,
13 openSession,
14 poll,
15 readNeedsSetup,
16 setPaneOpen,
17 toggleCompose,
18 toggleGroup,
19 togglePreview,
20} from "./lib/board";
21import {
22 CHANGE_CHECK_MS,
23 INBOX_POLL_MS,
24 PANE,
25 PLUGIN,
26 POLL_MS,
27 discoverHost,
28 log,
29 setTicketPrefixes,
30} from "./lib/config";
31import {
32 RANKING_TOOL,
33 RANKING_TOOL_SPEC,
34 runRankingTool,
35} from "./lib/ranking-tool";
36import { renderAbovePrompt } from "./view/above-prompt";
37import { renderPane } from "./view/pane";
38import { renderSetupBand } from "./view/setup";
39
40export const register: Register = (on, options) => {
41 setTicketPrefixes(String(options.ticketPrefixes ?? ""));
42
43 on("session.start", async ($, e, next) => {
44 await followOtherSessions($).catch((error: unknown) =>
45 log($, `could not open the panel: ${String(error)}`),
46 );
47 await $.command.register({
48 name: "tower",
49 description: "Open the claudetower session board",
50 });
51 await $.tool.register(RANKING_TOOL_SPEC);
52 void discoverHost($)
53 .then(() => {
54 $.clock.every(POLL_MS, () =>
55 poll($).catch((error: unknown) =>
56 log($, `could not refresh: ${String(error)}`),
57 ),
58 );
59 $.clock.every(CHANGE_CHECK_MS, () =>
60 loadOnChange($).catch((error: unknown) =>
61 log($, `could not check for changes: ${String(error)}`),
62 ),
63 );
64 $.clock.every(INBOX_POLL_MS, () =>
65 checkInbox($).catch((error: unknown) =>
66 log($, `could not check for answers: ${String(error)}`),
67 ),
68 );
69 return poll($);
70 })
71 .catch((error: unknown) => log($, `could not start: ${String(error)}`));
72
73 return next(e);
74 });
75
76 on("session.attach", async ($, e, next) => {
77 const attached = await next(e);
78 await followOtherSessions($).catch((error: unknown) =>
79 log($, `could not open the panel: ${String(error)}`),
80 );
81
82 return attached;
83 });
84
85 on("command.run", { command: "tower" }, async ($) => {
86 markPanelAsked();
87 await openPane($);
88 void load($).catch((error: unknown) =>
89 log($, `could not refresh: ${String(error)}`),
90 );
91
92 return { text: "Session board opened." };
93 });
94
95 on("ui.message", async ($, e) => {
96 const message = e.data as CardHitMessage;
97 const handled =
98 message.kind === "open"
99 ? openSession($, message.id)
100 : message.kind === "toggle"
101 ? togglePreview($, message.id)
102 : message.kind === "group"
103 ? toggleGroup($, message.id)
104 : toggleCompose($, message.id);
105 await handled.catch((error: unknown) =>
106 log($, `could not handle a ${message.kind} click: ${String(error)}`),
107 );
108
109 return {};
110 });
111
112 on(
113 "tool.call",
114 { tool: `mcp__${PLUGIN}__${RANKING_TOOL}` },
115 async ($, e) => ({
116 result: JSON.stringify(await runRankingTool($, e), null, 2),
117 }),
118 );
119
120 on("ui.fault", async ($, e, next) => {
121 await noteClickLayerFailure($, e.reason);
122 return next(e);
123 });
124
125 on("ui.close", async ($, e, next) => {
126 const closed = await next(e);
127 if (e.id === PANE && e.origin.kind === "person")
128 await setPaneOpen($, false).catch((error: unknown) =>
129 log($, `could not record the close: ${String(error)}`),
130 );
131
132 return closed;
133 });
134
135 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
136 if (e.props.hasSurvey) return next(e);
137 if (await readNeedsSetup($)) return renderSetupBand($, e);
138 return (await isPaneOpen($)) ? next(e) : renderAbovePrompt($, e);
139 });
140
141 on("ui.render", { component: "Pane", requestId: PANE }, ($, e) =>
142 renderPane($, e),
143 );
144};
145hooks/lib/board.ts 590 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface } from "claude-code";
3
4import type { SessionAction, SessionCard } from "../../types";
5import { classify } from "./classify";
6import {
7 ARCHIVE_SESSION,
8 ANSWER_CHECK_MS,
9 ANSWER_PICKUP_MS,
10 inboxDir,
11 host,
12 PANE,
13 PANE_COLUMNS,
14 PENDING_MS,
15 PREVIEW_REPLY_CHARS,
16 READ_CONCURRENCY,
17 READS_PER_POLL,
18 STORE_KEYS,
19 log,
20 instanceKey,
21 findInstance,
22 MINUTE_MS,
23} from "./config";
24import type { PrStore, TicketStore } from "./enrich";
25import {
26 isOpenPr,
27 prBadgesOf,
28 readPrs,
29 readTickets,
30 refreshPrs,
31 refreshTickets,
32} from "./enrich";
33import type { ProjectProfile } from "./project";
34import {
35 claim,
36 projectRootOf,
37 readProjects,
38 refreshProjects,
39 ticketPatternOf,
40} from "./project";
41import type { Ranking } from "./ranking";
42import {
43 hasRankingFile,
44 loadRanking,
45 readingFingerprintOf,
46 writeRanking,
47} from "./ranking";
48import type { Live } from "./transcript";
49import {
50 loadLive,
51 projectOf,
52 sessionDirs,
53 ticketOf,
54 titleOf,
55 transcriptPath,
56} from "./transcript";
57import type { VerdictContext, VerdictStore } from "./verdict";
58import {
59 SILENT_STOP,
60 judge,
61 readVerdicts,
62 verdictKeyOf,
63 writeVerdicts,
64} from "./verdict";
65
66export const cards = atom({ plugin: "claudetower", key: "cards" } as const, []);
67export const isOpen = atom(
68 { plugin: "claudetower", key: "isOpen" } as const,
69 true,
70);
71export const previewed = atom(
72 { plugin: "claudetower", key: "previewed" } as const,
73 "",
74);
75export const draft = atom({ plugin: "claudetower", key: "draft" } as const, "");
76export const composing = atom(
77 { plugin: "claudetower", key: "composing" } as const,
78 "",
79);
80export const needsSetup = atom(
81 { plugin: "claudetower", key: "needsSetup" } as const,
82 false,
83);
84export const collapsedGroups = atom(
85 { plugin: "claudetower", key: "collapsedGroups" } as const,
86 {},
87);
88export const clickLayersFailedAt = atom(
89 { plugin: "claudetower", key: "clickLayersFailedAt" } as const,
90 0,
91);
92export const pending = atom(
93 { plugin: "claudetower", key: "pending" } as const,
94 {},
95);
96
97type Stores = {
98 verdicts: VerdictStore;
99 tickets: TicketStore;
100 prs: PrStore;
101 projects: Record<string, ProjectProfile>;
102 ranking: Ranking;
103};
104
105type Enriched = Live & { context: VerdictContext; ticket: string };
106
107const reading = new Set<string>();
108let isLoading = false;
109let watched: string[] = [];
110let lastSeen = "";
111
112const fromParagraphStart = (text: string, limit: number) => {
113 if (text.length <= limit) return text;
114 const cut = text.slice(-limit);
115 const paragraph = cut.indexOf("\n\n");
116 return paragraph >= 0 ? cut.slice(paragraph + 2) : cut;
117};
118
119async function readStores($: EngineInterface): Promise<Stores> {
120 const [verdicts, tickets, prs, projects, ranking] = await Promise.all([
121 readVerdicts($),
122 readTickets($),
123 readPrs($),
124 readProjects($),
125 loadRanking($),
126 ]);
127 return { verdicts, tickets, prs, projects, ranking };
128}
129
130const enrich = (live: Live[], stores: Stores): Enriched[] => {
131 const rankingFingerprint = readingFingerprintOf(stores.ranking);
132 return live.map((one) => {
133 const project = stores.projects[projectRootOf(one.s)];
134 const openPrs = prBadgesOf(one.s.prs, stores.prs)
135 .filter((pr) => isOpenPr(pr.state))
136 .map((pr) => pr.number);
137 const ticket = ticketOf(one.s, ticketPatternOf(project));
138 const info = stores.tickets[ticket];
139 return {
140 ...one,
141 ticket,
142 context: {
143 openPrs,
144 project,
145 ticket: ticket && info ? { ...info, id: ticket } : null,
146 ranking: stores.ranking,
147 rankingFingerprint,
148 },
149 };
150 });
151};
152
153const keyOf = (one: Enriched) => verdictKeyOf(one, one.context);
154
155const cardOf = (one: Enriched, now: number, stores: Stores): SessionCard => {
156 const { s, end, ticket } = one;
157 const stored = stores.verdicts[s.sessionId];
158 const current =
159 stored?.key === keyOf(one)
160 ? stored.verdict
161 : end.isStopped && end.lastText === ""
162 ? SILENT_STOP
163 : null;
164 const info = stores.tickets[ticket];
165 return classify(
166 {
167 id: s.sessionId,
168 cliId: s.cliSessionId ?? "",
169 title: titleOf(s, ticket),
170 project: projectOf(s),
171 ticket,
172 ticketStatus: info?.status ?? "",
173 ticketPriority: info?.isOpen ? info.priority : 0,
174 prs: prBadgesOf(s.prs, stores.prs),
175 uncommittedFiles: end.uncommittedFiles,
176 lastAsk: end.prompt,
177 lastAskAgoMs: end.promptAt ? now - end.promptAt : 0,
178 lastReply: fromParagraphStart(end.lastText, PREVIEW_REPLY_CHARS),
179 question: end.question,
180 options: end.options,
181 isRunning: !end.isStopped,
182 isScheduled: Boolean(s.scheduledTaskId),
183 ageMs: now - end.lastAt,
184 lastAt: end.lastAt,
185 verdict: current ?? stored?.verdict ?? null,
186 isRereading: end.isStopped && current === null,
187 },
188 stores.ranking,
189 );
190};
191
192async function build($: EngineInterface, live: Live[], now: number) {
193 const stores = await readStores($);
194 const enriched = enrich(live, stores);
195 const built = enriched
196 .map((one) => cardOf(one, now, stores))
197 .sort((a, b) => b.score - a.score);
198 await update($, cards, () => built);
199 await update($, pending, (current) =>
200 Object.fromEntries(
201 Object.entries(current).filter(
202 ([id, at]) =>
203 now - at < PENDING_MS &&
204 (built.find((card) => card.id === id)?.lastAt ?? 0) < at,
205 ),
206 ),
207 );
208 return { stores, enriched };
209}
210
211async function readNewVerdicts(
212 $: EngineInterface,
213 enriched: Enriched[],
214 verdicts: VerdictStore,
215) {
216 const unread = enriched
217 .filter(
218 (one) =>
219 one.end.isStopped &&
220 one.end.lastText !== "" &&
221 verdicts[one.s.sessionId]?.key !== keyOf(one) &&
222 !reading.has(one.s.sessionId),
223 )
224 .sort((a, b) => b.end.lastAt - a.end.lastAt)
225 .slice(0, READS_PER_POLL);
226 const fresh: VerdictStore = {};
227 for (let index = 0; index < unread.length; index += READ_CONCURRENCY)
228 await Promise.all(
229 unread.slice(index, index + READ_CONCURRENCY).map(async (one) => {
230 const key = keyOf(one);
231 if (!(await claim($, `verdict:${one.s.sessionId}:${key}`))) return;
232 reading.add(one.s.sessionId);
233 try {
234 const verdict = await judge($, one, one.context);
235 if (verdict) fresh[one.s.sessionId] = { key, verdict };
236 } catch (error) {
237 log($, `could not read "${one.s.title}": ${String(error)}`);
238 } finally {
239 reading.delete(one.s.sessionId);
240 }
241 }),
242 );
243 return fresh;
244}
245
246const runningInBackground = new Set<string>();
247
248function inBackground(
249 $: EngineInterface,
250 name: string,
251 work: () => Promise<void>,
252) {
253 if (runningInBackground.has(name)) return;
254 runningInBackground.add(name);
255 void work()
256 .catch((error: unknown) =>
257 log($, `${name} refresh failed: ${String(error)}`),
258 )
259 .finally(() => runningInBackground.delete(name));
260}
261
262export async function load($: EngineInterface) {
263 if (isLoading || !(await findInstance($))) return;
264 isLoading = true;
265 try {
266 const now = Date.now();
267 const live = await loadLive($, now);
268 const { stores, enriched } = await build($, live, now);
269 inBackground($, "projects", () => refreshProjects($, live));
270 inBackground($, "tickets", () =>
271 refreshTickets(
272 $,
273 enriched.flatMap((one) =>
274 one.ticket && one.context.project
275 ? [{ id: one.ticket, profile: one.context.project }]
276 : [],
277 ),
278 ),
279 );
280 inBackground($, "prs", () => refreshPrs($, live));
281 inBackground($, "verdicts", async () => {
282 const fresh = await readNewVerdicts($, enriched, stores.verdicts);
283 if (Object.keys(fresh).length === 0) return;
284 await writeVerdicts($, fresh, new Set(live.map(({ s }) => s.sessionId)));
285 await load($);
286 });
287 watched = [
288 ...(await sessionDirs($)),
289 ...live.map(({ s }) => transcriptPath(s)),
290 ];
291 } finally {
292 isLoading = false;
293 }
294}
295
296export async function loadOnChange($: EngineInterface) {
297 if (watched.length === 0 || isLoading) return;
298 const stats = await $.process.run(["/usr/bin/stat", "-f", "%Fm", ...watched]);
299 const seen = stats.stdout;
300 if (seen === lastSeen) return;
301 const isFirst = lastSeen === "";
302 lastSeen = seen;
303 if (!isFirst) await load($);
304}
305
306const SEND_URL_TO_PID = `ObjC.import("AppKit");
307function run(argv) {
308 const pid = Number(argv[0]);
309 const code = (text) => [...text].reduce((n, c) => (n << 8) | c.charCodeAt(0), 0);
310 const event = $.NSAppleEventDescriptor.appleEventWithEventClassEventIDTargetDescriptorReturnIDTransactionID(
311 code("GURL"), code("GURL"), $.NSAppleEventDescriptor.descriptorWithProcessIdentifier(pid), -1, 0);
312 event.setParamDescriptorForKeyword($.NSAppleEventDescriptor.descriptorWithString(argv[1]), code("----"));
313 const error = Ref();
314 event.sendEventWithOptionsTimeoutError(1, 10, error);
315 if (error[0] && !error[0].isNil()) throw new Error(ObjC.unwrap(error[0].localizedDescription));
316 const app = $.NSRunningApplication.runningApplicationWithProcessIdentifier(pid);
317 if (!app.isNil()) app.activateWithOptions(0);
318}`;
319
320async function openInOwnApp($: EngineInterface, url: string) {
321 if (!host.appPid) return false;
322 const sent = await $.process.run([
323 "/usr/bin/osascript",
324 "-l",
325 "JavaScript",
326 "-e",
327 SEND_URL_TO_PID,
328 host.appPid,
329 url,
330 ]);
331 if (sent.exitCode !== 0)
332 log($, `could not open ${url} in this app: ${sent.stderr.trim()}`);
333 return sent.exitCode === 0;
334}
335
336export async function openSession($: EngineInterface, id: string) {
337 const url = `claude://claude.ai/epitaxy/${id}`;
338 if (await openInOwnApp($, url)) return;
339 const opened = await $.process.run(["open", url]);
340 if (opened.exitCode !== 0)
341 $.ui.toast(`Could not open the session: ${opened.stderr.trim()}`);
342}
343
344const ARCHIVE_ACTION: SessionAction = {
345 label: "Archive",
346 message: "Archive this session.",
347};
348
349async function archiveDirectly($: EngineInterface, card: SessionCard) {
350 const input = {
351 session_id: card.id,
352 reason: card.verdict?.waitingFor || "Finished with nothing left to do",
353 };
354 const { decision } = await $.tool.check({ tool: ARCHIVE_SESSION, input });
355 if (decision !== "allow") return false;
356 const archived = await $.tool.call({ tool: ARCHIVE_SESSION, ...input });
357 if (archived.deny !== undefined || archived.isError) {
358 log(
359 $,
360 `could not archive "${card.title}": ${archived.deny ?? archived.text ?? "no answer"}`,
361 );
362 return false;
363 }
364 await update($, cards, (list) => list.filter((one) => one.id !== card.id));
365 return true;
366}
367
368export async function archiveSession($: EngineInterface, card: SessionCard) {
369 const isArchived = await archiveDirectly($, card).catch((error: unknown) => {
370 log($, `could not archive "${card.title}": ${String(error)}`);
371 return false;
372 });
373 if (!isArchived) startAction($, card, ARCHIVE_ACTION);
374}
375
376export function togglePreview($: EngineInterface, id: string) {
377 return update($, previewed, (current) => (current === id ? "" : id));
378}
379
380export async function setPaneOpen($: EngineInterface, value: boolean) {
381 await update($, isOpen, () => value);
382 await $.store.set(instanceKey(STORE_KEYS.isOpen), value);
383}
384
385export async function openPane($: EngineInterface) {
386 await setPaneOpen($, true);
387 await $.ui.open({ id: PANE, title: "claudetower", columns: PANE_COLUMNS });
388}
389
390async function refreshSetupState($: EngineInterface) {
391 const isNeeded = (await findInstance($)) !== "" && !(await hasRankingFile($));
392 await update($, needsSetup, () => isNeeded);
393 return isNeeded;
394}
395
396let isPanelAsked = false;
397
398export function markPanelAsked() {
399 isPanelAsked = true;
400}
401
402async function showSetupBand($: EngineInterface, isUp: boolean) {
403 if (!isPanelAsked && isUp) await $.ui.close({ id: PANE });
404}
405
406export function readNeedsSetup($: EngineInterface) {
407 return read($, needsSetup);
408}
409
410export async function followOtherSessions($: EngineInterface) {
411 const isSettingUp = await refreshSetupState($);
412 const isUp = (await $.ui.panes()).some((pane) => pane.id === PANE);
413 if (isSettingUp) {
414 await showSetupBand($, isUp);
415 } else {
416 const wantsOpen =
417 (await $.store.get(instanceKey(STORE_KEYS.isOpen))) !== false;
418 if (wantsOpen && !isUp && (await $.session.surfaces()).length > 0)
419 await openPane($);
420 if (!wantsOpen && isUp) await $.ui.close({ id: PANE });
421 }
422 await followCollapsedGroups($);
423 const isShown = (await $.ui.panes()).some((pane) => pane.id === PANE);
424 await update($, isOpen, () => isShown);
425}
426
427export async function poll($: EngineInterface) {
428 await followOtherSessions($);
429 await load($);
430}
431
432export function readCards($: EngineInterface) {
433 return read($, cards);
434}
435
436export function readPreviewed($: EngineInterface) {
437 return read($, previewed);
438}
439
440export function isPaneOpen($: EngineInterface) {
441 return read($, isOpen);
442}
443
444const inboxPathOf = (cliId: string) => `${inboxDir()}/${cliId}.json`;
445
446async function removeFile($: EngineInterface, path: string) {
447 const removed = await $.process.run(["/bin/rm", "-f", path]);
448 if (removed.exitCode !== 0)
449 log($, `could not remove ${path}: ${removed.stderr.trim()}`);
450}
451
452export function readPending($: EngineInterface) {
453 return read($, pending);
454}
455
456async function markPending($: EngineInterface, id: string) {
457 await update($, composing, () => "");
458 await update($, draft, () => "");
459 await update($, pending, (current) => ({ ...current, [id]: Date.now() }));
460}
461
462function clearPending($: EngineInterface, id: string) {
463 return update($, pending, (current) =>
464 Object.fromEntries(Object.entries(current).filter(([key]) => key !== id)),
465 );
466}
467
468async function sendAction(
469 $: EngineInterface,
470 card: SessionCard,
471 action: SessionAction,
472) {
473 await markPending($, card.id);
474 if (card.cliId === host.selfCliId) {
475 await $.prompt.submit({ text: action.message, asUser: true });
476 return;
477 }
478 const path = inboxPathOf(card.cliId);
479 await $.fs.write(
480 path,
481 JSON.stringify({ text: action.message, at: Date.now() }),
482 );
483 for (let waited = 0; waited < ANSWER_PICKUP_MS; waited += ANSWER_CHECK_MS) {
484 await $.clock.sleep(ANSWER_CHECK_MS);
485 if (!(await $.fs.exists(path))) return;
486 }
487 await removeFile($, path);
488 await clearPending($, card.id);
489 $.ui.toast(`"${card.title}" isn't running, so it opened instead`);
490 await openSession($, card.id);
491}
492
493export async function checkInbox($: EngineInterface) {
494 const path = inboxPathOf(host.selfCliId);
495 if (!host.selfCliId || !(await $.fs.exists(path))) return;
496 const raw = await $.fs.read(path);
497 await removeFile($, path);
498 const { text, at } = JSON.parse(raw) as { text?: string; at?: number };
499 if (!text || Date.now() - (at ?? 0) > ANSWER_PICKUP_MS) return;
500 await $.prompt.submit({ text, asUser: true });
501}
502
503export function readComposing($: EngineInterface) {
504 return read($, composing);
505}
506
507export async function toggleCompose($: EngineInterface, id: string) {
508 await update($, draft, () => "");
509 const opened = await update($, composing, (current) =>
510 current === id ? "" : id,
511 );
512 if (opened !== id) return;
513 const focused = await $.ui.focus({ requestId: PANE, key: `compose-${id}` });
514 if (focused.deny !== undefined)
515 log($, `could not focus the message box: ${focused.deny}`);
516}
517
518export function readDraft($: EngineInterface) {
519 return read($, draft);
520}
521
522export function setDraft($: EngineInterface, text: string) {
523 return update($, draft, () => text);
524}
525
526export function startAction(
527 $: EngineInterface,
528 card: SessionCard,
529 action: SessionAction,
530) {
531 void sendAction($, card, action).catch(async (error: unknown) => {
532 log($, `could not send to "${card.title}": ${String(error)}`);
533 $.ui.toast(`Could not send to "${card.title}"`);
534 await clearPending($, card.id);
535 });
536}
537
538export function sendText($: EngineInterface, card: SessionCard, text: string) {
539 const message = text.trim();
540 if (message !== "") startAction($, card, { label: message, message });
541}
542
543const SETUP_MESSAGE =
544 "Start a new session that sets up claudetower with its claudetower:ranking skill.";
545
546export async function startSetup($: EngineInterface) {
547 await $.prompt.submit({ text: SETUP_MESSAGE, asUser: true });
548}
549
550export async function useDefaultRanking($: EngineInterface) {
551 await writeRanking($, {});
552 await refreshSetupState($);
553 await openPane($);
554 await load($);
555}
556
557export function readCollapsedGroups($: EngineInterface) {
558 return read($, collapsedGroups);
559}
560
561export async function toggleGroup($: EngineInterface, id: string) {
562 const next = await update($, collapsedGroups, (current) => ({
563 ...current,
564 [id]: !current[id],
565 }));
566 await $.store.set(instanceKey(STORE_KEYS.collapsedGroups), next);
567}
568
569async function followCollapsedGroups($: EngineInterface) {
570 const saved = (await $.store.get(instanceKey(STORE_KEYS.collapsedGroups))) as
571 Record<string, boolean> | undefined;
572 if (saved) await update($, collapsedGroups, () => saved);
573}
574
575const CLICK_LAYER_RETRY_MS = 2 * MINUTE_MS;
576
577export async function areClickLayersHealthy($: EngineInterface) {
578 return (
579 Date.now() - (await read($, clickLayersFailedAt)) > CLICK_LAYER_RETRY_MS
580 );
581}
582
583export async function noteClickLayerFailure(
584 $: EngineInterface,
585 reason: string,
586) {
587 log($, `a click layer failed, using buttons for now: ${reason}`);
588 await update($, clickLayersFailedAt, () => Date.now());
589}
590hooks/lib/config.ts 200 lines1import type { EngineInterface } from "claude-code";
2
3export const PLUGIN = "claudetower";
4export const PANE = "claudetower";
5
6const SECOND_MS = 1000;
7export const MINUTE_MS = 60 * SECOND_MS;
8export const HOUR_MS = 60 * MINUTE_MS;
9export const DAY_MS = 24 * HOUR_MS;
10
11export const FRESH_MS = 3 * DAY_MS;
12export const LOOKBACK_MS = 30 * DAY_MS;
13export const RUNNING_GRACE_MS = 15 * MINUTE_MS;
14export const TOOL_GRACE_MS = 2 * HOUR_MS;
15export const PROJECT_TTL_MS = 7 * DAY_MS;
16export const CLAIM_MS = 5 * MINUTE_MS;
17export const ANSWER_CHECK_MS = SECOND_MS;
18export const ANSWER_PICKUP_MS = 10 * SECOND_MS;
19export const INBOX_POLL_MS = 2 * SECOND_MS;
20export const POLL_MS = 15 * SECOND_MS;
21export const CHANGE_CHECK_MS = 2 * SECOND_MS;
22export const PENDING_MS = 2 * MINUTE_MS;
23
24export const TAIL_BYTES = 1500000;
25export const LAST_TEXT_CHARS = 6000;
26export const PREVIEW_REPLY_CHARS = 2500;
27export const PROMPT_CHARS = 800;
28
29export const READS_PER_POLL = 6;
30export const READ_CONCURRENCY = 3;
31export const MAX_FOLLOW_UPS = 3;
32
33export const PANE_COLUMNS = 50;
34export const BOARD_MIN_COLUMNS = 100;
35export const CARD_LIMIT = 30;
36export const LINE_LIMIT = 8;
37export const CARDS_PER_LANE = 8;
38
39export const VERDICT_MODEL = "sonnet";
40
41export const STORE_KEYS = {
42 verdicts: "verdicts-v10",
43 claims: "claims",
44 tickets: "tickets-v2",
45 prs: "prs",
46 ends: "transcript-ends-v1",
47 projects: "projects-v1",
48 isOpen: "isOpen",
49 collapsedGroups: "collapsed-groups",
50} as const;
51
52export const TICKET_TTL_MS = 30 * MINUTE_MS;
53export const TICKET_LOOKUPS_PER_POLL = 4;
54export const PR_TTL_MS = 5 * MINUTE_MS;
55export const PR_LOOKUPS_PER_POLL = 4;
56export const GH_TIMEOUT_MS = 15 * SECOND_MS;
57
58export const ARCHIVE_SESSION = "mcp__ccd_session_mgmt__archive_session";
59
60const APP_SUPPORT = "Library/Application Support";
61const SESSIONS_DIR = "claude-code-sessions";
62
63export type McpToolName = `mcp__${string}__${string}`;
64
65export const isMcpToolName = (name: string): name is McpToolName =>
66 /^mcp__.+__.+$/.test(name);
67
68export type ToolSummary = { name: string; description: string };
69
70export const host: {
71 home: string;
72 gh: string;
73 selfCliId: string;
74 instance: string;
75 appPid: string;
76 tools: ToolSummary[];
77 ticketPrefixOverride: string[];
78} = {
79 home: "",
80 gh: "",
81 selfCliId: "",
82 instance: "",
83 appPid: "",
84 tools: [],
85 ticketPrefixOverride: [],
86};
87
88export function log($: EngineInterface, message: string) {
89 $.ui.log(`${PLUGIN}: ${message}`, { to: "debug" });
90}
91
92export const isTicketPrefix = (prefix: string) =>
93 /^[A-Za-z][A-Za-z0-9]*$/.test(prefix);
94
95export const setTicketPrefixes = (prefixes: string) => {
96 host.ticketPrefixOverride = prefixes
97 .split(",")
98 .map((prefix) => prefix.trim())
99 .filter(isTicketPrefix);
100};
101
102const GH_CANDIDATES = [
103 "/opt/homebrew/bin/gh",
104 "/usr/local/bin/gh",
105 "/usr/bin/gh",
106];
107
108const FIND_APP_PID =
109 'p=$PPID; while [ "$p" -gt 1 ]; do q=$(ps -o ppid= -p "$p" | tr -d " "); [ "$q" = 1 ] && case "$(ps -o comm= -p "$p")" in */Contents/MacOS/Claude) echo "$p"; exit 0;; *) exit 1;; esac; p=$q; done; exit 1';
110
111async function findAppPid($: EngineInterface) {
112 const found = await $.process.run(["/bin/sh", "-c", FIND_APP_PID]);
113 return found.exitCode === 0 ? found.stdout.trim() : "";
114}
115
116async function findGh($: EngineInterface) {
117 for (const path of GH_CANDIDATES) if (await $.fs.exists(path)) return path;
118 const found = await $.process.run(["/bin/sh", "-c", "command -v gh"]);
119 return found.exitCode === 0 ? found.stdout.trim() : "";
120}
121
122export async function discoverHost($: EngineInterface) {
123 const [home, gh, selfCliId, appPid] = await Promise.all([
124 $.process.run(["/usr/bin/printenv", "HOME"]),
125 findGh($),
126 $.session.id(),
127 findAppPid($),
128 ]);
129 host.home = home.stdout.trim();
130 host.gh = gh;
131 host.selfCliId = selfCliId;
132 host.appPid = appPid;
133 await findInstance($);
134 if (!host.gh) log($, "gh not found; PR details are off");
135}
136
137export async function loadTools($: EngineInterface) {
138 if (host.tools.length > 0) return;
139 host.tools = (await $.tool.list())
140 .filter((tool) => tool.mcp)
141 .map((tool) => ({ name: tool.name, description: tool.description }));
142}
143
144const sessionsRootOf = (instance: string) =>
145 `${host.home}/${APP_SUPPORT}/${instance}/${SESSIONS_DIR}`;
146
147export const sessionsRoot = () => sessionsRootOf(host.instance);
148
149export const instanceKey = (key: string) => `${host.instance}/${key}`;
150
151const DEFAULT_INSTANCE = "Claude";
152
153async function instanceOfApp($: EngineInterface) {
154 if (!host.appPid) return "";
155 const shown = await $.process.run([
156 "/bin/ps",
157 "-o",
158 "command=",
159 "-p",
160 host.appPid,
161 ]);
162 if (shown.exitCode !== 0) return "";
163 const dataDir = shown.stdout.match(/--user-data-dir=(.+?)(?= --|$)/m)?.[1];
164 const name = dataDir
165 ? (dataDir.trim().split("/").pop() ?? "")
166 : DEFAULT_INSTANCE;
167 return name && (await $.fs.exists(sessionsRootOf(name))) ? name : "";
168}
169
170export async function findInstance($: EngineInterface) {
171 if (host.instance) return host.instance;
172 host.instance = await instanceOfApp($);
173 if (host.instance || !host.selfCliId) return host.instance;
174 const candidates = (await $.fs.list(`${host.home}/${APP_SUPPORT}`))
175 .filter((entry) => entry.kind === "dir")
176 .map((entry) => entry.name);
177 const instances = (
178 await Promise.all(
179 candidates.map(async (name) =>
180 (await $.fs.exists(sessionsRootOf(name))) ? [name] : [],
181 ),
182 )
183 ).flat();
184 if (instances.length === 0) return "";
185 const found = await $.process.run([
186 "/usr/bin/grep",
187 "-rlF",
188 "--include=local_*.json",
189 `"cliSessionId":"${host.selfCliId}"`,
190 ...instances.map(sessionsRootOf),
191 ]);
192 const match = found.stdout.split("\n").find(Boolean) ?? "";
193 host.instance =
194 instances.find((name) => match.startsWith(`${sessionsRootOf(name)}/`)) ??
195 "";
196 return host.instance;
197}
198
199export const inboxDir = () => `${host.home}/.claude/claudetower/inbox`;
200hooks/lib/ranking-tool.ts 171 lines1import type { EngineInterface } from "claude-code";
2
3import type { RankingToolInput } from "../../types";
4import { HOUR_MS, READ_CONCURRENCY, host } from "./config";
5import type { Ranking } from "./ranking";
6import {
7 BUILT_IN_LABELS,
8 DEFAULT_RANKING,
9 bandOf,
10 checkRanking,
11 hasRankingFile,
12 loadRanking,
13 rankingOf,
14 rankingPath,
15 readingFingerprintOf,
16 writeRanking,
17} from "./ranking";
18import { setPaneOpen } from "./board";
19import { recencyOf } from "./classify";
20import { judgeScenario } from "./verdict";
21
22export const RANKING_TOOL = "ranking";
23
24const MAX_SCENARIOS = 12;
25
26export const RANKING_TOOL_SPEC = {
27 name: RANKING_TOOL,
28 description:
29 "Read, preview or save how claudetower ranks this Desktop instance's sessions. show: the ranking file's path, its content, the effective ranking, the defaults and the built-in labels. preview: run the real session reader on made-up scenarios under a draft ranking and under the current one, and return each scenario's urgency, band, labels and order. save: check a ranking and write it as this instance's ranking file. Used by the claudetower ranking skill.",
30 inputSchema: {
31 type: "object",
32 properties: {
33 action: { type: "string", enum: ["show", "preview", "save"] },
34 ranking: {
35 type: "object",
36 description:
37 "preview and save: the ranking file's full content (rules, labels, bands, recency, lanes, finish, scheduledTasks; parts left out come from the default)",
38 },
39 scenarios: {
40 type: "array",
41 description: `preview: up to ${MAX_SCENARIOS} made-up sessions to read`,
42 items: {
43 type: "object",
44 properties: {
45 name: { type: "string" },
46 description: {
47 type: "string",
48 description:
49 "What the session is about and where it stands, as its history and last words would show it",
50 },
51 ageHours: { type: "number" },
52 lane: {
53 type: "string",
54 enum: [
55 "answer",
56 "act",
57 "review",
58 "follow-up",
59 "in-flight",
60 "wrap-up",
61 ],
62 },
63 },
64 required: ["name", "description"],
65 },
66 },
67 },
68 required: ["action"],
69 },
70};
71
72type Scenario = NonNullable<RankingToolInput["scenarios"]>[number];
73type RankingRequest = Partial<RankingToolInput>;
74
75async function readScenario(
76 $: EngineInterface,
77 ranking: Ranking,
78 scenario: Scenario,
79) {
80 const verdict = await judgeScenario($, ranking, scenario.description);
81 if (!verdict) return { name: scenario.name, error: "could not read it" };
82 return {
83 name: scenario.name,
84 urgency: verdict.urgency,
85 band: bandOf(ranking, verdict.urgency).name,
86 labels: verdict.labels,
87 isLastMile: verdict.isLastMile,
88 score: Math.round(
89 verdict.urgency *
90 recencyOf(ranking, (scenario.ageHours ?? 0) * HOUR_MS) *
91 ranking.lanes[scenario.lane ?? "answer"],
92 ),
93 };
94}
95
96async function readAll(
97 $: EngineInterface,
98 ranking: Ranking,
99 scenarios: Scenario[],
100) {
101 const results: Awaited<ReturnType<typeof readScenario>>[] = [];
102 for (let index = 0; index < scenarios.length; index += READ_CONCURRENCY)
103 results.push(
104 ...(await Promise.all(
105 scenarios
106 .slice(index, index + READ_CONCURRENCY)
107 .map((scenario) => readScenario($, ranking, scenario)),
108 )),
109 );
110 const scoreOf = (result: (typeof results)[number]) =>
111 "error" in result ? -1 : result.score;
112 return results.sort((a, b) => scoreOf(b) - scoreOf(a));
113}
114
115async function show($: EngineInterface) {
116 const hasFile = await hasRankingFile($);
117 return {
118 instance: host.instance,
119 path: rankingPath(),
120 hasFile,
121 file: hasFile ? JSON.parse(await $.fs.read(rankingPath())) : null,
122 effective: await loadRanking($),
123 defaults: DEFAULT_RANKING,
124 builtInLabels: BUILT_IN_LABELS,
125 };
126}
127
128async function preview($: EngineInterface, input: RankingRequest) {
129 const problems = checkRanking(input.ranking);
130 if (problems.length > 0) return { problems };
131 const scenarios = (input.scenarios ?? []).slice(0, MAX_SCENARIOS);
132 if (scenarios.length === 0) return { problems: ["no scenarios to preview"] };
133 const draft = rankingOf(input.ranking as Partial<Ranking>);
134 const current = await loadRanking($);
135 const [withDraft, withCurrent] = await Promise.all([
136 readAll($, draft, scenarios),
137 readAll($, current, scenarios),
138 ]);
139 return {
140 draft: withDraft,
141 current: withCurrent,
142 reReadsSessions:
143 readingFingerprintOf(draft) !== readingFingerprintOf(current),
144 };
145}
146
147async function save($: EngineInterface, input: RankingRequest) {
148 const problems = checkRanking(input.ranking);
149 if (problems.length > 0) return { problems };
150 const before = readingFingerprintOf(await loadRanking($));
151 const isFirst = !(await hasRankingFile($));
152 await writeRanking($, input.ranking as Partial<Ranking>);
153 if (isFirst) await setPaneOpen($, true);
154 return {
155 saved: rankingPath(),
156 reReadsSessions:
157 readingFingerprintOf(rankingOf(input.ranking as Partial<Ranking>)) !==
158 before,
159 };
160}
161
162export async function runRankingTool($: EngineInterface, input: unknown) {
163 const request = (input ?? {}) as RankingRequest;
164 if (!host.instance)
165 return { problems: ["claudetower has not found its Desktop instance yet"] };
166 if (request.action === "show") return show($);
167 if (request.action === "preview") return preview($, request);
168 if (request.action === "save") return save($, request);
169 return { problems: ['action must be "show", "preview" or "save"'] };
170}
171hooks/view/above-prompt.tsx 37 lines1import type { EngineInterface, RenderInput } from "claude-code";
2
3import { openPane, readCards } from "../lib/board";
4import { isAskingYou } from "../lib/classify";
5import { FRESH_MS, host } from "../lib/config";
6
7export async function renderAbovePrompt(
8 $: EngineInterface,
9 e: RenderInput<"AbovePrompt">,
10) {
11 const { Box, Text, Button } = $.ui.resolve(e);
12 const waiting = (await readCards($)).filter(
13 (card) =>
14 isAskingYou(card) &&
15 !card.isRunning &&
16 card.ageMs < FRESH_MS &&
17 card.cliId !== host.selfCliId,
18 );
19 const top = waiting[0];
20
21 return (
22 <Box flexDirection="row" columnGap={1}>
23 <Text color={waiting.length > 0 ? "error" : "success"}>
24 {waiting.length > 0
25 ? `${waiting.length} need you`
26 : "Nothing needs you"}
27 </Text>
28 {top && (
29 <Text dimColor wrap="truncate-end">
30 · {top.title}
31 </Text>
32 )}
33 <Button key="tower-open" label="Sessions" onPress={() => openPane($)} />
34 </Box>
35 );
36}
37hooks/view/pane.tsx 576 lines1import type { EngineInterface, RenderChildren, RenderInput } from "claude-code";
2
3import type { PrState, SessionCard } from "../../types";
4import {
5 archiveSession,
6 readCards,
7 openSession,
8 readPreviewed,
9 readComposing,
10 readDraft,
11 setDraft,
12 readPending,
13 readNeedsSetup,
14 readCollapsedGroups,
15 areClickLayersHealthy,
16 toggleCompose,
17 toggleGroup,
18 sendText,
19 startAction,
20 togglePreview,
21} from "../lib/board";
22import {
23 LANES,
24 badgeOf,
25 belongsInList,
26 isAskingYou,
27 isUnread,
28 reasonOf,
29} from "../lib/classify";
30import {
31 BOARD_MIN_COLUMNS,
32 CARDS_PER_LANE,
33 CARD_LIMIT,
34 DAY_MS,
35 FRESH_MS,
36 HOUR_MS,
37 LINE_LIMIT,
38 MINUTE_MS,
39 PANE_COLUMNS,
40 host,
41} from "../lib/config";
42import { isOpenPr } from "../lib/enrich";
43import { loadRanking } from "../lib/ranking";
44import { SETUP_TITLE, setupButtons } from "./setup";
45import {
46 BUBBLE_BORDER,
47 CHEVRON_SIZE,
48 PENCIL_SIZE,
49 PR_ICON_SIZE,
50 PR_STYLES,
51 chevron,
52 pencil,
53 prIcon,
54} from "./icons";
55
56export const ago = (ms: number) => {
57 if (ms < HOUR_MS) return `${Math.max(0, Math.round(ms / MINUTE_MS))}m`;
58 if (ms < 2 * DAY_MS) return `${Math.round(ms / HOUR_MS)}h`;
59 return `${Math.round(ms / DAY_MS)}d`;
60};
61
62const TICKET_PRIORITY_NAMES: Record<number, string> = {
63 1: "Urgent",
64 2: "High",
65 3: "Medium",
66 4: "Low",
67};
68
69const factsLine = (card: SessionCard) =>
70 [
71 card.project,
72 card.ticket &&
73 [
74 card.ticket,
75 TICKET_PRIORITY_NAMES[card.ticketPriority],
76 card.ticketStatus,
77 ]
78 .filter(Boolean)
79 .join(" "),
80 card.uncommittedFiles > 0 ? `${card.uncommittedFiles} uncommitted` : "",
81 ago(card.ageMs),
82 ]
83 .filter(Boolean)
84 .join(" · ");
85
86const COMPOSER_MAX_ROWS = 8;
87const COMPOSER_CHROME_COLUMNS = 10;
88
89const composerRows = (text: string, width: number) => {
90 const perRow = Math.max(20, width - COMPOSER_CHROME_COLUMNS);
91 const rows = text
92 .split("\n")
93 .reduce(
94 (total, line) => total + Math.max(1, Math.ceil(line.length / perRow)),
95 0,
96 );
97 return Math.min(COMPOSER_MAX_ROWS, Math.max(2, rows));
98};
99
100const hasPreview = (card: SessionCard) =>
101 card.lastAsk !== "" || card.lastReply !== "";
102
103export async function renderPane($: EngineInterface, e: RenderInput<"Pane">) {
104 const resolved = $.ui.resolve(e);
105 const { Box, Text, Button, Markdown } = resolved;
106 const Svg = "Svg" in resolved ? resolved.Svg : null;
107 const Client =
108 "Client" in resolved && (await areClickLayersHealthy($))
109 ? resolved.Client
110 : null;
111 const [
112 list,
113 previewedId,
114 pendingAt,
115 composingId,
116 draftText,
117 ranking,
118 isSettingUp,
119 collapsedGroups,
120 ] = await Promise.all([
121 readCards($),
122 readPreviewed($),
123 readPending($),
124 readComposing($),
125 readDraft($),
126 loadRanking($),
127 readNeedsSetup($),
128 readCollapsedGroups($),
129 ]);
130 const Input = "Input" in resolved ? resolved.Input : null;
131 const isProcessing = (card: SessionCard) =>
132 card.isRunning || (pendingAt[card.id] ?? 0) > card.lastAt;
133 const canCompose = (card: SessionCard) =>
134 card.cliId !== host.selfCliId && !isProcessing(card);
135 const width = e.props.bodyColumns || e.viewport?.columns || PANE_COLUMNS;
136 const isSelf = (card: SessionCard) => card.cliId === host.selfCliId;
137 const self = list.find(isSelf);
138 const others = list.filter((card) => !isSelf(card));
139 const listed = others.filter((card) => belongsInList(card, ranking));
140 const ranked = listed.filter((card) => !card.isFinishing);
141 const finishing = listed
142 .filter((card) => card.isFinishing)
143 .sort((a, b) => b.ageMs - a.ageMs);
144 const running = others.filter(
145 (card) => card.isRunning && card.ageMs < FRESH_MS,
146 );
147 const asking = ranked.filter(isAskingYou).length;
148 const unread = others.filter(isUnread).length;
149
150 const summary = (
151 <Box flexDirection="row" columnGap={1}>
152 <Text weight="semibold">{asking} need you</Text>
153 {finishing.length > 0 && (
154 <Text dimColor>· {finishing.length} to finish</Text>
155 )}
156 {unread > 0 && <Text dimColor>· reading {unread}…</Text>}
157 </Box>
158 );
159
160 const group = (id: string, title: string, items: RenderChildren[]) => (
161 <Box key={`group-${id}`} flexDirection="column" rowGap={1}>
162 <Box
163 position="relative"
164 flexDirection="row"
165 columnGap={1}
166 alignItems="center"
167 >
168 {Client ? (
169 <Text dimColor weight="semibold">
170 {title}
171 </Text>
172 ) : (
173 <Button
174 key={`group-toggle-${id}`}
175 plain
176 dimColor
177 label={`${title} ${collapsedGroups[id] ? "▸" : "▾"}`}
178 onPress={() => toggleGroup($, id)}
179 />
180 )}
181 {Svg && Client && (
182 <Svg
183 source={chevron(!collapsedGroups[id])}
184 alt={collapsedGroups[id] ? "Expand" : "Collapse"}
185 width={CHEVRON_SIZE}
186 height={CHEVRON_SIZE}
187 />
188 )}
189 {Client && (
190 <Box position="absolute" top={0} left={0} right={0} bottom={0}>
191 <Client
192 key={`group-hit-${id}`}
193 module="../card-hit.tsx"
194 props={{ id, canToggle: false, canCompose: false, isGroup: true }}
195 width="100%"
196 height="100%"
197 />
198 </Box>
199 )}
200 </Box>
201 {!collapsedGroups[id] && items}
202 </Box>
203 );
204
205 const prIconOf = (state: PrState) =>
206 Svg ? (
207 <Svg
208 source={prIcon(state)}
209 alt={PR_STYLES[state].label}
210 width={PR_ICON_SIZE}
211 height={PR_ICON_SIZE}
212 />
213 ) : (
214 <Text dimColor>{state}</Text>
215 );
216
217 const mergedCount = (count: number) =>
218 count > 0 && (
219 <Box flexDirection="row" columnGap={1} alignItems="center">
220 {prIconOf("merged")}
221 <Text dimColor>{count}</Text>
222 </Box>
223 );
224
225 const prLines = (card: SessionCard) => {
226 const open = card.prs.filter((pr) => isOpenPr(pr.state));
227 const merged = card.prs.filter((pr) => pr.state === "merged").length;
228 if (open.length === 0)
229 return merged > 0 ? (
230 <Box key={`prs-${card.id}`}>{mergedCount(merged)}</Box>
231 ) : null;
232 return open.map((pr, index) => (
233 <Box
234 key={`pr-${card.id}-${pr.number}`}
235 flexDirection="row"
236 columnGap={1}
237 alignItems="center"
238 >
239 {prIconOf(pr.state)}
240 <Text dimColor>#{pr.number}</Text>
241 {pr.additions + pr.deletions > 0 && (
242 <Text>
243 <Text color="success">+{pr.additions}</Text>{" "}
244 <Text color="error">−{pr.deletions}</Text>
245 </Text>
246 )}
247 {pr.failing > 0 && <Text color="error">{pr.failing} failing</Text>}
248 {pr.failing === 0 && pr.pending > 0 && (
249 <Text color="warning">{pr.pending} running</Text>
250 )}
251 {index === 0 && mergedCount(merged)}
252 </Box>
253 ));
254 };
255
256 const reason = (card: SessionCard) => {
257 const badge = badgeOf(card);
258 const band = card.isRunning ? null : card.band;
259 return (
260 <Text wrap="wrap">
261 {band && (
262 <Text color={band.color} weight="semibold">
263 {band.name}{" "}
264 </Text>
265 )}
266 {badge && (
267 <Text color={badge.color} weight="semibold">
268 {badge.title}{" "}
269 </Text>
270 )}
271 <Text>{reasonOf(card)}</Text>
272 {card.isRereading && <Text dimColor> · updating…</Text>}
273 </Text>
274 );
275 };
276
277 const actions = (card: SessionCard) => {
278 if (isProcessing(card))
279 return (
280 <Box marginTop={1}>
281 <Text dimColor>Working…</Text>
282 </Box>
283 );
284 const { options } = card;
285 if (options.length === 0 && !card.canArchive) return null;
286 return (
287 <Box flexDirection="column" marginTop={1} rowGap={1}>
288 {options.length > 0 && card.question !== "" && (
289 <Text dimColor wrap="wrap">
290 {card.question}
291 </Text>
292 )}
293 <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
294 {options.map((option, index) => (
295 <Button
296 key={`option-${card.id}-${index}`}
297 variant={index === 0 ? "primary" : "secondary"}
298 label={option.label}
299 onPress={() => startAction($, card, option)}
300 />
301 ))}
302 {card.canArchive && (
303 <Button
304 key={`archive-${card.id}`}
305 variant={options.length === 0 ? "primary" : "secondary"}
306 label="Archive"
307 onPress={() => archiveSession($, card)}
308 />
309 )}
310 </Box>
311 </Box>
312 );
313 };
314
315 const preview = (card: SessionCard) =>
316 previewedId === card.id && (
317 <Box flexDirection="column" marginTop={1} rowGap={1}>
318 {card.lastAsk !== "" && (
319 <Box flexDirection="column" alignItems="flex-end">
320 <Text dimColor size="caption">
321 You wrote
322 {card.lastAskAgoMs > 0 ? ` · ${ago(card.lastAskAgoMs)} ago` : ""}
323 </Text>
324 <Box
325 width="85%"
326 borderStyle="bold"
327 borderColor={BUBBLE_BORDER}
328 paddingX={2}
329 >
330 <Text wrap="wrap">{card.lastAsk}</Text>
331 </Box>
332 </Box>
333 )}
334 {card.lastReply !== "" && <Markdown text={card.lastReply} />}
335 </Box>
336 );
337
338 const cardView = (card: SessionCard) => (
339 <Box
340 key={`card-${card.id}`}
341 position="relative"
342 flexDirection="column"
343 borderStyle="round"
344 paddingX={1}
345 >
346 <Box key={`body-${card.id}`} position="relative" flexDirection="column">
347 <Box paddingRight={7}>
348 {Client ? (
349 <Text weight="semibold" wrap="wrap">
350 {card.title}
351 </Text>
352 ) : (
353 <Button
354 key={`open-title-${card.id}`}
355 plain
356 onPress={() => openSession($, card.id)}
357 >
358 {card.title}
359 </Button>
360 )}
361 </Box>
362 <Text dimColor size="caption" wrap="wrap">
363 {factsLine(card)}
364 </Text>
365 {card.verdict && card.verdict.labels.length > 0 && (
366 <Text color="subtle" size="caption" wrap="wrap">
367 {card.verdict.labels.join(" · ")}
368 </Text>
369 )}
370 {prLines(card)}
371 {reason(card)}
372 {canCompose(card) && (
373 <Box position="absolute" top={0} right={3}>
374 {Svg && Client ? (
375 <Svg
376 source={pencil(composingId === card.id)}
377 alt="Write to this session"
378 width={PENCIL_SIZE}
379 height={PENCIL_SIZE}
380 />
381 ) : (
382 <Button
383 key={`compose-${card.id}-fallback`}
384 plain
385 dimColor
386 label="✎"
387 onPress={() => toggleCompose($, card.id)}
388 />
389 )}
390 </Box>
391 )}
392 {hasPreview(card) && (
393 <Box position="absolute" top={0} right={0}>
394 {Svg && Client ? (
395 <Svg
396 source={chevron(previewedId === card.id)}
397 alt={previewedId === card.id ? "Collapse" : "Expand"}
398 width={CHEVRON_SIZE}
399 height={CHEVRON_SIZE}
400 />
401 ) : (
402 <Button
403 key={`toggle-${card.id}-fallback`}
404 plain
405 dimColor
406 label={previewedId === card.id ? "▾" : "▸"}
407 onPress={() => togglePreview($, card.id)}
408 />
409 )}
410 </Box>
411 )}
412 {Client && (
413 <Box position="absolute" top={0} left={0} right={0} bottom={0}>
414 <Client
415 key={`hit-${card.id}`}
416 module="../card-hit.tsx"
417 props={{
418 id: card.id,
419 canToggle: hasPreview(card),
420 canCompose: canCompose(card),
421 }}
422 width="100%"
423 height="100%"
424 />
425 </Box>
426 )}
427 </Box>
428 {preview(card)}
429 {actions(card)}
430 {composingId === card.id && canCompose(card) && Input && (
431 <Box marginTop={1} width="100%" flexDirection="column">
432 <Input
433 key={`compose-${card.id}`}
434 placeholder="Write to this session…"
435 submitLabel="↵"
436 rows={composerRows(draftText, width)}
437 value={draftText}
438 autoFocus
439 onInput={(text) => setDraft($, text)}
440 onSubmit={(text) => sendText($, card, text)}
441 />
442 </Box>
443 )}
444 {previewedId === card.id && (
445 <Box marginTop={1} flexDirection="row" justifyContent="center">
446 <Button
447 key={`collapse-${card.id}`}
448 plain
449 dimColor
450 hover={{ dimColor: false, underline: true }}
451 label="Collapse ▴"
452 onPress={() => togglePreview($, card.id)}
453 />
454 </Box>
455 )}
456 </Box>
457 );
458
459 const runningLine = (card: SessionCard) => (
460 <Box key={`line-${card.id}`} flexDirection="row" columnGap={1}>
461 <Button
462 key={`open-${card.id}`}
463 plain
464 onPress={() => openSession($, card.id)}
465 >
466 {card.title}
467 </Button>
468 <Text dimColor wrap="truncate-end">
469 {card.project}
470 </Text>
471 </Box>
472 );
473
474 const setupCard = (
475 <Box
476 flexDirection="column"
477 borderStyle="round"
478 borderColor="claude"
479 paddingX={1}
480 rowGap={1}
481 >
482 <Text weight="semibold" color="claude">
483 {SETUP_TITLE}
484 </Text>
485 <Text wrap="wrap">
486 claudetower ranks your sessions with a default that works for anyone.
487 Tell it what matters to you in a short setup, run in a new session.
488 </Text>
489 <Box flexDirection="row" columnGap={1}>
490 {setupButtons($, resolved, "pane")}
491 </Box>
492 </Box>
493 );
494
495 if (isSettingUp)
496 return (
497 <Box flexDirection="column" rowGap={1}>
498 {setupCard}
499 <Text dimColor wrap="wrap">
500 Your sessions show here once you choose how to rank them.
501 </Text>
502 </Box>
503 );
504
505 if (list.length === 0) return <Text dimColor>Loading your sessions…</Text>;
506
507 if (width >= BOARD_MIN_COLUMNS) {
508 const laneWidth = `${Math.floor(100 / LANES.length)}%`;
509 return (
510 <Box flexDirection="column" rowGap={1}>
511 {summary}
512 <Box flexDirection="row" columnGap={1}>
513 {LANES.map((lane) => {
514 const items = [...finishing, ...ranked, ...running].filter(
515 (card) => card.lane === lane.id,
516 );
517 return (
518 <Box
519 key={lane.id}
520 flexDirection="column"
521 width={laneWidth}
522 flexGrow={0}
523 flexShrink={0}
524 minWidth={0}
525 rowGap={1}
526 >
527 <Text color={lane.color} weight="semibold">
528 {lane.title} ({items.length})
529 </Text>
530 <Text dimColor size="caption" wrap="wrap">
531 {lane.hint}
532 </Text>
533 {items.slice(0, CARDS_PER_LANE).map(cardView)}
534 {items.length > CARDS_PER_LANE && (
535 <Text dimColor>+{items.length - CARDS_PER_LANE} more</Text>
536 )}
537 </Box>
538 );
539 })}
540 </Box>
541 </Box>
542 );
543 }
544
545 return (
546 <Box flexDirection="column" rowGap={1}>
547 {self && group("self", "This session", [cardView(self)])}
548 {summary}
549 {finishing.length > 0 &&
550 group("finish", `Finish (${finishing.length})`, [
551 ...finishing.slice(0, ranking.finish.show).map(cardView),
552 finishing.length > ranking.finish.show && (
553 <Text key="finish-more" dimColor>
554 +{finishing.length - ranking.finish.show} more to finish
555 </Text>
556 ),
557 ])}
558 {ranked.length > 0 &&
559 group("open", `Open sessions (${ranked.length})`, [
560 ...ranked.slice(0, CARD_LIMIT).map(cardView),
561 ranked.length > CARD_LIMIT && (
562 <Text key="open-more" dimColor>
563 +{ranked.length - CARD_LIMIT} more
564 </Text>
565 ),
566 ])}
567 {running.length > 0 &&
568 group(
569 "running",
570 `Running (${running.length})`,
571 running.slice(0, LINE_LIMIT).map(runningLine),
572 )}
573 </Box>
574 );
575}
576hooks/view/setup.tsx 46 lines1import type { EngineInterface, RenderInput } from "claude-code";
2
3import { startSetup, useDefaultRanking } from "../lib/board";
4
5export const SETUP_TITLE = "✨ Rank sessions your way";
6
7export function setupButtons(
8 $: EngineInterface,
9 { Button }: ReturnType<EngineInterface["ui"]["resolve"]>,
10 place: string,
11) {
12 return [
13 <Button
14 key={`setup-start-${place}`}
15 variant="primary"
16 label="Set up"
17 onPress={() => startSetup($)}
18 />,
19 <Button
20 key={`setup-default-${place}`}
21 variant="secondary"
22 label="Use the default"
23 onPress={() => useDefaultRanking($)}
24 />,
25 ];
26}
27
28export function renderSetupBand(
29 $: EngineInterface,
30 e: RenderInput<"AbovePrompt">,
31) {
32 const elements = $.ui.resolve(e);
33 const { Box, Text } = elements;
34 return (
35 <Box flexDirection="row" columnGap={1} alignItems="center">
36 <Text weight="semibold" color="claude">
37 {SETUP_TITLE}
38 </Text>
39 <Text dimColor wrap="truncate-end">
40 · choose how claudetower ranks your sessions, then its panel opens
41 </Text>
42 {setupButtons($, elements, "band")}
43 </Box>
44 );
45}
46hooks/lib/classify.ts 159 lines1import type { LaneId, PrBadge, SessionCard, Verdict } from "../../types";
2import { HOUR_MS } from "./config";
3import { isOpenPr } from "./enrich";
4import type { Ranking } from "./ranking";
5import { bandOf } from "./ranking";
6
7export type CardFacts = Omit<
8 SessionCard,
9 "lane" | "canArchive" | "isFinishing" | "band" | "score"
10>;
11
12export const LANES: {
13 id: LaneId;
14 title: string;
15 hint: string;
16 color: string;
17 asksYou: boolean;
18}[] = [
19 {
20 id: "answer",
21 title: "Answer",
22 hint: "A question or a go-ahead only you can give",
23 color: "suggestion",
24 asksYou: true,
25 },
26 {
27 id: "act",
28 title: "Act",
29 hint: "Something you do outside the session",
30 color: "error",
31 asksYou: true,
32 },
33 {
34 id: "review",
35 title: "Review",
36 hint: "Look at what it produced",
37 color: "warning",
38 asksYou: true,
39 },
40 {
41 id: "follow-up",
42 title: "Follow up",
43 hint: "Its part is done; a next step is yours",
44 color: "claude",
45 asksYou: true,
46 },
47 {
48 id: "in-flight",
49 title: "In flight",
50 hint: "Running, or waiting on CI, a bot or someone else",
51 color: "subtle",
52 asksYou: false,
53 },
54 {
55 id: "wrap-up",
56 title: "Wrap up",
57 hint: "Finished; archive, or clear what is left",
58 color: "success",
59 asksYou: false,
60 },
61];
62
63const LANE_BY_ID = Object.fromEntries(LANES.map((lane) => [lane.id, lane]));
64export const laneInfo = (id: LaneId) => LANE_BY_ID[id]!;
65
66const UNREAD_URGENCY = 50;
67
68const followUpOf = (verdict: Verdict | null) =>
69 verdict && verdict.status !== "needs_you" ? (verdict.followUps[0] ?? "") : "";
70
71const needsYou = (facts: CardFacts) =>
72 !facts.isRunning &&
73 (facts.verdict?.status === "needs_you" || followUpOf(facts.verdict) !== "");
74
75const hasOpenPr = (prs: PrBadge[]) => prs.some((pr) => isOpenPr(pr.state));
76
77const canArchiveOf = (facts: CardFacts) =>
78 !facts.isRunning &&
79 !facts.isScheduled &&
80 facts.verdict?.isFinished === true &&
81 !needsYou(facts) &&
82 facts.uncommittedFiles === 0 &&
83 !hasOpenPr(facts.prs);
84
85const laneOf = (facts: CardFacts, canArchive: boolean): LaneId => {
86 const verdict = facts.verdict;
87 if (facts.isRunning) return "in-flight";
88 if (verdict?.status === "needs_you")
89 return verdict.kind === "do"
90 ? "act"
91 : verdict.kind === "review"
92 ? "review"
93 : "answer";
94 if (followUpOf(verdict) !== "") return "follow-up";
95 if (!canArchive && verdict?.status === "waiting_on_others")
96 return "in-flight";
97 return "wrap-up";
98};
99
100export const recencyOf = (ranking: Ranking, ageMs: number) =>
101 ranking.recency.floor +
102 (1 - ranking.recency.floor) *
103 0.5 ** (ageMs / HOUR_MS / ranking.recency.halfLifeHours);
104
105export const classify = (facts: CardFacts, ranking: Ranking): SessionCard => {
106 const canArchive = canArchiveOf(facts);
107 const lane = laneOf(facts, canArchive);
108 const urgency = facts.verdict?.urgency ?? UNREAD_URGENCY;
109 const band = bandOf(ranking, urgency);
110 return {
111 ...facts,
112 lane,
113 canArchive,
114 isFinishing:
115 !facts.isRunning &&
116 !facts.isScheduled &&
117 (canArchive || facts.verdict?.isLastMile === true),
118 band:
119 facts.verdict && band.color
120 ? { name: band.name, color: band.color }
121 : null,
122 score: urgency * recencyOf(ranking, facts.ageMs) * ranking.lanes[lane],
123 };
124};
125
126export const isAskingYou = (card: SessionCard) => laneInfo(card.lane).asksYou;
127
128export const belongsInList = (card: SessionCard, ranking: Ranking) =>
129 !card.isRunning &&
130 card.verdict !== null &&
131 !card.isRereading &&
132 !(
133 card.isScheduled &&
134 ranking.scheduledTasks === "problems-only" &&
135 card.verdict.status === "done"
136 );
137
138export const isUnread = (card: SessionCard) =>
139 !card.isRunning && card.verdict === null;
140
141export const reasonOf = (card: SessionCard) => {
142 if (card.isRunning) return "Working now";
143 const verdict = card.verdict;
144 if (verdict?.status === "needs_you") return verdict.waitingFor;
145 const followUp = followUpOf(verdict);
146 if (followUp !== "") return followUp;
147 const blockers = [
148 hasOpenPr(card.prs) ? "PR still open" : "",
149 card.uncommittedFiles > 0
150 ? `${card.uncommittedFiles} uncommitted files`
151 : "",
152 ].filter(Boolean);
153 const said = verdict?.waitingFor ?? "";
154 return blockers.length > 0 ? `${said} · ${blockers.join(", ")}` : said;
155};
156
157export const badgeOf = (card: SessionCard) =>
158 card.isRunning || card.lane === "wrap-up" ? null : laneInfo(card.lane);
159hooks/lib/enrich.ts 268 lines1import type { EngineInterface } from "claude-code";
2
3import type { PrBadge, PrState, TicketInfo } from "../../types";
4import {
5 GH_TIMEOUT_MS,
6 PR_LOOKUPS_PER_POLL,
7 PR_TTL_MS,
8 STORE_KEYS,
9 TICKET_LOOKUPS_PER_POLL,
10 TICKET_TTL_MS,
11 host,
12 log,
13} from "./config";
14import type { ProjectProfile } from "./project";
15import type { Live, SessionPr } from "./transcript";
16
17type CheckRun = {
18 conclusion?: string | null;
19 state?: string | null;
20 status?: string | null;
21};
22
23type StoredPr = PrBadge & { checkedAt: number };
24export type PrStore = Record<string, StoredPr>;
25export type TicketStore = Record<string, TicketInfo>;
26export type TicketRequest = { id: string; profile: ProjectProfile };
27
28const FAILED = [
29 "FAILURE",
30 "TIMED_OUT",
31 "CANCELLED",
32 "ACTION_REQUIRED",
33 "STARTUP_FAILURE",
34 "ERROR",
35];
36const PENDING = ["IN_PROGRESS", "QUEUED", "PENDING", "WAITING", "REQUESTED"];
37const CLOSED_TICKET =
38 /^(completed|done|closed|resolved|canceled|cancelled|duplicate|won't do)$/i;
39const PRIORITY_NAMES: Record<string, number> = {
40 urgent: 1,
41 highest: 1,
42 critical: 1,
43 p0: 1,
44 high: 2,
45 p1: 2,
46 medium: 3,
47 normal: 3,
48 p2: 3,
49 low: 4,
50 lowest: 4,
51 p3: 4,
52};
53
54export const prStateOf = (state?: string, isDraft = false): PrState =>
55 state === "MERGED"
56 ? "merged"
57 : state === "CLOSED"
58 ? "closed"
59 : isDraft
60 ? "draft"
61 : "open";
62
63export const isOpenPr = (state: PrState) =>
64 state === "open" || state === "draft";
65
66const prKey = (pr: SessionPr) => `${pr.repo}#${pr.prNumber}`;
67
68const isStale = (checkedAt: number | undefined, ttl: number) =>
69 Date.now() - (checkedAt ?? 0) > ttl;
70
71export async function readTickets($: EngineInterface) {
72 return ((await $.store.get(STORE_KEYS.tickets)) ?? {}) as TicketStore;
73}
74
75export async function readPrs($: EngineInterface) {
76 return ((await $.store.get(STORE_KEYS.prs)) ?? {}) as PrStore;
77}
78
79const nameOf = (value: unknown): string =>
80 typeof value === "string"
81 ? value
82 : value && typeof value === "object" && "name" in value
83 ? String(value.name)
84 : "";
85
86const priorityOf = (value: unknown): number => {
87 if (typeof value === "number") return value;
88 if (value && typeof value === "object" && "value" in value)
89 return Number(value.value) || 0;
90 return PRIORITY_NAMES[nameOf(value).toLowerCase()] ?? 0;
91};
92
93async function lookUpTicket(
94 $: EngineInterface,
95 { id, profile }: TicketRequest,
96) {
97 const lookup = profile.issueLookup;
98 if (!lookup) return null;
99 const input = {
100 tool: lookup.tool,
101 [lookup.idArgument]: id,
102 } as Parameters<EngineInterface["tool"]["call"]>[0];
103 const called = await $.tool.call(input);
104 if (called.deny !== undefined || called.isError || !called.text) {
105 log(
106 $,
107 `could not look up ${id}: ${called.deny ?? called.text ?? "no answer"}`,
108 );
109 return null;
110 }
111 const issue = JSON.parse(called.text) as Record<string, unknown>;
112 const status = nameOf(issue.status ?? issue.state);
113 const statusType = nameOf(issue.statusType ?? issue.stateType ?? "");
114 const info: TicketInfo = {
115 priority: priorityOf(issue.priority),
116 status,
117 isOpen: !CLOSED_TICKET.test(statusType || status),
118 checkedAt: Date.now(),
119 };
120 return [id, info] as const;
121}
122
123export async function refreshTickets(
124 $: EngineInterface,
125 requests: TicketRequest[],
126) {
127 const tickets = await readTickets($);
128 const stale = requests
129 .filter(
130 (request, index) =>
131 request.profile.issueLookup &&
132 requests.findIndex((other) => other.id === request.id) === index &&
133 isStale(tickets[request.id]?.checkedAt, TICKET_TTL_MS),
134 )
135 .slice(0, TICKET_LOOKUPS_PER_POLL);
136 if (stale.length === 0) return;
137 const found = await Promise.all(
138 stale.map((request) =>
139 lookUpTicket($, request).catch((error: unknown) => {
140 log($, `could not look up ${request.id}: ${String(error)}`);
141 return null;
142 }),
143 ),
144 );
145 const current = await readTickets($);
146 const retryLater = Object.fromEntries(
147 stale.map(({ id }) => [
148 id,
149 {
150 ...(current[id] ?? { priority: 0, status: "", isOpen: true }),
151 checkedAt: Date.now(),
152 },
153 ]),
154 );
155 await $.store.set(STORE_KEYS.tickets, {
156 ...current,
157 ...retryLater,
158 ...Object.fromEntries(found.filter((entry) => entry !== null)),
159 });
160}
161
162async function lookUpPr($: EngineInterface, pr: SessionPr) {
163 const viewed = await $.process.run(
164 [
165 host.gh,
166 "pr",
167 "view",
168 String(pr.prNumber),
169 "--repo",
170 pr.repo ?? "",
171 "--json",
172 "state,isDraft,additions,deletions,statusCheckRollup",
173 ],
174 { timeoutMs: GH_TIMEOUT_MS },
175 );
176 if (viewed.exitCode !== 0) {
177 log($, `could not look up PR ${prKey(pr)}: ${viewed.stderr.trim()}`);
178 return null;
179 }
180 const data = JSON.parse(viewed.stdout) as {
181 state?: string;
182 isDraft?: boolean;
183 additions?: number;
184 deletions?: number;
185 statusCheckRollup?: CheckRun[];
186 };
187 const state = prStateOf(data.state, data.isDraft);
188 const checks = isOpenPr(state) ? (data.statusCheckRollup ?? []) : [];
189 const has = (check: CheckRun, values: string[]) =>
190 [check.conclusion, check.state, check.status].some(
191 (value) => value && values.includes(value),
192 );
193 const stored: StoredPr = {
194 number: pr.prNumber ?? 0,
195 state,
196 additions: data.additions ?? 0,
197 deletions: data.deletions ?? 0,
198 failing: checks.filter((check) => has(check, FAILED)).length,
199 pending: checks.filter(
200 (check) => !has(check, FAILED) && has(check, PENDING),
201 ).length,
202 checkedAt: Date.now(),
203 };
204 return [prKey(pr), stored] as const;
205}
206
207export async function refreshPrs($: EngineInterface, live: Live[]) {
208 if (!host.gh) return;
209 const known = await readPrs($);
210 const stale = live
211 .flatMap(({ s }) => s.prs ?? [])
212 .filter((pr) => pr.prNumber && pr.repo)
213 .filter((pr) => {
214 const entry = known[prKey(pr)];
215 return (
216 (!entry || isOpenPr(entry.state)) &&
217 isStale(entry?.checkedAt, PR_TTL_MS)
218 );
219 })
220 .slice(0, PR_LOOKUPS_PER_POLL);
221 if (stale.length === 0) return;
222 const found = await Promise.all(
223 stale.map((pr) =>
224 lookUpPr($, pr).catch((error: unknown) => {
225 log($, `could not look up PR ${prKey(pr)}: ${String(error)}`);
226 return null;
227 }),
228 ),
229 );
230 const current = await readPrs($);
231 const retryLater = Object.fromEntries(
232 stale.map((pr) => [
233 prKey(pr),
234 {
235 ...(current[prKey(pr)] ?? {
236 number: pr.prNumber ?? 0,
237 state: prStateOf(pr.state),
238 additions: 0,
239 deletions: 0,
240 failing: 0,
241 pending: 0,
242 }),
243 checkedAt: Date.now(),
244 },
245 ]),
246 );
247 await $.store.set(STORE_KEYS.prs, {
248 ...current,
249 ...retryLater,
250 ...Object.fromEntries(found.filter((entry) => entry !== null)),
251 });
252}
253
254export const prBadgesOf = (prs: SessionPr[] = [], known: PrStore): PrBadge[] =>
255 prs
256 .filter((pr) => pr.prNumber)
257 .map(
258 (pr) =>
259 known[prKey(pr)] ?? {
260 number: pr.prNumber ?? 0,
261 state: prStateOf(pr.state),
262 additions: 0,
263 deletions: 0,
264 failing: 0,
265 pending: 0,
266 },
267 );
268hooks/lib/project.ts 205 lines1import type { EngineInterface } from "claude-code";
2
3import {
4 CLAIM_MS,
5 PROJECT_TTL_MS,
6 STORE_KEYS,
7 VERDICT_MODEL,
8 host,
9 isMcpToolName,
10 isTicketPrefix,
11 loadTools,
12 log,
13} from "./config";
14import type { McpToolName } from "./config";
15import type { Live, SessionFile } from "./transcript";
16
17export type ProjectProfile = {
18 summary: string;
19 ticketPrefixes: string[];
20 issueLookup: { tool: McpToolName; idArgument: string } | null;
21 criticalSignals: string[];
22 services: string[];
23 checkedAt: number;
24};
25
26type ProjectStore = Record<string, ProjectProfile>;
27type ClaimStore = Record<string, { by: string; until: number }>;
28
29const README_CHARS = 3000;
30const INSTRUCTIONS_CHARS = 5000;
31const BRANCHES = 40;
32const ISSUE_TOOL_HINT = /issue|ticket|task|story|bug/i;
33
34const PROJECT_RULES = `You profile a software project for a tool that triages the user's Claude Code sessions. You get facts about the repo and the tools the user's Claude Code can call. Name only what the facts support; never assume a service the facts don't show.
35Answer with one JSON object and nothing else:
36{"summary": string, "ticketPrefixes": string[], "issueLookup": {"tool": string, "idArgument": string} | null, "criticalSignals": string[], "services": string[]}
37- summary: at most 20 words. What the project is.
38- ticketPrefixes: issue-key prefixes this project's branches and session titles use (for "ENG-123", "ENG"). Empty when there are none.
39- issueLookup: the tool from availableIssueTools that fetches one issue of this project's tracker by its key, and the name of that tool's argument for the key. null when none fits.
40- criticalSignals: up to 6 short phrases saying what a P0 emergency looks like for this project specifically (for example "production API down for users", "main branch CI broken, nobody can merge", "payments failing"). Base them on what the project runs and deploys.
41- services: external services the project depends on (error tracking, hosting, CI, issue tracker, payments...), as named in the facts.`;
42
43export const projectRootOf = (s: SessionFile) => {
44 const path = s.originCwd ?? s.cwd ?? "";
45 return path.split("/.claude/worktrees/")[0] ?? path;
46};
47
48export async function readProjects($: EngineInterface) {
49 return ((await $.store.get(STORE_KEYS.projects)) ?? {}) as ProjectStore;
50}
51
52async function readFileStart($: EngineInterface, path: string, chars: number) {
53 if (!(await $.fs.exists(path))) return "";
54 const stat = await $.fs.stat(path);
55 if (stat.kind !== "file") return "";
56 return (await $.fs.read(path)).slice(0, chars);
57}
58
59async function run($: EngineInterface, argv: string[]) {
60 const result = await $.process.run(argv);
61 return result.exitCode === 0 ? result.stdout.trim() : "";
62}
63
64async function factsOf($: EngineInterface, root: string, titles: string[]) {
65 await loadTools($);
66 const [remote, branches, entries, workflows, readme, instructions] =
67 await Promise.all([
68 run($, ["git", "-C", root, "remote", "get-url", "origin"]),
69 run($, [
70 "git",
71 "-C",
72 root,
73 "for-each-ref",
74 `--count=${BRANCHES}`,
75 "--sort=-committerdate",
76 "--format=%(refname:short)",
77 "refs/heads",
78 ]),
79 $.fs.list(root),
80 $.fs
81 .exists(`${root}/.github/workflows`)
82 .then((exists) =>
83 exists ? $.fs.list(`${root}/.github/workflows`) : [],
84 ),
85 readFileStart($, `${root}/README.md`, README_CHARS),
86 readFileStart($, `${root}/CLAUDE.md`, INSTRUCTIONS_CHARS),
87 ]);
88 return {
89 gitRemote: remote,
90 recentBranches: branches.split("\n").filter(Boolean),
91 sessionTitles: titles,
92 topLevelEntries: entries.map((entry) => entry.name),
93 ciWorkflows: workflows.map((entry) => entry.name),
94 readme,
95 claudeInstructions: instructions,
96 availableIssueTools: host.tools.filter(
97 (tool) =>
98 ISSUE_TOOL_HINT.test(tool.name) ||
99 ISSUE_TOOL_HINT.test(tool.description),
100 ),
101 };
102}
103
104const textList = (value: unknown, limit: number) =>
105 Array.isArray(value) ? value.map(String).slice(0, limit) : [];
106
107function parseProfile(text: string): ProjectProfile | null {
108 const json = text.match(/\{[\s\S]*\}/)?.[0];
109 if (!json) return null;
110 const raw = JSON.parse(json) as {
111 summary?: unknown;
112 ticketPrefixes?: unknown;
113 issueLookup?: { tool?: unknown; idArgument?: unknown } | null;
114 criticalSignals?: unknown;
115 services?: unknown;
116 };
117 const tool = String(raw.issueLookup?.tool ?? "");
118 const idArgument = String(raw.issueLookup?.idArgument ?? "");
119 return {
120 summary: String(raw.summary ?? ""),
121 ticketPrefixes: textList(raw.ticketPrefixes, 10).filter(isTicketPrefix),
122 issueLookup:
123 isMcpToolName(tool) &&
124 idArgument &&
125 host.tools.some((known) => known.name === tool)
126 ? { tool, idArgument }
127 : null,
128 criticalSignals: textList(raw.criticalSignals, 6),
129 services: textList(raw.services, 15),
130 checkedAt: Date.now(),
131 };
132}
133
134async function profileProject(
135 $: EngineInterface,
136 root: string,
137 titles: string[],
138) {
139 const answer = await $.model.complete({
140 model: VERDICT_MODEL,
141 system: [{ text: PROJECT_RULES, cache: true }],
142 prompt: JSON.stringify(await factsOf($, root, titles)),
143 maxTokens: 800,
144 });
145 if (!answer.isAnswered) {
146 log($, `could not profile ${root}: ${answer.reason}`);
147 return null;
148 }
149 const profile = parseProfile(answer.text);
150 if (!profile) log($, `unusable project profile for ${root}`);
151 return profile;
152}
153
154export async function claim($: EngineInterface, key: string) {
155 const claims = ((await $.store.get(STORE_KEYS.claims)) ?? {}) as ClaimStore;
156 const now = Date.now();
157 const held = claims[key];
158 if (held && held.by !== host.selfCliId && held.until > now) return false;
159 const live = Object.fromEntries(
160 Object.entries(claims).filter(([, value]) => value.until > now),
161 );
162 await $.store.set(STORE_KEYS.claims, {
163 ...live,
164 [key]: { by: host.selfCliId, until: now + CLAIM_MS },
165 });
166 return true;
167}
168
169export async function refreshProjects($: EngineInterface, live: Live[]) {
170 const projects = await readProjects($);
171 const titlesByRoot = new Map<string, string[]>();
172 for (const { s } of live)
173 titlesByRoot.set(projectRootOf(s), [
174 ...(titlesByRoot.get(projectRootOf(s)) ?? []),
175 s.title ?? "",
176 ]);
177 const stale = [...titlesByRoot.keys()].find(
178 (root) => Date.now() - (projects[root]?.checkedAt ?? 0) > PROJECT_TTL_MS,
179 );
180 if (!stale || !(await claim($, `project:${stale}`))) return;
181 try {
182 const profile = await profileProject(
183 $,
184 stale,
185 (titlesByRoot.get(stale) ?? []).slice(0, 30),
186 );
187 if (!profile) return;
188 await $.store.set(STORE_KEYS.projects, {
189 ...(await readProjects($)),
190 [stale]: profile,
191 });
192 } catch (error) {
193 log($, `could not profile ${stale}: ${String(error)}`);
194 }
195}
196
197export const ticketPatternOf = (profile: ProjectProfile | undefined) => {
198 const prefixes = host.ticketPrefixOverride.length
199 ? host.ticketPrefixOverride
200 : (profile?.ticketPrefixes ?? []);
201 return prefixes.length > 0
202 ? new RegExp(`\\b(?:${prefixes.join("|")})-\\d+\\b`, "i")
203 : null;
204};
205hooks/lib/ranking.ts 270 lines1import type { EngineInterface } from "claude-code";
2
3import type { LaneId } from "../../types";
4import { host, log } from "./config";
5
6export type UrgencyRule = { said: string; rule: string };
7export type LabelDefinition = { name: string; means: string };
8export type Band = { name: string; from: number; color?: BandColor };
9export type ScheduledTasks = "problems-only" | "all";
10
11export type Ranking = {
12 rules: UrgencyRule[];
13 labels: LabelDefinition[];
14 bands: Band[];
15 recency: { halfLifeHours: number; floor: number };
16 lanes: Record<LaneId, number>;
17 finish: { show: number };
18 scheduledTasks: ScheduledTasks;
19};
20
21const BAND_COLORS = [
22 "error",
23 "warning",
24 "success",
25 "suggestion",
26 "claude",
27 "subtle",
28] as const;
29type BandColor = (typeof BAND_COLORS)[number];
30
31const LANE_IDS: LaneId[] = [
32 "answer",
33 "act",
34 "review",
35 "follow-up",
36 "in-flight",
37 "wrap-up",
38];
39
40export const BUILT_IN_LABELS: LabelDefinition[] = [
41 {
42 name: "production",
43 means: "Concerns a live production service, app or its users",
44 },
45 {
46 name: "security",
47 means: "Concerns a security issue, a leaked secret or access control",
48 },
49 {
50 name: "ci",
51 means: "Concerns CI: failing checks, broken builds or test pipelines",
52 },
53 { name: "deploy", means: "Concerns deploying or releasing" },
54 {
55 name: "blocks-others",
56 means: "Other people are waiting on this work to continue theirs",
57 },
58];
59
60export const DEFAULT_RANKING: Ranking = {
61 rules: [],
62 labels: [],
63 bands: [
64 { name: "Urgent", from: 80, color: "error" },
65 { name: "High", from: 60, color: "warning" },
66 { name: "Normal", from: 30 },
67 { name: "Low", from: 0 },
68 ],
69 recency: { halfLifeHours: 48, floor: 0.8 },
70 lanes: {
71 answer: 1,
72 act: 1,
73 review: 1,
74 "follow-up": 0.95,
75 "in-flight": 0.85,
76 "wrap-up": 0.8,
77 },
78 finish: { show: 3 },
79 scheduledTasks: "problems-only",
80};
81
82const LABEL_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
83
84const isRecord = (value: unknown): value is Record<string, unknown> =>
85 typeof value === "object" && value !== null && !Array.isArray(value);
86
87const isText = (value: unknown): value is string =>
88 typeof value === "string" && value.trim() !== "";
89
90const checkRules = (value: unknown) =>
91 Array.isArray(value) &&
92 value.every(
93 (rule) => isRecord(rule) && isText(rule.said) && isText(rule.rule),
94 )
95 ? []
96 : ["rules must be a list of { said, rule }, both non-empty text"];
97
98const checkLabels = (value: unknown) => {
99 if (
100 !Array.isArray(value) ||
101 !value.every(
102 (label) =>
103 isRecord(label) &&
104 typeof label.name === "string" &&
105 LABEL_NAME.test(label.name) &&
106 isText(label.means),
107 )
108 )
109 return [
110 "labels must be a list of { name, means }, name in lowercase-with-dashes",
111 ];
112 const names = value.map((label) => (label as LabelDefinition).name);
113 const taken = names.filter(
114 (name, index) =>
115 names.indexOf(name) !== index ||
116 BUILT_IN_LABELS.some((builtIn) => builtIn.name === name),
117 );
118 return taken.length > 0
119 ? [`label names must be unique and not built in: ${taken.join(", ")}`]
120 : [];
121};
122
123const checkBands = (value: unknown) => {
124 if (
125 !Array.isArray(value) ||
126 !value.every(
127 (band) =>
128 isRecord(band) &&
129 isText(band.name) &&
130 typeof band.from === "number" &&
131 band.from >= 0 &&
132 band.from <= 100 &&
133 (band.color === undefined ||
134 BAND_COLORS.includes(band.color as BandColor)),
135 )
136 )
137 return [
138 `bands must be a list of { name, from (0-100), color? (${BAND_COLORS.join(", ")}) }`,
139 ];
140 return value.some((band) => (band as Band).from === 0)
141 ? []
142 : ["one band must start from 0"];
143};
144
145const checkRecency = (value: unknown) =>
146 isRecord(value) &&
147 typeof value.halfLifeHours === "number" &&
148 value.halfLifeHours > 0 &&
149 typeof value.floor === "number" &&
150 value.floor >= 0 &&
151 value.floor <= 1
152 ? []
153 : ["recency must be { halfLifeHours > 0, floor 0-1 }"];
154
155const checkLanes = (value: unknown) =>
156 isRecord(value) &&
157 Object.entries(value).every(
158 ([lane, factor]) =>
159 LANE_IDS.includes(lane as LaneId) &&
160 typeof factor === "number" &&
161 factor > 0,
162 )
163 ? []
164 : [`lanes must map ${LANE_IDS.join(", ")} to numbers above 0`];
165
166const checkFinish = (value: unknown) =>
167 isRecord(value) &&
168 typeof value.show === "number" &&
169 Number.isInteger(value.show) &&
170 value.show >= 0
171 ? []
172 : ["finish must be { show: a whole number of 0 or more }"];
173
174const checkScheduled = (value: unknown) =>
175 value === "problems-only" || value === "all"
176 ? []
177 : ['scheduledTasks must be "problems-only" or "all"'];
178
179const CHECKS: Record<keyof Ranking, (value: unknown) => string[]> = {
180 rules: checkRules,
181 labels: checkLabels,
182 bands: checkBands,
183 recency: checkRecency,
184 lanes: checkLanes,
185 finish: checkFinish,
186 scheduledTasks: checkScheduled,
187};
188
189export function checkRanking(raw: unknown) {
190 if (!isRecord(raw)) return ["the ranking must be a JSON object"];
191 const unknownKeys = Object.keys(raw).filter((key) => !(key in CHECKS));
192 return [
193 ...unknownKeys.map((key) => `unknown part: ${key}`),
194 ...Object.entries(CHECKS).flatMap(([key, check]) =>
195 key in raw ? check(raw[key]) : [],
196 ),
197 ];
198}
199
200export function rankingOf(raw: Partial<Ranking>): Ranking {
201 return {
202 ...DEFAULT_RANKING,
203 ...raw,
204 recency: { ...DEFAULT_RANKING.recency, ...raw.recency },
205 lanes: { ...DEFAULT_RANKING.lanes, ...raw.lanes },
206 finish: { ...DEFAULT_RANKING.finish, ...raw.finish },
207 bands: [...(raw.bands ?? DEFAULT_RANKING.bands)].sort(
208 (a, b) => b.from - a.from,
209 ),
210 };
211}
212
213export const allLabelsOf = (ranking: Ranking) => [
214 ...BUILT_IN_LABELS,
215 ...ranking.labels,
216];
217
218const hashOf = (text: string) =>
219 [...text]
220 .reduce((hash, char) => (hash * 31 + char.charCodeAt(0)) | 0, 7)
221 .toString(36);
222
223export const readingFingerprintOf = (ranking: Ranking) =>
224 hashOf(
225 JSON.stringify({
226 rules: ranking.rules.map((rule) => rule.rule),
227 labels: allLabelsOf(ranking),
228 }),
229 );
230
231export const bandOf = (ranking: Ranking, urgency: number) =>
232 ranking.bands.find((band) => urgency >= band.from) ??
233 ranking.bands[ranking.bands.length - 1]!;
234
235export const rankingPath = () =>
236 `${host.home}/.claude/claudetower/rankings/${host.instance}.json`;
237
238let cached: { path: string; mtimeMs: number; ranking: Ranking } | null = null;
239
240export async function hasRankingFile($: EngineInterface) {
241 return Boolean(host.instance) && (await $.fs.exists(rankingPath()));
242}
243
244export async function loadRanking($: EngineInterface): Promise<Ranking> {
245 const path = rankingPath();
246 if (!host.instance || !(await $.fs.exists(path))) return DEFAULT_RANKING;
247 const { mtimeMs } = await $.fs.stat(path);
248 if (cached?.path === path && cached.mtimeMs === mtimeMs)
249 return cached.ranking;
250 const raw = JSON.parse(await $.fs.read(path)) as unknown;
251 const problems = checkRanking(raw);
252 if (problems.length > 0) {
253 log($, `ignoring ${path}: ${problems.join("; ")}`);
254 $.ui.toast(
255 `claudetower: your ranking file has a problem, so the default ranking is in use: ${problems[0]}`,
256 );
257 cached = { path, mtimeMs, ranking: DEFAULT_RANKING };
258 return DEFAULT_RANKING;
259 }
260 const ranking = rankingOf(raw as Partial<Ranking>);
261 cached = { path, mtimeMs, ranking };
262 return ranking;
263}
264
265export async function writeRanking($: EngineInterface, raw: Partial<Ranking>) {
266 const path = rankingPath();
267 await $.process.run(["/bin/mkdir", "-p", path.replace(/\/[^/]+$/, "")]);
268 await $.fs.write(path, `${JSON.stringify(raw, null, 2)}\n`);
269}
270hooks/lib/transcript.ts 467 lines1import type { EngineInterface } from "claude-code";
2
3import type { SessionAction } from "../../types";
4import {
5 LAST_TEXT_CHARS,
6 STORE_KEYS,
7 LOOKBACK_MS,
8 PROMPT_CHARS,
9 RUNNING_GRACE_MS,
10 TAIL_BYTES,
11 TOOL_GRACE_MS,
12 HOUR_MS,
13 host,
14 log,
15 sessionsRoot,
16 instanceKey,
17} from "./config";
18
19export type SessionPr = { prNumber?: number; state?: string; repo?: string };
20
21export type SessionFile = {
22 sessionId: string;
23 cliSessionId?: string;
24 title?: string;
25 isArchived?: boolean;
26 cwd?: string;
27 originCwd?: string;
28 branch?: string;
29 prs?: SessionPr[];
30 scheduledTaskId?: string;
31 lastActivityAt?: number;
32 spawnedFrom?: { sessionId?: string };
33};
34
35export type TranscriptEnd = {
36 mtimeMs: number;
37 lastAt: number;
38 isStopped: boolean;
39 lastAssistantId: string;
40 prompt: string;
41 promptAt: number;
42 lastText: string;
43 question: string;
44 options: SessionAction[];
45 uncommittedFiles: number;
46};
47
48export type Live = { s: SessionFile; end: TranscriptEnd };
49
50export type StartedSession = {
51 title: string;
52 isArchived: boolean;
53 hoursAgo: number;
54};
55
56type ContentBlock = {
57 type?: string;
58 text?: string;
59 name?: string;
60 input?: {
61 ask?: string;
62 actions?: { label?: string; message?: string }[];
63 questions?: { question?: string; options?: { label?: string }[] }[];
64 };
65};
66
67type TranscriptLine = {
68 lastPrompt?: string;
69 type?: string;
70 uuid?: string;
71 timestamp?: string;
72 isSidechain?: boolean;
73 message?: {
74 stop_reason?: string | null;
75 content?: string | ContentBlock[];
76 };
77};
78
79const REPLY_MARKER = /<!--\s*reply[^>]*-->/gi;
80
81const sessionFiles = new Map<string, { mtimeMs: number; file: SessionFile }>();
82const transcriptEnds = new Map<string, TranscriptEnd>();
83export const startedFrom = new Map<string, StartedSession[]>();
84
85export const projectOf = (s: SessionFile) => {
86 const path = s.originCwd ?? s.cwd ?? "";
87 const repoRoot = path.split("/.claude/worktrees/")[0] ?? path;
88 return repoRoot.split("/").filter(Boolean).pop() ?? "unknown";
89};
90
91export const ticketOf = (s: SessionFile, pattern: RegExp | null) =>
92 (pattern &&
93 `${s.title ?? ""} ${s.branch ?? ""}`.match(pattern)?.[0]?.toUpperCase()) ||
94 "";
95
96export const titleOf = (s: SessionFile, ticket: string) => {
97 const title = s.title ?? "Untitled";
98 return ticket && title.toUpperCase().startsWith(`${ticket}:`)
99 ? title.slice(ticket.length + 1).trim()
100 : title;
101};
102
103export const transcriptPath = (s: SessionFile) =>
104 `${host.home}/.claude/projects/${(s.cwd ?? "").replace(/[/.]/g, "-")}/${s.cliSessionId}.jsonl`;
105
106const textOf = (line: TranscriptLine) => {
107 const content = line.message?.content;
108 if (typeof content === "string") return content;
109 return (content ?? [])
110 .filter((block) => block.type === "text")
111 .map((block) => block.text ?? "")
112 .join("\n");
113};
114
115const hasBlock = (line: TranscriptLine, type: string) => {
116 const content = line.message?.content;
117 return Array.isArray(content) && content.some((block) => block.type === type);
118};
119
120const withoutQuotedReplies = (text: string) => {
121 const unmarked = text.replace(REPLY_MARKER, "");
122 const own = unmarked
123 .split("\n")
124 .filter((line) => !line.trimStart().startsWith(">"))
125 .join("\n")
126 .trim();
127 return own || unmarked.trim();
128};
129
130const isPrompt = (line: TranscriptLine) => {
131 if (line.type !== "user" || hasBlock(line, "tool_result")) return false;
132 const text = textOf(line).trimStart();
133 return text !== "" && (!text.startsWith("<") || text.startsWith("<!--"));
134};
135
136const SUGGESTION_TOOLS = /(__wrap_up|^AskUserQuestion)$/;
137
138const suggestedActionsOf = (turn: TranscriptLine[]) => {
139 const blocks = turn.flatMap((line) =>
140 Array.isArray(line.message?.content) ? line.message.content : [],
141 );
142 const call = blocks.findLast(
143 (block) =>
144 block.type === "tool_use" && SUGGESTION_TOOLS.test(block.name ?? ""),
145 );
146 const input = call?.input;
147 const asked = input?.questions?.[0];
148 return {
149 question: input?.ask ?? asked?.question ?? "",
150 options: [
151 ...(input?.actions ?? []).map((action) => ({
152 label: action.label ?? "",
153 message: action.message || action.label || "",
154 })),
155 ...(asked?.options ?? []).map((option) => ({
156 label: option.label ?? "",
157 message: option.label ?? "",
158 })),
159 ].filter((action) => action.label !== ""),
160 };
161};
162
163const timeOf = (line: TranscriptLine | undefined) =>
164 line?.timestamp ? Date.parse(line.timestamp) : 0;
165
166async function countUncommitted($: EngineInterface, cwd: string) {
167 if (!cwd.includes("/.claude/worktrees/") || !(await $.fs.exists(cwd)))
168 return 0;
169 const status = await $.process.run([
170 "git",
171 "-C",
172 cwd,
173 "status",
174 "--porcelain",
175 ]);
176 if (status.exitCode !== 0) {
177 log($, `git status failed in ${cwd}: ${status.stderr.trim()}`);
178 return 0;
179 }
180 return status.stdout.split("\n").filter(Boolean).length;
181}
182
183function parseLines(
184 $: EngineInterface,
185 path: string,
186 raw: string,
187 isPartial: boolean,
188) {
189 const lines: TranscriptLine[] = [];
190 let recordedPrompt = "";
191 for (const text of raw.split("\n").slice(isPartial ? 1 : 0)) {
192 if (text.trim() === "") continue;
193 try {
194 const line = JSON.parse(text) as TranscriptLine;
195 if (line.type === "last-prompt" && line.lastPrompt)
196 recordedPrompt = line.lastPrompt;
197 if (
198 !line.isSidechain &&
199 (line.type === "user" || line.type === "assistant")
200 )
201 lines.push(line);
202 } catch (error) {
203 log(
204 $,
205 `skipped an unreadable transcript line in ${path}: ${String(error)}`,
206 );
207 }
208 }
209 return { lines, recordedPrompt };
210}
211
212async function readTranscriptEnd(
213 $: EngineInterface,
214 s: SessionFile,
215 path: string,
216 stat: { mtimeMs: number; size: number },
217): Promise<TranscriptEnd> {
218 const { mtimeMs } = stat;
219 const tail = await $.process.run(["tail", "-c", String(TAIL_BYTES), path]);
220 if (tail.exitCode !== 0) throw new Error(tail.stderr.trim());
221 const { lines, recordedPrompt } = parseLines(
222 $,
223 path,
224 tail.stdout,
225 stat.size > TAIL_BYTES,
226 );
227 const last = lines[lines.length - 1];
228 const promptIndex = lines.findLastIndex(isPrompt);
229 const turn = lines
230 .slice(promptIndex + 1)
231 .filter((line) => line.type === "assistant");
232 const promptLine =
233 (recordedPrompt &&
234 lines.findLast(
235 (line) =>
236 isPrompt(line) && textOf(line).trim() === recordedPrompt.trim(),
237 )) ||
238 lines[promptIndex];
239 const isWaitingOnTool =
240 last?.type === "assistant" && hasBlock(last, "tool_use");
241 const endedCleanly =
242 last?.type === "assistant" &&
243 !isWaitingOnTool &&
244 last.message?.stop_reason !== null;
245 const isStopped =
246 endedCleanly ||
247 Date.now() - mtimeMs > (isWaitingOnTool ? TOOL_GRACE_MS : RUNNING_GRACE_MS);
248 const { question, options } = suggestedActionsOf(turn);
249 return {
250 mtimeMs,
251 lastAt: timeOf(last) || mtimeMs,
252 isStopped,
253 lastAssistantId: turn[turn.length - 1]?.uuid ?? "",
254 prompt: withoutQuotedReplies(
255 recordedPrompt || (promptLine ? textOf(promptLine) : ""),
256 )
257 .replace(/\s+/g, " ")
258 .slice(0, PROMPT_CHARS),
259 promptAt: timeOf(promptLine),
260 lastText: turn
261 .map(textOf)
262 .filter(Boolean)
263 .join("\n\n")
264 .slice(-LAST_TEXT_CHARS),
265 question,
266 options,
267 uncommittedFiles: isStopped ? await countUncommitted($, s.cwd ?? "") : 0,
268 };
269}
270
271export async function sessionDirs($: EngineInterface) {
272 const root = sessionsRoot();
273 if (!(await $.fs.exists(root))) return [];
274 const accounts = (await $.fs.list(root)).filter(
275 (entry) => entry.kind === "dir",
276 );
277 const orgs = await Promise.all(
278 accounts.map(async (account) =>
279 (await $.fs.list(`${root}/${account.name}`))
280 .filter((entry) => entry.kind === "dir")
281 .map((org) => `${root}/${account.name}/${org.name}`),
282 ),
283 );
284 return orgs.flat();
285}
286
287const recordStartedSessions = (files: SessionFile[], now: number) => {
288 startedFrom.clear();
289 for (const file of files) {
290 const parent = file.spawnedFrom?.sessionId;
291 if (!parent) continue;
292 startedFrom.set(parent, [
293 ...(startedFrom.get(parent) ?? []),
294 {
295 title: file.title ?? "",
296 isArchived: file.isArchived === true,
297 hoursAgo: Math.round((now - (file.lastActivityAt ?? now)) / HOUR_MS),
298 },
299 ]);
300 }
301};
302
303export async function loadSessionFiles($: EngineInterface, now: number) {
304 const listings = await Promise.all(
305 (await sessionDirs($)).map(async (dir) =>
306 (await $.fs.list(dir)).map((entry) => ({ dir, entry })),
307 ),
308 );
309 const files = await Promise.all(
310 listings
311 .flat()
312 .filter(
313 ({ entry }) =>
314 entry.name.startsWith("local_") && now - entry.mtimeMs <= LOOKBACK_MS,
315 )
316 .map(async ({ dir, entry }) => {
317 const path = `${dir}/${entry.name}`;
318 const cached = sessionFiles.get(path);
319 if (cached?.mtimeMs === entry.mtimeMs) return cached.file;
320 const file = JSON.parse(await $.fs.read(path)) as SessionFile;
321 sessionFiles.set(path, { mtimeMs: entry.mtimeMs, file });
322 return file;
323 }),
324 );
325 recordStartedSessions(files, now);
326 return files.filter((s) => !s.isArchived && s.cliSessionId && s.cwd);
327}
328
329const latestRunsOnly = (live: Live[]) => {
330 const latest = new Map<string, number>();
331 for (const { s, end } of live)
332 if (s.scheduledTaskId)
333 latest.set(
334 s.scheduledTaskId,
335 Math.max(latest.get(s.scheduledTaskId) ?? 0, end.lastAt),
336 );
337 return live.filter(
338 ({ s, end }) =>
339 !s.scheduledTaskId || latest.get(s.scheduledTaskId) === end.lastAt,
340 );
341};
342
343type EndStore = Record<string, TranscriptEnd>;
344
345async function transcriptEndOf(
346 $: EngineInterface,
347 s: SessionFile,
348 now: number,
349 shared: EndStore,
350) {
351 const path = transcriptPath(s);
352 if (!(await $.fs.exists(path))) return null;
353 const stat = await $.fs.stat(path);
354 if (now - stat.mtimeMs > LOOKBACK_MS) return null;
355 const cached = transcriptEnds.get(path) ?? shared[path];
356 const isIdleRun =
357 cached && !cached.isStopped && now - cached.mtimeMs > RUNNING_GRACE_MS;
358 if (cached?.mtimeMs === stat.mtimeMs && !isIdleRun) {
359 transcriptEnds.set(path, cached);
360 return cached;
361 }
362 try {
363 const end = await readTranscriptEnd($, s, path, stat);
364 transcriptEnds.set(path, end);
365 return end;
366 } catch (error) {
367 log($, `could not read the transcript of "${s.title}": ${String(error)}`);
368 return null;
369 }
370}
371
372async function shareEnds(
373 $: EngineInterface,
374 shared: EndStore,
375 paths: string[],
376) {
377 const mine = Object.fromEntries(
378 paths.flatMap((path) => {
379 const end = transcriptEnds.get(path);
380 return end ? [[path, end] as const] : [];
381 }),
382 );
383 const changed = paths.some(
384 (path) => mine[path] && mine[path].mtimeMs !== shared[path]?.mtimeMs,
385 );
386 if (changed || Object.keys(shared).some((path) => !paths.includes(path)))
387 await $.store.set(instanceKey(STORE_KEYS.ends), mine);
388}
389
390export async function loadLive($: EngineInterface, now: number) {
391 const files = await loadSessionFiles($, now);
392 const shared = ((await $.store.get(instanceKey(STORE_KEYS.ends))) ??
393 {}) as EndStore;
394 const ends = await Promise.all(
395 files.map((s) => transcriptEndOf($, s, now, shared)),
396 );
397 await shareEnds(
398 $,
399 shared,
400 files.filter((_, index) => ends[index]).map(transcriptPath),
401 );
402 return latestRunsOnly(
403 files.flatMap((s, index) => {
404 const end = ends[index];
405 return end ? [{ s, end }] : [];
406 }),
407 );
408}
409
410const FIRST_ASK_CHARS = 3000;
411const DIGEST_PROMPT_CHARS = 400;
412const DIGEST_REPLY_CHARS = 1200;
413const DIGEST_PROMPTS = 40;
414const DIGEST_REPLIES = 30;
415
416const keepLast = <T>(list: T[], item: T, limit: number) => {
417 list.push(item);
418 if (list.length > limit * 2) list.splice(0, list.length - limit);
419};
420
421export async function digestOf($: EngineInterface, path: string) {
422 let firstAsk = "";
423 let promptCount = 0;
424 const prompts: string[] = [];
425 const replies: string[] = [];
426 const take = (raw: string) => {
427 if (raw.trim() === "") return;
428 let line: TranscriptLine;
429 try {
430 line = JSON.parse(raw) as TranscriptLine;
431 } catch (error) {
432 log(
433 $,
434 `skipped an unreadable transcript line in ${path}: ${String(error)}`,
435 );
436 return;
437 }
438 if (line.isSidechain) return;
439 if (isPrompt(line)) {
440 const text = withoutQuotedReplies(textOf(line));
441 if (!firstAsk) firstAsk = text.slice(0, FIRST_ASK_CHARS);
442 promptCount += 1;
443 keepLast(prompts, text.slice(0, DIGEST_PROMPT_CHARS), DIGEST_PROMPTS);
444 } else if (line.type === "assistant") {
445 const text = textOf(line).trim();
446 if (text)
447 keepLast(replies, text.slice(-DIGEST_REPLY_CHARS), DIGEST_REPLIES);
448 }
449 };
450 let pending = "";
451 for await (const { stream, text } of $.process.spawn({
452 argv: ["cat", path],
453 })) {
454 if (stream !== "stdout") continue;
455 const parts = (pending + text).split("\n");
456 pending = parts.pop() ?? "";
457 parts.forEach(take);
458 }
459 take(pending);
460 return {
461 firstAsk,
462 promptCount,
463 recentPrompts: prompts.slice(-DIGEST_PROMPTS),
464 recentReplies: replies.slice(-DIGEST_REPLIES),
465 };
466}
467