@mnemom/mnemom 0.17.3-next.0 → 0.17.3-next.2

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.
@@ -33,7 +33,8 @@ import { basename, join, dirname } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
34
  import { MNEMOM_DIR } from "../lib/config.js";
35
35
  import { getAgentByName } from "../lib/api.js";
36
- import { LEDGER_FILE, appendSessionRecord, toLedgerContract } from "../lib/agent-ledger.js";
36
+ import { LEDGER_FILE, appendSessionRecord, resolveRetentionDays, toLedgerContract, } from "../lib/agent-ledger.js";
37
+ import { loadCliConfig } from "../lib/cli-config.js";
37
38
  import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
38
39
  import { fmt } from "../lib/format.js";
39
40
  import { askInput, askYesNo, isInteractive } from "../lib/prompt.js";
@@ -377,7 +378,12 @@ export async function recordSessionLaunch(ctx) {
377
378
  contract: toLedgerContract(ctx.envelope?.contract ?? null),
378
379
  goal_mode: goalAlignmentMode(ctx.envelope, ctx.goalMode),
379
380
  cc_session_id: ctx.ccSessionId ?? null,
380
- }, { dir: MNEMOM_DIR, now: () => new Date(), pid: process.pid });
381
+ }, {
382
+ dir: MNEMOM_DIR,
383
+ now: () => new Date(),
384
+ pid: process.pid,
385
+ retentionDays: resolveRetentionDays(loadCliConfig().sessions?.retentionDays, process.env),
386
+ });
381
387
  if (!result.ok) {
382
388
  say(fmt.warn(`mnemom agent: could not record this session in ~/.mnemom/${LEDGER_FILE} (${result.error}) — launching anyway.`));
383
389
  }
@@ -1,30 +1,30 @@
1
1
  /**
2
2
  * `mnemom config list|get|set|unset` — CLI-wide preferences in
3
- * ~/.mnemom/config.json. Today: the auto-update keys. (Launcher settings for
3
+ * ~/.mnemom/config.json: auto-update and local session retention. (Launcher settings for
4
4
  * `mnemom agent` stay in `mnemom agent config` / launcher.toml.)
5
5
  *
6
6
  * Keys are data (CONFIG_KEYS) so list/get/set/unset share one source of truth.
7
7
  */
8
- import { type InstallHint, type UpdatePrefs } from "../lib/update/decide.js";
9
- import { setUpdatePref } from "../lib/cli-config.js";
8
+ import { type InstallHint } from "../lib/update/decide.js";
9
+ import { setPref, type CliConfig } from "../lib/cli-config.js";
10
10
  export interface CliConfigKey {
11
11
  key: string;
12
12
  describe: string;
13
13
  env: string;
14
14
  /** Stored value (undefined when unset). */
15
- stored(prefs: UpdatePrefs): string | undefined;
15
+ stored(config: CliConfig): string | undefined;
16
16
  /** Value in effect after env + default (`hint` picks the channel default). */
17
- effective(prefs: UpdatePrefs, env: Record<string, string | undefined>, hint: InstallHint): string;
17
+ effective(config: CliConfig, env: Record<string, string | undefined>, hint: InstallHint): string;
18
18
  /** Validate + persist; throws with a user-facing message on a bad value. */
19
- set(raw: string, persist: typeof setUpdatePref): void;
20
- unset(persist: typeof setUpdatePref): void;
19
+ set(raw: string, persist: typeof setPref): void;
20
+ unset(persist: typeof setPref): void;
21
21
  }
22
22
  export declare const CONFIG_KEYS: readonly CliConfigKey[];
23
23
  export declare function findKey(key: string): CliConfigKey;
