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
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `resumecontext mcp` -- prints the MCP endpoint and the bearer token for
3
+ * this project, so the user can configure their coding agent by hand.
4
+ *
5
+ * This exists because the endpoint is useless without both halves. `init`
6
+ * prints the URL and tells you to add it to your agent, but the server
7
+ * rejects any request without an `Authorization: Bearer` header, and nothing
8
+ * else in the CLI ever surfaces the token -- it is written to
9
+ * ~/.resumecontext/credentials.json by `auth` and never shown again. Anyone
10
+ * following init's instructions got a 401.
11
+ *
12
+ * The URL is fetched rather than reconstructed locally: findProject returns
13
+ * whatever the backend considers this project's MCP endpoint, so a CLI
14
+ * pointed at a local or staging backend prints that one, and the URL can
15
+ * change server-side without the CLI needing to know the shape. The call
16
+ * doubles as an access check -- a pending invite is reported as such instead
17
+ * of handing out a config that would 403.
18
+ */
19
+ import { requireProject } from "../projectRoot.js";
20
+ import { requireCredentials } from "../session.js";
21
+ import * as ui from "../ui.js";
22
+ /** The config block every major agent accepts, ready to paste. */
23
+ function configSnippet(mcpUrl, token) {
24
+ return JSON.stringify({ mcpServers: { resumecontext: { url: mcpUrl, headers: { Authorization: `Bearer ${token}` } } } }, null, 2);
25
+ }
26
+ export async function runMcp(deps) {
27
+ const { token } = requireCredentials();
28
+ const { root, projectId } = requireProject(deps.cwd);
29
+ await deps.ensureAgentConfig(projectId, root);
30
+ const outcome = await deps.cloudApi.findProject(token, projectId);
31
+ if (outcome.status === "pending_invite") {
32
+ ui.log.warn(`You have a pending invite for this project as ${outcome.invitedEmail}.`);
33
+ ui.outro("Run `resumecontext accept` first -- the MCP endpoint rejects you until then.");
34
+ return null;
35
+ }
36
+ ui.log.info(`URL: ${outcome.mcpUrl}`);
37
+ ui.log.info(`Token: ${token}`);
38
+ console.log();
39
+ console.log("Add this to your coding agent's MCP config:");
40
+ console.log();
41
+ console.log(configSnippet(outcome.mcpUrl, token));
42
+ console.log();
43
+ // Said plainly because this command's whole job is to put a long-lived
44
+ // credential on screen, where it will outlive the terminal in scrollback.
45
+ //
46
+ // What this deliberately does NOT say: that logging out invalidates it. The
47
+ // token is a JWT with a 365-day expiry and there is no server-side
48
+ // revocation -- `logout` only deletes this machine's copy (see logout.ts),
49
+ // so a leaked token stays valid until it expires. Telling someone to log out
50
+ // after a leak would leave them believing they had fixed it.
51
+ ui.log.warn("That token grants access to your projects for a year and cannot be revoked.");
52
+ ui.outro("Treat it like a password: don't commit it or share it.");
53
+ return { mcpUrl: outcome.mcpUrl, token };
54
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `resumecontext members` -- owner-only. Lists everyone with access (or a
3
+ * pending invite) to this project.
4
+ */
5
+ import { requireProject } from "../projectRoot.js";
6
+ import { requireCredentials } from "../session.js";
7
+ import * as ui from "../ui.js";
8
+ export async function runMembers(deps) {
9
+ const { token } = requireCredentials();
10
+ const { root, projectId } = requireProject(deps.cwd);
11
+ await deps.ensureAgentConfig(projectId, root);
12
+ const members = await deps.cloudApi.listMembers(token, projectId);
13
+ const rows = members.map((m) => [
14
+ m.email,
15
+ m.role,
16
+ m.status === "accepted" ? "✓ accepted" : "… pending",
17
+ ]);
18
+ console.log(ui.table(rows, ["Email", "Role", "Status"]));
19
+ return members;
20
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `resumecontext projects list` / `resumecontext projects delete <projectId>`
3
+ * -- account-wide project management, not scoped to the current directory
4
+ * (unlike share/accept/revoke/members/sync, which all require a local
5
+ * .resumecontext.json marker). Deleting a project here is what makes the
6
+ * daemon (and any future manual `sync`) stop trying to reach it -- see
7
+ * daemon.ts's deregisterProject, triggered by the 404 this produces.
8
+ */
9
+ import { requireCredentials } from "../session.js";
10
+ import * as ui from "../ui.js";
11
+ export async function runProjectsList(deps) {
12
+ const { token } = requireCredentials();
13
+ const projects = await deps.cloudApi.listProjects(token);
14
+ if (projects.length === 0) {
15
+ ui.log.info("No projects yet -- run `resumecontext init` in a project directory to create one.");
16
+ return projects;
17
+ }
18
+ // Pending invites are listed (the backend returns them so an invitee can
19
+ // find a project they were told about) but cannot be synced or queried
20
+ // until accepted, so they say so rather than reading as ordinary access.
21
+ const rows = projects.map((p) => [p.label, p.projectId, p.status === "pending" ? "invited (pending)" : p.role]);
22
+ console.log(ui.table(rows, ["Label", "Project ID", "Role"]));
23
+ const pending = projects.filter((p) => p.status === "pending");
24
+ if (pending.length > 0) {
25
+ ui.log.info("To join a project you were invited to, run `resumecontext init` in that project's directory, then `resumecontext accept`.");
26
+ }
27
+ return projects;
28
+ }
29
+ export async function runProjectsDelete(deps, projectId, opts = {}) {
30
+ const confirmFn = opts.confirmFn ?? ((message) => ui.confirm(message, false));
31
+ ui.intro("projects delete");
32
+ const { token } = requireCredentials();
33
+ // Pre-check via listProjects rather than attempting the delete blind:
34
+ // shows the label in the confirmation prompt, and gives a clear message
35
+ // for "doesn't exist" / "not yours" before ever asking to confirm, instead
36
+ // of confirming an action that would just fail server-side anyway. The
37
+ // server still enforces owner-only itself (assertIsOwner) regardless --
38
+ // this is only a friendlier UX layer on top of that real check.
39
+ const projects = await deps.cloudApi.listProjects(token);
40
+ const project = projects.find((p) => p.projectId === projectId);
41
+ if (!project) {
42
+ throw new Error(`No project "${projectId}" found (or you don't have access to it) -- run \`resumecontext projects list\` to see your projects.`);
43
+ }
44
+ if (project.role !== "owner") {
45
+ throw new Error(`Only the project owner can delete "${project.label}".`);
46
+ }
47
+ const confirmed = await confirmFn(`Permanently delete project "${project.label}" (${projectId})? This removes it for every member and cannot be undone.`);
48
+ if (!confirmed) {
49
+ ui.outro("Cancelled -- nothing was deleted.");
50
+ return { deleted: false };
51
+ }
52
+ await deps.cloudApi.deleteProject(token, projectId);
53
+ ui.outro(`Deleted "${project.label}".`);
54
+ return { deleted: true };
55
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `resumecontext revoke <email>` -- owner-only. Immediately removes an
3
+ * accepted member or a still-pending invite.
4
+ */
5
+ import { requireProject } from "../projectRoot.js";
6
+ import { requireCredentials } from "../session.js";
7
+ import * as ui from "../ui.js";
8
+ export async function runRevoke(deps, email) {
9
+ ui.intro("revoke");
10
+ const { token } = requireCredentials();
11
+ const { root, projectId } = requireProject(deps.cwd);
12
+ await deps.ensureAgentConfig(projectId, root);
13
+ await ui.withSpinner(`Revoking access for ${email}...`, "Revoked.", () => deps.cloudApi.revokeAccess(token, projectId, email));
14
+ ui.outro(`${email} no longer has access to this project.`);
15
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `resumecontext share <email>` -- owner-only. Invites someone to this
3
+ * project; they gain access once they run `resumecontext accept` themselves.
4
+ */
5
+ import { requireProject } from "../projectRoot.js";
6
+ import { requireCredentials } from "../session.js";
7
+ import { isValidEmail } from "../utils.js";
8
+ import * as ui from "../ui.js";
9
+ export async function runShare(deps, email) {
10
+ if (!isValidEmail(email))
11
+ throw new Error(`"${email}" doesn't look like a valid email address.`);
12
+ ui.intro("share");
13
+ const { token } = requireCredentials();
14
+ const { root, projectId } = requireProject(deps.cwd);
15
+ await deps.ensureAgentConfig(projectId, root);
16
+ await ui.withSpinner(`Inviting ${email}...`, "Invited.", () => deps.cloudApi.shareProject(token, projectId, email));
17
+ ui.outro(`${email} can now run \`resumecontext accept\` to get access.`);
18
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `resumecontext sync` -- scans local coding-agent history for this project
3
+ * and pushes only what's new since the last successful sync (see
4
+ * syncCore.ts). Shares the daemon's exclusive lock (daemon.ts) so a manual
5
+ * run can't race a background tick on the same sync-state cursor. Unlike
6
+ * the daemon, this only covers the current project (see requireProject),
7
+ * not every project in the registry.
8
+ */
9
+ import { acquireLock, releaseLock, deregisterProject } from "../daemon.js";
10
+ import { requireProject } from "../projectRoot.js";
11
+ import { requireCredentials } from "../session.js";
12
+ import { scanForNewTurns, pushNewTurns } from "../syncCore.js";
13
+ import { apiErrorStatus } from "../apiClient.js";
14
+ import * as ui from "../ui.js";
15
+ export async function runSync(deps) {
16
+ ui.intro("sync");
17
+ const { token } = requireCredentials();
18
+ const { root, projectId } = requireProject(deps.cwd);
19
+ // Before the spinner starts: this may prompt (first run), and an
20
+ // interactive prompt underneath a running spinner would fight it for the
21
+ // same lines. It also registers this project with the auto-sync daemon
22
+ // and makes sure one is running -- see agentConfigWithDaemon in index.ts.
23
+ const agentConfig = await deps.ensureAgentConfig(projectId, root);
24
+ if (!acquireLock()) {
25
+ throw new Error("Auto-sync is already running in the background. Wait a moment and try again.");
26
+ }
27
+ const spin = ui.spinner();
28
+ spin.start("Scanning local coding-agent history...");
29
+ let scanned = 0;
30
+ let newCount = 0;
31
+ let accepted = 0;
32
+ try {
33
+ const { allTurns, newTurns } = await scanForNewTurns(deps.localCapture, root, agentConfig, projectId);
34
+ scanned = allTurns.length;
35
+ newCount = newTurns.length;
36
+ if (newCount > 0) {
37
+ spin.message(`Found ${newCount} new turn(s) out of ${scanned} total. Pushing...`);
38
+ ({ accepted } = await pushNewTurns(deps.cloudApi, token, projectId, newTurns));
39
+ }
40
+ spin.stop("Synced.");
41
+ }
42
+ catch (err) {
43
+ spin.stop("Failed.");
44
+ if (apiErrorStatus(err) === 404) {
45
+ deregisterProject(projectId);
46
+ }
47
+ throw err;
48
+ }
49
+ finally {
50
+ releaseLock();
51
+ }
52
+ if (newCount === 0) {
53
+ ui.outro("Already up to date -- nothing new to push.");
54
+ }
55
+ else {
56
+ ui.outro(`Pushed ${accepted} new turn(s) out of ${scanned} scanned.`);
57
+ }
58
+ return { scanned, accepted };
59
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `resumecontext uninstall` -- stops the background auto-sync service and
3
+ * deletes every bit of local resumecontext state: credentials, every
4
+ * project's sync progress and agent configuration, the daemon's project
5
+ * registry and log. This is the one command in the whole CLI that can't
6
+ * be undone by running another command afterward, so it asks for
7
+ * confirmation first, defaulting to No.
8
+ */
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import { uninstallPersistentService } from "../daemonService.js";
12
+ import { defaultResumecontextHome, KNOWN_HOME_ENTRIES, resumecontextHome } from "../paths.js";
13
+ import * as ui from "../ui.js";
14
+ /** True if it's safe to delete `dir` as resumecontext state. The default
15
+ * `~/.resumecontext` path is always accepted (legacy/extra files from older
16
+ * versions shouldn't block uninstall). A custom RESUMECONTEXT_HOME must be
17
+ * empty or contain at least one known entry -- enough to show it's ours,
18
+ * without requiring every entry to match (which breaks whenever we rename
19
+ * something or the user has leftover files from an upgrade). */
20
+ function looksLikeResumecontextHome(dir) {
21
+ if (!fs.existsSync(dir))
22
+ return true;
23
+ if (dir === defaultResumecontextHome())
24
+ return true;
25
+ const entries = fs.readdirSync(dir);
26
+ if (entries.length === 0)
27
+ return true;
28
+ return entries.some((entry) => KNOWN_HOME_ENTRIES.has(entry));
29
+ }
30
+ export async function runUninstall(opts = {}) {
31
+ const confirmFn = opts.confirmFn ?? ((message) => ui.confirm(message, false));
32
+ const uninstallService = opts.uninstallService ?? uninstallPersistentService;
33
+ ui.intro("uninstall");
34
+ const home = resumecontextHome();
35
+ // A misconfigured RESUMECONTEXT_HOME (empty, "/", or the real home
36
+ // directory itself) must never turn this into "delete everything" --
37
+ // refuse outright rather than trust a single env var with something
38
+ // this destructive.
39
+ if (!home || home === "/" || home === os.homedir()) {
40
+ throw new Error(`Refusing to delete "${home}" -- this doesn't look like a resumecontext state directory.`);
41
+ }
42
+ // Beyond blocking known-dangerous values above, also refuse anything whose
43
+ // actual contents don't match what resumecontext creates -- a
44
+ // RESUMECONTEXT_HOME pointed at some unrelated directory should never get
45
+ // deleted just because it dodges the denylist.
46
+ if (!looksLikeResumecontextHome(home)) {
47
+ throw new Error(`Refusing to delete "${home}" -- it contains files resumecontext didn't create, so this doesn't look like a resumecontext state directory.`);
48
+ }
49
+ ui.log.warn("This will stop auto-sync and permanently delete:");
50
+ ui.log.warn(` ${home}`);
51
+ ui.log.warn("(your login session, and every project's sync progress and agent configuration)");
52
+ const confirmed = await confirmFn("Are you sure you want to uninstall resumecontext? This cannot be undone.");
53
+ if (!confirmed) {
54
+ ui.outro("Cancelled -- nothing was removed.");
55
+ return { uninstalled: false };
56
+ }
57
+ uninstallService();
58
+ fs.rmSync(home, { recursive: true, force: true });
59
+ ui.outro("Uninstalled. Run `resumecontext auth` any time to start fresh.");
60
+ return { uninstalled: true };
61
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Every tunable timing value in this CLI, in one place -- intervals,
3
+ * timeouts, TTLs, and thresholds that would otherwise be scattered across
4
+ * whichever file happens to use them, with no single place to see "how
5
+ * long does X take" or "how often does Y run" without hunting.
6
+ */
7
+ /** How often the OS re-invokes a tick.
8
+ *
9
+ * 20s, not something shorter: each tick is now a real process spin-up
10
+ * (Node/tsx startup, module load), not a loop iteration in an
11
+ * already-running process -- at 5s that was ~17,000 process launches a
12
+ * day, continuously waking the machine, for sync lag no user would
13
+ * actually notice the difference on. 20s keeps sync feeling immediate
14
+ * while cutting that by 4x. */
15
+ export const DAEMON_INTERVAL_MS = 20_000;
16
+ /** Rotate the log once it passes this. Each tick writes a line per push
17
+ * and per failure, and a persistent failure (revoked access, a deleted
18
+ * directory) logs every DAEMON_INTERVAL_MS forever -- at 20s, roughly 4.3k
19
+ * lines a day -- so an unbounded log file would quietly eat disk for as
20
+ * long as auto-sync stays scheduled, which is to say indefinitely. */
21
+ export const MAX_LOG_BYTES = 5_000_000;
22
+ /** How long after a session's source file(s) last changed before we treat
23
+ * its trailing turn as safe to sync. While still inside this window the
24
+ * session is "hot" and only turns *before* the last one are pushed -- the
25
+ * last turn may still be mid-write (valid JSON but semantically incomplete). */
26
+ export const SOURCE_SETTLE_MS = 5_000;
27
+ /** Path segment every versioned backend API call is prefixed with (e.g.
28
+ * `/v1/projects`). Bump when the backend introduces a breaking change and
29
+ * this CLI version is updated to speak the new one. */
30
+ export const API_VERSION = "v1";
31
+ export const DEVICE_CODE_TTL_MS = 5 * 60_000;
32
+ export const AUTH_POLL_INTERVAL_MS = 1_500;
33
+ export const API_REQUEST_TIMEOUT_MS = 15_000;
34
+ export const CURSOR_SQLITE_TIMEOUT_MS = 5_000;
35
+ /** Max turns per POST /projects/:id/turns request. Keeps a single sync of
36
+ * a large/long-lived project from assembling one huge request body, and
37
+ * means a failure partway through a big push only has to redo the batch
38
+ * that failed (idempotent regardless, via the backend's content-hash
39
+ * dedup -- this is about efficiency, not correctness) instead of
40
+ * everything scanned that tick. */
41
+ export const PUSH_BATCH_SIZE = 500;
42
+ /**
43
+ * Max BYTES per push, which is the bound that actually matters.
44
+ *
45
+ * A turn count is not a bound on request size: capture keeps full tool output,
46
+ * so turn length spans orders of magnitude -- measured across the two eval
47
+ * corpora, the average turn is ~1.4-2KB while the largest single turn is
48
+ * 626KB. 500 of those is not "500 turns", it is up to 300MB, and the server
49
+ * has to hold the raw body AND its parsed form AND re-serialise each turn to
50
+ * hash it. That is what OOM-killed a 150MB backend mid-sync.
51
+ *
52
+ * Batches are therefore closed on whichever limit is reached first. A single
53
+ * turn larger than this budget is still sent on its own -- splitting a turn
54
+ * would corrupt it, and the server's own limit is the backstop that turns
55
+ * "too big" into a clear error instead of a dead process.
56
+ *
57
+ * 500KB rather than something rounder because peak server RSS was measured at
58
+ * about 20x the body: 0.5MB costs ~13MB, 1MB ~23MB, 2MB ~42MB, against a
59
+ * 150MB process that idles at 60MB and must still serve reads while syncing.
60
+ */
61
+ export const PUSH_BATCH_BYTES = 500_000;