@mnemom/mnemom 0.17.31-next.1 → 0.17.32-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/base-url-proxy.mjs +30 -0
  2. package/dist/commands/agent-fuel.d.ts +75 -0
  3. package/dist/commands/agent-fuel.js +298 -0
  4. package/dist/commands/agent-implicit-notice.d.ts +31 -0
  5. package/dist/commands/agent-implicit-notice.js +73 -0
  6. package/dist/commands/agent-session.d.ts +2 -1
  7. package/dist/commands/agent-session.js +2 -1
  8. package/dist/commands/agent.d.ts +52 -1
  9. package/dist/commands/agent.js +111 -26
  10. package/dist/commands/auth.js +20 -0
  11. package/dist/commands/status.js +1 -1
  12. package/dist/commands/update.js +3 -1
  13. package/dist/commands/validate.d.ts +1 -1
  14. package/dist/commands/validate.js +1 -1
  15. package/dist/index.js +51 -12
  16. package/dist/lib/agent.d.ts +32 -19
  17. package/dist/lib/agent.js +34 -32
  18. package/dist/lib/api.d.ts +53 -0
  19. package/dist/lib/api.js +31 -10
  20. package/dist/lib/canonical-json.d.ts +12 -0
  21. package/dist/lib/canonical-json.js +22 -0
  22. package/dist/lib/conduct-receipt.d.ts +81 -0
  23. package/dist/lib/conduct-receipt.js +177 -0
  24. package/dist/lib/contract-hash.d.ts +61 -0
  25. package/dist/lib/contract-hash.js +126 -0
  26. package/dist/lib/experimental.d.ts +10 -1
  27. package/dist/lib/experimental.js +35 -1
  28. package/dist/lib/oauth.d.ts +32 -1
  29. package/dist/lib/oauth.js +157 -44
  30. package/dist/lib/statusline-bin.d.ts +2 -2
  31. package/dist/lib/statusline-bin.js +4 -7
  32. package/dist/lib/statusline-feed.d.ts +33 -0
  33. package/dist/lib/statusline-feed.js +68 -0
  34. package/dist/lib/statusline.d.ts +39 -88
  35. package/dist/lib/statusline.js +58 -250
  36. package/dist/lib/update/decide.d.ts +22 -3
  37. package/dist/lib/update/decide.js +35 -12
  38. package/dist/lib/update/doctor.js +5 -1
  39. package/dist/lib/update/run.d.ts +2 -0
  40. package/dist/lib/update/run.js +22 -8
  41. package/package.json +4 -4
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The contract's full sha256 and seal-time check, as the gateway computes them (Doors W3.5).
3
+ *
4
+ * Callers that hash contracts outside the gateway (the fuel step's CLI verb and the thin runner,
5
+ * each in its own change) use this module. It computes a contract's hash, its committed file's
6
+ * bytes and its repo path; writing the file is the caller's job. It ports the gateway's
7
+ * `normaliseContractObject` / `checkContractObject` / `canonicalHash` (gateway/src/goal-contract.ts). The full sha256 is the digest whose first 16
8
+ * hex characters are the gateway's `x-mnemom-contract-hash`: sha256 of the canonical JSON of the
9
+ * normalised contract, with a default `"nudge"` mode dropped and `"enforce"` kept. The 8 KB
10
+ * limit is measured, like the gateway's, on the canonical JSON with the mode still present.
11
+ *
12
+ * `gateway/test/fixtures/contract-sha256-parity.json` pins the gateway's own outputs; the CLI's
13
+ * parity suite and mnemom-api's (`src/contract-step/hash.ts`) read the same cases.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { canonicalJson } from "./canonical-json.js";
17
+ /** The gateway's `CONTRACT_JSON_MAX`: bytes of canonical JSON of the normalised contract. */
18
+ export const CONTRACT_JSON_MAX = 8_192;
19
+ /** The gateway's `STATEMENT_MAX` (its `USER_CLIP`), in characters. */
20
+ export const STATEMENT_MAX = 4_000;
21
+ const MODES = new Set(["nudge", "enforce"]);
22
+ function stringArray(value) {
23
+ if (!Array.isArray(value))
24
+ return [];
25
+ return value.filter((v) => typeof v === "string" && v.trim().length > 0);
26
+ }
27
+ function positiveFinite(value) {
28
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
29
+ }
30
+ /** The gateway's `normaliseContractObject`: null without a non-empty statement. Mode defaults to "nudge". */
31
+ export function normaliseContract(obj) {
32
+ const statement = typeof obj.statement === "string" ? obj.statement.trim() : "";
33
+ if (!statement)
34
+ return null;
35
+ const contract = {
36
+ statement,
37
+ requirements: stringArray(obj.requirements),
38
+ allowed_paths: stringArray(obj.allowed_paths),
39
+ forbidden_paths: stringArray(obj.forbidden_paths),
40
+ };
41
+ if (typeof obj.goal_id === "string" && obj.goal_id.trim())
42
+ contract.goal_id = obj.goal_id.trim();
43
+ const maxTurns = positiveFinite(obj.max_turns);
44
+ if (maxTurns !== undefined)
45
+ contract.max_turns = maxTurns;
46
+ const budgetUsd = positiveFinite(obj.budget_usd);
47
+ if (budgetUsd !== undefined)
48
+ contract.budget_usd = budgetUsd;
49
+ const stallTurns = positiveFinite(obj.stall_turns);
50
+ if (stallTurns !== undefined)
51
+ contract.stall_turns = stallTurns;
52
+ const timeLimitS = positiveFinite(obj.time_limit_s);
53
+ if (timeLimitS !== undefined)
54
+ contract.time_limit_s = timeLimitS;
55
+ contract.mode = MODES.has(obj.mode) ? obj.mode : "nudge";
56
+ return contract;
57
+ }
58
+ /** The gateway's seal-time gate (`checkContractObject`), with the same reasons. */
59
+ export function checkContract(value, label = "contract") {
60
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
61
+ return { ok: false, reason: "unparseable", message: `${label} must be a JSON object` };
62
+ }
63
+ const obj = value;
64
+ const contract = normaliseContract(obj);
65
+ if (!contract) {
66
+ return {
67
+ ok: false,
68
+ reason: "statement_missing",
69
+ message: `${label} needs a non-empty "statement"`,
70
+ };
71
+ }
72
+ if (contract.statement.length > STATEMENT_MAX) {
73
+ return {
74
+ ok: false,
75
+ reason: "statement_too_long",
76
+ message: `"statement" is ${contract.statement.length} characters; the maximum is ${STATEMENT_MAX}`,
77
+ };
78
+ }
79
+ if (obj.mode !== undefined && !MODES.has(obj.mode)) {
80
+ return {
81
+ ok: false,
82
+ reason: "mode_invalid",
83
+ message: `"mode" must be "nudge" or "enforce" (got ${JSON.stringify(obj.mode)})`,
84
+ };
85
+ }
86
+ const bytes = contractSizeBytes(contract);
87
+ if (bytes > CONTRACT_JSON_MAX) {
88
+ return {
89
+ ok: false,
90
+ reason: "contract_too_large",
91
+ message: `the contract is ${bytes} bytes of canonical JSON; the maximum is ${CONTRACT_JSON_MAX}`,
92
+ };
93
+ }
94
+ return { ok: true, contract };
95
+ }
96
+ /** Bytes the 8 KB limit measures: canonical JSON of the normalised contract, mode included. */
97
+ export function contractSizeBytes(contract) {
98
+ return Buffer.byteLength(canonicalJson(contract), "utf8");
99
+ }
100
+ /** The hash form: a default "nudge" mode dropped, "enforce" kept (the gateway's `canonicalHash`). */
101
+ export function hashForm(contract) {
102
+ const form = { ...contract };
103
+ if ((contract.mode ?? "nudge") === "nudge")
104
+ delete form.mode;
105
+ return form;
106
+ }
107
+ /**
108
+ * The committed contract file's exact bytes: canonical JSON of the hash form, no trailing newline,
109
+ * so sha256 of the file equals `contractSha256`.
110
+ */
111
+ export function contractFileText(contract) {
112
+ return canonicalJson(hashForm(contract));
113
+ }
114
+ /** Full lowercase-hex sha256. Its first 16 characters are the gateway's `x-mnemom-contract-hash`. */
115
+ export function contractSha256(contract) {
116
+ return createHash("sha256").update(contractFileText(contract), "utf8").digest("hex");
117
+ }
118
+ /**
119
+ * Where a sealed contract is committed in the repo: content-addressed by its full sha256. Throws
120
+ * on anything but 64 lowercase hex characters, so the path can never leave `.mnemom/contracts/`.
121
+ */
122
+ export function contractRepoPath(sha256) {
123
+ if (!/^[0-9a-f]{64}$/.test(sha256))
124
+ throw new Error("contract sha256 must be 64 lowercase hex characters");
125
+ return `.mnemom/contracts/${sha256}.json`;
126
+ }
@@ -12,7 +12,7 @@
12
12
  * Everything here is pure or dependency-injected; index.ts and the command