24
24
  export interface ConfigDeps {
25
- prefs(): UpdatePrefs;
25
+ config(): CliConfig;
26
26
  env: Record<string, string | undefined>;
27
- persist: typeof setUpdatePref;
27
+ persist: typeof setPref;
28
28
  log(line: string): void;
29
29
  /** Running version + last-followed channel (picks the default channel). */
30
30
  hint: InstallHint;
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * `mnemom config list|get|set|unset` — CLI-wide preferences in
3
- * ~/.mnemom/config.json. Today: the auto-update keys. (Launcher settings for
3
+ * ~/.mnemom/config.json: auto-update and local session retention. (Launcher settings for
4
4
  * `mnemom agent` stay in `mnemom agent config` / launcher.toml.)
5
5
  *
6
6
  * Keys are data (CONFIG_KEYS) so list/get/set/unset share one source of truth.
7
7
  */
8
8
  import { isUpdateChannel, isUpdateMode, resolveChannel, resolveMode, UPDATE_CHANNELS, UPDATE_MODES, } from "../lib/update/decide.js";
9
- import { loadCliConfig, setUpdatePref } from "../lib/cli-config.js";
9
+ import { DEFAULT_RETENTION_DAYS, parseRetentionDays, resolveRetentionDays, RETENTION_ENV, } from "../lib/agent-ledger.js";
10
+ import { loadCliConfig, setPref } from "../lib/cli-config.js";
10
11
  import { loadUpdateState } from "../lib/update/state.js";
11
12
  import { CLI_VERSION } from "../version.js";
12
13
  export const CONFIG_KEYS = [
@@ -14,29 +15,43 @@ export const CONFIG_KEYS = [
14
15
  key: "update.channel",
15
16
  describe: `Release channel for auto-update: ${UPDATE_CHANNELS.join(" | ")} (default: next on a next build, else latest; next requires an invite)`,
16
17
  env: "MNEMOM_UPDATE_CHANNEL",
17
- stored: (p) => p.channel,
18
- effective: (p, env, hint) => resolveChannel(p, env, undefined, hint),
18
+ stored: (c) => c.update?.channel,
19
+ effective: (c, env, hint) => resolveChannel(c.update ?? {}, env, undefined, hint),
19
20
  set: (raw, persist) => {
20
21
  const v = raw.trim();
21
22
  if (!isUpdateChannel(v))
22
23
  throw new Error(`update.channel must be one of ${UPDATE_CHANNELS.join(", ")} (got '${raw}').`);
23
- persist("channel", v);
24
+ persist("update", "channel", v);
24
25
  },
25
- unset: (persist) => persist("channel", undefined),
26
+ unset: (persist) => persist("update", "channel", undefined),
26
27
  },
27
28
  {
28
29
  key: "update.mode",
29
30
  describe: `Auto-update behaviour: ${UPDATE_MODES.join(" | ")}`,
30
31
  env: "MNEMOM_UPDATE_MODE",
31
- stored: (p) => p.mode,
32
- effective: (p, env) => resolveMode(p, env),
32
+ stored: (c) => c.update?.mode,
33
+ effective: (c, env) => resolveMode(c.update ?? {}, env),
33
34
  set: (raw, persist) => {
34
35
  const v = raw.trim();
35
36
  if (!isUpdateMode(v))
36
37
  throw new Error(`update.mode must be one of ${UPDATE_MODES.join(", ")} (got '${raw}').`);
37
- persist("mode", v);
38
+ persist("update", "mode", v);
38
39
  },
39
- unset: (persist) => persist("mode", undefined),
40
+ unset: (persist) => persist("update", "mode", undefined),
41
+ },
42
+ {
43
+ key: "sessions.retention_days",
44
+ describe: `Days \`mnemom agent sessions\` keeps a launch in ~/.mnemom/sessions.jsonl (default ${DEFAULT_RETENTION_DAYS}; older ones are removed at the next launch)`,
45
+ env: RETENTION_ENV,
46
+ stored: (c) => c.sessions?.retentionDays?.toString(),
47
+ effective: (c, env) => String(resolveRetentionDays(c.sessions?.retentionDays, env)),
48
+ set: (raw, persist) => {
49
+ const days = parseRetentionDays(raw);
50
+ if (days === null)
51
+ throw new Error(`sessions.retention_days must be a whole number of days, at least 1 (got '${raw}').`);
52
+ persist("sessions", "retentionDays", days);
53
+ },
54
+ unset: (persist) => persist("sessions", "retentionDays", undefined),
40
55
  },
41
56
  ];
42
57
  export function findKey(key) {
@@ -47,22 +62,22 @@ export function findKey(key) {
47
62
  }
48
63
  function defaultDeps() {
49
64
  return {
50
- prefs: () => loadCliConfig().update ?? {},
65
+ config: loadCliConfig,
51
66
  env: process.env,
52
- persist: setUpdatePref,
67
+ persist: setPref,
53
68
  log: (line) => console.log(line),
54
69
  hint: { current: CLI_VERSION, followed: loadUpdateState().followChannel },
55
70
  };
56
71
  }
57
72
  /** One line per key: `key = effective (source) — description`. */
58
73
  export function configListCommand(opts = {}, deps = defaultDeps()) {
59
- const prefs = deps.prefs();
74
+ const config = deps.config();
60
75
  const rows = CONFIG_KEYS.map((k) => {
61
- const stored = k.stored(prefs);
76
+ const stored = k.stored(config);
62
77
  const source = deps.env[k.env] ? `env ${k.env}` : stored !== undefined ? "config" : "default";
63
78
  return {
64
79
  key: k.key,
65
- value: k.effective(prefs, deps.env, deps.hint),
80
+ value: k.effective(config, deps.env, deps.hint),
66
81
  source,
67
82
  describe: k.describe,
68
83
  };
@@ -75,7 +90,7 @@ export function configListCommand(opts = {}, deps = defaultDeps()) {
75
90
  deps.log(`${r.key} = ${r.value} (${r.source}) — ${r.describe}`);
76
91
  }
77
92
  export function configGetCommand(key, deps = defaultDeps()) {
78
- deps.log(findKey(key).effective(deps.prefs(), deps.env, deps.hint));
93
+ deps.log(findKey(key).effective(deps.config(), deps.env, deps.hint));
79
94
  }
80
95
  export function configSetCommand(key, value, deps = defaultDeps()) {
81
96
  const spec = findKey(key);
package/dist/index.js CHANGED
@@ -1898,7 +1898,7 @@ program
1898
1898
  .action((options) => runAction(() => updateCommand(options)));
1899
1899
  const configCmd = program
1900
1900
  .command("config")
1901
- .description("CLI-wide preferences in ~/.mnemom/config.json (auto-update channel and mode)");
1901
+ .description("CLI-wide preferences in ~/.mnemom/config.json (auto-update, session retention)");
1902
1902
  configCmd
1903
1903
  .command("list")
1904
1904
  .description("Show every setting, its effective value, and where it came from")
@@ -9,12 +9,24 @@
9
9
  *
10
10
  * Writes are best-effort: `appendSessionRecord` never throws — a failure comes
11
11
  * back as `{ ok: false, error }` and the caller decides how loudly to warn.
12
+ *
13
+ * Retention mirrors Claude Code's `cleanupPeriodDays`: launches older than
14
+ * `sessions.retention_days` (default 30) are dropped when the next one is
15
+ * recorded. No count cap — a long retention keeps everything in the window.
12
16
  */
13
17
  import type { LaunchShape, SealedContract } from "./agent.js";
14
18
  /** The ledger's file name under MNEMOM_DIR. */
15
19
  export declare const LEDGER_FILE = "sessions.jsonl";
16
- /** The ledger keeps only the newest this-many launches. */
17
- export declare const LEDGER_CAP = 200;
20
+ /** Days a launch stays in the ledger when `sessions.retention_days` is unset. */
21
+ export declare const DEFAULT_RETENTION_DAYS = 30;
22
+ export declare const RETENTION_ENV = "MNEMOM_SESSIONS_RETENTION_DAYS";
23
+ /** A retention setting must be a whole number of days, at least 1. */
24
+ export declare function parseRetentionDays(raw: string): number | null;
25
+ /**
26
+ * Precedence: MNEMOM_SESSIONS_RETENTION_DAYS > config.json > 30. An invalid value from either
27
+ * source is ignored (config.json can be hand-edited past `mnemom config set`'s validation).
28
+ */
29
+ export declare function resolveRetentionDays(stored: unknown, env: Record<string, string | undefined>): number;
18
30
  /** The sealed-contract fields the ledger keeps (no goal_id — the statement is the identity). */
19
31
  export interface SessionRecordContract {
20
32
  statement: string;
@@ -76,6 +88,8 @@ export interface LedgerDeps {
76
88
  now: () => Date;
77
89
  /** The writing process's pid (names the rewrite temp file). */
78
90
  pid: number;
91
+ /** Drop launches older than this many days (default DEFAULT_RETENTION_DAYS). */
92
+ retentionDays?: number;
79
93
  }
80
94
  export type AppendResult = {
81
95
  ok: true;
@@ -93,11 +107,11 @@ export declare function toLedgerContract(sealed: SealedContract | null): Session
93
107
  /** The ledger's path under `dir`. */
94
108
  export declare function ledgerPath(dir: string): string;
95
109
  /**
96
- * Append one launch to the ledger, atomically and capped:
110
+ * Append one launch to the ledger, dropping expired ones:
97
111
  * - `dir` is created 0o700 when missing; the file is created 0o600;
98
- * - the new line is appended when the ledger stays within LEDGER_CAP lines,
99
- * otherwise the newest LEDGER_CAP lines (this one included) are rewritten
100
- * via a temp file + rename, so a reader never sees a torn file.
112
+ * - when no launch is older than the retention window the new line is
113
+ * appended; otherwise the surviving lines plus this one are rewritten via a
114
+ * temp file + rename, so a reader never sees a torn file.
101
115
  * Never throws — a failure is returned as `{ ok: false, error }`.
102
116
  */
103
117
  export declare function appendSessionRecord(input: SessionRecordInput, deps: LedgerDeps): AppendResult;
@@ -9,13 +9,36 @@
9
9
  *
10
10
  * Writes are best-effort: `appendSessionRecord` never throws — a failure comes
11
11
  * back as `{ ok: false, error }` and the caller decides how loudly to warn.
12
+ *
13
+ * Retention mirrors Claude Code's `cleanupPeriodDays`: launches older than
14
+ * `sessions.retention_days` (default 30) are dropped when the next one is
15
+ * recorded. No count cap — a long retention keeps everything in the window.
12
16
  */
13
17
  import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
14
18
  import { join } from "node:path";
15
19
  /** The ledger's file name under MNEMOM_DIR. */
16
20
  export const LEDGER_FILE = "sessions.jsonl";
17
- /** The ledger keeps only the newest this-many launches. */
18
- export const LEDGER_CAP = 200;
21
+ /** Days a launch stays in the ledger when `sessions.retention_days` is unset. */
22
+ export const DEFAULT_RETENTION_DAYS = 30;
23
+ export const RETENTION_ENV = "MNEMOM_SESSIONS_RETENTION_DAYS";
24
+ const DAY_MS = 24 * 60 * 60 * 1000;
25
+ /** A retention setting must be a whole number of days, at least 1. */
26
+ export function parseRetentionDays(raw) {
27
+ const v = raw.trim();
28
+ if (!/^\d+$/.test(v))
29
+ return null;
30
+ const n = Number(v);
31
+ return Number.isSafeInteger(n) && n >= 1 ? n : null;
32
+ }
33
+ /**
34
+ * Precedence: MNEMOM_SESSIONS_RETENTION_DAYS > config.json > 30. An invalid value from either
35
+ * source is ignored (config.json can be hand-edited past `mnemom config set`'s validation).
36
+ */
37
+ export function resolveRetentionDays(stored, env) {
38
+ const fromEnv = env[RETENTION_ENV] === undefined ? null : parseRetentionDays(env[RETENTION_ENV]);
39
+ const fromConfig = typeof stored === "number" && Number.isSafeInteger(stored) && stored >= 1 ? stored : null;
40
+ return fromEnv ?? fromConfig ?? DEFAULT_RETENTION_DAYS;
41
+ }
19
42
  /**
20
43
  * Project a sealed contract onto the ledger's contract shape (drops `goal_id`,
21
44
  * keeps only defined fields). `null` in, `null` out.
@@ -58,25 +81,43 @@ function readLedgerText(path) {
58
81
  }
59
82
  }
60
83
  /**
61
- * Append one launch to the ledger, atomically and capped:
84
+ * True when `line` is a launch that started before `cutoffMs`. A line whose
85
+ * age can't be read is kept — never delete what we can't date.
86
+ */
87
+ function isExpired(line, cutoffMs) {
88
+ try {
89
+ const startedAt = JSON.parse(line).started_at;
90
+ if (typeof startedAt !== "string")
91
+ return false;
92
+ const t = Date.parse(startedAt);
93
+ return !Number.isNaN(t) && t < cutoffMs;
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
99
+ /**
100
+ * Append one launch to the ledger, dropping expired ones:
62
101
  * - `dir` is created 0o700 when missing; the file is created 0o600;
63
- * - the new line is appended when the ledger stays within LEDGER_CAP lines,
64
- * otherwise the newest LEDGER_CAP lines (this one included) are rewritten
65
- * via a temp file + rename, so a reader never sees a torn file.
102
+ * - when no launch is older than the retention window the new line is
103
+ * appended; otherwise the surviving lines plus this one are rewritten via a
104
+ * temp file + rename, so a reader never sees a torn file.
66
105
  * Never throws — a failure is returned as `{ ok: false, error }`.
67
106
  */
68
107
  export function appendSessionRecord(input, deps) {
69
108
  const path = ledgerPath(deps.dir);
70
109
  try {
71
- const record = { v: 1, started_at: deps.now().toISOString(), ...input };
110
+ const now = deps.now();
111
+ const record = { v: 1, started_at: now.toISOString(), ...input };
72
112
  mkdirSync(deps.dir, { recursive: true, mode: 0o700 });
73
113
  const line = `${JSON.stringify(record)}\n`;
74
114
  const text = readLedgerText(path);
75
115
  const existing = text === null ? [] : nonEmptyLines(text);
76
- if (existing.length + 1 > LEDGER_CAP) {
77
- const kept = [...existing, JSON.stringify(record)].slice(-LEDGER_CAP);
78
- const tmp = join(deps.dir, `.${LEDGER_FILE}.tmp-${deps.pid}-${deps.now().getTime()}`);
79
- writeFileSync(tmp, `${kept.join("\n")}\n`, { mode: 0o600 });
116
+ const cutoff = now.getTime() - (deps.retentionDays ?? DEFAULT_RETENTION_DAYS) * DAY_MS;
117
+ const kept = existing.filter((l) => !isExpired(l, cutoff));
118
+ if (kept.length < existing.length) {
119
+ const tmp = join(deps.dir, `.${LEDGER_FILE}.tmp-${deps.pid}-${now.getTime()}`);
120
+ writeFileSync(tmp, `${[...kept, JSON.stringify(record)].join("\n")}\n`, { mode: 0o600 });
80
121
  renameSync(tmp, path);
81
122
  }
82
123
  else {
@@ -23,10 +23,20 @@ export interface ActiveOrg {
23
23
  slug: string;
24
24
  name: string;
25
25
  }
26
+ /** Local session-ledger preferences (`mnemom config set sessions.retention_days`). */
27
+ export interface SessionPrefs {
28
+ retentionDays?: number;
29
+ }
26
30
  export interface CliConfig {
27
31
  activeOrg?: ActiveOrg;
28
32
  /** Auto-update preferences (`mnemom config set update.channel|update.mode`). */
29
33
  update?: UpdatePrefs;
34
+ sessions?: SessionPrefs;
35
+ }
36
+ /** The config.json sections `mnemom config` edits. */
37
+ export interface PrefSections {
38
+ update: UpdatePrefs;
39
+ sessions: SessionPrefs;
30
40
  }
31
41
  /** Load the config; a missing or corrupt file degrades to `{}`. */
32
42
  export declare function loadCliConfig(): CliConfig;
@@ -35,7 +45,10 @@ export declare function setActiveOrg(org: ActiveOrg | null): void;
35
45
  /** The active org, or undefined when none is set. */
36
46
  export declare function getActiveOrg(): ActiveOrg | undefined;
37
47
  /**
38
- * Set (or with `undefined`, clear) one auto-update preference. Validation is
39
- * the caller's job (commands/config.ts) — this only persists.
48
+ * Set (or with `undefined`, clear) one preference in a config.json section;
49
+ * an emptied section is removed. Validation is the caller's job
50
+ * (commands/config.ts) — this only persists.
40
51
  */
52
+ export declare function setPref<S extends keyof PrefSections, K extends keyof PrefSections[S] & string>(section: S, key: K, value: PrefSections[S][K] | undefined): void;
53
+ /** One auto-update preference — `setPref("update", …)`. */
41
54
  export declare function setUpdatePref<K extends keyof UpdatePrefs>(key: K, value: UpdatePrefs[K] | undefined): void;
@@ -69,15 +69,18 @@ export function getActiveOrg() {
69
69
  return loadCliConfig().activeOrg;
70
70
  }
71
71
  /**
72
- * Set (or with `undefined`, clear) one auto-update preference. Validation is
73
- * the caller's job (commands/config.ts) — this only persists.
72
+ * Set (or with `undefined`, clear) one preference in a config.json section;
73
+ * an emptied section is removed. Validation is the caller's job
74
+ * (commands/config.ts) — this only persists.
74
75
  */
75
- export function setUpdatePref(key, value) {
76
+ export function setPref(section, key, value) {
76
77
  const config = loadCliConfig();
77
- const update = Object.fromEntries(Object.entries({ ...config.update, [key]: value }).filter(([, v]) => v !== undefined));
78
- if (Object.keys(update).length === 0)
79
- delete config.update;
80
- else
81
- config.update = update;
78
+ const next = Object.fromEntries(Object.entries({ ...config[section], [key]: value }).filter(([, v]) => v !== undefined));
79
+ // An emptied section becomes undefined, which JSON.stringify omits.
80
+ config[section] = Object.keys(next).length > 0 ? next : undefined;
82
81
  saveCliConfig(config);
83
82
  }
83
+ /** One auto-update preference — `setPref("update", …)`. */
84
+ export function setUpdatePref(key, value) {
85
+ setPref("update", key, value);
86
+ }
@@ -64,6 +64,9 @@ export interface UpdateState {
64
64
  method?: InstallMethod;
65
65
  /** Channel the last successful update installed from; feeds defaultChannel. */
66
66
  followChannel?: UpdateChannel;
67
+ /** The background worker startup last spawned, while it runs (cleared when it finishes). */
68
+ workerPid?: number;
69
+ workerSince?: number;
67
70
  }
68
71
  export interface UpdatePrefs {
69
72
  channel?: UpdateChannel;
@@ -121,6 +124,19 @@ export declare function entitlementFresh(cache: EntitlementCache | undefined, no
121
124
  * registry response).
122
125
  */
123
126
  export declare function pickTarget(tags: DistTags, channel: UpdateChannel): string | undefined;
127
+ /**
128
+ * Longest a worker lives: it waits for its parent `mnemom` (a whole agent session)
129
+ * up to this long. A recorded worker older than this is treated as gone, so a
130
+ * recycled pid can't block updates.
131
+ */
132
+ export declare const WORKER_MAX_AGE_MS: number;
133
+ /**
134
+ * A known update is still pending and nothing is working on it: the last worker
135
+ * recorded `available` but was killed before installing (e.g. while waiting out
136
+ * a long agent session). Startup respawns one without waiting for the next
137
+ * scheduled check.
138
+ */
139
+ export declare function shouldResume(state: UpdateState, current: string, now: number, alive: (pid: number) => boolean): boolean;
124
140
  /** Whether the background worker should poll the registry now. */
125
141
  export declare function shouldCheck(state: UpdateState, channel: UpdateChannel, now: number): boolean;
126
142
  export declare function backoffDelay(failures: number): number;
@@ -115,6 +115,33 @@ export function pickTarget(tags, channel) {
115
115
  return next;
116
116
  return compareVersions(next, stable) >= 0 ? next : stable;
117
117
  }
118
+ /**
119
+ * Longest a worker lives: it waits for its parent `mnemom` (a whole agent session)
120
+ * up to this long. A recorded worker older than this is treated as gone, so a
121
+ * recycled pid can't block updates.
122
+ */
123
+ export const WORKER_MAX_AGE_MS = 12 * HOUR;
124
+ /**
125
+ * A known update is still pending and nothing is working on it: the last worker
126
+ * recorded `available` but was killed before installing (e.g. while waiting out
127
+ * a long agent session). Startup respawns one without waiting for the next
128
+ * scheduled check.
129
+ */
130
+ export function shouldResume(state, current, now, alive) {
131
+ if (!state.available || !isValidVersion(state.available))
132
+ return false;
133
+ if (compareVersions(state.available, current) <= 0)
134
+ return false;
135
+ if (state.installBlocked || !canSelfUpdate(state.method ?? "unknown"))
136
+ return false;
137
+ if (state.backoffUntil !== undefined && now < state.backoffUntil)
138
+ return false;
139
+ const working = state.workerPid !== undefined &&
140
+ state.workerSince !== undefined &&
141
+ now - state.workerSince < WORKER_MAX_AGE_MS &&
142
+ alive(state.workerPid);
143
+ return !working;
144
+ }
118
145
  /** Whether the background worker should poll the registry now. */
119
146
  export function shouldCheck(state, channel, now) {
120
147
  if (state.backoffUntil !== undefined && now < state.backoffUntil)
@@ -29,6 +29,8 @@ export interface RunDeps {
29
29
  * `mnemom agent` session). Resolving false skips the install this pass.
30
30
  */
31
31
  beforeInstall?(): Promise<boolean>;
32
+ /** The background worker's own pid: it clears its `workerPid` record when done. */
33
+ pid?: number;
32
34
  }
33
35
  export interface RunResult {
34
36
  /** The channel actually honoured (next falls back to latest when not entitled). */
@@ -37,13 +37,17 @@ function failurePatch(state, now, message) {
37
37
  export async function runUpdate(deps, settings, opts = {}) {
38
38
  const now = deps.now();
39
39
  const state = deps.loadState();
40
+ // Every exit path forgets this worker's record, so startup can tell it's gone.
41
+ const done = deps.pid !== undefined && state.workerPid === deps.pid
42
+ ? { workerPid: undefined, workerSince: undefined }
43
+ : {};
40
44
  let tags;
41
45
  try {
42
46
  tags = await deps.fetchDistTags();
43
47
  }
44
48
  catch (err) {
45
49
  const message = err instanceof Error ? err.message : String(err);
46
- deps.saveState(failurePatch(state, now, message));
50
+ deps.saveState({ ...failurePatch(state, now, message), ...done });
47
51
  return { channel: settings.channel, channelDowngraded: false, error: message };
48
52
  }
49
53
  const entitled = settings.channel === "next" ? await resolveEntitlement(deps, state, now) : false;
@@ -71,12 +75,18 @@ export async function runUpdate(deps, settings, opts = {}) {
71
75
  };
72
76
  const result = { channel, channelDowngraded, method, target, action };
73
77
  if (action.kind !== "install") {
74
- deps.saveState(base);
78
+ deps.saveState({ ...base, ...done });
75
79
  return result;
76
80
  }
77
- const proceed = deps.beforeInstall ? await deps.beforeInstall() : true;
78
- if (!proceed) {
81
+ let proceed = true;
82
+ if (deps.beforeInstall) {
83
+ // Record what we found BEFORE the (possibly hours-long) wait: if this worker
84
+ // is killed meanwhile, the next launch still knows an update is pending.
79
85
  deps.saveState(base);
86
+ proceed = await deps.beforeInstall();
87
+ }
88
+ if (!proceed) {
89
+ deps.saveState({ ...base, ...done });
80
90
  return { ...result, action: { kind: "notify", version: action.version, reason: "deferred" } };
81
91
  }
82
92
  const outcome = await deps.install(action.version);
@@ -87,15 +97,20 @@ export async function runUpdate(deps, settings, opts = {}) {
87
97
  installBlocked: undefined,
88
98
  pending: { from: deps.current, to: action.version, channel },
89
99
  followChannel: channel,
100
+ ...done,
90
101
  });
91
102
  return { ...result, installed: action.version };
92
103
  }
93
104
  if (outcome.eacces) {
94
105
  // Retrying can't fix permissions — switch to notify-with-instructions.
95
- deps.saveState({ ...base, installBlocked: true, lastError: outcome.message });
106
+ deps.saveState({ ...base, installBlocked: true, lastError: outcome.message, ...done });
96
107
  }
97
108
  else {
98
- deps.saveState({ ...failurePatch(state, now, outcome.message), available: base.available });
109
+ deps.saveState({
110
+ ...failurePatch(state, now, outcome.message),
111
+ available: base.available,
112
+ ...done,
113
+ });
99
114
  }
100
115
  return { ...result, error: outcome.message };
101
116
  }
@@ -2,7 +2,8 @@
2
2
  * The per-invocation hook, run before commander parses argv. It must cost
3
3
  * ~nothing and never fail the user's command:
4
4
  * 1. print a one-line stderr notice (completed update, or an available one);
5
- * 2. if a check is due, spawn the detached worker and return immediately.
5
+ * 2. if a check is due — or a known update was left uninstalled by a worker
6
+ * that died — spawn the detached worker and return immediately.
6
7
  * All I/O is injected; `defaultStartupDeps()` wires the real process.
7
8
  */
8
9
  import { type UpdateChannel, type UpdateMode, type UpdatePrefs, type UpdateState } from "./decide.js";
@@ -18,8 +19,10 @@ export interface StartupDeps {
18
19
  saveState(patch: Partial<UpdateState>): void;
19
20
  entryPath: string;
20
21
  writeErr(line: string): void;
21
- spawnWorker(args: [entry: string, channel: UpdateChannel, mode: UpdateMode, parentPid: string]): void;
22
+ /** Returns the worker's pid (undefined if the spawn failed). */
23
+ spawnWorker(args: [entry: string, channel: UpdateChannel, mode: UpdateMode, parentPid: string]): number | undefined;
22
24
  pid: number;
25
+ alive(pid: number): boolean;
23
26
  }
24
27
  export declare function maybeRunUpdateHook(deps: StartupDeps): void;
25
28
  /** dist/lib/update/startup.js → dist/update-worker.js */
@@ -2,16 +2,18 @@
2
2
  * The per-invocation hook, run before commander parses argv. It must cost
3
3
  * ~nothing and never fail the user's command:
4
4
  * 1. print a one-line stderr notice (completed update, or an available one);
5
- * 2. if a check is due, spawn the detached worker and return immediately.
5
+ * 2. if a check is due — or a known update was left uninstalled by a worker
6
+ * that died — spawn the detached worker and return immediately.
6
7
  * All I/O is injected; `defaultStartupDeps()` wires the real process.
7
8
  */
8
9
  import { spawn } from "node:child_process";
9
10
  import { fileURLToPath } from "node:url";
10
11
  import { CLI_VERSION } from "../../version.js";
11
- import { resolveSettings, shouldCheck, startupNotice, } from "./decide.js";
12
+ import { resolveSettings, shouldCheck, shouldResume, startupNotice, } from "./decide.js";
12
13
  import { classifyInstall } from "./install-method.js";
13
14
  import { loadUpdateState, saveUpdateState } from "./state.js";
14
15
  import { entryRealpath, loadUpdatePrefs } from "./system.js";
16
+ import { isAlive } from "./worker.js";
15
17
  /** Commands that manage updates themselves, or must stay side-effect free. */
16
18
  const SKIP_COMMANDS = new Set(["update", "config"]);
17
19
  export function maybeRunUpdateHook(deps) {
@@ -41,11 +43,21 @@ export function maybeRunUpdateHook(deps) {
41
43
  }
42
44
  if (SKIP_COMMANDS.has(deps.argv[0] ?? ""))
43
45
  return;
44
- if (!shouldCheck(state, settings.channel, now))
46
+ const due = shouldCheck(state, settings.channel, now);
47
+ const resume = !due && settings.mode === "auto" && shouldResume(state, deps.current, now, deps.alive);
48
+ if (!due && !resume)
45
49
  return;
46
50
  // Stamp first so a burst of commands doesn't spawn a burst of workers.
47
- deps.saveState({ lastCheck: now });
48
- deps.spawnWorker([deps.entryPath, settings.channel, settings.mode, String(deps.pid)]);
51
+ if (due)
52
+ deps.saveState({ lastCheck: now });
53
+ const pid = deps.spawnWorker([
54
+ deps.entryPath,
55
+ settings.channel,
56
+ settings.mode,
57
+ String(deps.pid),
58
+ ]);
59
+ if (pid !== undefined)
60
+ deps.saveState({ workerPid: pid, workerSince: now });
49
61
  }
50
62
  catch {
51
63
  /* the updater never breaks the user's command */
@@ -75,6 +87,8 @@ export function defaultStartupDeps(argv) {
75
87
  });
76
88
  child.on("error", () => { });
77
89
  child.unref();
90
+ return child.pid;
78
91
  },
92
+ alive: (pid) => isAlive(pid),
79
93
  };
80
94
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { CLI_VERSION } from "../../version.js";
10
10
  import { getApiUrl } from "../config.js";
11
- import { isUpdateChannel, isUpdateMode } from "./decide.js";
11
+ import { isUpdateChannel, isUpdateMode, WORKER_MAX_AGE_MS, } from "./decide.js";
12
12
  import { classifyInstall } from "./install-method.js";
13
13
  import { fetchChannelEntitlement, fetchDistTags } from "./registry.js";
14
14
  import { runUpdate } from "./run.js";
@@ -23,7 +23,7 @@ export function parseWorkerArgs(args) {
23
23
  }
24
24
  export const PARENT_POLL_MS = 5000;
25
25
  /** Give up waiting on a very long session; the next run picks the update up. */
26
- export const PARENT_WAIT_MAX_MS = 12 * 60 * 60 * 1000;
26
+ export const PARENT_WAIT_MAX_MS = WORKER_MAX_AGE_MS;
27
27
  /** `kill(pid, 0)` probes liveness without signalling. EPERM still means alive. */
28
28
  export function isAlive(pid, kill = (p, s) => void process.kill(p, s)) {
29
29
  try {
@@ -60,6 +60,7 @@ export function realRunDeps(entry, credential) {
60
60
  fetchEntitlement: async () => fetchChannelEntitlement(fetch, getApiUrl(), await credential()),
61
61
  installMethod: async () => classifyInstall(entry, await npmGlobalRoot()),
62
62
  install: (version) => npmInstallGlobal(version),
63
+ pid: process.pid,
63
64
  };
64
65
  }
65
66
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.3-next.0",
3
+ "version": "0.17.3-next.2",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {