@mnemom/mnemom 0.17.3-next.1 → 0.17.3-next.3
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/README.md +25 -0
- package/dist/commands/agent-config.js +3 -0
- package/dist/commands/agent-sessions.js +2 -0
- package/dist/commands/agent.d.ts +13 -1
- package/dist/commands/agent.js +37 -12
- package/dist/index.js +13 -2
- package/dist/lib/agent-ledger.d.ts +7 -1
- package/dist/lib/agent.d.ts +57 -5
- package/dist/lib/agent.js +86 -9
- package/dist/lib/launcher-config.d.ts +7 -1
- package/dist/lib/launcher-config.js +16 -1
- 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/README.md
CHANGED
|
@@ -84,6 +84,31 @@ An explicit launch shape (`--remote-control`, `--server`, `MNEMOM_AGENT_REMOTE_C
|
|
|
84
84
|
or `launch` in `launcher.toml`) also skips the Remote Control capability probe
|
|
85
85
|
(`claude --help`), which only feeds the automatic upgrade to Remote Control.
|
|
86
86
|
|
|
87
|
+
#### Context profile: how the gateway folds a session's context
|
|
88
|
+
|
|
89
|
+
`--context-profile <off|balanced|aggressive>` picks how the Mnemom gateway folds a
|
|
90
|
+
session's context. Each profile serves a different set of use cases:
|
|
91
|
+
|
|
92
|
+
- **`balanced`** (the default) keeps more of the earlier work verbatim and folds later —
|
|
93
|
+
for long sessions that revisit earlier files and decisions.
|
|
94
|
+
- **`aggressive`** folds earlier and keeps a tighter working set — for cost-sensitive
|
|
95
|
+
sessions and short, focused tasks.
|
|
96
|
+
- **`off`** leaves compaction to the coding agent.
|
|
97
|
+
|
|
98
|
+
Set it per launch, as a standing default, or in the environment (flag > env > `launcher.toml`):
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
mnemom agent my-task --context-profile aggressive # this launch only
|
|
102
|
+
mnemom agent config set context_profile aggressive # persists in ~/.mnemom/launcher.toml
|
|
103
|
+
export MNEMOM_AGENT_CONTEXT_PROFILE=aggressive # for every launch in this shell
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`--no-context` (or `context = false` in `launcher.toml`) is the same as `off`. Pass one or the
|
|
107
|
+
other: `--no-context` together with `--context-profile balanced` or `aggressive` is refused. A
|
|
108
|
+
`--context-profile` flag overrides a standing `context = false` in `launcher.toml`. `--dry-run`
|
|
109
|
+
prints the resolved profile and, when the launch sends the per-session config header, the
|
|
110
|
+
decoded key/value list; `mnemom agent show` and the session ledger record the profile.
|
|
111
|
+
|
|
87
112
|
#### Context handoff (experimental, off by default)
|
|
88
113
|
|
|
89
114
|
**Off by default.** `--context-handoff` (or the standing default `mnemom agent config set
|
|
@@ -190,6 +190,9 @@ function deleteKey(cfg, key) {
|
|
|
190
190
|
case "context_handoff":
|
|
191
191
|
delete cfg.contextHandoff;
|
|
192
192
|
break;
|
|
193
|
+
case "context_profile":
|
|
194
|
+
delete cfg.contextProfile;
|
|
195
|
+
break;
|
|
193
196
|
default:
|
|
194
197
|
requireSpec(key); // throws the known-keys message for anything unknown
|
|
195
198
|
}
|
|
@@ -177,6 +177,8 @@ export function buildShowView(local, remote, reason) {
|
|
|
177
177
|
launchBits.push(`model ${local.model}`);
|
|
178
178
|
if (local.effort)
|
|
179
179
|
launchBits.push(`effort ${local.effort}`);
|
|
180
|
+
if (local.context_profile)
|
|
181
|
+
launchBits.push(`context ${local.context_profile}`);
|
|
180
182
|
L.push(fmt.label("launch: ", launchBits.join(" · ")));
|
|
181
183
|
L.push(fmt.label("conversation: ", local.conversation_id));
|
|
182
184
|
const goalMode = resolveGoalMode(local, remote);
|
package/dist/commands/agent.d.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* line, or the proxy's stdin under RC). It is never logged, printed, or passed as
|
|
26
26
|
* an argv value.
|
|
27
27
|
*/
|
|
28
|
-
import { type LaunchShape, type SealedContractEnvelope } from "../lib/agent.js";
|
|
28
|
+
import { type ContextProfile, type LaunchShape, type SealedContractEnvelope } from "../lib/agent.js";
|
|
29
29
|
export interface AgentOptions {
|
|
30
30
|
goal?: string;
|
|
31
31
|
requirement?: string[];
|
|
@@ -58,6 +58,12 @@ export interface AgentOptions {
|
|
|
58
58
|
* gateway to skip folding for this session only.
|
|
59
59
|
*/
|
|
60
60
|
context?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* How the gateway folds this session's context (`--context-profile`): `off`, `balanced` or
|
|
63
|
+
* `aggressive`. Undefined ⇒ MNEMOM_AGENT_CONTEXT_PROFILE (the `context_profile` setting), else
|
|
64
|
+
* `balanced`. `--no-context` is `off`; combined with balanced|aggressive the launch refuses.
|
|
65
|
+
*/
|
|
66
|
+
contextProfile?: ContextProfile;
|
|
61
67
|
/** context-hint seed default: commander `--no-context-hint` sets this false. */
|
|
62
68
|
contextHint?: boolean;
|
|
63
69
|
/** Attach the Slack channel server (`--slack`; lib/slack-launch.ts). */
|
|
@@ -108,6 +114,8 @@ export declare function recordSessionLaunch(ctx: {
|
|
|
108
114
|
goalMode: string | null;
|
|
109
115
|
/** The Claude Code session id (pickCcSessionId); null/absent when Claude Code picks it. */
|
|
110
116
|
ccSessionId?: string | null;
|
|
117
|
+
/** The resolved context profile this launch runs under. */
|
|
118
|
+
contextProfile: ContextProfile;
|
|
111
119
|
}): Promise<void>;
|
|
112
120
|
/**
|
|
113
121
|
* Resolve the Anthropic key with NO 1Password dependency (built for external
|
|
@@ -233,4 +241,8 @@ export declare function printPlan(ctx: {
|
|
|
233
241
|
goalMode: string | null;
|
|
234
242
|
yolo: boolean;
|
|
235
243
|
cli: ResolvedCli;
|
|
244
|
+
/** The resolved context profile (`resolveContextProfile`). */
|
|
245
|
+
contextProfile: ContextProfile;
|
|
246
|
+
/** The decoded `x-mnemom-session-config` overlay (`buildSessionConfig`); empty ⇒ no header. */
|
|
247
|
+
sessionConfig: Record<string, string>;
|
|
236
248
|
}): void;
|
package/dist/commands/agent.js
CHANGED
|
@@ -40,12 +40,24 @@ import { fmt } from "../lib/format.js";
|
|
|
40
40
|
import { askInput, askYesNo, isInteractive } from "../lib/prompt.js";
|
|
41
41
|
import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
|
|
42
42
|
import { AgentIdentityMismatchError, defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, resolvePreflightedAccount, } from "./agent-setup.js";
|
|
43
|
-
import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson,
|
|
43
|
+
import { assembleContract, buildGoalModeHeader, buildCustomHeaders, buildHandoffMcpConfigJson, buildSessionConfig, buildSessionConfigB64, resolveContextHandoff, resolveContextProfile, resolveHandoffGatewayHost, HANDOFF_RC_KEY_ENV_NAME, isProdGateway, launchPreferenceExplicit, launchPreflighted, remoteControlAllowed, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, resolveRemoteControlShape, passthroughName, sanitizeAgentName, sessionDisplayName, SESSION_NAME_ENV, SESSION_CONFIG_HEADER, } from "../lib/agent.js";
|
|
44
44
|
import { LAUNCHER_STORE_PATH, mergeStore } from "../lib/launcher-store.js";
|
|
45
45
|
import { slackChannelArgs } from "../lib/slack-launch.js";
|
|
46
46
|
/** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
|
|
47
47
|
const CODE_STORE = LAUNCHER_STORE_PATH;
|
|
48
48
|
const say = (line = "") => console.error(line);
|
|
49
|
+
/**
|
|
50
|
+
* The one-line launch note for a non-default context profile (`balanced`, the default, is
|
|
51
|
+
* silent — nothing is sent). `off` keeps the wording `--no-context` has always printed.
|
|
52
|
+
*/
|
|
53
|
+
function sayContextProfile(profile) {
|
|
54
|
+
if (profile === "off") {
|
|
55
|
+
say("mnemom agent: context folding OFF for this session (--no-context or context = false).");
|
|
56
|
+
}
|
|
57
|
+
else if (profile === "aggressive") {
|
|
58
|
+
say("mnemom agent: context profile aggressive — the gateway folds earlier and keeps a tighter working set.");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
49
61
|
/** Entry point for `mnemom agent [scenario]`. Throws on fatal misconfig. */
|
|
50
62
|
export async function agentCommand(scenarioArg, options = {}) {
|
|
51
63
|
// ── session name + goal ── (a bare `mnemom agent` in a terminal asks for both,
|
|
@@ -99,12 +111,18 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
99
111
|
const cellNote = isProdGateway(env) ? "us-2/prod" : `custom (${gatewayHost})`;
|
|
100
112
|
const yolo = options.yolo !== false; // default ON
|
|
101
113
|
const contextHint = options.contextHint !== false; // default ON
|
|
102
|
-
// Context
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
114
|
+
// Context profile: `balanced` (the gateway's own defaults) unless --context-profile /
|
|
115
|
+
// MNEMOM_AGENT_CONTEXT_PROFILE (the `context_profile` setting) says otherwise; --no-context
|
|
116
|
+
// (or `context = false` in launcher.toml → MNEMOM_AGENT_CONTEXT) is `off`. Anything but
|
|
117
|
+
// balanced rides in the per-session config header; `undefined` sessionConfigB64 ⇒ nothing sent.
|
|
118
|
+
const contextProfile = resolveContextProfile({
|
|
119
|
+
contextProfile: options.contextProfile,
|
|
120
|
+
context: options.context,
|
|
121
|
+
env,
|
|
122
|
+
});
|
|
106
123
|
const contextHandoff = resolveContextHandoff({ contextHandoff: options.contextHandoff, env });
|
|
107
|
-
const
|
|
124
|
+
const sessionConfig = buildSessionConfig({ profile: contextProfile, contextHandoff });
|
|
125
|
+
const sessionConfigB64 = buildSessionConfigB64({ profile: contextProfile, contextHandoff });
|
|
108
126
|
// ── which CLI executable (name or full path) ── resolve GRACEFULLY: if it's not
|
|
109
127
|
// found we don't crash with a technical error — we ask for the path at launch.
|
|
110
128
|
let cli;
|
|
@@ -143,13 +161,13 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
143
161
|
goalMode,
|
|
144
162
|
yolo,
|
|
145
163
|
cli: planCli,
|
|
164
|
+
contextProfile,
|
|
165
|
+
sessionConfig,
|
|
146
166
|
});
|
|
147
167
|
if (previewShape === "remote-control") {
|
|
148
168
|
say("mnemom agent: the plan shows Remote Control because this machine can run it; the real launch uses it only if your account is entitled (internal Mnemom accounts), else terminal-only.");
|
|
149
169
|
}
|
|
150
|
-
|
|
151
|
-
say("mnemom agent: context folding OFF for this session (--no-context or context = false).");
|
|
152
|
-
}
|
|
170
|
+
sayContextProfile(contextProfile);
|
|
153
171
|
if (contextHandoff) {
|
|
154
172
|
say("mnemom agent: context handoff ON (experimental) — the `handoff` MCP tool would be registered.");
|
|
155
173
|
}
|
|
@@ -269,12 +287,11 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
269
287
|
envelope,
|
|
270
288
|
goalMode,
|
|
271
289
|
ccSessionId: cc.id,
|
|
290
|
+
contextProfile,
|
|
272
291
|
});
|
|
273
292
|
logLaunch({ shape, door, agent, conversationId, cellNote, envelope, goalMode, cli });
|
|
274
293
|
say(goalAlignmentLine(goalAlignmentMode(envelope, goalMode)));
|
|
275
|
-
|
|
276
|
-
say("mnemom agent: context folding OFF for this session (--no-context or context = false).");
|
|
277
|
-
}
|
|
294
|
+
sayContextProfile(contextProfile);
|
|
278
295
|
// ── handoff MCP tool (MNE-7564) ── OFF by default: absent/false ⇒ handoffMcpConfigPath stays
|
|
279
296
|
// undefined and buildCliArgs/spawnRcCli are byte-identical to a build without this flag. ON:
|
|
280
297
|
// the config is written to a 0600 temp file and referenced by PATH only — never inlined into
|
|
@@ -377,6 +394,7 @@ export async function recordSessionLaunch(ctx) {
|
|
|
377
394
|
cli: ctx.cli.name,
|
|
378
395
|
contract: toLedgerContract(ctx.envelope?.contract ?? null),
|
|
379
396
|
goal_mode: goalAlignmentMode(ctx.envelope, ctx.goalMode),
|
|
397
|
+
context_profile: ctx.contextProfile,
|
|
380
398
|
cc_session_id: ctx.ccSessionId ?? null,
|
|
381
399
|
}, {
|
|
382
400
|
dir: MNEMOM_DIR,
|
|
@@ -1085,6 +1103,13 @@ export function printPlan(ctx) {
|
|
|
1085
1103
|
L.push(` agent: ${ctx.agent}`);
|
|
1086
1104
|
L.push(` conversation: ${ctx.conversationId}`);
|
|
1087
1105
|
L.push(` yolo: ${ctx.yolo ? "on (--dangerously-skip-permissions)" : "off"}`);
|
|
1106
|
+
L.push(` context: ${ctx.contextProfile}`);
|
|
1107
|
+
const overlay = Object.entries(ctx.sessionConfig);
|
|
1108
|
+
if (overlay.length > 0) {
|
|
1109
|
+
L.push(` session config (${SESSION_CONFIG_HEADER}):`);
|
|
1110
|
+
for (const [k, v] of overlay)
|
|
1111
|
+
L.push(` ${k}=${v}`);
|
|
1112
|
+
}
|
|
1088
1113
|
if (ctx.envelope) {
|
|
1089
1114
|
L.push(` contract: ${ctx.envelope.canonical}`);
|
|
1090
1115
|
L.push(` contract sha: ${ctx.envelope.sha}`);
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { program, Option } from "commander";
|
|
2
|
+
import { program, InvalidArgumentError, Option } from "commander";
|
|
3
3
|
import { isEntrypoint } from "./lib/entrypoint.js";
|
|
4
4
|
import { CLI_VERSION } from "./version.js";
|
|
5
5
|
import { statusCommand } from "./commands/status.js";
|
|
@@ -29,7 +29,7 @@ import { agentDoctorCommand } from "./commands/agent-doctor.js";
|
|
|
29
29
|
import { agentSetupCommand } from "./commands/agent-setup.js";
|
|
30
30
|
import { agentInviteCommand, runAgentInvocation } from "./commands/agent-invite.js";
|
|
31
31
|
import { applyConfigToEnv, loadLauncherConfig } from "./lib/launcher-config.js";
|
|
32
|
-
import { resolveImplicit } from "./lib/agent.js";
|
|
32
|
+
import { parseContextProfile, resolveImplicit } from "./lib/agent.js";
|
|
33
33
|
import { wrapCommand } from "./commands/wrap.js";
|
|
34
34
|
import { onboardCommand } from "./commands/onboard.js";
|
|
35
35
|
import { skillsListCommand, skillsDescribeCommand } from "./commands/skills.js";
|
|
@@ -143,6 +143,16 @@ const agentCmd = program
|
|
|
143
143
|
.option("--slack", "Attach the Slack channel: messages addressed to this session arrive in it, and it can reply (see `mnemom agent slack`)")
|
|
144
144
|
.option("--context", "Force context folding ON for this launch even when launcher.toml (context = false) or MNEMOM_AGENT_CONTEXT turns it off")
|
|
145
145
|
.option("--no-context", "Disable context folding for this launch (folding is on by default; tells the gateway to skip it for this session)")
|
|
146
|
+
.addOption(new Option("--context-profile <profile>", 'How the gateway folds this session\'s context. "balanced" (default) keeps more of the earlier work verbatim and folds later — for long sessions that revisit earlier files and decisions. "aggressive" folds earlier and keeps a tighter working set — for cost-sensitive sessions and short, focused tasks. "off" leaves compaction to the coding agent. Also: config key context_profile, env MNEMOM_AGENT_CONTEXT_PROFILE.').argParser((v) => {
|
|
147
|
+
// One validator for all three surfaces (flag, MNEMOM_AGENT_CONTEXT_PROFILE,
|
|
148
|
+
// launcher.toml context_profile): trimmed, case-insensitive, same error wording.
|
|
149
|
+
try {
|
|
150
|
+
return parseContextProfile(v, "--context-profile");
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
throw new InvalidArgumentError(e.message);
|
|
154
|
+
}
|
|
155
|
+
}))
|
|
146
156
|
.option("--context-handoff", "EXPERIMENTAL, off by default: register the `handoff(note)` MCP tool and tell the gateway to turn on the context-handoff capability for this session")
|
|
147
157
|
.option("--no-context-handoff", "Keep context handoff off for this launch even when launcher.toml (context_handoff = true) or MNEMOM_AGENT_CONTEXT_HANDOFF turns it on")
|
|
148
158
|
.option("--dry-run", "Print the resolved launch plan and exit — no key read, no spawn")
|
|
@@ -234,6 +244,7 @@ const agentCmd = program
|
|
|
234
244
|
contextHint: opts.contextHint,
|
|
235
245
|
slack: opts.slack ?? cfg.slackListen,
|
|
236
246
|
context: opts.context,
|
|
247
|
+
contextProfile: opts.contextProfile,
|
|
237
248
|
contextHandoff: opts.contextHandoff,
|
|
238
249
|
setup: opts.setup,
|
|
239
250
|
keySource: cfg.keySource,
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* `sessions.retention_days` (default 30) are dropped when the next one is
|
|
15
15
|
* recorded. No count cap — a long retention keeps everything in the window.
|
|
16
16
|
*/
|
|
17
|
-
import type { LaunchShape, SealedContract } from "./agent.js";
|
|
17
|
+
import type { ContextProfile, LaunchShape, SealedContract } from "./agent.js";
|
|
18
18
|
/** The ledger's file name under MNEMOM_DIR. */
|
|
19
19
|
export declare const LEDGER_FILE = "sessions.jsonl";
|
|
20
20
|
/** Days a launch stays in the ledger when `sessions.retention_days` is unset. */
|
|
@@ -75,6 +75,12 @@ export interface SessionRecord {
|
|
|
75
75
|
* records written before this field existed omit it (still `v: 1`).
|
|
76
76
|
*/
|
|
77
77
|
cc_session_id?: string | null;
|
|
78
|
+
/**
|
|
79
|
+
* The context profile the launch ran under (`--context-profile` / `context_profile` /
|
|
80
|
+
* `MNEMOM_AGENT_CONTEXT_PROFILE`, or `balanced` by default). Optional: records written
|
|
81
|
+
* before profiles existed omit it (still `v: 1`).
|
|
82
|
+
*/
|
|
83
|
+
context_profile?: ContextProfile;
|
|
78
84
|
}
|
|
79
85
|
/** Goal-alignment mode as recorded at launch. */
|
|
80
86
|
export type GoalMode = "off" | "on" | "implicit";
|
package/dist/lib/agent.d.ts
CHANGED
|
@@ -161,6 +161,21 @@ export declare const GOAL_MODE_HEADER = "x-mnemom-goal-mode";
|
|
|
161
161
|
export declare const SESSION_CONFIG_HEADER = "x-mnemom-session-config";
|
|
162
162
|
/** The gateway env key that gates context folding (allow-listed for per-session overlay). */
|
|
163
163
|
export declare const CONTEXT_RUNTIME_KEY = "CONTEXT_RUNTIME_ENABLED";
|
|
164
|
+
/** Gateway fold-tuning keys the `aggressive` context profile sets (all allow-listed per session). */
|
|
165
|
+
export declare const CONTEXT_TAIL_BUDGET_KEY = "CONTEXT_RUNTIME_TAIL_BUDGET_TOKENS";
|
|
166
|
+
export declare const CONTEXT_READ_CACHE_KEY = "CONTEXT_RUNTIME_READ_CACHE_TOKENS";
|
|
167
|
+
export declare const CONTEXT_HEAD_RECLAIM_KEY = "CONTEXT_RUNTIME_HEAD_BREAKPOINT_RECLAIM";
|
|
168
|
+
/**
|
|
169
|
+
* The named presets for how the gateway folds a session's context (`--context-profile`,
|
|
170
|
+
* launcher.toml `context_profile`, `MNEMOM_AGENT_CONTEXT_PROFILE`):
|
|
171
|
+
* - `off` → `CONTEXT_RUNTIME_ENABLED: "false"` — compaction is left to the coding agent
|
|
172
|
+
* (the same bytes `--no-context` has always sent).
|
|
173
|
+
* - `balanced` → NO fold keys: the gateway's own defaults stand. The default.
|
|
174
|
+
* - `aggressive` → a smaller tail budget + read cache and head-breakpoint reclaim on: folds
|
|
175
|
+
* earlier and keeps a tighter working set.
|
|
176
|
+
*/
|
|
177
|
+
export declare const CONTEXT_PROFILES: readonly ["off", "balanced", "aggressive"];
|
|
178
|
+
export type ContextProfile = (typeof CONTEXT_PROFILES)[number];
|
|
164
179
|
/**
|
|
165
180
|
* The gateway env key that gates the whole context-handoff capability (level lines +
|
|
166
181
|
* `/mnemom/context/handoff` route + the digest note section) for a single session
|
|
@@ -272,12 +287,49 @@ export declare function resolveContextFold(opts: {
|
|
|
272
287
|
context?: boolean;
|
|
273
288
|
env?: NodeJS.ProcessEnv;
|
|
274
289
|
}): boolean;
|
|
290
|
+
/**
|
|
291
|
+
* Validate a context-profile word — the ONE validator behind all three surfaces (the
|
|
292
|
+
* `--context-profile` flag, launcher.toml `context_profile`, the `MNEMOM_AGENT_CONTEXT_PROFILE`
|
|
293
|
+
* env var); trimmed, case-insensitive. `source` names the surface in the error so a customer
|
|
294
|
+
* knows which setting to fix.
|
|
295
|
+
*/
|
|
296
|
+
export declare function parseContextProfile(raw: string, source: string): ContextProfile;
|
|
297
|
+
/**
|
|
298
|
+
* Which context profile this launch runs under. Precedence, highest wins:
|
|
299
|
+
* 1. `--no-context` — always `off`. Combined with an explicit `--context-profile
|
|
300
|
+
* balanced|aggressive` it is a CONFLICT and the launch refuses (never a silent override);
|
|
301
|
+
* `--context-profile off` agrees with it.
|
|
302
|
+
* 2. an explicit `--context-profile <profile>` flag (`--context` alongside it is fine — the
|
|
303
|
+
* profile wins);
|
|
304
|
+
* 3. `MNEMOM_AGENT_CONTEXT_PROFILE` (which `applyConfigToEnv` sets from the `context_profile`
|
|
305
|
+
* config key) — an invalid value is an error, never a silent default;
|
|
306
|
+
* 4. the folding boolean when NO profile is set anywhere: `context = false` in launcher.toml
|
|
307
|
+
* (→ `MNEMOM_AGENT_CONTEXT`, see `resolveContextFold`) means `off`;
|
|
308
|
+
* 5. `balanced` (the default).
|
|
309
|
+
*/
|
|
310
|
+
export declare function resolveContextProfile(opts: {
|
|
311
|
+
contextProfile?: ContextProfile;
|
|
312
|
+
context?: boolean;
|
|
313
|
+
env?: NodeJS.ProcessEnv;
|
|
314
|
+
}): ContextProfile;
|
|
315
|
+
/**
|
|
316
|
+
* The decoded per-session env overlay for this launch — the `config` object of the
|
|
317
|
+
* `x-mnemom-session-config` payload, in wire order: the profile's keys first
|
|
318
|
+
* (`CONTEXT_RUNTIME_ENABLED` for `off`; the three fold-tuning keys for `aggressive`; nothing
|
|
319
|
+
* for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON. Empty
|
|
320
|
+
* for `balanced` with handoff off — the common case, where no header is sent at all.
|
|
321
|
+
* Exported so `--dry-run` can list exactly what the header carries.
|
|
322
|
+
*/
|
|
323
|
+
export declare function buildSessionConfig(opts: {
|
|
324
|
+
profile: ContextProfile;
|
|
325
|
+
contextHandoff: boolean;
|
|
326
|
+
}): Record<string, string>;
|
|
275
327
|
/**
|
|
276
328
|
* The `x-mnemom-session-config` value for this launch, or `undefined` when there is
|
|
277
|
-
* nothing to override (the gateway's own defaults stand —
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
329
|
+
* nothing to override (the gateway's own defaults stand — `balanced` with handoff off,
|
|
330
|
+
* byte-identical to a launch before profiles existed). Two independent overlays:
|
|
331
|
+
* - the context profile (`buildSessionConfig`): `off` → `CONTEXT_RUNTIME_ENABLED: "false"`,
|
|
332
|
+
* `aggressive` → the three fold-tuning keys, `balanced` → nothing;
|
|
281
333
|
* - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
|
|
282
334
|
* setting) — the SAME header the `handoff` MCP tool's own call to
|
|
283
335
|
* `/mnemom/context/handoff` must also send, since the gateway resolves the flag
|
|
@@ -285,7 +337,7 @@ export declare function resolveContextFold(opts: {
|
|
|
285
337
|
* base64url so it survives the gateway's inbound strip.
|
|
286
338
|
*/
|
|
287
339
|
export declare function buildSessionConfigB64(opts: {
|
|
288
|
-
|
|
340
|
+
profile: ContextProfile;
|
|
289
341
|
contextHandoff: boolean;
|
|
290
342
|
}): string | undefined;
|
|
291
343
|
/**
|
package/dist/lib/agent.js
CHANGED
|
@@ -226,6 +226,30 @@ export const GOAL_MODE_HEADER = "x-mnemom-goal-mode";
|
|
|
226
226
|
export const SESSION_CONFIG_HEADER = "x-mnemom-session-config";
|
|
227
227
|
/** The gateway env key that gates context folding (allow-listed for per-session overlay). */
|
|
228
228
|
export const CONTEXT_RUNTIME_KEY = "CONTEXT_RUNTIME_ENABLED";
|
|
229
|
+
/** Gateway fold-tuning keys the `aggressive` context profile sets (all allow-listed per session). */
|
|
230
|
+
export const CONTEXT_TAIL_BUDGET_KEY = "CONTEXT_RUNTIME_TAIL_BUDGET_TOKENS";
|
|
231
|
+
export const CONTEXT_READ_CACHE_KEY = "CONTEXT_RUNTIME_READ_CACHE_TOKENS";
|
|
232
|
+
export const CONTEXT_HEAD_RECLAIM_KEY = "CONTEXT_RUNTIME_HEAD_BREAKPOINT_RECLAIM";
|
|
233
|
+
/**
|
|
234
|
+
* The named presets for how the gateway folds a session's context (`--context-profile`,
|
|
235
|
+
* launcher.toml `context_profile`, `MNEMOM_AGENT_CONTEXT_PROFILE`):
|
|
236
|
+
* - `off` → `CONTEXT_RUNTIME_ENABLED: "false"` — compaction is left to the coding agent
|
|
237
|
+
* (the same bytes `--no-context` has always sent).
|
|
238
|
+
* - `balanced` → NO fold keys: the gateway's own defaults stand. The default.
|
|
239
|
+
* - `aggressive` → a smaller tail budget + read cache and head-breakpoint reclaim on: folds
|
|
240
|
+
* earlier and keeps a tighter working set.
|
|
241
|
+
*/
|
|
242
|
+
export const CONTEXT_PROFILES = ["off", "balanced", "aggressive"];
|
|
243
|
+
/** The per-session env overlay each profile expands to (key order is the wire order). */
|
|
244
|
+
const CONTEXT_PROFILE_CONFIG = {
|
|
245
|
+
off: [[CONTEXT_RUNTIME_KEY, "false"]],
|
|
246
|
+
balanced: [],
|
|
247
|
+
aggressive: [
|
|
248
|
+
[CONTEXT_TAIL_BUDGET_KEY, "60000"],
|
|
249
|
+
[CONTEXT_READ_CACHE_KEY, "8000"],
|
|
250
|
+
[CONTEXT_HEAD_RECLAIM_KEY, "true"],
|
|
251
|
+
],
|
|
252
|
+
};
|
|
229
253
|
/**
|
|
230
254
|
* The gateway env key that gates the whole context-handoff capability (level lines +
|
|
231
255
|
* `/mnemom/context/handoff` route + the digest note section) for a single session
|
|
@@ -432,12 +456,69 @@ export function resolveContextFold(opts) {
|
|
|
432
456
|
return true;
|
|
433
457
|
return !(raw === "0" || raw === "false" || raw === "off" || raw === "no");
|
|
434
458
|
}
|
|
459
|
+
/**
|
|
460
|
+
* Validate a context-profile word — the ONE validator behind all three surfaces (the
|
|
461
|
+
* `--context-profile` flag, launcher.toml `context_profile`, the `MNEMOM_AGENT_CONTEXT_PROFILE`
|
|
462
|
+
* env var); trimmed, case-insensitive. `source` names the surface in the error so a customer
|
|
463
|
+
* knows which setting to fix.
|
|
464
|
+
*/
|
|
465
|
+
export function parseContextProfile(raw, source) {
|
|
466
|
+
const v = raw.trim().toLowerCase();
|
|
467
|
+
if (CONTEXT_PROFILES.includes(v))
|
|
468
|
+
return v;
|
|
469
|
+
throw new Error(`${source} must be one of ${CONTEXT_PROFILES.join(", ")} (got '${raw}').`);
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Which context profile this launch runs under. Precedence, highest wins:
|
|
473
|
+
* 1. `--no-context` — always `off`. Combined with an explicit `--context-profile
|
|
474
|
+
* balanced|aggressive` it is a CONFLICT and the launch refuses (never a silent override);
|
|
475
|
+
* `--context-profile off` agrees with it.
|
|
476
|
+
* 2. an explicit `--context-profile <profile>` flag (`--context` alongside it is fine — the
|
|
477
|
+
* profile wins);
|
|
478
|
+
* 3. `MNEMOM_AGENT_CONTEXT_PROFILE` (which `applyConfigToEnv` sets from the `context_profile`
|
|
479
|
+
* config key) — an invalid value is an error, never a silent default;
|
|
480
|
+
* 4. the folding boolean when NO profile is set anywhere: `context = false` in launcher.toml
|
|
481
|
+
* (→ `MNEMOM_AGENT_CONTEXT`, see `resolveContextFold`) means `off`;
|
|
482
|
+
* 5. `balanced` (the default).
|
|
483
|
+
*/
|
|
484
|
+
export function resolveContextProfile(opts) {
|
|
485
|
+
if (opts.context === false) {
|
|
486
|
+
if (opts.contextProfile !== undefined && opts.contextProfile !== "off") {
|
|
487
|
+
throw new Error(`mnemom agent: --no-context and --context-profile ${opts.contextProfile} disagree — drop one (--no-context is the same as --context-profile off).`);
|
|
488
|
+
}
|
|
489
|
+
return "off";
|
|
490
|
+
}
|
|
491
|
+
if (opts.contextProfile !== undefined)
|
|
492
|
+
return opts.contextProfile;
|
|
493
|
+
const raw = (opts.env?.MNEMOM_AGENT_CONTEXT_PROFILE ?? "").trim();
|
|
494
|
+
if (raw !== "")
|
|
495
|
+
return parseContextProfile(raw, "MNEMOM_AGENT_CONTEXT_PROFILE");
|
|
496
|
+
if (!resolveContextFold({ context: opts.context, env: opts.env }))
|
|
497
|
+
return "off";
|
|
498
|
+
return "balanced";
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* The decoded per-session env overlay for this launch — the `config` object of the
|
|
502
|
+
* `x-mnemom-session-config` payload, in wire order: the profile's keys first
|
|
503
|
+
* (`CONTEXT_RUNTIME_ENABLED` for `off`; the three fold-tuning keys for `aggressive`; nothing
|
|
504
|
+
* for `balanced`), then `CONTEXT_HANDOFF_ENABLED: "true"` when context handoff is ON. Empty
|
|
505
|
+
* for `balanced` with handoff off — the common case, where no header is sent at all.
|
|
506
|
+
* Exported so `--dry-run` can list exactly what the header carries.
|
|
507
|
+
*/
|
|
508
|
+
export function buildSessionConfig(opts) {
|
|
509
|
+
const config = {};
|
|
510
|
+
for (const [k, v] of CONTEXT_PROFILE_CONFIG[opts.profile])
|
|
511
|
+
config[k] = v;
|
|
512
|
+
if (opts.contextHandoff)
|
|
513
|
+
config[CONTEXT_HANDOFF_KEY] = "true";
|
|
514
|
+
return config;
|
|
515
|
+
}
|
|
435
516
|
/**
|
|
436
517
|
* The `x-mnemom-session-config` value for this launch, or `undefined` when there is
|
|
437
|
-
* nothing to override (the gateway's own defaults stand —
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
518
|
+
* nothing to override (the gateway's own defaults stand — `balanced` with handoff off,
|
|
519
|
+
* byte-identical to a launch before profiles existed). Two independent overlays:
|
|
520
|
+
* - the context profile (`buildSessionConfig`): `off` → `CONTEXT_RUNTIME_ENABLED: "false"`,
|
|
521
|
+
* `aggressive` → the three fold-tuning keys, `balanced` → nothing;
|
|
441
522
|
* - `contextHandoff: true` → `CONTEXT_HANDOFF_ENABLED: "true"` (the `context_handoff`
|
|
442
523
|
* setting) — the SAME header the `handoff` MCP tool's own call to
|
|
443
524
|
* `/mnemom/context/handoff` must also send, since the gateway resolves the flag
|
|
@@ -445,11 +526,7 @@ export function resolveContextFold(opts) {
|
|
|
445
526
|
* base64url so it survives the gateway's inbound strip.
|
|
446
527
|
*/
|
|
447
528
|
export function buildSessionConfigB64(opts) {
|
|
448
|
-
const config =
|
|
449
|
-
if (!opts.fold)
|
|
450
|
-
config[CONTEXT_RUNTIME_KEY] = "false";
|
|
451
|
-
if (opts.contextHandoff)
|
|
452
|
-
config[CONTEXT_HANDOFF_KEY] = "true";
|
|
529
|
+
const config = buildSessionConfig(opts);
|
|
453
530
|
if (Object.keys(config).length === 0)
|
|
454
531
|
return undefined;
|
|
455
532
|
const payload = JSON.stringify({ v: 1, config });
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* resolvers in lib/code.ts keep the built-in default as the final fallback);
|
|
16
16
|
* the caller folds the plain-value settings under the CLI flags.
|
|
17
17
|
*/
|
|
18
|
-
import { type GoalModeDefault, type LaunchShape } from "./agent.js";
|
|
18
|
+
import { type ContextProfile, type GoalModeDefault, type LaunchShape } from "./agent.js";
|
|
19
19
|
/** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
|
|
20
20
|
export declare const LAUNCHER_CONFIG_PATH: string;
|
|
21
21
|
/** Where the CLI resolves the Anthropic key from; advisory ordering/restriction. */
|
|
@@ -45,6 +45,12 @@ export interface LauncherConfig {
|
|
|
45
45
|
* every launch. Off means byte-identical to a launch with no MNE-7564 change at all.
|
|
46
46
|
*/
|
|
47
47
|
contextHandoff?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* How the gateway folds each session's context (`balanced` when unset): `off` leaves
|
|
50
|
+
* compaction to the coding agent, `balanced` keeps more earlier work verbatim, `aggressive`
|
|
51
|
+
* folds earlier with a tighter working set. `--context-profile` overrides it per launch.
|
|
52
|
+
*/
|
|
53
|
+
contextProfile?: ContextProfile;
|
|
48
54
|
guardrails?: {
|
|
49
55
|
maxTurns?: number;
|
|
50
56
|
budgetUsd?: number;
|
|
@@ -19,7 +19,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
|
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
20
|
import { parse as parseToml, stringify as stringifyToml, TomlError } from "smol-toml";
|
|
21
21
|
import { MNEMOM_DIR } from "./config.js";
|
|
22
|
-
import { isPositiveInt, isPositiveNumber, } from "./agent.js";
|
|
22
|
+
import { CONTEXT_PROFILES, isPositiveInt, isPositiveNumber, parseContextProfile, } from "./agent.js";
|
|
23
23
|
/** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
|
|
24
24
|
export const LAUNCHER_CONFIG_PATH = join(MNEMOM_DIR, "launcher.toml");
|
|
25
25
|
const LAUNCH_SHAPES = ["terminal", "remote-control", "server"];
|
|
@@ -188,6 +188,14 @@ export const CONFIG_KEYS = [
|
|
|
188
188
|
c.contextHandoff = coerceBool("context_handoff", v);
|
|
189
189
|
},
|
|
190
190
|
},
|
|
191
|
+
{
|
|
192
|
+
key: "context_profile",
|
|
193
|
+
describe: `How the gateway folds each session's context (${CONTEXT_PROFILES.join("|")}; balanced by default)`,
|
|
194
|
+
get: (c) => c.contextProfile,
|
|
195
|
+
set: (c, v) => {
|
|
196
|
+
c.contextProfile = parseContextProfile(v, "context_profile");
|
|
197
|
+
},
|
|
198
|
+
},
|
|
191
199
|
{
|
|
192
200
|
key: "guardrails.max_turns",
|
|
193
201
|
describe: "Contract guardrail: turn ceiling (positive integer)",
|
|
@@ -266,6 +274,8 @@ export function parseLauncherConfig(text) {
|
|
|
266
274
|
cfg.slackChannel = coerceNonEmpty("slack_channel", str(raw.slack_channel) ?? "");
|
|
267
275
|
if (raw.context_handoff !== undefined)
|
|
268
276
|
cfg.contextHandoff = coerceBool("context_handoff", str(raw.context_handoff) ?? "");
|
|
277
|
+
if (raw.context_profile !== undefined)
|
|
278
|
+
cfg.contextProfile = parseContextProfile(str(raw.context_profile) ?? "", "context_profile");
|
|
269
279
|
const g = raw.guardrails;
|
|
270
280
|
if (g !== undefined) {
|
|
271
281
|
if (typeof g !== "object" || g === null || Array.isArray(g)) {
|
|
@@ -334,6 +344,8 @@ export function serializeLauncherConfig(cfg) {
|
|
|
334
344
|
doc.slack_allow = cfg.slackAllow;
|
|
335
345
|
if (cfg.contextHandoff !== undefined)
|
|
336
346
|
doc.context_handoff = cfg.contextHandoff;
|
|
347
|
+
if (cfg.contextProfile !== undefined)
|
|
348
|
+
doc.context_profile = cfg.contextProfile;
|
|
337
349
|
const g = cfg.guardrails;
|
|
338
350
|
if (g && (g.maxTurns !== undefined || g.budgetUsd !== undefined || g.stallTurns !== undefined)) {
|
|
339
351
|
const gr = {};
|
|
@@ -386,4 +398,7 @@ export function applyConfigToEnv(cfg, env = process.env) {
|
|
|
386
398
|
if (cfg.contextHandoff !== undefined && !(env.MNEMOM_AGENT_CONTEXT_HANDOFF ?? "").trim()) {
|
|
387
399
|
env.MNEMOM_AGENT_CONTEXT_HANDOFF = cfg.contextHandoff ? "1" : "0";
|
|
388
400
|
}
|
|
401
|
+
if (cfg.contextProfile !== undefined && !(env.MNEMOM_AGENT_CONTEXT_PROFILE ?? "").trim()) {
|
|
402
|
+
env.MNEMOM_AGENT_CONTEXT_PROFILE = cfg.contextProfile;
|
|
403
|
+
}
|
|
389
404
|
}
|
|
@@ -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
|
/**
|