13
13
  * modules are the thin drivers.
14
14
  */
15
- export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "measured-savings";
15
+ export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "measured-savings" | "record" | "fuel";
16
16
  /**
17
17
  * Features that used to be experimental and are now on by default for everyone. Their ids are
18
18
  * no longer in the registry, but `mnemom experimental enable|disable <id>` still accepts them
@@ -63,6 +63,15 @@ export declare function resolveExperimental(opts: {
63
63
  stored: ExperimentalPrefs | undefined;
64
64
  env: Record<string, string | undefined>;
65
65
  }): ExperimentalState;
66
+ /**
67
+ * Features a prerelease (`-next.N`) build makes available without `mnemom experimental
68
+ * enable`: door one's T16 turns implicit goals on by default on `next` (decisions 12 and
69
+ * 22), so its flag, its launcher.toml value and `mnemom agent goal` are not refused there.
70
+ * Stable is unchanged.
71
+ */
72
+ export declare const PRERELEASE_AVAILABLE: readonly ExperimentalId[];
73
+ /** The features whose flags, keys and commands are usable: the enabled ones, plus PRERELEASE_AVAILABLE on a prerelease. */
74
+ export declare function availableFeatures(enabled: readonly ExperimentalId[], prerelease: boolean): ExperimentalId[];
66
75
  /** The one-line stderr notice for a launch with experimental features on; null when none are. */
