@mnemom/mnemom 0.15.0 → 0.15.1-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.
@@ -40,6 +40,13 @@ export async function tryMeCommand(token, options = {}) {
40
40
  const json = !!options.json;
41
41
  // JSON output is only coherent non-interactively — no prompts can be shown.
42
42
  const nonInteractive = !!options.yes || json || !isInteractive();
43
+ // The name pick is a deliberate human-handoff checkpoint (the manifest marks
44
+ // it human_handoff:true). It must fire on EVERY real run — agent-driven,
45
+ // piped, and inherited-stdin runs all report isTTY=false yet can still answer
46
+ // a prompt — so it is auto-skipped ONLY on an explicit non-interactive
47
+ // request: --yes, --json, or --name (the last handled inside pickName). It is
48
+ // intentionally NOT gated on isInteractive(), unlike the open/login prompts.
49
+ const skipNamePrompt = !!options.yes || json;
43
50
  const autoOpen = options.open !== false; // --no-open → false
44
51
  const result = { token, version: "", dry_run: !!options.dryRun, steps: [] };
45
52
  // A human-facing log that is silenced in --json mode (the JSON is the output).
@@ -69,7 +76,7 @@ export async function tryMeCommand(token, options = {}) {
69
76
  }
70
77
  const apiBase = (options.api ?? getApiUrl()).replace(/\/$/, "");
71
78
  // ── State: name (human checkpoint) ──────────────────────────────────────────
72
- const name = await pickName(manifest, options, nonInteractive, say);
79
+ const name = await pickName(manifest, options, skipNamePrompt, say);
73
80
  result.steps.push({ step: "name", status: "ok", detail: name });
74
81
  // ── State: birth ────────────────────────────────────────────────────────────
75
82
  let agentId;
@@ -143,12 +150,12 @@ export async function tryMeCommand(token, options = {}) {
143
150
  console.log(JSON.stringify(result, null, 2));
144
151
  }
145
152
  // ── name (human checkpoint) ──────────────────────────────────────────────────
146
- async function pickName(manifest, options, nonInteractive, say) {
153
+ async function pickName(manifest, options, skipPrompt, say) {
147
154
  if (options.name && options.name.trim())
148
155
  return options.name.trim();
149
156
  const opts = manifest.handoff.name_options ?? [];
150
- if (nonInteractive) {
151
- const fallback = opts[0] ?? "mnemom-dojo-agent";
157
+ const fallback = opts[0] ?? "mnemom-dojo-agent";
158
+ if (skipPrompt) {
152
159
  say(fmt.dim(`Non-interactive: naming the agent "${fallback}" (override with --name).`));
153
160
  return fallback;
154
161
  }
@@ -157,13 +164,19 @@ async function pickName(manifest, options, nonInteractive, say) {
157
164
  const choice = await askSelect(manifest.handoff.name_question, [...opts, TYPE_MY_OWN]);
158
165
  if (choice && choice !== TYPE_MY_OWN)
159
166
  return choice;
160
- let typed = "";
161
- while (!typed) {
162
- typed = (await askInput("Enter a name for your agent:")).trim();
163
- if (!typed)
164
- say(fmt.warn("A name is required (it's permanent) — please enter one."));
167
+ // "Type my own" — or no valid selection — falls through to free-form entry.
168
+ for (;;) {
169
+ const typed = (await askInput("Enter a name for your agent:")).trim();
170
+ if (typed)
171
+ return typed;
172
+ // A non-responsive stream (EOF on a pipe) returns "" on every read; don't
173
+ // spin forever — fall back to the default so an unattended run completes.
174
+ if (!process.stdin.isTTY) {
175
+ say(fmt.warn(`No name entered on a non-interactive stream — using "${fallback}".`));
176
+ return fallback;
177
+ }
178
+ say(fmt.warn("A name is required (it's permanent) — please enter one."));
165
179
  }
166
- return typed;
167
180
  }
168
181
  /** Token mode is the shipped reality; legacy provider-key claims aren't supported by this runner. */
169
182
  function missingLegacyProof() {
@@ -119,3 +119,14 @@ export declare function refreshTokens(refreshToken: string, clientId: string): P
119
119
  * fallback, so a missing browser must not crash login.
120
120
  */
121
121
  export declare function openBrowser(url: string): void;
122
+ /**
123
+ * Await `promise` while emitting a heartbeat tick every `intervalMs`, so a long
124
+ * silent wait (a browser sign-in round-trip) doesn't look hung. `onTick` is
125
+ * called with the elapsed whole-seconds count. The timer is always cleared when
126
+ * the promise settles, and is unref'd so it never keeps the event loop alive on
127
+ * its own. `timers` is injectable so tests drive the ticks without real clocks.
128
+ */
129
+ export declare function withHeartbeat<T>(promise: Promise<T>, intervalMs: number, onTick: (elapsedSeconds: number) => void, timers?: {
130
+ set?: (cb: () => void, ms: number) => unknown;
131
+ clear?: (handle: unknown) => void;
132
+ }): Promise<T>;
package/dist/lib/oauth.js CHANGED
@@ -35,6 +35,10 @@ 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
+ // Cadence for the "…still waiting for you to finish signing in" heartbeat that
39
+ // both interactive login poll loops emit, so a multi-minute browser sign-in
40
+ // doesn't read as a hung CLI (matches the card-write retry tick in try-me).
41
+ const LOGIN_HEARTBEAT_MS = 15 * 1000;
38
42
  let cachedMetadata = null;
39
43
  /**
40
44
  * Fetch (and process-cache) the AS metadata document. We resolve it relative to
@@ -157,7 +161,9 @@ export async function loginWithLoopback(openUrl = openBrowser) {
157
161
  openUrl(authUrl.toString());
158
162
  console.log("Waiting for authentication...");
159
163
  try {
160
- const code = await codePromise;
164
+ // Heartbeat while we await the loopback redirect — otherwise the sign-in
165
+ // round-trip is dead silent and reads as a hung CLI.
166
+ const code = await withHeartbeat(codePromise, LOGIN_HEARTBEAT_MS, (s) => console.log(`…still waiting for you to finish signing in (${s}s)`));
161
167
  const tokens = await exchangeCode(meta, clientId, code, pkce.verifier, redirectUri);
162
168
  return { tokens, clientId };
163
169
  }
@@ -293,19 +299,24 @@ export async function loginWithDevice(opts) {
293
299
  }
294
300
  display("");
295
301
  display("Waiting for authorization...");
296
- const tokens = await pollDeviceToken(meta, clientId, authz, sleep);
302
+ const tokens = await pollDeviceToken(meta, clientId, authz, sleep, display);
297
303
  return { tokens, clientId };
298
304
  }
299
- async function pollDeviceToken(meta, clientId, authz, sleep) {
305
+ async function pollDeviceToken(meta, clientId, authz, sleep, display) {
300
306
  // RFC 8628 §3.5: default interval is 5s if the server omits it; on slow_down
301
307
  // we increase the interval by 5s and keep that as the new minimum.
302
308
  let intervalMs = (authz.interval ?? 5) * 1000;
303
309
  const deadline = Date.now() + authz.expires_in * 1000;
310
+ // Heartbeat off accumulated poll time (not wall-clock) so it stays correct
311
+ // even when sleep is stubbed in tests; ticks every LOGIN_HEARTBEAT_MS.
312
+ let waitedMs = 0;
313
+ let nextHeartbeatMs = LOGIN_HEARTBEAT_MS;
304
314
  for (;;) {
305
315
  if (Date.now() >= deadline) {
306
316
  throw new Error("Device authorization expired before approval. Please try again.");
307
317
  }
308
318
  await sleep(intervalMs);
319
+ waitedMs += intervalMs;
309
320
  const res = await fetch(meta.token_endpoint, {
310
321
  method: "POST",
311
322
  headers: {
@@ -324,10 +335,10 @@ async function pollDeviceToken(meta, clientId, authz, sleep) {
324
335
  const body = (await res.json().catch(() => ({})));
325
336
  switch (body.error) {
326
337
  case "authorization_pending":
327
- continue; // keep polling at the current interval
338
+ break; // keep polling at the current interval
328
339
  case "slow_down":
329
340
  intervalMs += 5000; // RFC 8628 §3.5
330
- continue;
341
+ break;
331
342
  case "expired_token":
332
343
  throw new Error("Device authorization expired before approval. Please try again.");
333
344
  case "access_denied":
@@ -336,6 +347,12 @@ async function pollDeviceToken(meta, clientId, authz, sleep) {
336
347
  throw new Error(`Device authorization failed: ${body.error ?? `HTTP ${res.status}`}` +
337
348
  (body.error_description ? ` — ${body.error_description}` : ""));
338
349
  }
350
+ // Still pending (authorization_pending / slow_down) — emit a heartbeat
351
+ // every ~15s so a multi-minute approval doesn't look hung.
352
+ if (waitedMs >= nextHeartbeatMs) {
353
+ display(`…still waiting for you to finish signing in (${Math.round(waitedMs / 1000)}s)`);
354
+ nextHeartbeatMs += LOGIN_HEARTBEAT_MS;
355
+ }
339
356
  }
340
357
  }
341
358
  // ============================================================================
@@ -411,6 +428,29 @@ export function openBrowser(url) {
411
428
  function defaultSleep(ms) {
412
429
  return new Promise((resolve) => setTimeout(resolve, ms));
413
430
  }
431
+ /**
432
+ * Await `promise` while emitting a heartbeat tick every `intervalMs`, so a long
433
+ * silent wait (a browser sign-in round-trip) doesn't look hung. `onTick` is
434
+ * called with the elapsed whole-seconds count. The timer is always cleared when
435
+ * the promise settles, and is unref'd so it never keeps the event loop alive on
436
+ * its own. `timers` is injectable so tests drive the ticks without real clocks.
437
+ */
438
+ export async function withHeartbeat(promise, intervalMs, onTick, timers = {}) {
439
+ const set = timers.set ?? ((cb, ms) => setInterval(cb, ms));
440
+ const clear = timers.clear ?? ((h) => clearInterval(h));
441
+ let elapsedMs = 0;
442
+ const handle = set(() => {
443
+ elapsedMs += intervalMs;
444
+ onTick(Math.round(elapsedMs / 1000));
445
+ }, intervalMs);
446
+ handle?.unref?.();
447
+ try {
448
+ return await promise;
449
+ }
450
+ finally {
451
+ clear(handle);
452
+ }
453
+ }
414
454
  /** Constant-time string compare that tolerates length differences. */
415
455
  function timingSafeEqual(a, b) {
416
456
  const ab = Buffer.from(a);
@@ -162,6 +162,11 @@ export async function askMultiSelect(question, options) {
162
162
  });
163
163
  });
164
164
  }
165
+ /** Map a typed answer ("1".."n") to its option label, or null if out of range. */
166
+ function resolveSelection(answer, options) {
167
+ const idx = parseInt(answer.trim(), 10) - 1;
168
+ return idx >= 0 && idx < options.length ? options[idx] : null;
169
+ }
165
170
  /**
166
171
  * Single-select prompt. Displays numbered options, user enters a number.
167
172
  * Returns selected label or null if invalid.
@@ -171,6 +176,19 @@ export async function askSelect(question, options) {
171
176
  for (let i = 0; i < options.length; i++) {
172
177
  console.log(` ${i + 1}) ${options[i]}`);
173
178
  }
179
+ // Non-interactive stdin (pipe / CI / agent-driven): serve the next buffered
180
+ // line from the SAME shared reader askInput uses. A fresh per-prompt readline
181
+ // interface drops buffered lines on a pipe (MNE-269), and `rl.question` never
182
+ // resolves on EOF — so a non-TTY run would silently hang here. Read the
183
+ // shared buffer instead; on EOF there's nothing to pick, so return null and
184
+ // let the caller fall back.
185
+ if (!process.stdin.isTTY) {
186
+ process.stdout.write("Select: ");
187
+ const lines = await readPipedStdinLines();
188
+ const next = lines.shift();
189
+ process.stdout.write("\n");
190
+ return resolveSelection(next ?? "", options);
191
+ }
174
192
  const rl = readline.createInterface({
175
193
  input: process.stdin,
176
194
  output: process.stdout,
@@ -178,13 +196,7 @@ export async function askSelect(question, options) {
178
196
  return new Promise((resolve) => {
179
197
  rl.question("Select: ", (answer) => {
180
198
  rl.close();
181
- const idx = parseInt(answer.trim(), 10) - 1;
182
- if (idx >= 0 && idx < options.length) {
183
- resolve(options[idx]);
184
- }
185
- else {
186
- resolve(null);
187
- }
199
+ resolve(resolveSelection(answer, options));
188
200
  });
189
201
  });
190
202
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.15.0",
3
+ "version": "0.15.1-next.0",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {