resumecontext 0.1.3 → 0.1.4

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.
@@ -81,7 +81,7 @@ export async function runDaemonStart(ensureDaemonRunningFn = ensureDaemonRunning
81
81
  const platform = detectPlatform();
82
82
  const result = platform === "unsupported" ? { platform, installed: false } : ensureDaemonRunningFn();
83
83
  printDaemonNotice(result);
84
- if (result.platform === "unsupported" || result.location === "temporary") {
84
+ if (result.platform === "unsupported" || result.location === "temporary" || result.error) {
85
85
  ui.outro(result.platform === "unsupported" ? "Not available." : "Not started.");
86
86
  return { started: false };
87
87
  }
package/dist/daemon.js CHANGED
@@ -49,7 +49,7 @@ import { readAgentConfig } from "./agentConfig.js";
49
49
  import { readCredentials } from "./session.js";
50
50
  import { scanForNewTurns, pushNewTurns } from "./syncCore.js";
51
51
  import { MAX_LOG_BYTES } from "./constants.js";
52
- import { DAEMON_SUBCOMMAND, detectPlatform, installPersistentService } from "./daemonService.js";
52
+ import { DAEMON_SUBCOMMAND, detectPlatform, installPersistentService, stopsAtLogout } from "./daemonService.js";
53
53
  import { installLocation } from "./packageInstall.js";
54
54
  import { fingerprintDirsForConfig } from "./localHistory/registry.js";
55
55
  import { findProjectRoot } from "./projectRoot.js";
@@ -98,7 +98,6 @@ export function deregisterProject(projectId) {
98
98
  fs.rmSync(agentConfigFile(projectId), { force: true });
99
99
  fs.rmSync(syncStateFile(projectId), { force: true });
100
100
  }
101
- // ---- lifecycle: making sure auto-sync is scheduled with the OS --------
102
101
  /** Registers auto-sync as a real OS-managed schedule if this platform
103
102
  * supports one (see daemonService.ts) -- no fallback of any kind on a
104
103
  * platform with neither launchd nor systemd, so what's registered is
@@ -119,10 +118,17 @@ export function deregisterProject(projectId) {
119
118
  * `installService` is injectable so tests can verify what would be
120
119
  * installed -- which mechanism, which plan -- without ever registering a
121
120
  * real OS service. */
122
- export function ensureDaemonRunning(installService = installPersistentService, location = installLocation()) {
121
+ export function ensureDaemonRunning(installService = installPersistentService, location = installLocation(), checkStopsAtLogout = stopsAtLogout) {
123
122
  if (location === "temporary")
124
123
  return { platform: detectPlatform(), installed: false, location };
125
- return { ...installService(), location };
124
+ try {
125
+ const result = { ...installService(), location };
126
+ // Only on a fresh registration, so this runs once, not on every command.
127
+ return result.installed && checkStopsAtLogout() ? { ...result, stopsAtLogout: true } : result;
128
+ }
129
+ catch (err) {
130
+ return { platform: detectPlatform(), installed: false, location, error: err instanceof Error ? err.message : String(err) };
131
+ }
126
132
  }
127
133
  // ---- log rotation -------------------------------------------------------
128
134
  /** Keeps daemonLogFile() bounded, retaining one previous generation.
@@ -5,10 +5,19 @@ export function printDaemonNotice(result) {
5
5
  "Install it with `npm install -g resumecontext` to sync in the background. `resumecontext sync` still works.");
6
6
  return;
7
7
  }
8
+ if (result.error) {
9
+ ui.log.warn(`Auto-sync couldn't be set up: ${result.error}\n` +
10
+ "Everything else works, and `resumecontext sync` syncs by hand. Run `resumecontext daemon start` to retry.");
11
+ return;
12
+ }
8
13
  if (result.platform === "unsupported") {
9
14
  ui.log.warn("Auto-sync isn't available on this OS (no launchd or systemd found) -- run `resumecontext sync` manually.");
10
15
  return;
11
16
  }
17
+ if (result.stopsAtLogout) {
18
+ ui.log.warn("Auto-sync will stop when you log out of this machine. To keep it running (for example on a server), run:\n" +
19
+ " sudo loginctl enable-linger $USER");
20
+ }
12
21
  // Only when (re)registered, which is the first run and after an update --
13
22
  // not on every command.
14
23
  if (result.location === "folder" && result.installed) {
@@ -171,16 +171,23 @@ ${envBlock} <key>RunAtLoad</key>
171
171
  afterRemoveCommands: [],
172
172
  };
173
173
  }
174
+ export function systemdScope() {
175
+ return process.getuid?.() === 0 ? "system" : "user";
176
+ }
174
177
  /** Same `version` parameter and reasoning as buildLaunchdPlan above. */
175
- export function buildSystemdPlan(homeDir = os.homedir(), version = cliVersion()) {
178
+ export function buildSystemdPlan(homeDir = os.homedir(), version = cliVersion(), scope = "user") {
176
179
  const args = daemonProgramArguments();
177
180
  const logFile = daemonLogFile();
178
181
  const { systemdService: SYSTEMD_SERVICE_UNIT, systemdTimer: SYSTEMD_TIMER_UNIT } = serviceNames();
179
- const systemdUserDir = path.join(homeDir, ".config", "systemd", "user");
180
- const servicePath = path.join(systemdUserDir, SYSTEMD_SERVICE_UNIT);
181
- const timerPath = path.join(systemdUserDir, SYSTEMD_TIMER_UNIT);
182
+ const unitDir = scope === "system" ? "/etc/systemd/system" : path.join(homeDir, ".config", "systemd", "user");
183
+ const servicePath = path.join(unitDir, SYSTEMD_SERVICE_UNIT);
184
+ const timerPath = path.join(unitDir, SYSTEMD_TIMER_UNIT);
182
185
  const quote = (s) => (/\s/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s);
183
- const envLines = Object.entries(daemonEnvironment())
186
+ const systemctl = scope === "system" ? ["systemctl"] : ["systemctl", "--user"];
187
+ // A system service gets no $HOME of its own; set it, so every "~" the
188
+ // CLI resolves (its state, each agent's history) is the installing user's.
189
+ const env = scope === "system" ? { HOME: homeDir, ...daemonEnvironment() } : daemonEnvironment();
190
+ const envLines = Object.entries(env)
184
191
  .map(([k, v]) => `Environment=${k}=${quote(v)}\n`)
185
192
  .join("");
186
193
  const serviceContents = `# resumecontext-cli-version: ${version}
@@ -196,13 +203,16 @@ StandardError=append:${logFile}
196
203
  // OnBootSec=0 fires the first tick immediately once the timer starts
197
204
  // (mirrors launchd's RunAtLoad); OnUnitActiveSec counts from when the
198
205
  // triggered service last finished, not from a fixed clock, so a slow
199
- // tick doesn't cause the next one to fire early or overlap.
206
+ // tick doesn't cause the next one to fire early or overlap. AccuracySec:
207
+ // systemd otherwise lets a timer fire anywhere in a 1-minute window, which
208
+ // turned a 20s interval into 20-60s.
200
209
  const timerContents = `[Unit]
201
210
  Description=resumecontext auto-sync timer
202
211
 
203
212
  [Timer]
204
213
  OnBootSec=0
205
214
  OnUnitActiveSec=${DAEMON_INTERVAL_SECONDS}s
215
+ AccuracySec=1s
206
216
  Unit=${SYSTEMD_SERVICE_UNIT}
207
217
 
208
218
  [Install]
@@ -214,28 +224,55 @@ WantedBy=timers.target
214
224
  { path: timerPath, contents: timerContents },
215
225
  ],
216
226
  // Checks the TIMER, not the oneshot service -- see ServicePlan's doc.
217
- statusCommand: ["systemctl", "--user", "is-active", "--quiet", SYSTEMD_TIMER_UNIT],
227
+ statusCommand: [...systemctl, "is-active", "--quiet", SYSTEMD_TIMER_UNIT],
218
228
  // Enabling/starting the TIMER is what schedules the service; the
219
229
  // service unit itself is never enabled or started directly.
220
230
  installCommands: [
221
- ["systemctl", "--user", "daemon-reload"],
222
- ["systemctl", "--user", "enable", "--now", SYSTEMD_TIMER_UNIT],
231
+ [...systemctl, "daemon-reload"],
232
+ [...systemctl, "enable", "--now", SYSTEMD_TIMER_UNIT],
223
233
  ],
224
- uninstallCommands: [["systemctl", "--user", "disable", "--now", SYSTEMD_TIMER_UNIT]],
234
+ uninstallCommands: [[...systemctl, "disable", "--now", SYSTEMD_TIMER_UNIT]],
225
235
  // Stopping the timer does not stop a oneshot run it already started.
226
- stopRunningCommands: [["systemctl", "--user", "stop", SYSTEMD_SERVICE_UNIT]],
227
- afterRemoveCommands: [["systemctl", "--user", "daemon-reload"]],
236
+ stopRunningCommands: [[...systemctl, "stop", SYSTEMD_SERVICE_UNIT]],
237
+ afterRemoveCommands: [[...systemctl, "daemon-reload"]],
228
238
  };
229
239
  }
230
- export function buildPlan(platform, homeDir, version) {
240
+ export function buildPlan(platform, homeDir, version, scope = systemdScope()) {
231
241
  if (platform === "launchd")
232
242
  return buildLaunchdPlan(homeDir, version);
233
243
  if (platform === "systemd")
234
- return buildSystemdPlan(homeDir, version);
244
+ return buildSystemdPlan(homeDir, version, scope);
235
245
  return null;
236
246
  }
237
- function defaultExec(command, args) {
238
- execFileSync(command, args, { stdio: "ignore" });
247
+ export function defaultExec(command, args) {
248
+ try {
249
+ execFileSync(command, args, { stdio: ["ignore", "ignore", "pipe"] });
250
+ }
251
+ catch (err) {
252
+ if (err.code === "ENOENT")
253
+ throw new Error(`\`${command}\` was not found on this machine`);
254
+ const stderr = String(err.stderr ?? "").trim();
255
+ throw new Error(`\`${[command, ...args].join(" ")}\` failed${stderr ? `: ${stderr}` : ""}`);
256
+ }
257
+ }
258
+ /**
259
+ * True when a user-scope systemd timer will stop the moment this user logs
260
+ * out -- the normal state for an SSH account on a server, and invisible
261
+ * until syncing quietly stops. False when lingering is on, for system scope,
262
+ * or when it can't be determined.
263
+ */
264
+ export function stopsAtLogout(run = defaultRun) {
265
+ if (detectPlatform() !== "systemd" || systemdScope() !== "user")
266
+ return false;
267
+ try {
268
+ return run("loginctl", ["show-user", os.userInfo().username, "--property=Linger", "--value"]).trim() === "no";
269
+ }
270
+ catch {
271
+ return false;
272
+ }
273
+ }
274
+ function defaultRun(command, args) {
275
+ return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
239
276
  }
240
277
  /** True if `plan`'s schedule is already registered/active. */
241
278
  export function isServiceActive(execImpl = defaultExec, homeDir) {
@@ -250,6 +287,46 @@ export function isServiceActive(execImpl = defaultExec, homeDir) {
250
287
  return false;
251
288
  }
252
289
  }
290
+ /**
291
+ * Registrations an earlier version may have left that the current plan
292
+ * replaces. Today that is one case: root on systemd used to get a USER timer
293
+ * (~/.config/systemd/user) and now gets a system one. Left in place, it would
294
+ * keep firing a second timer -- and survive `daemon stop` and `uninstall`,
295
+ * which only know the current plan.
296
+ */
297
+ function supersededPlans(platform, homeDir) {
298
+ return platform === "systemd" && systemdScope() === "system" ? [buildSystemdPlan(homeDir, undefined, "user")] : [];
299
+ }
300
+ /** Best effort, and a no-op unless one of their files is actually on disk --
301
+ * so the common case runs no commands at all. */
302
+ function removeSupersededPlans(platform, homeDir, execImpl, fsImpl) {
303
+ let removed = false;
304
+ for (const plan of supersededPlans(platform, homeDir)) {
305
+ const present = plan.files.filter((f) => fsImpl.existsSync(f.path));
306
+ if (present.length === 0)
307
+ continue;
308
+ for (const [cmd, ...args] of [...plan.uninstallCommands, ...plan.stopRunningCommands]) {
309
+ try {
310
+ execImpl(cmd, args);
311
+ }
312
+ catch {
313
+ // that manager may not even be reachable -- removing the files is what matters
314
+ }
315
+ }
316
+ for (const file of present)
317
+ fsImpl.unlinkSync(file.path);
318
+ for (const [cmd, ...args] of plan.afterRemoveCommands) {
319
+ try {
320
+ execImpl(cmd, args);
321
+ }
322
+ catch {
323
+ // best effort
324
+ }
325
+ }
326
+ removed = true;
327
+ }
328
+ return removed;
329
+ }
253
330
  function readFileIfPresent(fsImpl, filePath) {
254
331
  try {
255
332
  return fsImpl.readFileSync(filePath, "utf8");
@@ -270,6 +347,13 @@ export function installPersistentService(fsImpl = fs, execImpl = defaultExec, ho
270
347
  const plan = buildPlan(platform, homeDir);
271
348
  if (!plan)
272
349
  return { platform, installed: false };
350
+ // systemd refuses to start a unit whose StandardOutput=append: file sits
351
+ // in a directory that doesn't exist ("Failed to set up standard output",
352
+ // status 209) -- and nothing else creates it until a project is added.
353
+ // Created on every call, before the early return, so a deleted directory
354
+ // is repaired by the next command too.
355
+ fsImpl.mkdirSync(path.dirname(daemonLogFile()), { recursive: true });
356
+ removeSupersededPlans(platform, homeDir, execImpl, fsImpl);
273
357
  // "Already active" is NOT enough to skip: what's registered could be
274
358
  // running the WRONG program, or an OUTDATED one. Two distinct cases,
275
359
  // both covered by the same plain string comparison:
@@ -313,6 +397,7 @@ export function uninstallPersistentService(execImpl = defaultExec, homeDir, fsIm
313
397
  if (!plan)
314
398
  return { platform, uninstalled: false };
315
399
  const wasActive = isServiceActive(execImpl, homeDir);
400
+ const removedSuperseded = removeSupersededPlans(platform, homeDir, execImpl, fsImpl);
316
401
  for (const [cmd, ...args] of [...plan.uninstallCommands, ...plan.stopRunningCommands]) {
317
402
  try {
318
403
  execImpl(cmd, args);
@@ -338,5 +423,5 @@ export function uninstallPersistentService(execImpl = defaultExec, homeDir, fsIm
338
423
  }
339
424
  }
340
425
  }
341
- return { platform, uninstalled: wasActive || removedFiles };
426
+ return { platform, uninstalled: wasActive || removedFiles || removedSuperseded };
342
427
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resumecontext",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Sync your coding-agent sessions to a shared archive your agents can search over MCP.",
5
5
  "keywords": [
6
6
  "mcp",