67
76
  export declare function experimentalNotice(enabled: readonly ExperimentalId[]): string | null;
68
77
  /** The error for using an experimental flag or command while its feature is off. */
@@ -75,7 +75,7 @@ export const EXPERIMENTAL_FEATURES = [
75
75
  {
76
76
  // No flag, launcher.toml key or env of its own: `mnemom experimental enable` is the switch.
77
77
  id: "measured-savings",
78
- summary: "The status bar shows the saving the gateway measured on this session instead of the benchmark estimate",
78
+ summary: "The gateway measures each request's saving into the response usage (the status bar never shows it)",
79
79
  since: "0.17.16",
80
80
  gates: {
81
81
  flags: [],
@@ -84,6 +84,29 @@ export const EXPERIMENTAL_FEATURES = [
84
84
  env: [],
85
85
  },
86
86
  },
87
+ {
88
+ // No flag, launcher.toml key or env of its own: `mnemom experimental enable` is the switch.
89
+ id: "record",
90
+ summary: "Print the conduct receipt at exit: the session's measured model cost and μ, or the record link",
91
+ since: "0.17.32",
92
+ gates: {
93
+ flags: [],
94
+ configKeys: [],
95
+ commands: [],
96
+ env: [],
97
+ },
98
+ },
99
+ {
100
+ id: "fuel",
101
+ summary: "The fuel step: `mnemom agent fuel <file>` seals a batch of job contracts with one confirmation and commits them to the repo",
102
+ since: "0.17.32",
103
+ gates: {
104
+ flags: [],
105
+ configKeys: [],
106
+ commands: ["mnemom agent fuel"],
107
+ env: [],
108
+ },
109
+ },
87
110
  ];
88
111
  export const EXPERIMENTAL_IDS = EXPERIMENTAL_FEATURES.map((f) => f.id);
89
112
  export const EXPERIMENTAL_ENV = "MNEMOM_EXPERIMENTAL";
@@ -126,6 +149,17 @@ export function resolveExperimental(opts) {
126
149
  unknown: stored.filter((t) => !isExperimentalId(t)),
127
150
  };
128
151
  }
152
+ /**
153
+ * Features a prerelease (`-next.N`) build makes available without `mnemom experimental
154
+ * enable`: door one's T16 turns implicit goals on by default on `next` (decisions 12 and
155
+ * 22), so its flag, its launcher.toml value and `mnemom agent goal` are not refused there.
156
+ * Stable is unchanged.
157
+ */
158
+ export const PRERELEASE_AVAILABLE = ["implicit"];
159
+ /** The features whose flags, keys and commands are usable: the enabled ones, plus PRERELEASE_AVAILABLE on a prerelease. */
160
+ export function availableFeatures(enabled, prerelease) {
161
+ return normalize(prerelease ? [...enabled, ...PRERELEASE_AVAILABLE] : enabled);
162
+ }
129
163
  /** The one-line stderr notice for a launch with experimental features on; null when none are. */
