resumecontext 0.1.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.
Files changed (44) hide show
  1. package/README.md +27 -0
  2. package/dist/agentConfig.js +202 -0
  3. package/dist/agentConfigWithDaemon.js +42 -0
  4. package/dist/apiClient.js +54 -0
  5. package/dist/browser.js +20 -0
  6. package/dist/cloudApi.js +15 -0
  7. package/dist/commands/accept.js +20 -0
  8. package/dist/commands/agents.js +35 -0
  9. package/dist/commands/auth.js +55 -0
  10. package/dist/commands/daemon.js +99 -0
  11. package/dist/commands/init.js +59 -0
  12. package/dist/commands/logout.js +20 -0
  13. package/dist/commands/mcp.js +54 -0
  14. package/dist/commands/members.js +20 -0
  15. package/dist/commands/projects.js +55 -0
  16. package/dist/commands/revoke.js +15 -0
  17. package/dist/commands/share.js +18 -0
  18. package/dist/commands/sync.js +59 -0
  19. package/dist/commands/uninstall.js +61 -0
  20. package/dist/constants.js +61 -0
  21. package/dist/daemon.js +409 -0
  22. package/dist/daemonService.js +326 -0
  23. package/dist/deps.js +1 -0
  24. package/dist/dev.js +32 -0
  25. package/dist/device.js +40 -0
  26. package/dist/httpCloudApi.js +61 -0
  27. package/dist/index.js +160 -0
  28. package/dist/localCapture.js +18 -0
  29. package/dist/localHistory/claudeCode.js +82 -0
  30. package/dist/localHistory/codex.js +106 -0
  31. package/dist/localHistory/cursor.js +492 -0
  32. package/dist/localHistory/index.js +96 -0
  33. package/dist/localHistory/opencode.js +148 -0
  34. package/dist/localHistory/registry.js +66 -0
  35. package/dist/localHistory/shared.js +174 -0
  36. package/dist/paths.js +85 -0
  37. package/dist/projectRoot.js +77 -0
  38. package/dist/session.js +36 -0
  39. package/dist/syncCore.js +108 -0
  40. package/dist/syncState.js +51 -0
  41. package/dist/ui.js +289 -0
  42. package/dist/utils.js +41 -0
  43. package/dist/version.js +43 -0
  44. package/package.json +64 -0
