ccakashic 0.6.0 → 0.6.1
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/dist/discover.js +27 -0
- package/dist/infer.js +27 -21
- package/dist/restore.js +21 -17
- package/dist/snapshot.js +41 -12
- package/package.json +1 -1
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,34 @@ 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
|
-
//
|
|
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)
|
|
157
|
+
return null;
|
|
158
|
+
// A session started before the stop and still running means the others were
|
|
159
|
+
// closed on purpose, not taken down together.
|
|
151
160
|
if (live.some((s) => s.startedAt !== undefined && s.startedAt <= stoppedAt))
|
|
152
161
|
return null;
|
|
153
|
-
const
|
|
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;
|
|
162
|
+
const sessions = group
|
|
162
163
|
// In a shutdown burst, only sessions that actually exited belong to it.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
.filter((f) => !burst || facts(f).exited)
|
|
165
|
+
.map((f) => ({
|
|
166
|
+
sessionId: f.sessionId,
|
|
167
|
+
cwd: facts(f).cwd,
|
|
168
|
+
name: null,
|
|
169
|
+
proc: 'log',
|
|
170
|
+
aliveSince: f.mtime,
|
|
171
|
+
lastSeenAlive: f.mtime,
|
|
172
|
+
}));
|
|
167
173
|
return { stoppedAt, sessions, estimated: true };
|
|
168
174
|
}
|
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,7 @@ exports.saveSnapshot = saveSnapshot;
|
|
|
40
40
|
exports.readLiveSessions = readLiveSessions;
|
|
41
41
|
exports.recordLive = recordLive;
|
|
42
42
|
exports.findLastStop = findLastStop;
|
|
43
|
+
exports.pickStoppedGroup = pickStoppedGroup;
|
|
43
44
|
exports.snapshotNow = snapshotNow;
|
|
44
45
|
const fs = __importStar(require("fs"));
|
|
45
46
|
const os = __importStar(require("os"));
|
|
@@ -56,18 +57,25 @@ const path = __importStar(require("path"));
|
|
|
56
57
|
const CONFIG_DIR = path.join(os.homedir(), '.config', 'ccakashic');
|
|
57
58
|
exports.SNAPSHOT_FILE = path.join(CONFIG_DIR, 'live-sessions.json');
|
|
58
59
|
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
|
-
|
|
60
|
+
// Sessions killed by the same event share their last sighting, give or take the
|
|
61
|
+
// run that was in progress while they went down. Measured between consecutive
|
|
62
|
+
// sightings, not from the newest one: a session that was opened and closed after
|
|
63
|
+
// the stop must not push the real group out of a fixed window.
|
|
64
|
+
const GROUP_GAP_MS = 150_000;
|
|
62
65
|
const RETENTION_MS = 14 * 24 * 60 * 60_000;
|
|
63
66
|
function emptyState() {
|
|
64
|
-
return { version: 1, lastRunAt: 0, recordingSince: null,
|
|
67
|
+
return { version: 1, lastRunAt: 0, recordingSince: null, lastOfferedStopAt: null, dismissedStopAt: null, sessions: {} };
|
|
65
68
|
}
|
|
66
69
|
function loadSnapshot(file = exports.SNAPSHOT_FILE) {
|
|
67
70
|
try {
|
|
68
71
|
const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
69
72
|
if (data && data.version === 1 && data.sessions && typeof data.sessions === 'object') {
|
|
70
|
-
|
|
73
|
+
const state = { ...emptyState(), ...data };
|
|
74
|
+
// 0.6.0 called it lastEstimatedStopAt, when only the log estimate used it.
|
|
75
|
+
if (state.lastOfferedStopAt === null && typeof data.lastEstimatedStopAt === 'number') {
|
|
76
|
+
state.lastOfferedStopAt = data.lastEstimatedStopAt;
|
|
77
|
+
}
|
|
78
|
+
return state;
|
|
71
79
|
}
|
|
72
80
|
}
|
|
73
81
|
catch {
|
|
@@ -150,24 +158,45 @@ function recordLive(state, live, now) {
|
|
|
150
158
|
// brought back. A group only counts as a stop when nothing survived it: if some
|
|
151
159
|
// session was alive both before and after, the others were closed one by one on
|
|
152
160
|
// purpose (/exit, closing a tab), not killed by a reboot or a hung cmux.
|
|
153
|
-
function findLastStop(state, liveIds
|
|
161
|
+
function findLastStop(state, liveIds,
|
|
162
|
+
// Sessions without a conversation log can't be resumed; leaving them in would
|
|
163
|
+
// let a throwaway session started after the stop stand in for the group.
|
|
164
|
+
resumable = () => true, notOlderThan = 0) {
|
|
154
165
|
const dead = [];
|
|
155
166
|
for (const [sessionId, e] of Object.entries(state.sessions)) {
|
|
156
|
-
if (!liveIds.has(sessionId))
|
|
167
|
+
if (!liveIds.has(sessionId) && resumable(sessionId))
|
|
157
168
|
dead.push({ sessionId, ...e });
|
|
158
169
|
}
|
|
159
170
|
if (!dead.length)
|
|
160
171
|
return null;
|
|
161
|
-
|
|
172
|
+
dead.sort((a, b) => b.lastSeenAlive - a.lastSeenAlive || a.cwd.localeCompare(b.cwd));
|
|
173
|
+
const group = pickStoppedGroup(dead, (d) => d.lastSeenAlive);
|
|
174
|
+
const stoppedAt = group[0].lastSeenAlive;
|
|
175
|
+
if (stoppedAt < notOlderThan)
|
|
176
|
+
return null;
|
|
177
|
+
// Something running from before the stop means the rest were closed one by
|
|
178
|
+
// one on purpose, not taken down together.
|
|
162
179
|
for (const id of liveIds) {
|
|
163
180
|
const e = state.sessions[id];
|
|
164
181
|
if (e && e.aliveSince <= stoppedAt)
|
|
165
182
|
return null;
|
|
166
183
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
184
|
+
return { stoppedAt, sessions: group };
|
|
185
|
+
}
|
|
186
|
+
// The most recent group that went down together, newest first. Sessions come
|
|
187
|
+
// and go all day — a scheduled run, a quick question — and any of them would
|
|
188
|
+
// otherwise stand in for the last stop just by being the newest. So a group of
|
|
189
|
+
// several wins over a lone session, even a more recent one.
|
|
190
|
+
function pickStoppedGroup(deadNewestFirst, at, gapMs = GROUP_GAP_MS) {
|
|
191
|
+
const clusters = [];
|
|
192
|
+
for (const item of deadNewestFirst) {
|
|
193
|
+
const current = clusters[clusters.length - 1];
|
|
194
|
+
if (current && at(current[current.length - 1]) - at(item) <= gapMs)
|
|
195
|
+
current.push(item);
|
|
196
|
+
else
|
|
197
|
+
clusters.push([item]);
|
|
198
|
+
}
|
|
199
|
+
return clusters.find((c) => c.length > 1) ?? clusters[0];
|
|
171
200
|
}
|
|
172
201
|
// One launchd tick. Kept free of any import beyond Node built-ins: the agent
|
|
173
202
|
// 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.
|
|
3
|
+
"version": "0.6.1",
|
|
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"
|