130
164
  export function experimentalNotice(enabled) {
131
165
  if (enabled.length === 0)
@@ -42,7 +42,8 @@ export interface AuthServerMetadata {
42
42
  }
43
43
  /**
44
44
  * Fetch (and process-cache) the AS metadata document. We resolve it relative to
45
- * the active API base URL so staging/local point at their own AS.
45
+ * the active API base URL so local (and any non-default `Environment`) points
46
+ * at its own AS.
46
47
  */
47
48
  export declare function discover(): Promise<AuthServerMetadata>;
48
49
  /** Reset the process-level discovery cache (used by tests). */
@@ -89,10 +90,35 @@ export interface LoopbackResult {
89
90
  * scoped tokens plus the client_id that was used (so the caller can persist it
90
91
  * for refresh). `openUrl` is injectable so tests can drive the flow without
91
92
  * spawning a real browser.
93
+ *
94
+ * Cross-device completion: when the AS advertises a device authorization
95
+ * endpoint, this also starts an RFC 8628 device authorization for the same
96
+ * client and prints its code with the verification URI. The login then
97
+ * finishes on whichever comes first: the loopback redirect (same machine) or an
98
+ * approval of the device code from any browser (e.g. the phone that opened the
99
+ * sign-up confirmation email), where the person types the code from this
100
+ * terminal. The code is never put in the authorize URL or any other link, so
101
+ * nothing that opens in a browser can approve this login without the person
102
+ * typing it. Tokens only ever come back to this process: the loopback code
103
+ * needs our PKCE verifier and the device grant needs our device_code. If the
104
+ * device authorization cannot be started, the login is exactly the loopback
105
+ * flow it always was.
92
106
  */
93
107
  export declare function loginWithLoopback(openUrl?: (url: string) => void, opts?: {
94
108
  inviteToken?: string;
109
+ sleep?: (ms: number) => Promise<void>;
95
110
  }): Promise<LoopbackResult>;
111
+ /** How long the loopback listener waits while a device code is live. */
112
+ export declare function crossDeviceWaitMs(expiresInSec: number): number;
113
+ /**
114
+ * Settle with whichever login path finishes first.
115
+ * - The loopback path settles the login either way, exactly as before: a
116
+ * denial, a state mismatch or the timeout in the browser ends it.
117
+ * - The device path ends it on success, or when the person explicitly denies
118
+ * the device code. Any other device failure (expiry, a transport error)
119
+ * only drops that path and the loopback wait continues.
120
+ */
121
+ export declare function firstLogin(viaLoopback: Promise<OAuthTokens>, viaDevice: Promise<OAuthTokens> | null): Promise<OAuthTokens>;
96
122
  export interface DeviceResult {
97
123
  tokens: OAuthTokens;
98
124
  clientId: string;
@@ -169,6 +195,11 @@ export declare function browserOpenerFor(platform: NodeJS.Platform, href: string
169
195
  * Returns whether a launch was attempted.
170
196
  */
171
197
  export declare function openBrowser(url: string): boolean;
198
+ /**
199
+ * setTimeout-based sleep that resolves early when `signal` aborts, so an
200
+ * abandoned device poll does not hold the process open after login finishes.
201
+ */
202
+ export declare function abortableSleep(ms: number, signal: AbortSignal): Promise<void>;
172
203
  /**
173
204
  * Await `promise` while emitting a heartbeat tick every `intervalMs`, so a long
174
205
  * silent wait (a browser sign-in round-trip) doesn't look hung. `onTick` is
package/dist/lib/oauth.js CHANGED
@@ -35,6 +35,12 @@ const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
35
35
  // Loopback login waits at most this long for the browser round-trip before
36
36
  // giving up and freeing the port.
37
37
  const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000;
38
+ // Browser login also starts a device authorization (RFC 8628) when the AS offers
39
+ // one, so the login can be approved from ANOTHER device: someone who signs up in
40
+ // the browser and confirms the email on their phone. While that is live the
41
+ // loopback listener waits as long as the device code is valid, capped here, so
42
+ // the port is never left open indefinitely.
43
+ const CROSS_DEVICE_MAX_WAIT_MS = 15 * 60 * 1000;
38
44
  // Cadence for the "…still waiting for you to finish signing in" heartbeat that
39
45
  // both interactive login poll loops emit, so a multi-minute browser sign-in
40
46
  // doesn't read as a hung CLI (matches the card-write retry tick in try-me).
@@ -111,7 +117,8 @@ async function oauthFetch(what, url, init = {}) {
111
117
  let cachedMetadata = null;
112
118
  /**
113
119
  * Fetch (and process-cache) the AS metadata document. We resolve it relative to
114
- * the active API base URL so staging/local point at their own AS.
120
+ * the active API base URL so local (and any non-default `Environment`) points
121
+ * at its own AS.
115
122
  */
116
123
  export async function discover() {
117
124
  if (cachedMetadata)
@@ -208,46 +215,120 @@ function tokensFromResponse(data) {
208
215
  * scoped tokens plus the client_id that was used (so the caller can persist it
209
216
  * for refresh). `openUrl` is injectable so tests can drive the flow without
210
217
  * spawning a real browser.
218
+ *
219
+ * Cross-device completion: when the AS advertises a device authorization
220
+ * endpoint, this also starts an RFC 8628 device authorization for the same
221
+ * client and prints its code with the verification URI. The login then
222
+ * finishes on whichever comes first: the loopback redirect (same machine) or an
223
+ * approval of the device code from any browser (e.g. the phone that opened the
224
+ * sign-up confirmation email), where the person types the code from this
225
+ * terminal. The code is never put in the authorize URL or any other link, so
226
+ * nothing that opens in a browser can approve this login without the person
227
+ * typing it. Tokens only ever come back to this process: the loopback code
228
+ * needs our PKCE verifier and the device grant needs our device_code. If the
229
+ * device authorization cannot be started, the login is exactly the loopback
230
+ * flow it always was.
211
231
  */
212
232
  export async function loginWithLoopback(openUrl = openBrowser, opts = {}) {
213
233
  const meta = await discover();
214
234
  const pkce = generatePkce();
215
235
  const state = crypto.randomBytes(16).toString("hex");
216
- const { port, codePromise, close } = await startLoopbackServer(state);
236
+ const { port, codePromise, close, setTimeoutMs } = await startLoopbackServer(state);
217
237
  const redirectUri = `http://127.0.0.1:${port}/callback`;
218
- // Register a client bound to the exact loopback redirect URI. OAuth 2.1
219
- // requires exact redirect_uri matching, and the loopback port is ephemeral,
220
- // so we register per-login (cheap, public client, no secret).
221
- const clientId = await registerClient([redirectUri]);
222
- const authUrl = new URL(meta.authorization_endpoint);
223
- authUrl.searchParams.set("response_type", "code");
224
- authUrl.searchParams.set("client_id", clientId);
225
- authUrl.searchParams.set("redirect_uri", redirectUri);
226
- authUrl.searchParams.set("scope", negotiateScopes(meta));
227
- authUrl.searchParams.set("state", state);
228
- authUrl.searchParams.set("code_challenge", pkce.challenge);
229
- authUrl.searchParams.set("code_challenge_method", pkce.method);
230
- // A Mnemom Agent invitation (`mnemom agent --invite`) rides along so a NEW
231
- // invitee who signs up in this browser round-trip skips the confirmation
232
- // email: the AS ignores the param but bounces a signed-out browser to /login
233
- // with this exact URL as return_to, and sign-up forwards the token to the api.
234
- if (opts.inviteToken)
235
- authUrl.searchParams.set("invite_token", opts.inviteToken);
236
- console.log("Opening browser to authenticate...");
237
- console.log(`If the browser doesn't open, visit:\n ${authUrl.toString()}\n`);
238
- openUrl(authUrl.toString());
239
- console.log("Waiting for authentication...");
238
+ const stopPolling = new AbortController();
240
239
  try {
241
- // Heartbeat while we await the loopback redirect — otherwise the sign-in
242
- // round-trip is dead silent and reads as a hung CLI.
243
- const code = await withHeartbeat(codePromise, LOGIN_HEARTBEAT_MS, (s) => console.log(`…still waiting for you to finish signing in (${s}s)`));
244
- const tokens = await exchangeCode(meta, clientId, code, pkce.verifier, redirectUri);
240
+ // Register a client bound to the exact loopback redirect URI. OAuth 2.1
241
+ // requires exact redirect_uri matching, and the loopback port is ephemeral,
242
+ // so we register per-login (cheap, public client, no secret). The same
243
+ // client also runs the device grant, so either path refreshes the same way.
244
+ const clientId = await registerClient([redirectUri]);
245
+ const device = await startCrossDeviceAuthorization(meta, clientId);
246
+ const authUrl = new URL(meta.authorization_endpoint);
247
+ authUrl.searchParams.set("response_type", "code");
248
+ authUrl.searchParams.set("client_id", clientId);
249
+ authUrl.searchParams.set("redirect_uri", redirectUri);
250
+ authUrl.searchParams.set("scope", negotiateScopes(meta));
251
+ authUrl.searchParams.set("state", state);
252
+ authUrl.searchParams.set("code_challenge", pkce.challenge);
253
+ authUrl.searchParams.set("code_challenge_method", pkce.method);
254
+ // A Mnemom Agent invitation (`mnemom agent --invite`) rides along so a NEW
255
+ // invitee who signs up in this browser round-trip skips the confirmation
256
+ // email: the AS ignores the param but bounces a signed-out browser to /login
257
+ // with this exact URL as return_to, and sign-up forwards the token to the api.
258
+ if (opts.inviteToken)
259
+ authUrl.searchParams.set("invite_token", opts.inviteToken);
260
+ console.log("Opening browser to authenticate...");
261
+ console.log(`If the browser doesn't open, visit:\n ${authUrl.toString()}\n`);
262
+ if (device) {
263
+ setTimeoutMs(crossDeviceWaitMs(device.expires_in));
264
+ // The plain verification_uri, never verification_uri_complete: the person
265
+ // types the code there, so a link alone can never approve this login.
266
+ console.log(`Confirming on another device? Open ${device.verification_uri} and enter: ${device.user_code}\n`);
267
+ }
268
+ openUrl(authUrl.toString());
269
+ console.log("Waiting for authentication...");
270
+ const viaLoopback = codePromise.then((code) => exchangeCode(meta, clientId, code, pkce.verifier, redirectUri));
271
+ const viaDevice = device
272
+ ? pollDeviceToken(meta, clientId, device, opts.sleep ?? ((ms) => abortableSleep(ms, stopPolling.signal)),
273
+ // The loopback wait below already prints the heartbeat.
274
+ () => { }, stopPolling.signal)
275
+ : null;
276
+ // Heartbeat while we wait — otherwise the sign-in round-trip is dead
277
+ // silent and reads as a hung CLI.
278
+ const tokens = await withHeartbeat(firstLogin(viaLoopback, viaDevice), LOGIN_HEARTBEAT_MS, (s) => console.log(`…still waiting for you to finish signing in (${s}s)`));
245
279
  return { tokens, clientId };
246
280
  }
247
281
  finally {
282
+ stopPolling.abort();
248
283
  close();
249
284
  }
250
285
  }
286
+ /**
287
+ * Start the device authorization that lets a browser login be approved from
288
+ * another device. Best-effort: an AS without the endpoint, or any failure,
289
+ * returns null and the login carries on as loopback only.
290
+ */
291
+ async function startCrossDeviceAuthorization(meta, clientId) {
292
+ if (!meta.device_authorization_endpoint)
293
+ return null;
294
+ try {
295
+ const authz = await requestDeviceAuthorization(meta.device_authorization_endpoint, meta, clientId);
296
+ if (typeof authz.device_code !== "string" ||
297
+ typeof authz.user_code !== "string" ||
298
+ typeof authz.verification_uri !== "string" ||
299
+ typeof authz.expires_in !== "number") {
300
+ return null;
301
+ }
302
+ return authz;
303
+ }
304
+ catch {
305
+ return null;
306
+ }
307
+ }
308
+ /** How long the loopback listener waits while a device code is live. */
309
+ export function crossDeviceWaitMs(expiresInSec) {
310
+ const ms = Number.isFinite(expiresInSec) ? expiresInSec * 1000 : 0;
311
+ return Math.min(Math.max(ms, LOOPBACK_TIMEOUT_MS), CROSS_DEVICE_MAX_WAIT_MS);
312
+ }
313
+ /**
314
+ * Settle with whichever login path finishes first.
315
+ * - The loopback path settles the login either way, exactly as before: a
316
+ * denial, a state mismatch or the timeout in the browser ends it.
317
+ * - The device path ends it on success, or when the person explicitly denies
318
+ * the device code. Any other device failure (expiry, a transport error)
319
+ * only drops that path and the loopback wait continues.
320
+ */
321
+ export function firstLogin(viaLoopback, viaDevice) {
322
+ if (!viaDevice)
323
+ return viaLoopback;
324
+ return new Promise((resolve, reject) => {
325
+ viaLoopback.then(resolve, reject);
326
+ viaDevice.then(resolve, (err) => {
327
+ if (err instanceof Error && err.message === DEVICE_ACCESS_DENIED)
328
+ reject(err);
329
+ });
330
+ });
331
+ }
251
332
  /**
252
333
  * Start a loopback HTTP listener that captures the OAuth redirect
253
334
  * (GET /callback?code=...&state=...). Resolves with the authorization code once
@@ -293,13 +374,18 @@ function startLoopbackServer(expectedState) {
293
374
  return new Promise((resolve) => {
294
375
  server.listen(0, "127.0.0.1", () => {
295
376
  const port = server.address().port;
296
- const timeout = setTimeout(() => {
377
+ const giveUp = () => {
297
378
  rejectCode(new Error("Login timed out. Please try again."));
298
379
  server.close();
299
- }, LOOPBACK_TIMEOUT_MS);
380
+ };
381
+ let timeout = setTimeout(giveUp, LOOPBACK_TIMEOUT_MS);
300
382
  resolve({
301
383
  port,
302
384
  codePromise,
385
+ setTimeoutMs: (ms) => {
386
+ clearTimeout(timeout);
387
+ timeout = setTimeout(giveUp, ms);
388
+ },
303
389
  close: () => {
304
390
  clearTimeout(timeout);
305
391
  server.close();
@@ -358,18 +444,7 @@ export async function loginWithDevice(opts) {
358
444
  // clients"). Register a loopback placeholder — it satisfies validation and is
359
445
  // never used, since the device grant never redirects.
360
446
  const clientId = await registerClient(["http://127.0.0.1/callback"]);
361
- const authzRes = await oauthFetch("Device authorization at", meta.device_authorization_endpoint, {
362
- method: "POST",
363
- headers: {
364
- "Content-Type": "application/x-www-form-urlencoded",
365
- Accept: "application/json",
366
- },
367
- body: new URLSearchParams({ client_id: clientId, scope: negotiateScopes(meta) }),
368
- });
369
- if (!authzRes.ok) {
370
- throw new Error(`Device authorization failed: ${await oauthError(authzRes)}`);
371
- }
372
- const authz = (await authzRes.json());
447
+ const authz = await requestDeviceAuthorization(meta.device_authorization_endpoint, meta, clientId);
373
448
  display("");
374
449
  display("To authenticate, visit:");
375
450
  display(` ${authz.verification_uri}`);
@@ -385,7 +460,26 @@ export async function loginWithDevice(opts) {
385
460
  const tokens = await pollDeviceToken(meta, clientId, authz, sleep, display);
386
461
  return { tokens, clientId };
387
462
  }
388
- async function pollDeviceToken(meta, clientId, authz, sleep, display) {
463
+ /** POST the device authorization request (RFC 8628 §3.1) for `clientId`. */
464
+ async function requestDeviceAuthorization(endpoint, meta, clientId) {
465
+ const authzRes = await oauthFetch("Device authorization at", endpoint, {
466
+ method: "POST",
467
+ headers: {
468
+ "Content-Type": "application/x-www-form-urlencoded",
469
+ Accept: "application/json",
470
+ },
471
+ body: new URLSearchParams({ client_id: clientId, scope: negotiateScopes(meta) }),
472
+ });
473
+ if (!authzRes.ok) {
474
+ throw new Error(`Device authorization failed: ${await oauthError(authzRes)}`);
475
+ }
476
+ return (await authzRes.json());
477
+ }
478
+ /** The message a device-code denial rejects with (matched by firstLogin). */
479
+ const DEVICE_ACCESS_DENIED = "Authorization was denied.";
480
+ /** Thrown into an abandoned device poll once the other login path has won. */
481
+ const POLL_STOPPED = "Device polling stopped.";
482
+ async function pollDeviceToken(meta, clientId, authz, sleep, display, signal) {
389
483
  // RFC 8628 §3.5: default interval is 5s if the server omits it; on slow_down
390
484
  // we increase the interval by 5s and keep that as the new minimum.
391
485
  let intervalMs = (authz.interval ?? 5) * 1000;
@@ -399,6 +493,8 @@ async function pollDeviceToken(meta, clientId, authz, sleep, display) {
399
493
  throw new Error("Device authorization expired before approval. Please try again.");
400
494
  }
401
495
  await sleep(intervalMs);
496
+ if (signal?.aborted)
497
+ throw new Error(POLL_STOPPED);
402
498
  waitedMs += intervalMs;
403
499
  let res;
404
500
  try {
@@ -437,7 +533,7 @@ async function pollDeviceToken(meta, clientId, authz, sleep, display) {
437
533
  case "expired_token":
438
534
  throw new Error("Device authorization expired before approval. Please try again.");
439
535
  case "access_denied":
440
- throw new Error("Authorization was denied.");
536
+ throw new Error(DEVICE_ACCESS_DENIED);
441
537
  default:
442
538
  throw new Error(`Device authorization failed: ${body.error ?? `HTTP ${res.status}`}` +
443
539
  (body.error_description ? ` — ${body.error_description}` : ""));
@@ -623,6 +719,23 @@ export function openBrowser(url) {
623
719
  function defaultSleep(ms) {
624
720
  return new Promise((resolve) => setTimeout(resolve, ms));
625
721
  }
722
+ /**
723
+ * setTimeout-based sleep that resolves early when `signal` aborts, so an
724
+ * abandoned device poll does not hold the process open after login finishes.
725
+ */
726
+ export function abortableSleep(ms, signal) {
727
+ return new Promise((resolve) => {
728
+ if (signal.aborted)
729
+ return resolve();
730
+ const done = () => {
731
+ clearTimeout(timer);
732
+ signal.removeEventListener("abort", done);
733
+ resolve();
734
+ };
735
+ const timer = setTimeout(done, ms);
736
+ signal.addEventListener("abort", done, { once: true });
737
+ });
738
+ }
626
739
  /**
627
740
  * Await `promise` while emitting a heartbeat tick every `intervalMs`, so a long
628
741
  * silent wait (a browser sign-in round-trip) doesn't look hung. `onTick` is
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Entry for the `mnemom agent` status-bar counter (lib/statusline.ts). Claude Code runs it
3
- * on every status update, so it imports only the counter — not the whole CLI.
2
+ * Entry for the `mnemom agent` status-bar segment (lib/statusline.ts). Claude Code runs it
3
+ * on every status update, so it imports only the segment — not the whole CLI.
4
4
  */
5
5
  export {};
@@ -1,17 +1,14 @@
1
1
  /**
2
- * Entry for the `mnemom agent` status-bar counter (lib/statusline.ts). Claude Code runs it
3
- * on every status update, so it imports only the counter — not the whole CLI.
2
+ * Entry for the `mnemom agent` status-bar segment (lib/statusline.ts). Claude Code runs it
3
+ * on every status update, so it imports only the segment — not the whole CLI.
4
4
  */
5
- import { homedir } from "node:os";
6
- import { join } from "node:path";
7
- import { INNER_ENV, measuredMode, render } from "./statusline.js";
5
+ import { FEED_ENV, INNER_ENV, render } from "./statusline.js";
8
6
  const chunks = [];
9
7
  process.stdin.on("data", (c) => chunks.push(c));
10
8
  process.stdin.on("end", () => {
11
9
  const line = render(Buffer.concat(chunks).toString("utf8"), {
12
- stateDir: join(homedir(), ".mnemom", "statusline"),
13
10
  inner: process.env[INNER_ENV] || undefined,
14
- measured: measuredMode(process.env),
11
+ feed: process.env[FEED_ENV] || undefined,
15
12
  });
16
13
  process.stdout.write(`${line}\n`);
17
14
  });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The launcher side of the fed status line (W1.4, decision 14), prerelease only.
3
+ *
4
+ * The local base-URL proxy records the gateway's `x-mnemom-lineage-key` for this session in
5
+ * its record-state file (the one the conduct receipt reads). While Claude Code runs, the launcher reads Record's accumulator totals for that
6
+ * lineage every few seconds and writes them to the feed file the status-line bin reads
7
+ * (lib/statusline.ts). The bin never touches the network.
8
+ *
9
+ * Fail open everywhere: no lineage yet, a dark route (404), a non-billing role (403) or any
10
+ * error leaves the feed as it was, so the status line stays "mnemom" until fed.
11
+ */
12
+ import { type RecordTotals } from "./api.js";
13
+ /** How often the launcher re-reads the totals. */
14
+ export declare const FEED_INTERVAL_MS = 10000;
15
+ export interface FeedOptions {
16
+ /** The proxy's record-state JSON (MNEMOM_AGENT_BU_RECORD_STATE), carrying `lineage_key`. */
17
+ recordStateFile: string;
18
+ /** Read by the status-line bin (MNEMOM_STATUSLINE_FEED). */
19
+ feedFile: string;
20
+ orgId: string;
21
+ intervalMs?: number;
22
+ /** Test seam; the real read is getRecordTotals (throws on any non-200). */
23
+ fetchTotals?: (orgId: string, lineageKey: string) => Promise<RecordTotals | null>;
24
+ }
25
+ /**
26
+ * The lineage key the proxy recorded, or null when there is none (yet) or it is not the
27
+ * gateway's `cid:`/`cr:` shape (the proxy's own check is looser; this read is exact).
28
+ */
29
+ export declare function readLineage(path: string): string | null;
30
+ /** One poll: read the lineage, fetch its totals, write the feed. Never throws. */
31
+ export declare function feedOnce(o: FeedOptions): Promise<boolean>;
32
+ /** Poll until stopped. The timer is unref'd, so it never holds the launcher open. */
33
+ export declare function startFeed(o: FeedOptions): () => void;