ccakashic 0.6.0 → 0.7.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.
package/README.md CHANGED
@@ -81,13 +81,16 @@ After a reboot (memory pressure, an update) or a hung cmux you had to kill, ever
81
81
 
82
82
  ```bash
83
83
  # From a terminal inside cmux
84
- npx ccakashic restore # lists the sessions you had open, then reopens them
84
+ npx ccakashic restore # pick from the sessions you had open, then reopen them
85
+ npx ccakashic restore --yes # reopen all of them, no prompt
85
86
  npx ccakashic restore --dry-run # just list them
86
87
 
87
88
  # Optional: record open sessions every minute (launchd, macOS) for an exact list
88
89
  npx ccakashic install-agent
89
90
  ```
90
91
 
92
+ `restore` opens a checkbox list with every session ticked: `space` toggles one, `a` toggles all, `enter` reopens what is ticked, `q` cancels. So reopening the lot is just `enter`, and reopening one is `a` then `space`. Piped or non-interactive, it needs `--yes`.
93
+
91
94
  **No setup needed.** Without the agent, the list is estimated from the conversation logs: the most recent group of sessions that stopped together. When they were closed normally — including by a shutdown or reboot — each wrote an exit record at the same moment, so the group is precise. When cmux was force-quit nothing gets written, so it falls back to "active within 15 minutes of the last one", which misses sessions that sat idle. Install the agent if that case matters to you.
92
95
 
93
96
  Sessions are only offered when **none of the group is still running** alongside something older: if you closed one session while others kept going, nothing is offered. Sessions you have already reopened drop off the list, and once a group is fully reopened, older ones are not dug up.
@@ -38,7 +38,6 @@ const http = __importStar(require("http"));
38
38
  const fs = __importStar(require("fs"));
39
39
  const os = __importStar(require("os"));
40
40
  const path = __importStar(require("path"));
41
- const readline = __importStar(require("readline"));
42
41
  const child_process_1 = require("child_process");
43
42
  const util_1 = require("../util");
44
43
  const discover_1 = require("../discover");
@@ -49,6 +48,7 @@ const pages_1 = require("../pages");
49
48
  const dashboard_1 = require("../dashboard");
50
49
  const snapshot_1 = require("../snapshot");
51
50
  const restore_1 = require("../restore");
51
+ const picker_1 = require("../picker");
52
52
  const agent_1 = require("../agent");
53
53
  const cmux_1 = require("../cmux");
54
54
  // Published at dist/bin/ccakashic.js, so ../../package.json resolves from dist/
@@ -534,15 +534,6 @@ function formatClock(ms) {
534
534
  const pad = (n) => String(n).padStart(2, '0');
535
535
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
536
536
  }
