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 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
- // 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.
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 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;
162
+ const sessions = group
162
163
  // 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
- }
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 { 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,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 a
60
- // run that was in progress while they went down.
61
- const GROUP_WINDOW_MS = 150_000;
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, lastEstimatedStopAt: null, dismissedStopAt: null, sessions: {} };
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
- return { ...emptyState(), ...data };
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
- const stoppedAt = Math.max(...dead.map((d) => d.lastSeenAlive));
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
- 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 };
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.0",
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"