SLOPSHOPPER

claudetower

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

newpanebandguardcommandtool
v0.1.0MITupdated 2026-10-24eshaham/claudetower
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claudetower
│ ┃ claudetower ✕ › fix the failing auth test and add an audit log call │ ┃ Loading your sessions… │ ● claudetower: claudetower: gh not found; PR details are off │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tower │ ⎿ claudetower: Session board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · claudetower
Loading your sessions…
README

claudetower

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.

What it shows

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:

  • The project, the ticket and its tracker priority (when the session names one), uncommitted files, and how long ago it last spoke.
  • Its pull requests: an icon and diff size for each open PR, with failing or running checks, and a count of merged ones.
  • What it's waiting for, labeled Answer, Act, Review, Follow up, In flight or Wrap up, with an Urgent or High label when the task is serious, and labels such as production or ci when they apply.
  • The options the session offered you at the end (its question and buttons), so you can see the choice without opening it.
  • An Archive button once the session is finished: no open PR, no uncommitted files, and nothing left for you. When your settings allow 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.
  • A chevron that expands your last message and Claude's reply.

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.

How it decides

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.

How it learns each project

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.

Requirements

  • Claude Code Desktop on macOS.
  • gh, signed in, for PR details. Without it the PR rows are skipped.
  • For ticket priority, an MCP connector to the project's issue tracker. Without one, tickets show without priority.

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.

Install

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.

Settings

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" } }
  }
}

Developing

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 ..

License

MIT

Source 16 files
hooks/register.tsx 145 lines
1import 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};
145
hooks/lib/board.ts 590 lines
1import { 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}
590
hooks/lib/config.ts 200 lines
1import 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`;
200
hooks/lib/ranking-tool.ts 171 lines
1import 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}
171
hooks/view/above-prompt.tsx 37 lines
1import 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}
37
hooks/view/pane.tsx 576 lines
1import 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}
576
hooks/view/setup.tsx 46 lines
1import 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}
46
hooks/lib/classify.ts 159 lines
1import 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);
159
hooks/lib/enrich.ts 268 lines
1import 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    );
268
hooks/lib/project.ts 205 lines
1import 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};
205
hooks/lib/ranking.ts 270 lines
1import 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}
270
hooks/lib/transcript.ts 467 lines
1import 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