claude-garage 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,38 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { EventEmitter } from "node:events";
4
- import { listSessions } from "./tmux.js";
5
- import { setStatus, getStatus, dropSession } from "./status.js";
4
+ import os from "node:os";
5
+ import { listSessions, normalizeTitle } from "./tmux.js";
6
+ import { setStatus, getStatus, dropSession as dropStatus } from "./status.js";
7
+ import { dropSession as dropStatuslineContext } from "./statusline.js";
6
8
  import { upsertSessionMeta } from "./registry.js";
7
9
 
10
+ // p11: a dead session's statusline-fed context must be cleared alongside
11
+ // its status entry (context-telemetry spec) — a future reuse of the same
12
+ // garage session id must never inherit a stale percentage from a prior,
13
+ // unrelated conversation.
14
+ function dropSession(id) {
15
+ dropStatus(id);
16
+ dropStatuslineContext(id);
17
+ }
18
+
8
19
  const run = promisify(execFile);
9
20
  const POLL_MS = 2000;
10
21
 
11
- // Emits "sessions-changed" whenever the poller notices the garage session
12
- // list itself changed (spawn/death), so events.js can tell the UI to refetch.
22
+ // Emits "sessions-changed" whenever the poller notices something the wall's
23
+ // session listing depends on changed: the garage session set itself
24
+ // (spawn/death) OR — p10 — a live session's normalized pane title, so a
25
+ // `tmux select-pane -T` (e.g. Claude Code's OSC title) gets pushed to the
26
+ // UI instead of only being picked up on the next unrelated refetch.
13
27
  export const pollerEvents = new EventEmitter();
14
28
  pollerEvents.setMaxListeners(0);
15
29
 
16
30
  let lastSessionIds = new Set();
31
+ // p10: normalized title per currently-live garage session, from the tick
32
+ // this map was last rebuilt in. Rebuilt wholesale every tick from `sessions`
33
+ // (never merged), so a session that dies has its entry dropped for free —
34
+ // memory-trivial, sessions-only.
35
+ let lastTitles = new Map();
17
36
 
18
37
  // `claude agents --json` — missing CLI or any failure is a silent no-op,
19
38
  // per D-status's graceful degradation to poller-absent behavior.
@@ -26,22 +45,26 @@ async function getAgents() {
26
45
  }
27
46
  }
28
47
 
29
- // tmux pane pid per session name — pane_pid joins exactly to the `pid`
30
- // reported by `claude agents --json` since garage spawns claude as the
31
- // pane's root process (design D-status).
48
+ // tmux pane pid + title per session name — pane_pid joins exactly to the
49
+ // `pid` reported by `claude agents --json` since garage spawns claude as the
50
+ // pane's root process (design D-status). p10: pane_title rides the same
51
+ // list-panes call (kept to ONE per tick daemon-wide) so the title-change
52
+ // diff below needs no tmux invocation of its own; title is free text, so it
53
+ // sits last in the format string and everything past the second tab is
54
+ // rejoined into it (same rationale as tmux.js's parsePaneLine).
32
55
  async function getPanePids() {
33
56
  try {
34
57
  const { stdout } = await run("tmux", [
35
58
  "list-panes",
36
59
  "-a",
37
60
  "-F",
38
- "#{session_name}\t#{pane_pid}",
61
+ "#{session_name}\t#{pane_pid}\t#{pane_title}",
39
62
  ]);
40
63
  const map = new Map();
41
64
  for (const line of stdout.split("\n")) {
42
65
  if (!line.trim()) continue;
43
- const [name, pid] = line.split("\t");
44
- map.set(name, Number(pid));
66
+ const [name, pid, ...rest] = line.split("\t");
67
+ map.set(name, { pid: Number(pid), title: rest.join("\t") });
45
68
  }
46
69
  return map;
47
70
  } catch {
@@ -65,38 +88,82 @@ export function applyAgentStatus(id, agentStatus) {
65
88
  setStatus(id, mapped);
66
89
  }
67
90
 
68
- async function tick() {
69
- const sessions = await listSessions().catch(() => []);
70
-
91
+ // p10 wave-2 fix: pure id+title diff, no tmux/IO — exported so it's
92
+ // exhaustively unit-testable without a live tmux server. `sessions` is this
93
+ // tick's listSessions() result; `panePids` maps session id -> {pid, title}
94
+ // (from this tick's single list-panes call — a session can be missing an
95
+ // entry, e.g. mid-spawn); `prevIds`/`prevTitles` are the previous tick's
96
+ // state. Session-id-set changes (spawn/death) AND a normalized-title change
97
+ // on any still-live session both count as "changed" — a pane-title-only
98
+ // change (`tmux select-pane -T`) never touches the id set, so without the
99
+ // title half this would sit unpushed until some unrelated refetch noticed.
100
+ export function diffSessionState(sessions, panePids, hostname, prevIds, prevTitles) {
71
101
  const currentIds = new Set(sessions.map((s) => s.id));
72
- let changed = currentIds.size !== lastSessionIds.size;
73
- for (const id of lastSessionIds) {
74
- if (!currentIds.has(id)) {
75
- dropSession(id);
76
- changed = true;
102
+ let changed = currentIds.size !== prevIds.size;
103
+ if (!changed) {
104
+ for (const id of prevIds) {
105
+ if (!currentIds.has(id)) {
106
+ changed = true;
107
+ break;
108
+ }
77
109
  }
78
110
  }
79
111
  if (!changed) {
80
112
  for (const id of currentIds) {
81
- if (!lastSessionIds.has(id)) {
113
+ if (!prevIds.has(id)) {
82
114
  changed = true;
83
115
  break;
84
116
  }
85
117
  }
86
118
  }
87
- lastSessionIds = currentIds;
88
- if (changed) pollerEvents.emit("sessions-changed");
89
119
 
90
- if (sessions.length === 0) return;
120
+ const titles = new Map();
121
+ for (const session of sessions) {
122
+ const title = normalizeTitle(panePids.get(session.id)?.title, hostname);
123
+ titles.set(session.id, title);
124
+ if (prevTitles.get(session.id) !== title) changed = true;
125
+ }
126
+
127
+ return { changed, ids: currentIds, titles };
128
+ }
129
+
130
+ async function tick() {
131
+ const sessions = await listSessions().catch(() => []);
132
+
133
+ if (sessions.length === 0) {
134
+ for (const id of lastSessionIds) dropSession(id);
135
+ const changed = lastSessionIds.size !== 0;
136
+ lastSessionIds = new Set();
137
+ lastTitles = new Map();
138
+ if (changed) pollerEvents.emit("sessions-changed");
139
+ return;
140
+ }
91
141
 
142
+ // p10: title rides the same list-panes call already made for the pid
143
+ // join — kept to ONE list-panes invocation per tick daemon-wide.
92
144
  const [agents, panePids] = await Promise.all([getAgents(), getPanePids()]);
145
+ const hostname = os.hostname();
146
+ const { changed, ids, titles } = diffSessionState(
147
+ sessions,
148
+ panePids,
149
+ hostname,
150
+ lastSessionIds,
151
+ lastTitles
152
+ );
153
+ for (const id of lastSessionIds) {
154
+ if (!ids.has(id)) dropSession(id);
155
+ }
156
+ lastSessionIds = ids;
157
+ lastTitles = titles;
158
+ if (changed) pollerEvents.emit("sessions-changed");
159
+
93
160
  if (agents.length === 0) return; // claude CLI missing/no live agents
94
161
 
95
162
  const agentByPid = new Map(agents.map((a) => [a.pid, a]));
96
163
  const unmatched = [];
97
164
 
98
165
  for (const session of sessions) {
99
- const panePid = panePids.get(session.id);
166
+ const panePid = panePids.get(session.id)?.pid;
100
167
  const agent = panePid !== undefined ? agentByPid.get(panePid) : undefined;
101
168
  if (agent) {
102
169
  applyAgentStatus(session.id, agent.status);
@@ -8,7 +8,9 @@ import { GARAGE_PREFIX } from "./tmux.js";
8
8
  // Created lazily on first write — a user who registers nothing has no file
9
9
  // to migrate or corrupt. tmux ls remains the sole source of truth for
10
10
  // sessions; this file is a directory mapping only, never a session list.
11
- const GARAGE_DIR = path.join(homedir(), ".garage");
11
+ // GARAGE_DIR env override exists for testability (scratch daemons must not
12
+ // share the real ~/.garage/state.json) — same spirit as GARAGE_PORT.
13
+ const GARAGE_DIR = process.env.GARAGE_DIR ?? path.join(homedir(), ".garage");
12
14
  const STATE_PATH = path.join(GARAGE_DIR, "state.json");
13
15
 
14
16
  async function readState() {
@@ -0,0 +1,58 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { listSessions } from "./tmux.js";
4
+
5
+ const run = promisify(execFile);
6
+
7
+ async function getAgents() {
8
+ try {
9
+ const { stdout } = await run("claude", ["agents", "--json"]);
10
+ return JSON.parse(stdout);
11
+ } catch {
12
+ return [];
13
+ }
14
+ }
15
+
16
+ async function getPanePids() {
17
+ try {
18
+ const { stdout } = await run("tmux", [
19
+ "list-panes",
20
+ "-a",
21
+ "-F",
22
+ "#{session_name}\t#{pane_pid}",
23
+ ]);
24
+ const map = new Map(); // pid -> session name
25
+ for (const line of stdout.split("\n")) {
26
+ if (!line.trim()) continue;
27
+ const [name, pid] = line.split("\t");
28
+ map.set(Number(pid), name);
29
+ }
30
+ return map;
31
+ } catch {
32
+ return new Map();
33
+ }
34
+ }
35
+
36
+ // Shared by hooks.js and statusline.js — both receive a Claude Code payload
37
+ // carrying `session_id`/`cwd` and need the same garage session id(s) it
38
+ // applies to. Precise path: payload session_id -> agents-json sessionId ->
39
+ // its pid -> pane_pid join. Fallback: cwd fail-open, applied to every garage
40
+ // session sharing that dir (over-notify/over-record rather than silently
41
+ // drop).
42
+ export async function resolveSessionIds({ session_id, cwd }) {
43
+ const [agents, pidToSession] = await Promise.all([getAgents(), getPanePids()]);
44
+
45
+ if (session_id) {
46
+ const agent = agents.find((a) => a.sessionId === session_id);
47
+ const sessionName = agent && pidToSession.get(agent.pid);
48
+ if (sessionName) return [sessionName];
49
+ }
50
+
51
+ if (cwd) {
52
+ const sessions = await listSessions();
53
+ const matches = sessions.filter((s) => s.dir === cwd).map((s) => s.id);
54
+ if (matches.length > 0) return matches;
55
+ }
56
+
57
+ return [];
58
+ }
@@ -1,4 +1,5 @@
1
1
  import { stat } from "node:fs/promises";
2
+ import os from "node:os";
2
3
  import {
3
4
  GARAGE_PREFIX,
4
5
  NAME_RE,
@@ -6,6 +7,7 @@ import {
6
7
  listSessions,
7
8
  listPanePaths,
8
9
  resolveBranch,
10
+ normalizeTitle,
9
11
  hasSession,
10
12
  createSession,
11
13
  killSession,
@@ -17,8 +19,10 @@ import {
17
19
  upsertSessionMeta,
18
20
  removeSessionMeta,
19
21
  } from "./registry.js";
20
- import { getStatus } from "./status.js";
22
+ import { getStatusEntry } from "./status.js";
21
23
  import { createWorktree } from "./worktrees.js";
24
+ import { getStatuslineContext, getRateLimits } from "./statusline.js";
25
+ import { getCachedContext, refreshContext } from "./transcript.js";
22
26
 
23
27
  const CLAUDE_CMD = process.env.GARAGE_CLAUDE_CMD ?? "claude";
24
28
 
@@ -26,6 +30,8 @@ export default async function sessionRoutes(app) {
26
30
  app.get("/api/sessions", async () => {
27
31
  const sessions = await listSessions();
28
32
  const panePaths = await listPanePaths();
33
+ // p10: same tmux hostname for every session in this request — read once.
34
+ const hostname = os.hostname();
29
35
 
30
36
  // D-branch: this route is re-hit on every SSE-driven refetch, so the
31
37
  // git calls it triggers must stay cheap — one execFile per unique
@@ -41,15 +47,57 @@ export default async function sessionRoutes(app) {
41
47
  return branchCache.get(dir);
42
48
  };
43
49
 
50
+ // p11: fetched once up front (not per-session) — same cost discipline as
51
+ // branchCache above — so both the live loop's claudeSessionId lookup and
52
+ // the restorable loop below share one registry read.
53
+ const metas = await listSessionMetas();
54
+ const metaById = new Map(metas.map((m) => [m.id, m]));
55
+
44
56
  const live = await Promise.all(
45
- sessions.map(async (s) => ({
46
- ...s,
47
- status: getStatus(s.id),
48
- // Live pane cwd, not session_path (see listPanePaths) — falls back
49
- // to session_path only if the pane vanished between the two tmux
50
- // calls above (a session that died mid-request).
51
- branch: await getBranch(panePaths.get(s.id) ?? s.dir),
52
- }))
57
+ sessions.map(async (s) => {
58
+ // since: epoch ms the current status began, so the UI can render
59
+ // elapsed time without a second lookup (see status.js getStatusEntry).
60
+ const { state: status, since, message } = getStatusEntry(s.id);
61
+ const pane = panePaths.get(s.id);
62
+ const dir = pane?.path ?? s.dir;
63
+
64
+ // p11: context — statusline data (pushed by the wrapper) beats the
65
+ // transcript fallback beats null. The transcript path is a cache
66
+ // read only: getCachedContext never blocks, refreshContext kicks a
67
+ // background read (a no-op if the cache is still fresh or a read is
68
+ // already in flight) — this route must never block on file IO.
69
+ let context = getStatuslineContext(s.id);
70
+ if (!context) {
71
+ context = getCachedContext(s.id);
72
+ const claudeSessionId = metaById.get(s.id)?.claudeSessionId;
73
+ if (claudeSessionId && dir) {
74
+ refreshContext(s.id, { dir, claudeSessionId });
75
+ }
76
+ }
77
+
78
+ return {
79
+ ...s,
80
+ status,
81
+ // p8: the Notification hook's text for a needs-input session; the
82
+ // store guarantees null for every other state (see status.js).
83
+ message: message ?? null,
84
+ // A session that has never transitioned has no recorded `since`
85
+ // (the store only stamps one on a real state change), which would
86
+ // render as "—" in the UI even though the session is live and has
87
+ // an obvious age. Fall back to when tmux created it.
88
+ since: since ?? s.createdAt ?? null,
89
+ // Live pane cwd, not session_path (see listPanePaths) — falls back
90
+ // to session_path only if the pane vanished between the two tmux
91
+ // calls above (a session that died mid-request).
92
+ branch: await getBranch(dir),
93
+ // p10: the pane's OSC title (e.g. Claude Code's "✳ <summary>"),
94
+ // filtered down to null when it's just tmux's own default (empty,
95
+ // hostname, bare shell name) — see normalizeTitle.
96
+ title: normalizeTitle(pane?.title, hostname),
97
+ // p11: {usedPercentage, source: "statusline"|"transcript"} | null.
98
+ context,
99
+ };
100
+ })
53
101
  );
54
102
  const liveIds = new Set(live.map((s) => s.id));
55
103
 
@@ -57,7 +105,6 @@ export default async function sessionRoutes(app) {
57
105
  // has no matching live tmux session. dir is re-resolved from the
58
106
  // registry (not a cached copy in the meta record) so a workspace
59
107
  // re-registered to a new path since the crash is reflected correctly.
60
- const metas = await listSessionMetas();
61
108
  const restorable = [];
62
109
  for (const meta of metas) {
63
110
  if (liveIds.has(meta.id)) continue;
@@ -70,16 +117,29 @@ export default async function sessionRoutes(app) {
70
117
  dir,
71
118
  attached: false,
72
119
  status: "restorable",
120
+ since: null,
121
+ message: null,
122
+ // p10: no live pane to read a title from — restorable entries never
123
+ // have one (see session-status spec).
124
+ title: null,
73
125
  restorable: true,
74
126
  // No live pane to read a cwd from — resolve against the
75
127
  // registered workspace dir instead; null if that's gone too.
76
128
  branch: await getBranch(dir),
129
+ // p11: restorable entries never carry context — there's no live
130
+ // session to have posted a statusline or grown a fresh transcript
131
+ // usage figure since it died (see context-telemetry spec).
132
+ context: null,
77
133
  });
78
134
  }
79
135
 
80
136
  return [...live, ...restorable];
81
137
  });
82
138
 
139
+ // p11: account-wide rate limits from the most recent statusline post —
140
+ // null until one arrives. See statusline.js for the store.
141
+ app.get("/api/usage", async () => getRateLimits());
142
+
83
143
  app.post("/api/sessions", async (req, reply) => {
84
144
  const { workspace, label, worktree: wantWorktree } = req.body ?? {};
85
145
 
@@ -236,6 +296,29 @@ export default async function sessionRoutes(app) {
236
296
  .code(403)
237
297
  .send({ error: "refusing to touch non-garage sessions" });
238
298
  }
299
+
300
+ // p8.1: `?meta=1` deletes ONLY the stored resume metadata of a NON-live
301
+ // (restorable) session — the plain DELETE below 404s for those since
302
+ // there is no tmux session to kill. Behavior without the param is
303
+ // unchanged (the web UI never sends it). The worktree record rides the
304
+ // response the same way, so the caller can still surface "worktree
305
+ // kept" for a discarded restorable worktree session.
306
+ if (req.query?.meta === "1") {
307
+ if (await hasSession(id)) {
308
+ return reply
309
+ .code(409)
310
+ .send({ error: `session is live — delete it without meta=1: ${id}` });
311
+ }
312
+ const meta = await getSessionMeta(id).catch(() => null);
313
+ if (!meta) {
314
+ return reply.code(404).send({ error: `no resume metadata for: ${id}` });
315
+ }
316
+ await removeSessionMeta(id).catch(() => {});
317
+ return reply
318
+ .code(200)
319
+ .send({ deleted: true, meta: true, worktree: meta?.worktree ?? null });
320
+ }
321
+
239
322
  if (!(await hasSession(id))) {
240
323
  return reply.code(404).send({ error: `no such session: ${id}` });
241
324
  }
@@ -0,0 +1,62 @@
1
+ import { copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+
4
+ // Shared by hooks.js and statusline.js — both merge a garage-owned fragment
5
+ // into ~/.claude/settings.json with the same safety order: parse-or-refuse
6
+ // (a corrupt file is reported as a 422, byte-for-byte untouched) -> caller
7
+ // merges its own fragment -> timestamped backup of the pre-write file ->
8
+ // atomic write (tmp + rename, same directory, so a crash mid-write can never
9
+ // leave a half-written settings.json).
10
+
11
+ // Read + parse settings.json. Returns `{settings, raw}`: `settings` is `{}`
12
+ // for a missing/empty file (nothing to refuse), the parsed object otherwise;
13
+ // `raw` is the exact original file text, or null if the file didn't exist
14
+ // (used by writeSettingsAtomic to decide whether a backup is needed).
15
+ // Throws a statusCode:422 error — file left completely untouched — for
16
+ // invalid JSON or a non-object top level.
17
+ export async function readSettingsOrRefuse(path) {
18
+ let raw = null;
19
+ try {
20
+ raw = await readFile(path, "utf8");
21
+ } catch (err) {
22
+ if (err.code !== "ENOENT") throw err;
23
+ }
24
+
25
+ let settings = {};
26
+ if (raw !== null && raw.trim() !== "") {
27
+ try {
28
+ settings = JSON.parse(raw);
29
+ } catch {
30
+ const error = new Error(
31
+ `${path} is not valid JSON — fix it (or remove it) and retry; the file was left untouched`
32
+ );
33
+ error.statusCode = 422;
34
+ throw error;
35
+ }
36
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
37
+ const error = new Error(`${path} is not a JSON object — the file was left untouched`);
38
+ error.statusCode = 422;
39
+ throw error;
40
+ }
41
+ }
42
+ return { settings, raw };
43
+ }
44
+
45
+ // Backs up (if `raw` is non-null, i.e. the file previously existed) then
46
+ // atomically writes `merged`. Returns the backup path, or null when there
47
+ // was nothing to back up.
48
+ export async function writeSettingsAtomic(path, raw, merged) {
49
+ await mkdir(dirname(path), { recursive: true });
50
+
51
+ let backup = null;
52
+ if (raw !== null) {
53
+ backup = `${path}.garage-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
54
+ await copyFile(path, backup);
55
+ }
56
+
57
+ const tmp = `${path}.garage-tmp-${process.pid}`;
58
+ await writeFile(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
59
+ await rename(tmp, path);
60
+
61
+ return backup;
62
+ }
@@ -2,58 +2,68 @@ import { EventEmitter } from "node:events";
2
2
 
3
3
  // D-status: StatusStore. Four states: needs-input | working | done | idle.
4
4
  // setStatus is the single write path — API, SSE, and notifications only
5
- // ever read (getStatus) or subscribe (statusEvents). `done` decays to `idle`
6
- // after DECAY_MS with no further transition; `needs-input` never
7
- // auto-decays — it only clears via a new setStatus call (poller 'busy' or a
8
- // hook Stop/new activity).
9
- const DECAY_MS = 2 * 60 * 1000;
5
+ // ever read (getStatus/getStatusEntry) or subscribe (statusEvents). There
6
+ // is NO auto-decay: `done` now holds until something else transitions it
7
+ // (poller sees 'busy', a hook fires, or dropSession removes the entry when
8
+ // the session dies). `needs-input` never auto-decayed either — same rule,
9
+ // it only clears via a new setStatus call. `since` records the epoch ms of
10
+ // the last actual state change (not touched by repeat setStatus calls with
11
+ // the same state), so the UI can render "how long in this state".
12
+ //
13
+ // p8 message capture: needs-input may carry the Notification hook's message
14
+ // text. Only hook-sourced writes pass one; the poller's coarse `waiting`
15
+ // observation passes none, and a messageless same-state write must not wipe
16
+ // a hook-set message (the 2s poller would otherwise erase it on the next
17
+ // tick). Any transition away from needs-input clears it.
10
18
 
11
- const store = new Map(); // id -> { state, since }
12
- const decayTimers = new Map(); // id -> Timeout
19
+ const store = new Map(); // id -> { state, since, message }
13
20
 
14
21
  export const statusEvents = new EventEmitter();
15
22
  statusEvents.setMaxListeners(0);
16
23
 
17
- function clearDecay(id) {
18
- const timer = decayTimers.get(id);
19
- if (timer) {
20
- clearTimeout(timer);
21
- decayTimers.delete(id);
22
- }
23
- }
24
-
25
24
  // Unknown/never-signaled sessions read as idle.
26
25
  export function getStatus(id) {
27
26
  return store.get(id)?.state ?? "idle";
28
27
  }
29
28
 
29
+ // Same lookup as getStatus but also exposes `since` (epoch ms the current
30
+ // state began, or null for a never-signaled id) so callers can compute
31
+ // elapsed time without a second store.
32
+ export function getStatusEntry(id) {
33
+ return store.get(id) ?? { state: "idle", since: null, message: null };
34
+ }
35
+
30
36
  export function getAllStatuses() {
31
37
  return new Map(store);
32
38
  }
33
39
 
34
- export function setStatus(id, state) {
40
+ export function setStatus(id, state, message = undefined) {
35
41
  const existing = store.get(id);
36
42
  const prev = existing?.state ?? "idle";
37
43
  const changed = prev !== state;
38
44
  const since = changed ? Date.now() : existing?.since ?? Date.now();
39
45
 
40
- clearDecay(id);
41
- store.set(id, { state, since });
46
+ // Only needs-input carries a message. A messageless needs-input write
47
+ // (the poller) preserves what a hook already captured on a same-state
48
+ // tick, but never invents one on a fresh transition.
49
+ const nextMessage =
50
+ state !== "needs-input"
51
+ ? null
52
+ : message !== undefined
53
+ ? message
54
+ : prev === "needs-input"
55
+ ? existing?.message ?? null
56
+ : null;
42
57
 
43
- if (state === "done") {
44
- const timer = setTimeout(() => setStatus(id, "idle"), DECAY_MS);
45
- timer.unref?.();
46
- decayTimers.set(id, timer);
47
- }
58
+ store.set(id, { state, since, message: nextMessage });
48
59
 
49
60
  if (changed) {
50
61
  statusEvents.emit("transition", { id, from: prev, to: state });
51
62
  }
52
63
  }
53
64
 
54
- // Called when a session disappears from tmux (poller-detected death) so
55
- // timers don't leak and a future reuse of the same id starts clean.
65
+ // Called when a session disappears from tmux (poller-detected death) so a
66
+ // future reuse of the same id starts clean.
56
67
  export function dropSession(id) {
57
- clearDecay(id);
58
68
  store.delete(id);
59
69
  }