package/dist/daemon.js ADDED
@@ -0,0 +1,409 @@
1
+ /**
2
+ * The auto-sync background work, and the machinery that keeps it scheduled.
3
+ * Once a project finishes agent configuration, the CLI registers it here
4
+ * and makes sure the OS is scheduled to run it; from then on, syncing that
5
+ * project happens on its own every DAEMON_INTERVAL_MS without the user ever
6
+ * running `resumecontext sync` again.
7
+ *
8
+ * "Making sure it's scheduled" means registering a real, OS-managed
9
+ * mechanism -- launchd StartInterval on macOS, a systemd --user timer on
10
+ * Linux, see daemonService.ts -- so this survives a reboot and keeps firing
11
+ * even if a given run crashes. There is deliberately no fallback to a plain
12
+ * detached subprocess: a background process a `kill` or a reboot silently
13
+ * ends for good, with nothing to notice or revive it, is not what
14
+ * "auto-sync" is supposed to mean. On a platform with neither mechanism,
15
+ * ensureDaemonRunning does nothing and auto-sync simply isn't available
16
+ * there -- `resumecontext sync` still works manually.
17
+ *
18
+ * Each tick is its own short-lived process, not one long-running loop.
19
+ * Earlier this ran as a single persistent process (launchd RunAtLoad +
20
+ * KeepAlive supervising an in-process `while (true)` loop) on the theory
21
+ * that starting a fresh Node process every 5 seconds costs more than a
22
+ * resident one sleeping between ticks. That's true, but it traded away
23
+ * something more important: in production, one tick hit an unrelated bug
24
+ * (a stack-overflow deep in a local-history parser) and the process was
25
+ * observed pinned at sustained high CPU minutes later, with nothing further
26
+ * ever logged -- one bad tick took down every tick after it, silently,
27
+ * until someone happened to notice. A supervisor restarts a process that
28
+ * *exits*; it does nothing for one that's merely stuck. A fresh process per
29
+ * tick is self-healing from exactly that failure mode: a hung or crashed
30
+ * tick wastes at most one interval, and the next scheduled tick runs
31
+ * unaffected in a clean process.
32
+ *
33
+ * Because ticks are now separate processes, TWO ticks can be scheduled
34
+ * before the first finishes (a slow scan, a hung tick that hasn't been
35
+ * cleaned up yet) -- see acquireLock below for how that's prevented.
36
+ *
37
+ * A second project finishing setup while auto-sync is already scheduled
38
+ * does NOT need a second registration: registerProject just adds an entry
39
+ * to a shared JSON file that every tick re-reads at the top, so it picks
40
+ * up the new project on its own within one interval -- see runDaemonTick
41
+ * below.
42
+ */
43
+ import fs from "node:fs";
44
+ import path from "node:path";
45
+ import { agentConfigFile, daemonRegistryFile, daemonLogFile, daemonLockFile, daemonFingerprintCacheFile, syncStateFile, } from "./paths.js";
46
+ import { apiErrorStatus } from "./apiClient.js";
47
+ import { readAgentConfig } from "./agentConfig.js";
48
+ import { readCredentials } from "./session.js";
49
+ import { scanForNewTurns, pushNewTurns } from "./syncCore.js";
50
+ import { MAX_LOG_BYTES } from "./constants.js";
51
+ import { installPersistentService } from "./daemonService.js";
52
+ import { fingerprintDirsForConfig } from "./localHistory/registry.js";
53
+ import { findProjectRoot } from "./projectRoot.js";
54
+ export function readRegistry() {
55
+ try {
56
+ const parsed = JSON.parse(fs.readFileSync(daemonRegistryFile(), "utf-8"));
57
+ if (parsed && typeof parsed.projects === "object")
58
+ return parsed;
59
+ }
60
+ catch {
61
+ // missing or unparseable -- fall through to empty
62
+ }
63
+ return { projects: {} };
64
+ }
65
+ function writeRegistry(registry) {
66
+ const file = daemonRegistryFile();
67
+ fs.mkdirSync(path.dirname(file), { recursive: true });
68
+ fs.writeFileSync(file, JSON.stringify(registry, null, 2));
69
+ }
70
+ /** Adds (or updates) one project in the shared registry. Idempotent --
71
+ * calling this for a project that's already registered just rewrites the
72
+ * same entry, so every command that touches a project's agent config can
73
+ * call it unconditionally rather than tracking "did I already register
74
+ * this." */
75
+ export function registerProject(projectId, root) {
76
+ const registry = readRegistry();
77
+ registry.projects[projectId] = { root };
78
+ writeRegistry(registry);
79
+ }
80
+ /** Removes one project from the registry and deletes its per-project local
81
+ * files -- the counterpart to registerProject, called when a project is
82
+ * confirmed gone (a 404 from the cloud API, meaning the project id no longer
83
+ * exists there at all, whether it was deleted through this CLI or the web
84
+ * UI). Idempotent. A project id is a UUID that's never reused, so once
85
+ * confirmed gone there's no future scenario where keeping its agent config
86
+ * or sync-state helps; deleting them here, not just the registry entry, is
87
+ * what keeps ~/.resumecontext/agents and ~/.resumecontext/sync-state from
88
+ * silently accumulating one dead file per deleted project forever. Does not
89
+ * touch `.resumecontext.json` or anything else. */
90
+ export function deregisterProject(projectId) {
91
+ const registry = readRegistry();
92
+ if (!(projectId in registry.projects))
93
+ return;
94
+ delete registry.projects[projectId];
95
+ writeRegistry(registry);
96
+ fs.rmSync(agentConfigFile(projectId), { force: true });
97
+ fs.rmSync(syncStateFile(projectId), { force: true });
98
+ }
99
+ // ---- lifecycle: making sure auto-sync is scheduled with the OS --------
100
+ /** Registers auto-sync as a real OS-managed schedule if this platform
101
+ * supports one (see daemonService.ts) -- no fallback of any kind on a
102
+ * platform with neither launchd nor systemd, so what's registered is
103
+ * always something the OS itself keeps firing after a crash or a reboot,
104
+ * never a subprocess someone can just `kill`.
105
+ *
106
+ * Idempotent: safe to call from every command that touches a project's
107
+ * agent config, not just the first time. Returns which platform mechanism
108
+ * applies (or "unsupported") and whether this call actually installed
109
+ * anything, so callers can tell the user when auto-sync isn't available
110
+ * here at all.
111
+ *
112
+ * `installService` is injectable so tests can verify what would be
113
+ * installed -- which mechanism, which plan -- without ever registering a
114
+ * real OS service. */
115
+ export function ensureDaemonRunning(installService = installPersistentService) {
116
+ return installService();
117
+ }
118
+ // ---- log rotation -------------------------------------------------------
119
+ /** Keeps daemonLogFile() bounded, retaining one previous generation.
120
+ *
121
+ * Truncates in place rather than renaming: launchd (StandardOutPath) and
122
+ * systemd (StandardOutput=append:) open this file themselves per-run and
123
+ * would recreate it anyway, but truncating keeps this correct even if a
124
+ * platform ever held the descriptor open across runs. Called at the start
125
+ * of every tick, not just some of them, since ticks no longer share a
126
+ * process to schedule it from.
127
+ *
128
+ * `fsImpl` is injectable so tests can drive the size logic without a real
129
+ * log file, matching the pattern used in daemonService.ts. */
130
+ export function rotateLogIfNeeded(fsImpl = fs, logPath = daemonLogFile(), maxBytes = MAX_LOG_BYTES) {
131
+ let size;
132
+ try {
133
+ size = fsImpl.statSync(logPath).size;
134
+ }
135
+ catch {
136
+ return false; // no log yet -- nothing to rotate
137
+ }
138
+ if (size <= maxBytes)
139
+ return false;
140
+ fsImpl.copyFileSync(logPath, `${logPath}.1`);
141
+ fsImpl.truncateSync(logPath, 0);
142
+ return true;
143
+ }
144
+ /** True if a process with this pid exists and we have permission to signal
145
+ * it -- signal 0 sends nothing, it only probes. Throws ESRCH ("no such
146
+ * process") for a dead pid, EPERM for one that exists but isn't ours;
147
+ * EPERM still means "alive", just not signalable, so only ESRCH (and any
148
+ * other throw, conservatively) counts as dead. */
149
+ function isProcessAlive(pid) {
150
+ try {
151
+ process.kill(pid, 0);
152
+ return true;
153
+ }
154
+ catch (err) {
155
+ return err.code === "EPERM";
156
+ }
157
+ }
158
+ /** Tries to take the lock for this tick. Returns false (meaning: skip this
159
+ * run entirely) when another tick already holds it AND that tick's process
160
+ * is still alive. A lock file left behind by a process that's no longer
161
+ * running (killed -9, machine slept mid-write, an old crash) is stale and
162
+ * self-heals here rather than wedging auto-sync forever -- there is no
163
+ * "unsupported platform" fallback for locking the way there is for
164
+ * scheduling, since flock-equivalent primitives (exclusive file create,
165
+ * process.kill(pid, 0)) are POSIX and both platforms we support have them.
166
+ *
167
+ * `fsImpl`/`pid` are injectable so tests can exercise every branch (fresh
168
+ * lock, live holder, stale holder) without real processes or a real lock
169
+ * file. */
170
+ export function acquireLock(fsImpl = fs, pid = process.pid, lockPath = daemonLockFile()) {
171
+ fsImpl.mkdirSync(path.dirname(lockPath), { recursive: true });
172
+ try {
173
+ fsImpl.writeFileSync(lockPath, String(pid), { flag: "wx" }); // atomic create-exclusive
174
+ return true;
175
+ }
176
+ catch (err) {
177
+ if (err.code !== "EEXIST")
178
+ throw err;
179
+ }
180
+ const holderPid = Number(fsImpl.readFileSync(lockPath, "utf8"));
181
+ if (Number.isInteger(holderPid) && isProcessAlive(holderPid))
182
+ return false;
183
+ // Stale: the holder is gone. Take over -- best-effort against another
184
+ // tick racing to do the same thing at the exact same moment, which is
185
+ // rare enough (two ticks, both finding the SAME stale lock, within the
186
+ // gap between the read above and this write) not to guard further; the
187
+ // worst case is two ticks briefly overlapping once, not corruption.
188
+ fsImpl.writeFileSync(lockPath, String(pid));
189
+ return true;
190
+ }
191
+ /** Releases the lock, but only if it's still ours -- guards against
192
+ * releasing a lock a different process has since taken over (the stale
193
+ * takeover race noted in acquireLock above). */
194
+ export function releaseLock(fsImpl = fs, pid = process.pid, lockPath = daemonLockFile()) {
195
+ try {
196
+ const holderPid = Number(fsImpl.readFileSync(lockPath, "utf8"));
197
+ if (holderPid === pid)
198
+ fsImpl.rmSync(lockPath, { force: true });
199
+ }
200
+ catch {
201
+ // already gone, or unreadable -- nothing more to do
202
+ }
203
+ }
204
+ // ---- change detection -----------------------------------------------------
205
+ /** SQLite's WAL-mode sidecar files -- `<db>-shm` (shared memory index) and
206
+ * `<db>-wal` (write-ahead log). Excluded from the fingerprint below: opening
207
+ * a WAL-mode database AT ALL, even with `readOnly: true`, touches -shm's
208
+ * mtime as an inherent part of SQLite establishing a consistent read
209
+ * snapshot -- verified directly against a real Cursor store.db (its -shm
210
+ * mtime advanced on every read-only open; -journal is rollback-mode's
211
+ * equivalent, same idea). Cursor's own history is exactly this shape (see
212
+ * cursor.ts), which without this exclusion turned the fingerprint gate into
213
+ * a self-defeating loop: scanning to check for real changes touched these
214
+ * files, which changed the next tick's fingerprint, which triggered another
215
+ * full scan, forever -- a ~73MB Cursor history measured at a genuinely
216
+ * unchanging multi-second cost on EVERY tick, not just when something
217
+ * actually changed. Fixing this here, not in cursor.ts's read path, keeps
218
+ * that path free to keep reading live/current data -- the alternative
219
+ * (SQLite's `immutable=1` connection mode) would avoid the mtime side
220
+ * effect but does so by promising SQLite the file won't change during the
221
+ * read, which is false while Cursor itself might be actively appending. */
222
+ function isFingerprintNoise(filename) {
223
+ return filename.endsWith("-shm") || filename.endsWith("-wal") || filename.endsWith("-journal");
224
+ }
225
+ /** A cheap fingerprint of everything under `dirs`: newest mtime, total
226
+ * size, and file count.
227
+ *
228
+ * This exists because a full scan is expensive and almost always finds
229
+ * nothing. collectLocalTurns parses every session file of every project on
230
+ * the machine and only then filters down to the one project being synced
231
+ * -- on a 73MB history that measured 442ms of CPU and a 410MB allocation
232
+ * spike, repeated on every tick regardless of interval, to usually
233
+ * discover nothing changed. Stat'ing the tree first turns the common case
234
+ * into a handful of syscalls.
235
+ *
236
+ * Size and count are folded in alongside mtime deliberately: mtime has
237
+ * millisecond granularity, so an append landing in the same millisecond as
238
+ * our stat would otherwise be invisible until the next unrelated write. */
239
+ export function fingerprintDirs(dirs) {
240
+ let newest = 0;
241
+ let totalSize = 0;
242
+ let fileCount = 0;
243
+ const walk = (dir) => {
244
+ let entries;
245
+ try {
246
+ entries = fs.readdirSync(dir, { withFileTypes: true });
247
+ }
248
+ catch {
249
+ return; // unreadable or deleted -- treat as contributing nothing
250
+ }
251
+ for (const entry of entries) {
252
+ if (isFingerprintNoise(entry.name))
253
+ continue;
254
+ const full = path.join(dir, entry.name);
255
+ if (entry.isDirectory()) {
256
+ walk(full);
257
+ continue;
258
+ }
259
+ try {
260
+ const st = fs.statSync(full);
261
+ newest = Math.max(newest, st.mtimeMs);
262
+ totalSize += st.size;
263
+ fileCount += 1;
264
+ }
265
+ catch {
266
+ // vanished between readdir and stat -- ignore
267
+ }
268
+ }
269
+ };
270
+ for (const dir of dirs)
271
+ walk(dir);
272
+ return `${newest}:${totalSize}:${fileCount}`;
273
+ }
274
+ function readFingerprintCache() {
275
+ try {
276
+ const parsed = JSON.parse(fs.readFileSync(daemonFingerprintCacheFile(), "utf-8"));
277
+ if (parsed && typeof parsed === "object")
278
+ return parsed;
279
+ }
280
+ catch {
281
+ // missing or unparseable -- fall through to empty, same as readRegistry
282
+ }
283
+ return {};
284
+ }
285
+ function writeFingerprintCache(cache) {
286
+ const file = daemonFingerprintCacheFile();
287
+ fs.mkdirSync(path.dirname(file), { recursive: true });
288
+ fs.writeFileSync(file, JSON.stringify(cache, null, 2));
289
+ }
290
+ // ---- the tick itself: only ever run inside a scheduled daemon process ----
291
+ function log(message) {
292
+ console.log(`[${new Date().toISOString()}] ${message}`);
293
+ }
294
+ export async function tick(cloudApi, localCapture) {
295
+ const creds = readCredentials();
296
+ if (!creds)
297
+ return; // logged out -- nothing to sync until logged back in
298
+ const fingerprintCache = readFingerprintCache();
299
+ let fingerprintCacheChanged = false;
300
+ for (const [projectId, { root: registeredRoot }] of Object.entries(readRegistry().projects)) {
301
+ try {
302
+ // The registry remembers where this project was last explicitly set up,
303
+ // but the marker remains the source of truth. This lets a marker moved
304
+ // to a parent directory heal itself on the next tick, while a deleted
305
+ // or replaced marker affects only this one project.
306
+ const resolved = findProjectRoot(registeredRoot);
307
+ if (!resolved.projectId) {
308
+ log(`${projectId}: no .resumecontext.json found at ${registeredRoot} or its parents -- skipped`);
309
+ continue;
310
+ }
311
+ if (resolved.projectId !== projectId) {
312
+ log(`${projectId}: .resumecontext.json at ${resolved.root} belongs to ${resolved.projectId} -- skipped`);
313
+ continue;
314
+ }
315
+ const root = resolved.root;
316
+ if (path.resolve(root) !== path.resolve(registeredRoot)) {
317
+ registerProject(projectId, root);
318
+ // The configured agent paths can resolve differently under the new
319
+ // root (notably Cursor), so force one scan instead of trusting a
320
+ // fingerprint produced for the old location.
321
+ delete fingerprintCache[projectId];
322
+ fingerprintCacheChanged = true;
323
+ log(`${projectId}: project marker moved to ${root} -- updated registration`);
324
+ }
325
+ const agentConfig = readAgentConfig(projectId);
326
+ if (!agentConfig)
327
+ continue; // agent setup never finished for this one
328
+ // Skip the expensive scan entirely when nothing on disk has moved.
329
+ // A config with no directories at all is left ungated on purpose: its
330
+ // fingerprint would be a constant carrying no information, and there
331
+ // is nothing to scan in that case anyway, so gating buys nothing and
332
+ // would only obscure why the scan was skipped.
333
+ const dirs = fingerprintDirsForConfig(root, agentConfig);
334
+ const fingerprint = dirs.length > 0 ? fingerprintDirs(dirs) : null;
335
+ if (fingerprint !== null && fingerprintCache[projectId] === fingerprint)
336
+ continue;
337
+ const { newTurns } = await scanForNewTurns(localCapture, root, agentConfig, projectId);
338
+ if (newTurns.length > 0) {
339
+ const { accepted } = await pushNewTurns(cloudApi, creds.token, projectId, newTurns);
340
+ log(`${projectId}: pushed ${accepted} new turn(s)`);
341
+ }
342
+ // Only recorded once the work above fully succeeded -- a push that
343
+ // throws must leave the cached fingerprint unchanged so the next
344
+ // tick retries rather than treating the failure as "handled".
345
+ if (fingerprint !== null) {
346
+ fingerprintCache[projectId] = fingerprint;
347
+ fingerprintCacheChanged = true;
348
+ }
349
+ }
350
+ catch (err) {
351
+ if (apiErrorStatus(err) === 404) {
352
+ deregisterProject(projectId);
353
+ log(`${projectId}: project no longer exists (deleted) -- stopped syncing it`);
354
+ }
355
+ else {
356
+ // One project's failure (network hiccup, revoked access, a deleted
357
+ // directory) must never stop this tick from covering the rest, or
358
+ // from trying this project again next tick.
359
+ log(`${projectId}: sync failed -- ${err instanceof Error ? err.message : String(err)}`);
360
+ }
361
+ }
362
+ }
363
+ if (fingerprintCacheChanged)
364
+ writeFingerprintCache(fingerprintCache);
365
+ }
366
+ /** The entire body of one daemon tick (see the hidden `__daemon-run`
367
+ * command in index.ts) -- runs the shared per-project tick() once and
368
+ * returns, letting the OS scheduler (launchd StartInterval / a systemd
369
+ * timer, see daemonService.ts) be the thing that calls this again in
370
+ * DAEMON_INTERVAL_MS. See this file's module doc comment for why a fresh
371
+ * process per tick, rather than one persistent process looping forever,
372
+ * is the design here.
373
+ *
374
+ * Takes the lock first and does nothing else at all if it can't get it --
375
+ * see acquireLock's doc comment. That's the expected, silent outcome when
376
+ * the previous tick is still running (a slow scan) or hasn't been cleaned
377
+ * up yet, and deliberately NOT logged: at DAEMON_INTERVAL_MS this is common
378
+ * enough (a slightly slow scan on one tick pushes the next one into an
379
+ * overlap) that logging it every time would itself be the noise problem
380
+ * this function's start/finish lines exist to avoid.
381
+ *
382
+ * Explicit "tick started"/"tick finished" lines bracket every real run --
383
+ * without them, a hung tick (the exact failure mode this whole per-tick-
384
+ * process design exists to survive, see the module doc comment) looks
385
+ * IDENTICAL in the log to a healthy one that simply hasn't logged a push
386
+ * yet: both show nothing after the last line. A start with no matching
387
+ * finish is the signal that this particular tick never came back, which a
388
+ * bare list of push/failure lines can't tell you on its own. The elapsed
389
+ * time on the finish line is the same debugging value: a normal tick
390
+ * finishes in well under a second; a tick that took 90 seconds to report
391
+ * "finished" already tells you where to look before you go near `ps`. */
392
+ export async function runDaemonTick(cloudApi, localCapture) {
393
+ if (!acquireLock())
394
+ return;
395
+ rotateLogIfNeeded();
396
+ const startedAt = Date.now();
397
+ log("tick started");
398
+ try {
399
+ await tick(cloudApi, localCapture);
400
+ log(`tick finished (${Date.now() - startedAt}ms)`);
401
+ }
402
+ catch (err) {
403
+ log(`tick finished (${Date.now() - startedAt}ms) -- unexpected error: ${err instanceof Error ? err.message : String(err)}`);
404
+ throw err;
405
+ }
406
+ finally {
407
+ releaseLock();
408
+ }
409
+ }