537
- function askYesNo(question) {
538
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
539
- return new Promise((resolve) => {
540
- rl.question(question, (answer) => {
541
- rl.close();
542
- resolve(/^(y|yes|)$/i.test(answer.trim()));
543
- });
544
- });
545
- }
546
537
  async function runRestoreCommand() {
547
538
  const dryRun = process.argv.includes('--dry-run');
548
539
  const yes = process.argv.includes('--yes') || process.argv.includes('-y');
@@ -565,21 +556,48 @@ async function runRestoreCommand() {
565
556
  return;
566
557
  }
567
558
  const items = await (0, restore_1.describeStopped)(stop.sessions);
568
- console.log(`${items.length} session(s) you had open until ${formatClock(stop.stoppedAt)}${stop.estimated ? ' (estimated)' : ''}:\n`);
569
- for (const it of items)
570
- console.log(` • ${it.title}\n ${it.cwd}`);
571
- console.log('');
559
+ const heading = `${items.length} session(s) you had open until ${formatClock(stop.stoppedAt)}${stop.estimated ? ' (estimated)' : ''}:`;
560
+ const printList = () => {
561
+ console.log(`${heading}\n`);
562
+ for (const it of items)
563
+ console.log(` • ${it.title}\n ${it.cwd}`);
564
+ console.log('');
565
+ };
572
566
  if (dryRun)
573
- return;
567
+ return printList();
574
568
  if (NO_CMUX || !(await (0, cmux_1.isCmuxAvailable)())) {
569
+ printList();
575
570
  console.log('cmux is not reachable (run this from a terminal inside cmux). Commands to resume by hand:\n');
576
571
  for (const it of items)
577
572
  console.log((0, cmux_1.buildResumeCommand)(it.cwd, it.sessionId));
578
573
  return;
579
574
  }
580
- if (!yes && !(await askYesNo(`Reopen all ${items.length} in new cmux workspaces? [Y/n] `)))
581
- return;
582
- const outcomes = await (0, restore_1.restoreAll)(items, (o) => {
575
+ // Interactive: a checkbox list, everything ticked, so reopening all is just
576
+ // Enter. --yes (or a pipe, where there are no keys to read) takes them all.
577
+ let chosen = items;
578
+ if (!yes) {
579
+ if (!process.stdin.isTTY) {
580
+ printList();
581
+ console.log('Re-run with --yes to reopen them (no terminal to choose in).');
582
+ return;
583
+ }
584
+ console.log(heading);
585
+ const picked = await (0, picker_1.pickItems)(items.map((it) => ({ label: it.title, hint: it.cwd })));
586
+ if (picked === null) {
587
+ console.log('Cancelled.');
588
+ return;
589
+ }
590
+ chosen = picked.map((i) => items[i]);
591
+ if (!chosen.length) {
592
+ console.log('Nothing selected.');
593
+ return;
594
+ }
595
+ console.log('');
596
+ }
597
+ else {
598
+ printList();
599
+ }
600
+ const outcomes = await (0, restore_1.restoreAll)(chosen, (o) => {
583
601
  console.log(o.ok ? ` ✓ ${o.title}` : ` ✗ ${o.title} — ${o.message}`);
584
602
  });
585
603
  const failed = outcomes.filter((o) => !o.ok).length;
package/dist/discover.js CHANGED
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CLAUDE_DIR = void 0;
37
+ exports.listKnownSessionIds = listKnownSessionIds;
37
38
  exports.decodeDirName = decodeDirName;
38
39
  exports.listProjects = listProjects;
39
40
  exports.listSessions = listSessions;
@@ -46,6 +47,32 @@ const os = __importStar(require("os"));
46
47
  const path = __importStar(require("path"));
47
48
  const readline = __importStar(require("readline"));
48
49
  exports.CLAUDE_DIR = path.join(os.homedir(), '.claude', 'projects');
50
+ // Every session id that has a conversation log, by filename only (no parsing).
51
+ // A session with no log cannot be resumed, so restore uses this to skip them.
52
+ function listKnownSessionIds(root = exports.CLAUDE_DIR) {
53
+ const ids = new Set();
54
+ let dirs;
55
+ try {
56
+ dirs = fs.readdirSync(root);
57
+ }
58
+ catch {
59
+ return ids;
60
+ }
61
+ for (const d of dirs) {
62
+ let names;
63
+ try {
64
+ names = fs.readdirSync(path.join(root, d));
65
+ }
66
+ catch {
67
+ continue;
68
+ }
69
+ for (const f of names) {
70
+ if (f.endsWith('.jsonl'))
71
+ ids.add(f.slice(0, -'.jsonl'.length));
72
+ }
73
+ }
74
+ return ids;
75
+ }
49
76
  function decodeDirName(dirName) {
50
77
  // Directory names encode paths: /Users/foo/bar → -Users-foo-bar
51
78
  // This is lossy (dots become dashes too), but good enough for display
package/dist/infer.js CHANGED
@@ -38,6 +38,7 @@ exports.inferLastStop = inferLastStop;
38
38
  const fs = __importStar(require("fs"));
39
39
  const path = __importStar(require("path"));
40
40
  const discover_1 = require("./discover");
41
+ const snapshot_1 = require("./snapshot");
41
42
  // Best-effort "what was running before the crash" from the conversation logs
42
43
  // alone, for when the snapshot agent is not installed.
43
44
  //
@@ -131,7 +132,7 @@ function listLogFiles(root, since) {
131
132
  }
132
133
  return out.sort((a, b) => b.mtime - a.mtime);
133
134
  }
134
- function inferLastStop(live, now = Date.now(), root = discover_1.CLAUDE_DIR, readFacts = readLogFacts) {
135
+ function inferLastStop(live, now = Date.now(), root = discover_1.CLAUDE_DIR, readFacts = readLogFacts, notOlderThan = 0) {
135
136
  const liveIds = new Set(live.map((s) => s.sessionId));
136
137
  const factsCache = new Map();
137
138
  const facts = (f) => {
@@ -140,29 +141,35 @@ function inferLastStop(live, now = Date.now(), root = discover_1.CLAUDE_DIR, rea
140
141
  factsCache.set(f.file, (v = readFacts(f.file)));
141
142
  return v;
142
143
  };
143
- // Newest interactive session that is no longer running marks the stop.
144
- const dead = listLogFiles(root, now - LOOKBACK_MS).filter((f) => !liveIds.has(f.sessionId));
145
- const newest = dead.find((f) => facts(f).interactive && facts(f).cwd);
146
- if (!newest)
144
+ const dead = listLogFiles(root, now - LOOKBACK_MS)
145
+ .filter((f) => !liveIds.has(f.sessionId) && facts(f).interactive && facts(f).cwd);
146
+ if (!dead.length)
147
147
  return null;
148
- const stoppedAt = Math.floor(newest.mtime); // an integer survives the round trip through the page
149
- // Same rule as the recorded path: a session started before the stop and still
150
- // running means the others were closed on purpose, not killed together.
151
- if (live.some((s) => s.startedAt !== undefined && s.startedAt <= stoppedAt))
148
+ // Same shape as the recorded path: prefer a group that stopped together over a
149
+ // lone session that happens to be newer (a scheduled run, a quick question).
150
+ // Without exit records the times are "last active", so they spread out more.
151
+ const anchor = dead[0];
152
+ const gap = facts(anchor).exited ? EXIT_BURST_WINDOW_MS : ACTIVITY_WINDOW_MS;
153
+ const group = (0, snapshot_1.pickStoppedGroup)(dead, (f) => f.mtime, gap);
154
+ const burst = facts(group[0]).exited;
155
+ const stoppedAt = Math.floor(group[0].mtime); // an integer survives the round trip through the page
156
+ if (stoppedAt < notOlderThan)
152
157
  return null;
153
- const burst = facts(newest).exited;
154
- const window = burst ? EXIT_BURST_WINDOW_MS : ACTIVITY_WINDOW_MS;
155
- const sessions = [];
156
- for (const f of dead) {
157
- if (f.mtime < stoppedAt - window)
158
- break; // sorted newest first
159
- const x = facts(f);
160
- if (!x.interactive || !x.cwd)
161
- continue;
158
+ // Same rule as the recorded path: only a lone stop is explained away by
159
+ // something older still running.
160
+ const liveSince = live.map((s) => s.startedAt).filter((v) => v !== undefined);
161
+ if ((0, snapshot_1.survivedBy)(group.length, liveSince, stoppedAt))
162
+ return null;
163
+ const sessions = group
162
164
  // In a shutdown burst, only sessions that actually exited belong to it.
163
- if (burst && !x.exited)
164
- continue;
165
- sessions.push({ sessionId: f.sessionId, cwd: x.cwd, name: null, proc: 'log', aliveSince: f.mtime, lastSeenAlive: f.mtime });
166
- }
165
+ .filter((f) => !burst || facts(f).exited)
166
+ .map((f) => ({
167
+ sessionId: f.sessionId,
168
+ cwd: facts(f).cwd,
169
+ name: null,
170
+ proc: 'log',
171
+ aliveSince: f.mtime,
172
+ lastSeenAlive: f.mtime,
173
+ }));
167
174
  return { stoppedAt, sessions, estimated: true };
168
175
  }
package/dist/picker.js ADDED
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ // A checkbox list for the terminal: pick which sessions to reopen. No
3
+ // dependencies, so the state machine and the rendering are plain functions and
4
+ // only the last few lines touch stdin.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.initialState = initialState;
7
+ exports.keyOf = keyOf;
8
+ exports.applyKey = applyKey;
9
+ exports.renderLines = renderLines;
10
+ exports.pickItems = pickItems;
11
+ const ESC = '\u001b';
12
+ function initialState(count, rows) {
13
+ return { cursor: 0, checked: new Array(count).fill(true), offset: 0, rows: Math.max(1, Math.min(rows, count)) };
14
+ }
15
+ // Names the keys the loop cares about; anything else is ignored.
16
+ function keyOf(data) {
17
+ switch (data) {
18
+ case '\u0003': return 'cancel'; // Ctrl-C
19
+ case '\u0004': return 'cancel'; // Ctrl-D
20
+ case 'q':
21
+ case ESC: return 'cancel';
22
+ case '\r':
23
+ case '\n': return 'confirm';
24
+ case ' ': return 'toggle';
25
+ case 'a': return 'toggleAll';
26
+ case `${ESC}[A`:
27
+ case 'k': return 'up';
28
+ case `${ESC}[B`:
29
+ case 'j': return 'down';
30
+ case `${ESC}[H`:
31
+ case 'g': return 'first';
32
+ case `${ESC}[F`:
33
+ case 'G': return 'last';
34
+ default: return '';
35
+ }
36
+ }
37
+ function applyKey(state, key) {
38
+ const count = state.checked.length;
39
+ const next = { ...state, checked: state.checked.slice() };
40
+ switch (key) {
41
+ case 'cancel': return { state, action: 'cancel' };
42
+ case 'confirm': return { state, action: 'confirm' };
43
+ case 'toggle':
44
+ next.checked[next.cursor] = !next.checked[next.cursor];
45
+ // Step down so space-space-space walks the list, as in most pickers.
46
+ next.cursor = Math.min(count - 1, next.cursor + 1);
47
+ break;
48
+ case 'toggleAll': {
49
+ // All on unless they already are, in which case clear them.
50
+ const allOn = next.checked.every(Boolean);
51
+ next.checked = next.checked.map(() => !allOn);
52
+ break;
53
+ }
54
+ case 'up':
55
+ next.cursor = Math.max(0, next.cursor - 1);
56
+ break;
57
+ case 'down':
58
+ next.cursor = Math.min(count - 1, next.cursor + 1);
59
+ break;
60
+ case 'first':
61
+ next.cursor = 0;
62
+ break;
63
+ case 'last':
64
+ next.cursor = count - 1;
65
+ break;
66
+ default: return { state, action: null };
67
+ }
68
+ // Keep the cursor inside the visible window.
69
+ if (next.cursor < next.offset)
70
+ next.offset = next.cursor;
71
+ if (next.cursor >= next.offset + next.rows)
72
+ next.offset = next.cursor - next.rows + 1;
73
+ return { state: next, action: null };
74
+ }
75
+ function renderLines(items, state, width = 80) {
76
+ const lines = [];
77
+ const picked = state.checked.filter(Boolean).length;
78
+ lines.push(`Select sessions to reopen (${picked}/${items.length})`);
79
+ lines.push('space toggle · a all/none · ↑↓ move · enter reopen · q cancel');
80
+ const end = Math.min(items.length, state.offset + state.rows);
81
+ for (let i = state.offset; i < end; i++) {
82
+ const box = state.checked[i] ? '[x]' : '[ ]';
83
+ const pointer = i === state.cursor ? '>' : ' ';
84
+ const hint = items[i].hint ? ` ${items[i].hint}` : '';
85
+ lines.push(truncate(`${pointer} ${box} ${items[i].label}${hint}`, width));
86
+ }
87
+ if (end < items.length || state.offset > 0) {
88
+ lines.push(` … ${state.offset + 1}-${end} of ${items.length}`);
89
+ }
90
+ return lines;
91
+ }
92
+ function truncate(s, width) {
93
+ return s.length <= width ? s : `${s.slice(0, Math.max(1, width - 1))}…`;
94
+ }
95
+ // Runs the list on a TTY and resolves with the chosen indices, or null if the
96
+ // user backed out. Callers must check process.stdin.isTTY first.
97
+ function pickItems(items, io = { input: process.stdin, output: process.stdout }) {
98
+ const { input, output } = io;
99
+ const viewRows = Math.max(3, (output.rows || 24) - 4);
100
+ let state = initialState(items.length, viewRows);
101
+ let drawn = 0;
102
+ const draw = () => {
103
+ if (drawn)
104
+ output.write(`${ESC}[${drawn}A`); // back to the top of the list
105
+ output.write(`${ESC}[J`); // clear from here down
106
+ const lines = renderLines(items, state, (output.columns || 80) - 1);
107
+ output.write(`${lines.join('\n')}\n`);
108
+ drawn = lines.length;
109
+ };
110
+ return new Promise((resolve) => {
111
+ const finish = (result) => {
112
+ input.setRawMode?.(false);
113
+ input.pause();
114
+ input.removeListener('data', onData);
115
+ output.write(`${ESC}[?25h`); // cursor back on
116
+ resolve(result);
117
+ };
118
+ const onData = (buf) => {
119
+ const key = keyOf(buf.toString());
120
+ if (!key)
121
+ return;
122
+ const next = applyKey(state, key);
123
+ state = next.state;
124
+ if (next.action === 'cancel')
125
+ return finish(null);
126
+ if (next.action === 'confirm') {
127
+ const chosen = state.checked.flatMap((on, i) => (on ? [i] : []));
128
+ draw();
129
+ return finish(chosen);
130
+ }
131
+ draw();
132
+ };
133
+ output.write(`${ESC}[?25l`); // hide the cursor while redrawing
134
+ input.setRawMode?.(true);
135
+ input.resume();
136
+ input.setEncoding('utf-8');
137
+ input.on('data', onData);
138
+ draw();
139
+ });
140
+ }
package/dist/restore.js CHANGED
@@ -53,7 +53,8 @@ const STAGGER_MS = 1500;
53
53
  function detectLastStop(now = Date.now()) {
54
54
  const live = (0, snapshot_1.readLiveSessions)();
55
55
  const agentInstalled = fs.existsSync(agent_1.AGENT_PLIST);
56
- const { state, stop } = chooseStop((0, snapshot_1.recordLive)((0, snapshot_1.loadSnapshot)(), live, now), live, agentInstalled, () => (0, infer_1.inferLastStop)(live, now));
56
+ const known = (0, discover_1.listKnownSessionIds)();
57
+ const { state, stop } = chooseStop((0, snapshot_1.recordLive)((0, snapshot_1.loadSnapshot)(), live, now), live, agentInstalled, (notOlderThan) => (0, infer_1.inferLastStop)(live, now, undefined, undefined, notOlderThan), (id) => known.has(id));
57
58
  try {
58
59
  (0, snapshot_1.saveSnapshot)(state);
59
60
  }
@@ -64,23 +65,26 @@ function detectLastStop(now = Date.now()) {
64
65
  }
65
66
  // Pure decision between the agent's records and a log estimate; returns the
66
67
  // state to persist alongside it.
67
- function chooseStop(state, live, agentInstalled, infer) {
68
- if (agentInstalled) {
69
- const recorded = (0, snapshot_1.findLastStop)(state, new Set(live.map((s) => s.sessionId)));
70
- if (recorded)
71
- return { state, stop: recorded };
72
- }
73
- const inferred = infer();
74
- if (!inferred)
75
- return { state, stop: null };
76
- // Within the recorded period the agent saw no stop, so trust it over a guess.
77
- if (agentInstalled && state.recordingSince !== null && inferred.stoppedAt >= state.recordingSince) {
78
- return { state, stop: null };
79
- }
80
- if (state.lastEstimatedStopAt !== null && inferred.stoppedAt < state.lastEstimatedStopAt) {
68
+ function chooseStop(state, live, agentInstalled, infer, resumable = () => true) {
69
+ // Never dig further back than the last group offered: once those are reopened,
70
+ // the group before them is just history, not something that stopped on you.
71
+ const floor = state.lastOfferedStopAt ?? 0;
72
+ const liveIds = new Set(live.map((s) => s.sessionId));
73
+ const stop = (agentInstalled ? (0, snapshot_1.findLastStop)(state, liveIds, resumable, floor) : null)
74
+ // Within the recorded period the agent's records are the truth, so the log
75
+ // estimate is only for stops from before recording began.
76
+ ?? inferBeforeRecording(state, agentInstalled, infer, floor);
77
+ if (!stop)
81
78
  return { state, stop: null };
82
- }
83
- return { state: { ...state, lastEstimatedStopAt: inferred.stoppedAt }, stop: inferred };
79
+ return { state: { ...state, lastOfferedStopAt: Math.max(floor, stop.stoppedAt) }, stop };
80
+ }
81
+ function inferBeforeRecording(state, agentInstalled, infer, floor) {
82
+ const inferred = infer(floor);
83
+ if (!inferred)
84
+ return null;
85
+ if (agentInstalled && state.recordingSince !== null && inferred.stoppedAt >= state.recordingSince)
86
+ return null;
87
+ return inferred;
84
88
  }
85
89
  async function describeStopped(sessions) {
86
90
  const previews = await (0, discover_1.findRecentSessionsByIds)(sessions.map((s) => s.sessionId));
package/dist/snapshot.js CHANGED
@@ -40,6 +40,8 @@ exports.saveSnapshot = saveSnapshot;
40
40
  exports.readLiveSessions = readLiveSessions;
41
41
  exports.recordLive = recordLive;
42
42
  exports.findLastStop = findLastStop;
43
+ exports.survivedBy = survivedBy;
44
+ exports.pickStoppedGroup = pickStoppedGroup;
43
45
  exports.snapshotNow = snapshotNow;
44
46
  const fs = __importStar(require("fs"));
45
47
  const os = __importStar(require("os"));
@@ -56,18 +58,25 @@ const path = __importStar(require("path"));
56
58
  const CONFIG_DIR = path.join(os.homedir(), '.config', 'ccakashic');
57
59
  exports.SNAPSHOT_FILE = path.join(CONFIG_DIR, 'live-sessions.json');
58
60
  const SESSION_REGISTRY_DIR = path.join(os.homedir(), '.claude', 'sessions');
59
- // Sessions killed by the same event share their last sighting, give or take a
60
- // run that was in progress while they went down.
61
- const GROUP_WINDOW_MS = 150_000;
61
+ // Sessions killed by the same event share their last sighting, give or take the
62
+ // run that was in progress while they went down. Measured between consecutive
63
+ // sightings, not from the newest one: a session that was opened and closed after
64
+ // the stop must not push the real group out of a fixed window.
65
+ const GROUP_GAP_MS = 150_000;
62
66
  const RETENTION_MS = 14 * 24 * 60 * 60_000;
63
67
  function emptyState() {
64
- return { version: 1, lastRunAt: 0, recordingSince: null, lastEstimatedStopAt: null, dismissedStopAt: null, sessions: {} };
68
+ return { version: 1, lastRunAt: 0, recordingSince: null, lastOfferedStopAt: null, dismissedStopAt: null, sessions: {} };
65
69
  }
66
70
  function loadSnapshot(file = exports.SNAPSHOT_FILE) {
67
71
  try {
68
72
  const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
69
73
  if (data && data.version === 1 && data.sessions && typeof data.sessions === 'object') {
70
- return { ...emptyState(), ...data };
74
+ const state = { ...emptyState(), ...data };
75
+ // 0.6.0 called it lastEstimatedStopAt, when only the log estimate used it.
76
+ if (state.lastOfferedStopAt === null && typeof data.lastEstimatedStopAt === 'number') {
77
+ state.lastOfferedStopAt = data.lastEstimatedStopAt;
78
+ }
79
+ return state;
71
80
  }
72
81
  }
73
82
  catch {
@@ -150,24 +159,50 @@ function recordLive(state, live, now) {
150
159
  // brought back. A group only counts as a stop when nothing survived it: if some
151
160
  // session was alive both before and after, the others were closed one by one on
152
161
  // purpose (/exit, closing a tab), not killed by a reboot or a hung cmux.
153
- function findLastStop(state, liveIds) {
162
+ function findLastStop(state, liveIds,
163
+ // Sessions without a conversation log can't be resumed; leaving them in would
164
+ // let a throwaway session started after the stop stand in for the group.
165
+ resumable = () => true, notOlderThan = 0) {
154
166
  const dead = [];
155
167
  for (const [sessionId, e] of Object.entries(state.sessions)) {
156
- if (!liveIds.has(sessionId))
168
+ if (!liveIds.has(sessionId) && resumable(sessionId))
157
169
  dead.push({ sessionId, ...e });
158
170
  }
159
171
  if (!dead.length)
160
172
  return null;
161
- const stoppedAt = Math.max(...dead.map((d) => d.lastSeenAlive));
162
- for (const id of liveIds) {
163
- const e = state.sessions[id];
164
- if (e && e.aliveSince <= stoppedAt)
165
- return null;
173
+ dead.sort((a, b) => b.lastSeenAlive - a.lastSeenAlive || a.cwd.localeCompare(b.cwd));
174
+ const group = pickStoppedGroup(dead, (d) => d.lastSeenAlive);
175
+ const stoppedAt = group[0].lastSeenAlive;
176
+ if (stoppedAt < notOlderThan)
177
+ return null;
178
+ const liveSince = [...liveIds].map((id) => state.sessions[id]?.aliveSince).filter((v) => v !== undefined);
179
+ if (survivedBy(group.length, liveSince, stoppedAt))
180
+ return null;
181
+ return { stoppedAt, sessions: group };
182
+ }
183
+ // A lone session that stopped while something older kept running was closed on
184
+ // purpose, so it is not offered. Several at once is a different story: they went
185
+ // down together even if a session elsewhere (another terminal, the desktop app,
186
+ // a scheduled run) was untouched by whatever took them.
187
+ function survivedBy(groupSize, liveSince, stoppedAt) {
188
+ if (groupSize > 1)
189
+ return false;
190
+ return liveSince.some((since) => since <= stoppedAt);
191
+ }
192
+ // The most recent group that went down together, newest first. Sessions come
193
+ // and go all day — a scheduled run, a quick question — and any of them would
194
+ // otherwise stand in for the last stop just by being the newest. So a group of
195
+ // several wins over a lone session, even a more recent one.
196
+ function pickStoppedGroup(deadNewestFirst, at, gapMs = GROUP_GAP_MS) {
197
+ const clusters = [];
198
+ for (const item of deadNewestFirst) {
199
+ const current = clusters[clusters.length - 1];
200
+ if (current && at(current[current.length - 1]) - at(item) <= gapMs)
201
+ current.push(item);
202
+ else
203
+ clusters.push([item]);
166
204
  }
167
- const sessions = dead
168
- .filter((d) => d.lastSeenAlive >= stoppedAt - GROUP_WINDOW_MS)
169
- .sort((a, b) => b.lastSeenAlive - a.lastSeenAlive || a.cwd.localeCompare(b.cwd));
170
- return { stoppedAt, sessions };
205
+ return clusters.find((c) => c.length > 1) ?? clusters[0];
171
206
  }
172
207
  // One launchd tick. Kept free of any import beyond Node built-ins: the agent
173
208
  // runs a copy of this compiled file, not the installed package (see agent.ts).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccakashic",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A cross-project dashboard for your Claude Code sessions (~/.claude/projects/) — browse logs as beautiful HTML, see which sessions are waiting for you, and resume any of them in one click via cmux",
5
5
  "bin": {
6
6
  "ccakashic": "dist/bin/ccakashic.js"