@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.
- package/dist/commands/agent.js +8 -2
- package/dist/commands/config.d.ts +9 -9
- package/dist/commands/config.js +31 -16
- package/dist/index.js +1 -1
- package/dist/lib/agent-ledger.d.ts +20 -6
- package/dist/lib/agent-ledger.js +52 -11
- package/dist/lib/cli-config.d.ts +15 -2
- package/dist/lib/cli-config.js +11 -8
- package/dist/lib/update/decide.d.ts +16 -0
- package/dist/lib/update/decide.js +27 -0
- package/dist/lib/update/run.d.ts +2 -0
- package/dist/lib/update/run.js +21 -6
- package/dist/lib/update/startup.d.ts +5 -2
- package/dist/lib/update/startup.js +19 -5
- package/dist/lib/update/worker.js +3 -2
- package/package.json +1 -1
package/dist/commands/agent.js
CHANGED
|
@@ -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
|
-
}, {
|
|
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
|
|
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
|
|
9
|
-
import {
|
|
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(
|
|
15
|
+
stored(config: CliConfig): string | undefined;
|
|
16
16
|
/** Value in effect after env + default (`hint` picks the channel default). */
|
|
17
|
-
effective(
|
|
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
|
|
20
|
-
unset(persist: typeof
|
|
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
|
-
|
|
25
|
+
config(): CliConfig;
|
|
26
26
|
env: Record<string, string | undefined>;
|
|
27
|
-
persist: typeof
|
|
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;
|
package/dist/commands/config.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `mnemom config list|get|set|unset` — CLI-wide preferences in
|
|
3
|
-
* ~/.mnemom/config.json
|
|
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 {
|
|
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: (
|
|
18
|
-
effective: (
|
|
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: (
|
|
32
|
-
effective: (
|
|
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
|
-
|
|
65
|
+
config: loadCliConfig,
|
|
51
66
|
env: process.env,
|
|
52
|
-
persist:
|
|
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
|
|
74
|
+
const config = deps.config();
|
|
60
75
|
const rows = CONFIG_KEYS.map((k) => {
|
|
61
|
-
const stored = k.stored(
|
|
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(
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
17
|
-
export declare const
|
|
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,
|
|
110
|
+
* Append one launch to the ledger, dropping expired ones:
|
|
97
111
|
* - `dir` is created 0o700 when missing; the file is created 0o600;
|
|
98
|
-
* -
|
|
99
|
-
* otherwise the
|
|
100
|
-
*
|
|
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;
|
package/dist/lib/agent-ledger.js
CHANGED
|
@@ -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
|
-
/**
|
|
18
|
-
export const
|
|
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
|
-
*
|
|
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
|
-
* -
|
|
64
|
-
* otherwise the
|
|
65
|
-
*
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 {
|
package/dist/lib/cli-config.d.ts
CHANGED
|
@@ -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
|
|
39
|
-
*
|
|
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;
|
package/dist/lib/cli-config.js
CHANGED
|
@@ -69,15 +69,18 @@ export function getActiveOrg() {
|
|
|
69
69
|
return loadCliConfig().activeOrg;
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
|
-
* Set (or with `undefined`, clear) one
|
|
73
|
-
*
|
|
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
|
|
76
|
+
export function setPref(section, key, value) {
|
|
76
77
|
const config = loadCliConfig();
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
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)
|
package/dist/lib/update/run.d.ts
CHANGED
|
@@ -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). */
|
package/dist/lib/update/run.js
CHANGED
|
@@ -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
|
-
|
|
78
|
-
if (
|
|
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({
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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 =
|
|
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
|
/**
|