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 +4 -1
- package/dist/bin/ccakashic.js +36 -18
- package/dist/discover.js +27 -0
- package/dist/infer.js +29 -22
- package/dist/picker.js +140 -0
- package/dist/restore.js +21 -17
- package/dist/snapshot.js +51 -16
- package/package.json +1 -1
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 #
|
|
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.
|
package/dist/bin/ccakashic.js
CHANGED
|
@@ -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
|
-
|
|
569
|
-
|
|
570
|
-
console.log(
|
|
571
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
60
|
-
// run that was in progress while they went down.
|
|
61
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|