ccakashic 0.4.0 → 0.6.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 +23 -0
- package/dist/agent.js +187 -0
- package/dist/api.js +48 -0
- package/dist/bin/ccakashic.js +176 -4
- package/dist/cmux.js +122 -0
- package/dist/dashboard.js +98 -1
- package/dist/discover.js +27 -0
- package/dist/infer.js +168 -0
- package/dist/restore.js +126 -0
- package/dist/snapshot.js +176 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,6 +53,8 @@ A local HTTP server starts and your browser opens automatically.
|
|
|
53
53
|
- **Filter search** — Incremental filtering on list pages
|
|
54
54
|
- **Keyboard navigation** — `j` / `k` to move between messages, `p` / `n` to move between your own prompts
|
|
55
55
|
- **One-click resume in cmux** — `▶ Resume` spawns a [cmux](https://github.com/manaflow-ai/cmux) workspace that runs `cd <session cwd> && claude --resume <id>`; `📋 Copy` copies the same command for any terminal
|
|
56
|
+
- **Reopen what you had open** — After a reboot or a hung cmux, `npx ccakashic restore` (or the dashboard banner) reopens the whole set of sessions you were working in, each in its own cmux workspace. See [below](#reopen-the-sessions-you-had-open)
|
|
57
|
+
- **Read-only JSON feed** — `GET /api/sessions?limit=40&waiting=1` returns what the dashboard shows (title, project, branch, model, `status`, `waiting`, `detailUrl`, `resumeCommand`) so other local tools can reuse the waiting signal. `waiting=1` returns only the sessions asking for you, and is the cheap path — it looks those up by id instead of parsing a whole window of session files
|
|
56
58
|
- **Zero dependencies** — Node.js built-in modules only
|
|
57
59
|
|
|
58
60
|
## cmux integration
|
|
@@ -73,6 +75,27 @@ Notes:
|
|
|
73
75
|
- The `cmux` binary is found via `$PATH`, then the common Homebrew locations. If it lives elsewhere, point `CCAKASHIC_CMUX` at it (e.g. `CCAKASHIC_CMUX=/path/to/cmux npx ccakashic`)
|
|
74
76
|
- Disable the integration with `--no-cmux` or `CCAKASHIC_NO_CMUX=1`
|
|
75
77
|
|
|
78
|
+
## Reopen the sessions you had open
|
|
79
|
+
|
|
80
|
+
After a reboot (memory pressure, an update) or a hung cmux you had to kill, every `claude` you had open is gone — cmux brings back its workspace tabs, but not the sessions inside them. One command reopens the last set you were working in.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# From a terminal inside cmux
|
|
84
|
+
npx ccakashic restore # lists the sessions you had open, then reopens them
|
|
85
|
+
npx ccakashic restore --dry-run # just list them
|
|
86
|
+
|
|
87
|
+
# Optional: record open sessions every minute (launchd, macOS) for an exact list
|
|
88
|
+
npx ccakashic install-agent
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**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
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
The dashboard shows the same list as a banner (`↺ N sessions you had open`) with checkboxes, `Reopen selected` and `Dismiss`. Each session opens in its own background cmux workspace, a moment apart so a dozen `claude` processes don't start at once. Without cmux, the command prints the `cd … && claude --resume …` lines to paste instead.
|
|
96
|
+
|
|
97
|
+
With the agent, the list comes from Claude Code's live-session registry (`~/.claude/sessions/`, interactive sessions only), copied every minute into `~/.config/ccakashic/live-sessions.json`. The agent runs its own copy of the recorder from `~/.config/ccakashic/agent/`, not the npx cache, so clearing the cache or upgrading doesn't silently stop it; any later `npx ccakashic` run refreshes that copy. Remove it with `npx ccakashic uninstall-agent`.
|
|
98
|
+
|
|
76
99
|
## Options
|
|
77
100
|
|
|
78
101
|
```bash
|
package/dist/agent.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.AGENT_DIR = exports.AGENT_PLIST = exports.AGENT_LABEL = void 0;
|
|
37
|
+
exports.buildPlist = buildPlist;
|
|
38
|
+
exports.refreshInstalledAgent = refreshInstalledAgent;
|
|
39
|
+
exports.installAgent = installAgent;
|
|
40
|
+
exports.uninstallAgent = uninstallAgent;
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const os = __importStar(require("os"));
|
|
43
|
+
const path = __importStar(require("path"));
|
|
44
|
+
const child_process_1 = require("child_process");
|
|
45
|
+
const snapshot_1 = require("./snapshot");
|
|
46
|
+
// launchd agent that records live sessions every minute. It has to live
|
|
47
|
+
// outside both cmux and the ccakashic server: a hung cmux takes the server
|
|
48
|
+
// (usually started inside it) down with it, and the whole point is to have
|
|
49
|
+
// recorded what was running before that happened.
|
|
50
|
+
//
|
|
51
|
+
// ccakashic is mostly run through npx, whose cache directory npm may clear or
|
|
52
|
+
// replace on any version bump. Pointing launchd at it would make the agent stop
|
|
53
|
+
// silently — noticed only after the next crash. So the agent runs its own copy
|
|
54
|
+
// of the compiled snapshot module (Node built-ins only) under ~/.config.
|
|
55
|
+
exports.AGENT_LABEL = 'com.ccakashic.snapshot';
|
|
56
|
+
exports.AGENT_PLIST = path.join(os.homedir(), 'Library', 'LaunchAgents', `${exports.AGENT_LABEL}.plist`);
|
|
57
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'ccakashic');
|
|
58
|
+
const LOG_FILE = path.join(CONFIG_DIR, 'snapshot.log');
|
|
59
|
+
exports.AGENT_DIR = path.join(CONFIG_DIR, 'agent');
|
|
60
|
+
const AGENT_MODULE = path.join(exports.AGENT_DIR, 'snapshot.js');
|
|
61
|
+
const AGENT_RUNNER = path.join(exports.AGENT_DIR, 'run.js');
|
|
62
|
+
const RUNNER_SOURCE = `// Written by \`ccakashic install-agent\`; run by launchd every minute.
|
|
63
|
+
require('./snapshot.js').snapshotNow();
|
|
64
|
+
`;
|
|
65
|
+
const INTERVAL_SEC = 60;
|
|
66
|
+
function xmlEscape(s) {
|
|
67
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
68
|
+
}
|
|
69
|
+
function buildPlist(nodePath, runnerPath) {
|
|
70
|
+
const args = [nodePath, runnerPath].map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
|
|
71
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
72
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
73
|
+
<plist version="1.0">
|
|
74
|
+
<dict>
|
|
75
|
+
<key>Label</key>
|
|
76
|
+
<string>${exports.AGENT_LABEL}</string>
|
|
77
|
+
<key>ProgramArguments</key>
|
|
78
|
+
<array>
|
|
79
|
+
${args}
|
|
80
|
+
</array>
|
|
81
|
+
<key>StartInterval</key>
|
|
82
|
+
<integer>${INTERVAL_SEC}</integer>
|
|
83
|
+
<key>RunAtLoad</key>
|
|
84
|
+
<true/>
|
|
85
|
+
<key>ProcessType</key>
|
|
86
|
+
<string>Background</string>
|
|
87
|
+
<key>StandardErrorPath</key>
|
|
88
|
+
<string>${xmlEscape(LOG_FILE)}</string>
|
|
89
|
+
</dict>
|
|
90
|
+
</plist>
|
|
91
|
+
`;
|
|
92
|
+
}
|
|
93
|
+
function launchctl(args) {
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
(0, child_process_1.execFile)('launchctl', args, { timeout: 10_000 }, (err, stdout, stderr) => {
|
|
96
|
+
resolve({ ok: !err, out: `${stdout}${stderr}`.trim() });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
// process.execPath is often version-pinned (e.g. ~/.volta/tools/image/node/24.x
|
|
101
|
+
// or an nvm version dir) and vanishes on the next upgrade, silently stopping the
|
|
102
|
+
// agent. A `node` elsewhere on PATH is usually a stable shim or symlink
|
|
103
|
+
// (~/.volta/bin, /opt/homebrew/bin). Version managers put the pinned dir itself
|
|
104
|
+
// first on PATH while running node, so that one is skipped.
|
|
105
|
+
function stableNodePath() {
|
|
106
|
+
const pinnedDir = path.dirname(process.execPath);
|
|
107
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
108
|
+
if (!dir || path.resolve(dir) === pinnedDir)
|
|
109
|
+
continue;
|
|
110
|
+
const candidate = path.join(dir, 'node');
|
|
111
|
+
try {
|
|
112
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
113
|
+
return candidate;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// not here
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return process.execPath;
|
|
120
|
+
}
|
|
121
|
+
const domain = () => `gui/${process.getuid ? process.getuid() : 501}`;
|
|
122
|
+
// The compiled snapshot module that shipped with this ccakashic.
|
|
123
|
+
const bundledModule = () => path.join(__dirname, 'snapshot.js');
|
|
124
|
+
// Write the agent's copy; returns true when anything changed. Atomic per file
|
|
125
|
+
// so a tick firing mid-update never loads a truncated module.
|
|
126
|
+
function writeAgentFiles() {
|
|
127
|
+
let changed = false;
|
|
128
|
+
fs.mkdirSync(exports.AGENT_DIR, { recursive: true });
|
|
129
|
+
for (const [dest, content] of [
|
|
130
|
+
[AGENT_MODULE, fs.readFileSync(bundledModule(), 'utf-8')],
|
|
131
|
+
[AGENT_RUNNER, RUNNER_SOURCE],
|
|
132
|
+
]) {
|
|
133
|
+
try {
|
|
134
|
+
if (fs.readFileSync(dest, 'utf-8') === content)
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// not written yet
|
|
139
|
+
}
|
|
140
|
+
const tmp = `${dest}.${process.pid}.tmp`;
|
|
141
|
+
fs.writeFileSync(tmp, content);
|
|
142
|
+
fs.renameSync(tmp, dest);
|
|
143
|
+
changed = true;
|
|
144
|
+
}
|
|
145
|
+
return changed;
|
|
146
|
+
}
|
|
147
|
+
// Keep an installed agent's copy in step with the ccakashic being run, so a
|
|
148
|
+
// newer version's snapshot format reaches the agent without a reinstall. The
|
|
149
|
+
// plist points at a fixed path, so launchd needs no reload.
|
|
150
|
+
function refreshInstalledAgent() {
|
|
151
|
+
if (!fs.existsSync(exports.AGENT_PLIST))
|
|
152
|
+
return;
|
|
153
|
+
try {
|
|
154
|
+
writeAgentFiles();
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// the previous copy keeps running
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function installAgent() {
|
|
161
|
+
if (process.platform !== 'darwin') {
|
|
162
|
+
throw new Error('The snapshot agent uses launchd and is macOS-only. Run `ccakashic snapshot` from cron instead.');
|
|
163
|
+
}
|
|
164
|
+
writeAgentFiles();
|
|
165
|
+
fs.mkdirSync(path.dirname(exports.AGENT_PLIST), { recursive: true });
|
|
166
|
+
const nodePath = stableNodePath();
|
|
167
|
+
fs.writeFileSync(exports.AGENT_PLIST, buildPlist(nodePath, AGENT_RUNNER));
|
|
168
|
+
// Re-install cleanly: bootout fails harmlessly when not loaded yet.
|
|
169
|
+
await launchctl(['bootout', `${domain()}/${exports.AGENT_LABEL}`]);
|
|
170
|
+
const res = await launchctl(['bootstrap', domain(), exports.AGENT_PLIST]);
|
|
171
|
+
if (!res.ok)
|
|
172
|
+
throw new Error(`launchctl bootstrap failed: ${res.out}`);
|
|
173
|
+
console.log(`Installed ${exports.AGENT_PLIST} (node: ${nodePath})`);
|
|
174
|
+
console.log(`Recording live sessions every ${INTERVAL_SEC}s → ${snapshot_1.SNAPSHOT_FILE}`);
|
|
175
|
+
console.log(`(runs its own copy in ${exports.AGENT_DIR}, so clearing the npx cache won't stop it)`);
|
|
176
|
+
}
|
|
177
|
+
async function uninstallAgent() {
|
|
178
|
+
await launchctl(['bootout', `${domain()}/${exports.AGENT_LABEL}`]);
|
|
179
|
+
fs.rmSync(exports.AGENT_DIR, { recursive: true, force: true });
|
|
180
|
+
try {
|
|
181
|
+
fs.unlinkSync(exports.AGENT_PLIST);
|
|
182
|
+
console.log(`Removed ${exports.AGENT_PLIST}`);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
console.log('Agent was not installed');
|
|
186
|
+
}
|
|
187
|
+
}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_SESSION_LIMIT = exports.DEFAULT_SESSION_LIMIT = void 0;
|
|
4
|
+
exports.toSessionRow = toSessionRow;
|
|
5
|
+
exports.parseSessionLimit = parseSessionLimit;
|
|
6
|
+
exports.orderSessionRows = orderSessionRows;
|
|
7
|
+
const dashboard_1 = require("./dashboard");
|
|
8
|
+
const cmux_1 = require("./cmux");
|
|
9
|
+
// Read-only JSON view of what the dashboard already computes, so other local
|
|
10
|
+
// tools can treat ccakashic as the source of truth for "which session is
|
|
11
|
+
// waiting for me" instead of re-deriving it from ~/.claude. The waiting signal
|
|
12
|
+
// in particular cannot be rebuilt elsewhere: it maps cmux's unread
|
|
13
|
+
// notifications through ccakashic's own resume map.
|
|
14
|
+
exports.DEFAULT_SESSION_LIMIT = 40;
|
|
15
|
+
// Each row costs a full session-file read, so cap what one request can ask for.
|
|
16
|
+
exports.MAX_SESSION_LIMIT = 100;
|
|
17
|
+
const PREVIEW_MAX = 200;
|
|
18
|
+
function toSessionRow(session, waiting) {
|
|
19
|
+
return {
|
|
20
|
+
id: session.id,
|
|
21
|
+
projectRawName: session.projectRawName,
|
|
22
|
+
projectName: session.projectName,
|
|
23
|
+
cwd: session.cwd,
|
|
24
|
+
title: (0, dashboard_1.paneTitle)(session),
|
|
25
|
+
preview: (session.preview || '').slice(0, PREVIEW_MAX),
|
|
26
|
+
gitBranch: session.gitBranch,
|
|
27
|
+
model: session.model,
|
|
28
|
+
lastModified: session.lastModified,
|
|
29
|
+
status: (0, dashboard_1.paneStatus)(session.lastModified),
|
|
30
|
+
waiting,
|
|
31
|
+
detailUrl: `/project/${encodeURIComponent(session.projectRawName)}/session/${encodeURIComponent(session.id)}`,
|
|
32
|
+
// /api/resume is POST-only and requires the CSRF token, so there is no GET
|
|
33
|
+
// URL to hand out — and publishing that token from an unauthenticated
|
|
34
|
+
// endpoint would defeat it. Callers that want to act on a session either
|
|
35
|
+
// open detailUrl and press Resume, or run resumeCommand themselves.
|
|
36
|
+
resumeUrl: null,
|
|
37
|
+
resumeCommand: session.cwd ? (0, cmux_1.buildResumeCommand)(session.cwd, session.id) : null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function parseSessionLimit(raw) {
|
|
41
|
+
const n = parseInt(raw || '', 10);
|
|
42
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
43
|
+
return exports.DEFAULT_SESSION_LIMIT;
|
|
44
|
+
return Math.min(n, exports.MAX_SESSION_LIMIT);
|
|
45
|
+
}
|
|
46
|
+
function orderSessionRows(rows, limit) {
|
|
47
|
+
return rows.slice().sort((a, b) => b.lastModified - a.lastModified).slice(0, limit);
|
|
48
|
+
}
|
package/dist/bin/ccakashic.js
CHANGED
|
@@ -38,13 +38,18 @@ 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"));
|
|
41
42
|
const child_process_1 = require("child_process");
|
|
42
43
|
const util_1 = require("../util");
|
|
43
44
|
const discover_1 = require("../discover");
|
|
45
|
+
const api_1 = require("../api");
|
|
44
46
|
const parser_1 = require("../parser");
|
|
45
47
|
const html_generator_1 = require("../html-generator");
|
|
46
48
|
const pages_1 = require("../pages");
|
|
47
49
|
const dashboard_1 = require("../dashboard");
|
|
50
|
+
const snapshot_1 = require("../snapshot");
|
|
51
|
+
const restore_1 = require("../restore");
|
|
52
|
+
const agent_1 = require("../agent");
|
|
48
53
|
const cmux_1 = require("../cmux");
|
|
49
54
|
// Published at dist/bin/ccakashic.js, so ../../package.json resolves from dist/
|
|
50
55
|
const pkg = __importStar(require("../../package.json"));
|
|
@@ -100,16 +105,25 @@ async function buildResumeContext() {
|
|
|
100
105
|
}
|
|
101
106
|
return { token: RESUME_TOKEN, cmuxAvailable, openSessionIds };
|
|
102
107
|
}
|
|
103
|
-
// sessionId → wait reason, from cmux's unread notifications
|
|
104
|
-
//
|
|
105
|
-
//
|
|
108
|
+
// sessionId → wait reason, from cmux's unread notifications resolved through
|
|
109
|
+
// two independent workspace→session sources. Empty when cmux is
|
|
110
|
+
// unavailable/disabled.
|
|
106
111
|
async function buildCmuxWaitMap() {
|
|
107
112
|
const result = new Map();
|
|
108
113
|
if (NO_CMUX || !(await (0, cmux_1.isCmuxAvailable)()))
|
|
109
114
|
return result;
|
|
110
115
|
try {
|
|
111
116
|
const waiting = await (0, cmux_1.listWaitingWorkspacesCached)();
|
|
117
|
+
// The resume map only covers sessions ccakashic resumed, which left every
|
|
118
|
+
// hand-started session permanently unbadged. The live map reads
|
|
119
|
+
// CMUX_WORKSPACE_ID from each running session's own process and covers
|
|
120
|
+
// those. They complement each other — the resume map still resolves
|
|
121
|
+
// sessions that have since exited — so the live one is layered on top,
|
|
122
|
+
// winning conflicts because it reflects the process attached right now.
|
|
112
123
|
const wsToSession = (0, cmux_1.loadWorkspaceToSession)();
|
|
124
|
+
for (const [wsId, sessionId] of await (0, cmux_1.liveWorkspaceToSessionCached)()) {
|
|
125
|
+
wsToSession.set(wsId, sessionId);
|
|
126
|
+
}
|
|
113
127
|
for (const [wsId, reason] of waiting) {
|
|
114
128
|
const sessionId = wsToSession.get(wsId);
|
|
115
129
|
if (sessionId)
|
|
@@ -214,6 +228,58 @@ async function handleResume(req, res) {
|
|
|
214
228
|
});
|
|
215
229
|
}
|
|
216
230
|
}
|
|
231
|
+
// The stopped group the dashboard offers to bring back, unless dismissed.
|
|
232
|
+
async function buildRestoreBanner() {
|
|
233
|
+
try {
|
|
234
|
+
const { state, stop } = (0, restore_1.detectLastStop)();
|
|
235
|
+
if (!stop || !stop.sessions.length || stop.stoppedAt === state.dismissedStopAt)
|
|
236
|
+
return undefined;
|
|
237
|
+
const cmuxAvailable = !NO_CMUX && await (0, cmux_1.isCmuxAvailable)();
|
|
238
|
+
return {
|
|
239
|
+
token: RESUME_TOKEN,
|
|
240
|
+
stoppedAt: stop.stoppedAt,
|
|
241
|
+
estimated: !!stop.estimated,
|
|
242
|
+
cmuxAvailable,
|
|
243
|
+
items: await (0, restore_1.describeStopped)(stop.sessions),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return undefined; // the banner is an extra; never break the dashboard over it
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async function handleRestore(req, res, action) {
|
|
251
|
+
const respond = (status, body) => {
|
|
252
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
253
|
+
res.end(JSON.stringify(body));
|
|
254
|
+
};
|
|
255
|
+
if (req.method !== 'POST')
|
|
256
|
+
return respond(405, { error: 'POST only' });
|
|
257
|
+
if (req.headers['x-ccakashic-token'] !== RESUME_TOKEN)
|
|
258
|
+
return respond(403, { error: 'Invalid token' });
|
|
259
|
+
let body;
|
|
260
|
+
try {
|
|
261
|
+
body = await readJsonBody(req);
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
return respond(400, { error: err?.message || 'Bad request' });
|
|
265
|
+
}
|
|
266
|
+
// Recompute rather than trust the page: sessions may have been resumed in the
|
|
267
|
+
// meantime, and resuming a live one again would fork it.
|
|
268
|
+
const { state, stop } = (0, restore_1.detectLastStop)();
|
|
269
|
+
if (!stop || stop.stoppedAt !== body?.stoppedAt) {
|
|
270
|
+
return respond(409, { error: 'The list is out of date — reload the dashboard' });
|
|
271
|
+
}
|
|
272
|
+
if (action === 'dismiss') {
|
|
273
|
+
(0, snapshot_1.saveSnapshot)({ ...state, dismissedStopAt: stop.stoppedAt });
|
|
274
|
+
return respond(200, { ok: true });
|
|
275
|
+
}
|
|
276
|
+
if (NO_CMUX || !(await (0, cmux_1.isCmuxAvailable)()))
|
|
277
|
+
return respond(503, { error: 'cmux is not reachable' });
|
|
278
|
+
const wanted = Array.isArray(body.sessions) ? new Set(body.sessions) : null;
|
|
279
|
+
const chosen = stop.sessions.filter((s) => !wanted || wanted.has(s.sessionId));
|
|
280
|
+
const outcomes = await (0, restore_1.restoreAll)(await (0, restore_1.describeStopped)(chosen));
|
|
281
|
+
return respond(200, { outcomes });
|
|
282
|
+
}
|
|
217
283
|
// Only accept loopback Host headers. The server binds 127.0.0.1, but without
|
|
218
284
|
// this check a malicious site could DNS-rebind its hostname to 127.0.0.1 and
|
|
219
285
|
// become same-origin, defeating the resume token and reading session content.
|
|
@@ -241,6 +307,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
241
307
|
await handleResume(req, res);
|
|
242
308
|
return;
|
|
243
309
|
}
|
|
310
|
+
if (pathname === '/api/restore' || pathname === '/api/restore/dismiss') {
|
|
311
|
+
await handleRestore(req, res, pathname === '/api/restore' ? 'run' : 'dismiss');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
244
314
|
if (pathname === '/' || pathname === '') {
|
|
245
315
|
const requested = parseInt(url.searchParams.get('n') || '') || dashboard_1.DEFAULT_PANE_COUNT;
|
|
246
316
|
const paneCount = dashboard_1.PANE_COUNTS.includes(requested) ? requested : dashboard_1.DEFAULT_PANE_COUNT;
|
|
@@ -252,7 +322,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
252
322
|
waiting: resolveWaiting(session.id, cmuxWait),
|
|
253
323
|
})));
|
|
254
324
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
255
|
-
res.end((0, dashboard_1.generateDashboard)(panes, paneCount, await buildResumeContext()));
|
|
325
|
+
res.end((0, dashboard_1.generateDashboard)(panes, paneCount, await buildResumeContext(), await buildRestoreBanner()));
|
|
256
326
|
return;
|
|
257
327
|
}
|
|
258
328
|
if (pathname === '/projects') {
|
|
@@ -291,6 +361,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
291
361
|
res.end(JSON.stringify({ changed: true, mtime, status, ago, waiting, html: (0, dashboard_1.renderPaneBody)(parsed) }));
|
|
292
362
|
return;
|
|
293
363
|
}
|
|
364
|
+
// Read-only feed of the dashboard's own view, for other local tools.
|
|
365
|
+
if (pathname === '/api/sessions') {
|
|
366
|
+
const limit = (0, api_1.parseSessionLimit)(url.searchParams.get('limit'));
|
|
367
|
+
const waitingOnly = url.searchParams.get('waiting') === '1';
|
|
368
|
+
const cmuxWait = await buildCmuxWaitMap();
|
|
369
|
+
// Waiting sessions are fetched by id rather than filtered out of the
|
|
370
|
+
// recent window: a session can sit waiting while other projects churn
|
|
371
|
+
// past it, and reading a wide window means parsing every file in it.
|
|
372
|
+
const sessions = waitingOnly
|
|
373
|
+
? await (0, discover_1.findRecentSessionsByIds)([...cmuxWait.keys()])
|
|
374
|
+
: await (0, discover_1.listRecentSessions)(limit);
|
|
375
|
+
const rows = (0, api_1.orderSessionRows)(sessions
|
|
376
|
+
.map((s) => (0, api_1.toSessionRow)(s, resolveWaiting(s.id, cmuxWait)))
|
|
377
|
+
.filter((r) => !waitingOnly || r.waiting !== null), limit);
|
|
378
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
379
|
+
res.end(JSON.stringify(rows));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
294
382
|
const projectMatch = pathname.match(/^\/project\/(.+)$/);
|
|
295
383
|
if (projectMatch && !pathname.includes('/session/')) {
|
|
296
384
|
const rawName = decodeURIComponent(projectMatch[1]);
|
|
@@ -441,7 +529,91 @@ async function startServer(startPort) {
|
|
|
441
529
|
}
|
|
442
530
|
throw new Error(`No available port after ${MAX_PORT_TRIES} tries starting at ${startPort}`);
|
|
443
531
|
}
|
|
532
|
+
function formatClock(ms) {
|
|
533
|
+
const d = new Date(ms);
|
|
534
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
535
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
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
|
+
async function runRestoreCommand() {
|
|
547
|
+
const dryRun = process.argv.includes('--dry-run');
|
|
548
|
+
const yes = process.argv.includes('--yes') || process.argv.includes('-y');
|
|
549
|
+
const previousRun = (0, snapshot_1.loadSnapshot)().lastRunAt;
|
|
550
|
+
const { stop, agentInstalled } = (0, restore_1.detectLastStop)();
|
|
551
|
+
if (stop?.estimated) {
|
|
552
|
+
console.log('Note: estimated from conversation logs. Sessions left idle before cmux was force-quit may be missing.');
|
|
553
|
+
console.log(agentInstalled
|
|
554
|
+
? ' (these closed before the snapshot agent started recording)\n'
|
|
555
|
+
: ' Run `npx ccakashic install-agent` to record live sessions every minute for an exact list.\n');
|
|
556
|
+
}
|
|
557
|
+
else if (!agentInstalled) {
|
|
558
|
+
console.log('Tip: `npx ccakashic install-agent` records open sessions every minute for an exact list.\n');
|
|
559
|
+
}
|
|
560
|
+
else if (previousRun && Date.now() - previousRun > 5 * 60_000) {
|
|
561
|
+
console.log(`Note: the last snapshot before this one was at ${formatClock(previousRun)}.\n`);
|
|
562
|
+
}
|
|
563
|
+
if (!stop || !stop.sessions.length) {
|
|
564
|
+
console.log('Nothing to reopen: no closed sessions found (or some from that time are still running).');
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
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('');
|
|
572
|
+
if (dryRun)
|
|
573
|
+
return;
|
|
574
|
+
if (NO_CMUX || !(await (0, cmux_1.isCmuxAvailable)())) {
|
|
575
|
+
console.log('cmux is not reachable (run this from a terminal inside cmux). Commands to resume by hand:\n');
|
|
576
|
+
for (const it of items)
|
|
577
|
+
console.log((0, cmux_1.buildResumeCommand)(it.cwd, it.sessionId));
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
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) => {
|
|
583
|
+
console.log(o.ok ? ` ✓ ${o.title}` : ` ✗ ${o.title} — ${o.message}`);
|
|
584
|
+
});
|
|
585
|
+
const failed = outcomes.filter((o) => !o.ok).length;
|
|
586
|
+
console.log(`\nReopened ${outcomes.length - failed} / ${outcomes.length}.`);
|
|
587
|
+
}
|
|
588
|
+
async function runSubcommand(name) {
|
|
589
|
+
switch (name) {
|
|
590
|
+
case 'snapshot': {
|
|
591
|
+
// One-off record; the launchd agent runs its own copy of the same code.
|
|
592
|
+
(0, snapshot_1.snapshotNow)();
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
case 'restore':
|
|
596
|
+
await runRestoreCommand();
|
|
597
|
+
return true;
|
|
598
|
+
case 'install-agent':
|
|
599
|
+
await (0, agent_1.installAgent)();
|
|
600
|
+
return true;
|
|
601
|
+
case 'uninstall-agent':
|
|
602
|
+
await (0, agent_1.uninstallAgent)();
|
|
603
|
+
return true;
|
|
604
|
+
default:
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
444
608
|
async function main() {
|
|
609
|
+
(0, agent_1.refreshInstalledAgent)();
|
|
610
|
+
const sub = process.argv[2];
|
|
611
|
+
if (sub && !sub.startsWith('-')) {
|
|
612
|
+
if (await runSubcommand(sub))
|
|
613
|
+
return;
|
|
614
|
+
console.error(`Unknown command: ${sub}\nUsage: ccakashic [restore [--dry-run] [--yes] | install-agent | uninstall-agent | snapshot]`);
|
|
615
|
+
process.exit(1);
|
|
616
|
+
}
|
|
445
617
|
const existing = await findExistingCcakashic(PORT);
|
|
446
618
|
if (existing) {
|
|
447
619
|
const url = `http://127.0.0.1:${existing}`;
|
package/dist/cmux.js
CHANGED
|
@@ -49,6 +49,10 @@ exports.openInCmuxBrowser = openInCmuxBrowser;
|
|
|
49
49
|
exports.loadResumeMap = loadResumeMap;
|
|
50
50
|
exports.saveResumeMapEntry = saveResumeMapEntry;
|
|
51
51
|
exports.loadWorkspaceToSession = loadWorkspaceToSession;
|
|
52
|
+
exports.loadSessionRegistry = loadSessionRegistry;
|
|
53
|
+
exports.parseWorkspaceEnv = parseWorkspaceEnv;
|
|
54
|
+
exports.liveWorkspaceToSession = liveWorkspaceToSession;
|
|
55
|
+
exports.liveWorkspaceToSessionCached = liveWorkspaceToSessionCached;
|
|
52
56
|
exports.findLiveWorkspaceForSession = findLiveWorkspaceForSession;
|
|
53
57
|
const child_process_1 = require("child_process");
|
|
54
58
|
const fs = __importStar(require("fs"));
|
|
@@ -260,6 +264,124 @@ function loadWorkspaceToSession() {
|
|
|
260
264
|
}
|
|
261
265
|
return inv;
|
|
262
266
|
}
|
|
267
|
+
// --- Live workspace mapping, read from the running processes themselves ---
|
|
268
|
+
//
|
|
269
|
+
// The resume map only knows about sessions ccakashic itself resumed, so a
|
|
270
|
+
// session you started by hand inside cmux has no workspace mapping and never
|
|
271
|
+
// gets a waiting badge. Claude Code registers every running session in
|
|
272
|
+
// ~/.claude/sessions/<pid>.json, and a session launched inside cmux inherits
|
|
273
|
+
// CMUX_WORKSPACE_ID in its environment — together those give the same mapping
|
|
274
|
+
// without ccakashic having been involved.
|
|
275
|
+
//
|
|
276
|
+
// The two sources are complements, not replacements: this one sees only live
|
|
277
|
+
// processes, while the resume map still covers sessions that have since exited.
|
|
278
|
+
const SESSION_REGISTRY_DIR = path.join(os.homedir(), '.claude', 'sessions');
|
|
279
|
+
function loadSessionRegistry() {
|
|
280
|
+
let names;
|
|
281
|
+
try {
|
|
282
|
+
names = fs.readdirSync(SESSION_REGISTRY_DIR);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return []; // no registry (older Claude Code, or nothing has run yet)
|
|
286
|
+
}
|
|
287
|
+
const out = [];
|
|
288
|
+
for (const f of names) {
|
|
289
|
+
if (!f.endsWith('.json'))
|
|
290
|
+
continue;
|
|
291
|
+
try {
|
|
292
|
+
const o = JSON.parse(fs.readFileSync(path.join(SESSION_REGISTRY_DIR, f), 'utf-8'));
|
|
293
|
+
if (typeof o?.pid === 'number' && typeof o?.sessionId === 'string') {
|
|
294
|
+
out.push({ pid: o.pid, sessionId: o.sessionId, procStart: o.procStart });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// a half-written or stale record; skip it
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
function isProcessAlive(pid) {
|
|
304
|
+
try {
|
|
305
|
+
process.kill(pid, 0); // signal 0 only probes; it does not signal
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// `ps eww` prints one line per process: pid, then the command, then the
|
|
313
|
+
// process's ENTIRE environment — which routinely holds API keys and tokens.
|
|
314
|
+
// Only CMUX_WORKSPACE_ID is ever pulled out of it; no other variable is
|
|
315
|
+
// stored, returned or logged, and the raw output is not retained.
|
|
316
|
+
function parseWorkspaceEnv(psOutput) {
|
|
317
|
+
const byPid = new Map();
|
|
318
|
+
for (const line of psOutput.split('\n')) {
|
|
319
|
+
const pid = line.match(/^\s*(\d+)\s/);
|
|
320
|
+
if (!pid)
|
|
321
|
+
continue;
|
|
322
|
+
const ws = line.match(/\bCMUX_WORKSPACE_ID=([A-Za-z0-9-]+)/);
|
|
323
|
+
if (ws)
|
|
324
|
+
byPid.set(parseInt(pid[1], 10), ws[1].toUpperCase());
|
|
325
|
+
}
|
|
326
|
+
return byPid;
|
|
327
|
+
}
|
|
328
|
+
function runPlain(bin, args, timeoutMs = 3000) {
|
|
329
|
+
return new Promise((resolve) => {
|
|
330
|
+
(0, child_process_1.execFile)(bin, args, { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
|
|
331
|
+
resolve(err ? '' : stdout);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
// A process's environment is fixed at exec time, so a pid only ever needs
|
|
336
|
+
// looking up once. Keyed with procStart as well so a recycled pid can't
|
|
337
|
+
// inherit the previous process's answer. null means "checked, not a cmux
|
|
338
|
+
// process" — cached too, so those aren't re-probed on every poll.
|
|
339
|
+
const workspaceEnvCache = new Map();
|
|
340
|
+
const envKey = (r) => `${r.pid}:${r.procStart ?? ''}`;
|
|
341
|
+
async function liveWorkspaceToSession() {
|
|
342
|
+
const live = loadSessionRegistry().filter((r) => isProcessAlive(r.pid));
|
|
343
|
+
const result = new Map();
|
|
344
|
+
const unknown = [];
|
|
345
|
+
for (const r of live) {
|
|
346
|
+
const key = envKey(r);
|
|
347
|
+
if (workspaceEnvCache.has(key)) {
|
|
348
|
+
const ws = workspaceEnvCache.get(key);
|
|
349
|
+
if (ws)
|
|
350
|
+
result.set(ws, r.sessionId);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
unknown.push(r);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (unknown.length) {
|
|
357
|
+
// One ps for every new pid at once, not one spawn per session.
|
|
358
|
+
const out = await runPlain('ps', ['eww', '-p', unknown.map((r) => r.pid).join(',')]);
|
|
359
|
+
const byPid = parseWorkspaceEnv(out);
|
|
360
|
+
for (const r of unknown) {
|
|
361
|
+
const ws = byPid.get(r.pid) ?? null;
|
|
362
|
+
workspaceEnvCache.set(envKey(r), ws);
|
|
363
|
+
if (ws)
|
|
364
|
+
result.set(ws, r.sessionId);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Drop entries for processes that are gone, so a long-lived server doesn't
|
|
368
|
+
// accumulate one per session ever started.
|
|
369
|
+
const alive = new Set(live.map(envKey));
|
|
370
|
+
for (const key of workspaceEnvCache.keys()) {
|
|
371
|
+
if (!alive.has(key))
|
|
372
|
+
workspaceEnvCache.delete(key);
|
|
373
|
+
}
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
let cachedLiveWorkspaces = null;
|
|
377
|
+
async function liveWorkspaceToSessionCached() {
|
|
378
|
+
if (cachedLiveWorkspaces && Date.now() - cachedLiveWorkspaces.at < 5_000) {
|
|
379
|
+
return cachedLiveWorkspaces.value;
|
|
380
|
+
}
|
|
381
|
+
const value = await liveWorkspaceToSession();
|
|
382
|
+
cachedLiveWorkspaces = { value, at: Date.now() };
|
|
383
|
+
return value;
|
|
384
|
+
}
|
|
263
385
|
async function findLiveWorkspaceForSession(sessionId) {
|
|
264
386
|
const mapped = loadResumeMap()[sessionId];
|
|
265
387
|
if (!mapped)
|
package/dist/dashboard.js
CHANGED
|
@@ -4,12 +4,15 @@ exports.DEFAULT_PANE_COUNT = exports.PANE_COUNTS = void 0;
|
|
|
4
4
|
exports.timeAgo = timeAgo;
|
|
5
5
|
exports.paneStatus = paneStatus;
|
|
6
6
|
exports.renderPaneBody = renderPaneBody;
|
|
7
|
+
exports.paneTitle = paneTitle;
|
|
7
8
|
exports.waitBadgeHtml = waitBadgeHtml;
|
|
9
|
+
exports.restoreBannerHtml = restoreBannerHtml;
|
|
8
10
|
exports.generateDashboard = generateDashboard;
|
|
9
11
|
const template_assets_1 = require("./template-assets");
|
|
10
12
|
const html_generator_1 = require("./html-generator");
|
|
11
13
|
const resume_ui_1 = require("./resume-ui");
|
|
12
14
|
const util_1 = require("./util");
|
|
15
|
+
const cmux_1 = require("./cmux");
|
|
13
16
|
// Multi-pane dashboard: the N most recently active sessions across all
|
|
14
17
|
// projects, each pane showing the last 24h of conversation as a scrollable
|
|
15
18
|
// thread, refreshed by polling /api/pane.
|
|
@@ -67,7 +70,30 @@ function waitBadgeHtml(waiting) {
|
|
|
67
70
|
const label = waiting === 'permission' ? '\u{1F510} Permission' : '⏳ Your turn';
|
|
68
71
|
return `<span class="dash-wait-badge dash-wait-${waiting}">${label}</span>`;
|
|
69
72
|
}
|
|
70
|
-
function
|
|
73
|
+
function restoreBannerHtml(banner) {
|
|
74
|
+
if (!banner || !banner.items.length)
|
|
75
|
+
return '';
|
|
76
|
+
const n = banner.items.length;
|
|
77
|
+
const rows = banner.items.map((it) => {
|
|
78
|
+
const title = it.projectRawName
|
|
79
|
+
? `<a href="/project/${encodeURIComponent(it.projectRawName)}/session/${encodeURIComponent(it.sessionId)}">${(0, util_1.escapeHtml)(it.title)}</a>`
|
|
80
|
+
: (0, util_1.escapeHtml)(it.title);
|
|
81
|
+
return `<li><label><input type="checkbox" class="dash-restore-check" value="${(0, util_1.escapeHtml)(it.sessionId)}" data-cmd="${(0, util_1.escapeHtml)((0, cmux_1.buildResumeCommand)(it.cwd, it.sessionId))}" checked> ${title}</label> <span class="dash-restore-cwd">${(0, util_1.escapeHtml)(it.cwd)}</span></li>`;
|
|
82
|
+
}).join('');
|
|
83
|
+
const action = banner.cmuxAvailable
|
|
84
|
+
? `<button type="button" class="dash-restore-run">▶ Reopen selected</button>`
|
|
85
|
+
: `<button type="button" class="dash-restore-copy">📋 Copy commands</button>`;
|
|
86
|
+
return `<div class="dash-restore" data-stopped-at="${banner.stoppedAt}" data-token="${(0, util_1.escapeHtml)(banner.token)}">
|
|
87
|
+
<div class="dash-restore-head">
|
|
88
|
+
<span>↺ <b>${n} session${n === 1 ? '' : 's'}</b> you had open until <span class="dash-restore-at" data-ts="${banner.stoppedAt}"></span>${banner.estimated ? ' <span class="dash-restore-est" title="Estimated from conversation logs; sessions left idle before cmux was force-quit may be missing. Run `npx ccakashic install-agent` for an exact list.">(estimated)</span>' : ''}</span>
|
|
89
|
+
${action}
|
|
90
|
+
<button type="button" class="dash-restore-dismiss">Dismiss</button>
|
|
91
|
+
<span class="dash-restore-status"></span>
|
|
92
|
+
</div>
|
|
93
|
+
<ul class="dash-restore-list">${rows}</ul>
|
|
94
|
+
</div>`;
|
|
95
|
+
}
|
|
96
|
+
function generateDashboard(panes, paneCount, resume, restore) {
|
|
71
97
|
const cols = paneCount <= 4 ? Math.max(panes.length, 1) : Math.ceil(paneCount / 2);
|
|
72
98
|
const rows = paneCount <= 4 ? 1 : 2;
|
|
73
99
|
const panesHtml = panes.map(({ session: s, bodyHtml, waiting }) => {
|
|
@@ -110,6 +136,7 @@ ${dashboardCSS(cols, rows)}
|
|
|
110
136
|
<span class="dash-counts">Panes: ${countLinks}</span>
|
|
111
137
|
<a class="dash-nav-link" href="/projects">All projects →</a>
|
|
112
138
|
</div>
|
|
139
|
+
${restoreBannerHtml(restore)}
|
|
113
140
|
<div class="dash-grid">
|
|
114
141
|
${panesHtml || '<div class="empty">No sessions found</div>'}
|
|
115
142
|
</div>
|
|
@@ -251,6 +278,36 @@ function dashboardCSS(cols, rows) {
|
|
|
251
278
|
}
|
|
252
279
|
.empty { text-align: center; color: var(--text-muted); padding: 40px; }
|
|
253
280
|
|
|
281
|
+
.dash-restore {
|
|
282
|
+
flex-shrink: 0;
|
|
283
|
+
max-height: 40vh;
|
|
284
|
+
overflow-y: auto;
|
|
285
|
+
margin: 8px 8px 0;
|
|
286
|
+
padding: 8px 12px;
|
|
287
|
+
border: 1px solid #f97316;
|
|
288
|
+
border-radius: 8px;
|
|
289
|
+
background: rgba(249, 115, 22, 0.08);
|
|
290
|
+
font-size: 0.82rem;
|
|
291
|
+
}
|
|
292
|
+
.dash-restore-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
293
|
+
.dash-restore-head button {
|
|
294
|
+
font-size: 0.75rem;
|
|
295
|
+
font-weight: 600;
|
|
296
|
+
padding: 3px 10px;
|
|
297
|
+
border-radius: 5px;
|
|
298
|
+
border: 1px solid var(--border);
|
|
299
|
+
background: var(--tool-bg);
|
|
300
|
+
color: var(--text);
|
|
301
|
+
cursor: pointer;
|
|
302
|
+
}
|
|
303
|
+
.dash-restore-head .dash-restore-run { border-color: #f97316; }
|
|
304
|
+
.dash-restore-head button:disabled { opacity: 0.5; cursor: wait; }
|
|
305
|
+
.dash-restore-status, .dash-restore-est { color: var(--text-muted); }
|
|
306
|
+
.dash-restore-list { list-style: none; margin: 6px 0 0; padding: 0; columns: 2 360px; }
|
|
307
|
+
.dash-restore-list li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 1px 0; }
|
|
308
|
+
.dash-restore-list a { color: var(--text); }
|
|
309
|
+
.dash-restore-cwd { color: var(--text-muted); font-size: 0.72rem; margin-left: 4px; }
|
|
310
|
+
|
|
254
311
|
@media (max-width: 900px) {
|
|
255
312
|
.dash-page { height: auto; overflow: auto; }
|
|
256
313
|
.dash-grid { grid-template-columns: 1fr; grid-template-rows: none; grid-auto-rows: 70vh; }
|
|
@@ -321,6 +378,46 @@ function dashboardJS() {
|
|
|
321
378
|
}).catch(function() { /* server briefly unavailable; retry next tick */ });
|
|
322
379
|
}
|
|
323
380
|
|
|
381
|
+
var restore = document.querySelector('.dash-restore');
|
|
382
|
+
if (restore) {
|
|
383
|
+
var at = restore.querySelector('.dash-restore-at');
|
|
384
|
+
if (at) at.textContent = new Date(Number(at.dataset.ts)).toLocaleString();
|
|
385
|
+
var status = restore.querySelector('.dash-restore-status');
|
|
386
|
+
var checked = function() {
|
|
387
|
+
return Array.prototype.slice.call(restore.querySelectorAll('.dash-restore-check:checked'));
|
|
388
|
+
};
|
|
389
|
+
var post = function(path, body) {
|
|
390
|
+
body.stoppedAt = Number(restore.dataset.stoppedAt);
|
|
391
|
+
return fetch(path, {
|
|
392
|
+
method: 'POST',
|
|
393
|
+
headers: { 'Content-Type': 'application/json', 'X-Ccakashic-Token': restore.dataset.token },
|
|
394
|
+
body: JSON.stringify(body)
|
|
395
|
+
}).then(function(res) { return res.json(); });
|
|
396
|
+
};
|
|
397
|
+
restore.addEventListener('click', function(e) {
|
|
398
|
+
var btn = e.target.closest ? e.target.closest('button') : null;
|
|
399
|
+
if (!btn) return;
|
|
400
|
+
if (btn.classList.contains('dash-restore-dismiss')) {
|
|
401
|
+
post('/api/restore/dismiss', {}).then(function() { restore.remove(); });
|
|
402
|
+
} else if (btn.classList.contains('dash-restore-copy')) {
|
|
403
|
+
var cmds = checked().map(function(c) { return c.dataset.cmd; }).join('\n');
|
|
404
|
+
navigator.clipboard.writeText(cmds).then(function() { status.textContent = 'Copied'; });
|
|
405
|
+
} else if (btn.classList.contains('dash-restore-run')) {
|
|
406
|
+
var ids = checked().map(function(c) { return c.value; });
|
|
407
|
+
if (!ids.length) return;
|
|
408
|
+
btn.disabled = true;
|
|
409
|
+
status.textContent = 'Reopening ' + ids.length + '…';
|
|
410
|
+
post('/api/restore', { sessions: ids }).then(function(data) {
|
|
411
|
+
if (!data.outcomes) { btn.disabled = false; status.textContent = data.error || 'Reopen failed'; return; }
|
|
412
|
+
var failed = data.outcomes.filter(function(o) { return !o.ok; });
|
|
413
|
+
if (!failed.length) { restore.remove(); return; }
|
|
414
|
+
btn.disabled = false;
|
|
415
|
+
status.textContent = 'Failed: ' + failed.map(function(o) { return o.title + ' (' + o.message + ')'; }).join(', ');
|
|
416
|
+
}).catch(function() { btn.disabled = false; status.textContent = 'Reopen failed: server unreachable'; });
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
324
421
|
syncWaitingIndicator();
|
|
325
422
|
setInterval(function() {
|
|
326
423
|
document.querySelectorAll('.dash-pane').forEach(refreshPane);
|
package/dist/discover.js
CHANGED
|
@@ -38,6 +38,7 @@ exports.decodeDirName = decodeDirName;
|
|
|
38
38
|
exports.listProjects = listProjects;
|
|
39
39
|
exports.listSessions = listSessions;
|
|
40
40
|
exports.listRecentSessions = listRecentSessions;
|
|
41
|
+
exports.findRecentSessionsByIds = findRecentSessionsByIds;
|
|
41
42
|
exports.readCwdFromSession = readCwdFromSession;
|
|
42
43
|
exports.findSessionForCwd = findSessionForCwd;
|
|
43
44
|
const fs = __importStar(require("fs"));
|
|
@@ -232,6 +233,32 @@ async function listRecentSessions(limit) {
|
|
|
232
233
|
projectName: top[i].scan.name,
|
|
233
234
|
}));
|
|
234
235
|
}
|
|
236
|
+
// Locate specific sessions by id. scanProjects only stats filenames, so this
|
|
237
|
+
// parses just the matched files — unlike listRecentSessions, which parses every
|
|
238
|
+
// file in its window (getSessionPreview reads a session end to end to total its
|
|
239
|
+
// tokens). Callers that know which ids they want should use this: a session can
|
|
240
|
+
// sit waiting for you while other projects churn past it, so it is not
|
|
241
|
+
// necessarily inside any "most recent N" window.
|
|
242
|
+
async function findRecentSessionsByIds(ids) {
|
|
243
|
+
const wanted = new Set(ids);
|
|
244
|
+
if (!wanted.size)
|
|
245
|
+
return [];
|
|
246
|
+
const scans = await scanProjects();
|
|
247
|
+
const hits = [];
|
|
248
|
+
for (const scan of scans) {
|
|
249
|
+
for (const f of scan.files) {
|
|
250
|
+
if (wanted.has(f.file.replace(/\.jsonl$/, ''))) {
|
|
251
|
+
hits.push({ file: path.join(scan.dir, f.file), scan });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const previews = await Promise.all(hits.map((h) => getSessionPreview(h.file)));
|
|
256
|
+
return previews.map((s, i) => ({
|
|
257
|
+
...s,
|
|
258
|
+
projectRawName: hits[i].scan.rawName,
|
|
259
|
+
projectName: hits[i].scan.name,
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
235
262
|
function readCwdFromSession(filePath) {
|
|
236
263
|
return new Promise((resolve) => {
|
|
237
264
|
const rl = readline.createInterface({
|
package/dist/infer.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.readLogFacts = readLogFacts;
|
|
37
|
+
exports.inferLastStop = inferLastStop;
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const discover_1 = require("./discover");
|
|
41
|
+
// Best-effort "what was running before the crash" from the conversation logs
|
|
42
|
+
// alone, for when the snapshot agent is not installed.
|
|
43
|
+
//
|
|
44
|
+
// What the logs can and cannot tell:
|
|
45
|
+
// - A session that exits (including during a normal shutdown/reboot) appends a
|
|
46
|
+
// `cost-state` record, usually followed by a line or two of bookkeeping. A
|
|
47
|
+
// burst of those right before a gap is a reliable signature.
|
|
48
|
+
// - A session killed outright (cmux force-quit while hung) writes nothing. All
|
|
49
|
+
// that is left is when it was last active, so a session idling for hours
|
|
50
|
+
// before the hang is missed. Results are therefore marked `estimated`.
|
|
51
|
+
// Sessions that exit on the same shutdown finish within seconds of each other.
|
|
52
|
+
const EXIT_BURST_WINDOW_MS = 3 * 60_000;
|
|
53
|
+
// Without exit records, "recently active before the newest one" is the best proxy.
|
|
54
|
+
const ACTIVITY_WINDOW_MS = 15 * 60_000;
|
|
55
|
+
const LOOKBACK_MS = 7 * 24 * 60 * 60_000;
|
|
56
|
+
const CHUNK = 64 * 1024;
|
|
57
|
+
function readChunk(file, fromEnd) {
|
|
58
|
+
let fd;
|
|
59
|
+
try {
|
|
60
|
+
fd = fs.openSync(file, 'r');
|
|
61
|
+
const size = fs.fstatSync(fd).size;
|
|
62
|
+
const len = Math.min(CHUNK, size);
|
|
63
|
+
const buf = Buffer.alloc(len);
|
|
64
|
+
fs.readSync(fd, buf, 0, len, fromEnd ? size - len : 0);
|
|
65
|
+
return buf.toString('utf-8');
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
if (fd !== undefined)
|
|
72
|
+
fs.closeSync(fd);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function parseLines(text) {
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const line of text.split('\n')) {
|
|
78
|
+
try {
|
|
79
|
+
out.push(JSON.parse(line));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// partial first/last line of the chunk
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function readLogFacts(file) {
|
|
88
|
+
let entrypoint = null;
|
|
89
|
+
let cwd = null;
|
|
90
|
+
for (const o of parseLines(readChunk(file, false))) {
|
|
91
|
+
entrypoint ??= typeof o?.entrypoint === 'string' ? o.entrypoint : null;
|
|
92
|
+
cwd ??= typeof o?.cwd === 'string' ? o.cwd : null;
|
|
93
|
+
if (entrypoint && cwd)
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
// Only the last few records: an exit writes cost-state and then a little
|
|
97
|
+
// bookkeeping (e.g. artifact-comment-monitor), so it isn't always the last line.
|
|
98
|
+
const exited = parseLines(readChunk(file, true)).slice(-8).some((o) => o?.type === 'cost-state');
|
|
99
|
+
return { interactive: !entrypoint || entrypoint === 'cli', cwd, exited };
|
|
100
|
+
}
|
|
101
|
+
function listLogFiles(root, since) {
|
|
102
|
+
const out = [];
|
|
103
|
+
let dirs;
|
|
104
|
+
try {
|
|
105
|
+
dirs = fs.readdirSync(root);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
for (const d of dirs) {
|
|
111
|
+
let names;
|
|
112
|
+
try {
|
|
113
|
+
names = fs.readdirSync(path.join(root, d));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
for (const f of names) {
|
|
119
|
+
if (!f.endsWith('.jsonl'))
|
|
120
|
+
continue;
|
|
121
|
+
const file = path.join(root, d, f);
|
|
122
|
+
try {
|
|
123
|
+
const mtime = fs.statSync(file).mtimeMs;
|
|
124
|
+
if (mtime >= since)
|
|
125
|
+
out.push({ sessionId: f.slice(0, -'.jsonl'.length), file, mtime });
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// vanished mid-scan
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out.sort((a, b) => b.mtime - a.mtime);
|
|
133
|
+
}
|
|
134
|
+
function inferLastStop(live, now = Date.now(), root = discover_1.CLAUDE_DIR, readFacts = readLogFacts) {
|
|
135
|
+
const liveIds = new Set(live.map((s) => s.sessionId));
|
|
136
|
+
const factsCache = new Map();
|
|
137
|
+
const facts = (f) => {
|
|
138
|
+
let v = factsCache.get(f.file);
|
|
139
|
+
if (!v)
|
|
140
|
+
factsCache.set(f.file, (v = readFacts(f.file)));
|
|
141
|
+
return v;
|
|
142
|
+
};
|
|
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)
|
|
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))
|
|
152
|
+
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
|
+
// 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
|
+
}
|
|
167
|
+
return { stoppedAt, sessions, estimated: true };
|
|
168
|
+
}
|
package/dist/restore.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.detectLastStop = detectLastStop;
|
|
37
|
+
exports.chooseStop = chooseStop;
|
|
38
|
+
exports.describeStopped = describeStopped;
|
|
39
|
+
exports.restoreAll = restoreAll;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const discover_1 = require("./discover");
|
|
42
|
+
const cmux_1 = require("./cmux");
|
|
43
|
+
const snapshot_1 = require("./snapshot");
|
|
44
|
+
const infer_1 = require("./infer");
|
|
45
|
+
const agent_1 = require("./agent");
|
|
46
|
+
// Starting a dozen `claude` processes at once is what tends to cause the memory
|
|
47
|
+
// pressure this feature recovers from, so space them out a little.
|
|
48
|
+
const STAGGER_MS = 1500;
|
|
49
|
+
// The stop to offer. The agent's records are exact but only reach back to when
|
|
50
|
+
// it started recording; anything else (no agent, or a stop that predates it)
|
|
51
|
+
// is guessed from the conversation logs. Either way the current registry is
|
|
52
|
+
// recorded first so already-resumed sessions count as running.
|
|
53
|
+
function detectLastStop(now = Date.now()) {
|
|
54
|
+
const live = (0, snapshot_1.readLiveSessions)();
|
|
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));
|
|
57
|
+
try {
|
|
58
|
+
(0, snapshot_1.saveSnapshot)(state);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// read-only use still works from the in-memory state
|
|
62
|
+
}
|
|
63
|
+
return { state, stop, agentInstalled };
|
|
64
|
+
}
|
|
65
|
+
// Pure decision between the agent's records and a log estimate; returns the
|
|
66
|
+
// 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) {
|
|
81
|
+
return { state, stop: null };
|
|
82
|
+
}
|
|
83
|
+
return { state: { ...state, lastEstimatedStopAt: inferred.stoppedAt }, stop: inferred };
|
|
84
|
+
}
|
|
85
|
+
async function describeStopped(sessions) {
|
|
86
|
+
const previews = await (0, discover_1.findRecentSessionsByIds)(sessions.map((s) => s.sessionId));
|
|
87
|
+
const byId = new Map(previews.map((p) => [p.id, p]));
|
|
88
|
+
return sessions.map((s) => {
|
|
89
|
+
const p = byId.get(s.sessionId);
|
|
90
|
+
return {
|
|
91
|
+
sessionId: s.sessionId,
|
|
92
|
+
cwd: s.cwd,
|
|
93
|
+
title: p?.customTitle || p?.aiTitle || s.name || p?.slug || s.sessionId.slice(0, 8),
|
|
94
|
+
projectRawName: p?.projectRawName ?? null,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
async function restoreAll(items, onProgress) {
|
|
99
|
+
const outcomes = [];
|
|
100
|
+
for (let i = 0; i < items.length; i++) {
|
|
101
|
+
const it = items[i];
|
|
102
|
+
let outcome;
|
|
103
|
+
if (!it.projectRawName) {
|
|
104
|
+
outcome = { sessionId: it.sessionId, title: it.title, ok: false, message: 'no conversation log (nothing to resume)' };
|
|
105
|
+
}
|
|
106
|
+
else if (!fs.existsSync(it.cwd)) {
|
|
107
|
+
outcome = { sessionId: it.sessionId, title: it.title, ok: false, message: `directory no longer exists: ${it.cwd}` };
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
try {
|
|
111
|
+
// Background: keep focus where it is instead of flicking through every tab.
|
|
112
|
+
const { workspaceId } = await (0, cmux_1.resumeInNewWorkspace)(it.cwd, it.sessionId, it.title, true);
|
|
113
|
+
(0, cmux_1.saveResumeMapEntry)(it.sessionId, workspaceId);
|
|
114
|
+
outcome = { sessionId: it.sessionId, title: it.title, ok: true };
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
outcome = { sessionId: it.sessionId, title: it.title, ok: false, message: err?.message || String(err) };
|
|
118
|
+
}
|
|
119
|
+
if (i < items.length - 1)
|
|
120
|
+
await new Promise((r) => setTimeout(r, STAGGER_MS));
|
|
121
|
+
}
|
|
122
|
+
outcomes.push(outcome);
|
|
123
|
+
onProgress?.(outcome);
|
|
124
|
+
}
|
|
125
|
+
return outcomes;
|
|
126
|
+
}
|
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.SNAPSHOT_FILE = void 0;
|
|
37
|
+
exports.emptyState = emptyState;
|
|
38
|
+
exports.loadSnapshot = loadSnapshot;
|
|
39
|
+
exports.saveSnapshot = saveSnapshot;
|
|
40
|
+
exports.readLiveSessions = readLiveSessions;
|
|
41
|
+
exports.recordLive = recordLive;
|
|
42
|
+
exports.findLastStop = findLastStop;
|
|
43
|
+
exports.snapshotNow = snapshotNow;
|
|
44
|
+
const fs = __importStar(require("fs"));
|
|
45
|
+
const os = __importStar(require("os"));
|
|
46
|
+
const path = __importStar(require("path"));
|
|
47
|
+
// "What was running before the crash?" — a record of live Claude Code sessions
|
|
48
|
+
// that outlives the processes, so they can be brought back in one go after a
|
|
49
|
+
// reboot or a hung cmux.
|
|
50
|
+
//
|
|
51
|
+
// Claude Code keeps ~/.claude/sessions/<pid>.json only while a session runs and
|
|
52
|
+
// removes it on exit (stale ones are swept at the next launch), so the registry
|
|
53
|
+
// itself is gone by the time you need it. `ccakashic snapshot` copies it into
|
|
54
|
+
// our own file on a timer (a launchd agent, see agent.ts) and keeps, per
|
|
55
|
+
// session, when it was last seen alive.
|
|
56
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'ccakashic');
|
|
57
|
+
exports.SNAPSHOT_FILE = path.join(CONFIG_DIR, 'live-sessions.json');
|
|
58
|
+
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;
|
|
62
|
+
const RETENTION_MS = 14 * 24 * 60 * 60_000;
|
|
63
|
+
function emptyState() {
|
|
64
|
+
return { version: 1, lastRunAt: 0, recordingSince: null, lastEstimatedStopAt: null, dismissedStopAt: null, sessions: {} };
|
|
65
|
+
}
|
|
66
|
+
function loadSnapshot(file = exports.SNAPSHOT_FILE) {
|
|
67
|
+
try {
|
|
68
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
69
|
+
if (data && data.version === 1 && data.sessions && typeof data.sessions === 'object') {
|
|
70
|
+
return { ...emptyState(), ...data };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// not recorded yet
|
|
75
|
+
}
|
|
76
|
+
return emptyState();
|
|
77
|
+
}
|
|
78
|
+
function saveSnapshot(state, file = exports.SNAPSHOT_FILE) {
|
|
79
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
80
|
+
// Atomic: this file is exactly what a crash must not leave half-written.
|
|
81
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
82
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
83
|
+
fs.renameSync(tmp, file);
|
|
84
|
+
}
|
|
85
|
+
function isProcessAlive(pid) {
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, 0);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
return err?.code === 'EPERM'; // exists, just not ours
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Interactive sessions whose process is running right now. Headless `claude -p`
|
|
95
|
+
// runs (git hooks, scripts) register too but are not something to resume.
|
|
96
|
+
function readLiveSessions(dir = SESSION_REGISTRY_DIR) {
|
|
97
|
+
let names;
|
|
98
|
+
try {
|
|
99
|
+
names = fs.readdirSync(dir);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
const out = new Map();
|
|
105
|
+
for (const f of names) {
|
|
106
|
+
if (!f.endsWith('.json'))
|
|
107
|
+
continue;
|
|
108
|
+
try {
|
|
109
|
+
const o = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'));
|
|
110
|
+
if (typeof o?.pid !== 'number' || typeof o?.sessionId !== 'string' || typeof o?.cwd !== 'string')
|
|
111
|
+
continue;
|
|
112
|
+
if (o.kind && o.kind !== 'interactive')
|
|
113
|
+
continue;
|
|
114
|
+
if (!isProcessAlive(o.pid))
|
|
115
|
+
continue;
|
|
116
|
+
out.set(o.sessionId, {
|
|
117
|
+
sessionId: o.sessionId,
|
|
118
|
+
cwd: o.cwd,
|
|
119
|
+
name: typeof o.name === 'string' ? o.name : null,
|
|
120
|
+
proc: `${o.pid}:${o.procStart ?? ''}`,
|
|
121
|
+
...(typeof o.startedAt === 'number' ? { startedAt: o.startedAt } : {}),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// half-written record
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return [...out.values()];
|
|
129
|
+
}
|
|
130
|
+
function recordLive(state, live, now) {
|
|
131
|
+
const sessions = {};
|
|
132
|
+
for (const [id, e] of Object.entries(state.sessions)) {
|
|
133
|
+
if (now - e.lastSeenAlive < RETENTION_MS)
|
|
134
|
+
sessions[id] = e;
|
|
135
|
+
}
|
|
136
|
+
for (const s of live) {
|
|
137
|
+
const prev = sessions[s.sessionId];
|
|
138
|
+
const sameProcess = prev && prev.proc === s.proc;
|
|
139
|
+
sessions[s.sessionId] = {
|
|
140
|
+
cwd: s.cwd,
|
|
141
|
+
name: s.name ?? prev?.name ?? null,
|
|
142
|
+
proc: s.proc,
|
|
143
|
+
aliveSince: sameProcess ? prev.aliveSince : now,
|
|
144
|
+
lastSeenAlive: now,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { ...state, lastRunAt: now, recordingSince: state.recordingSince ?? now, sessions };
|
|
148
|
+
}
|
|
149
|
+
// The most recent group of sessions that went down together and has not been
|
|
150
|
+
// brought back. A group only counts as a stop when nothing survived it: if some
|
|
151
|
+
// session was alive both before and after, the others were closed one by one on
|
|
152
|
+
// purpose (/exit, closing a tab), not killed by a reboot or a hung cmux.
|
|
153
|
+
function findLastStop(state, liveIds) {
|
|
154
|
+
const dead = [];
|
|
155
|
+
for (const [sessionId, e] of Object.entries(state.sessions)) {
|
|
156
|
+
if (!liveIds.has(sessionId))
|
|
157
|
+
dead.push({ sessionId, ...e });
|
|
158
|
+
}
|
|
159
|
+
if (!dead.length)
|
|
160
|
+
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;
|
|
166
|
+
}
|
|
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 };
|
|
171
|
+
}
|
|
172
|
+
// One launchd tick. Kept free of any import beyond Node built-ins: the agent
|
|
173
|
+
// runs a copy of this compiled file, not the installed package (see agent.ts).
|
|
174
|
+
function snapshotNow(now = Date.now()) {
|
|
175
|
+
saveSnapshot(recordLive(loadSnapshot(), readLiveSessions(), now));
|
|
176
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccakashic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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"
|