@tiens.nguyen/gu-cli 1.0.771 → 1.0.772

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/cli-token.mjs CHANGED
@@ -80,10 +80,10 @@ export function verifyCliToken(token, publicKeyB64) {
80
80
  !p || typeof p !== "object" ||
81
81
  p.v !== 1 ||
82
82
  typeof p.sub !== "string" || !p.sub ||
83
- (p.plan !== "pro" && p.plan !== "free") ||
83
+ (p.plan !== "pro" && p.plan !== "free" && p.plan !== "trial") ||
84
84
  !(p.paidUntil === null || isIso(p.paidUntil)) ||
85
85
  !isIso(p.issuedAt) ||
86
- (p.issuedBy !== "refresh" && p.issuedBy !== "account" && p.issuedBy !== "admin") ||
86
+ (p.issuedBy !== "refresh" && p.issuedBy !== "account" && p.issuedBy !== "admin" && p.issuedBy !== "trial") ||
87
87
  !(p.device === null || (typeof p.device === "string" && /^[0-9a-f]{64}$/.test(p.device))) ||
88
88
  !(p.activeDevices === null || (Number.isInteger(p.activeDevices) && p.activeDevices >= 0)) ||
89
89
  !(Number.isInteger(p.deviceLimit) && p.deviceLimit > 0) ||
@@ -132,6 +132,59 @@ export async function osMachineId({ platform = process.platform, read = (p) => r
132
132
  return "";
133
133
  }
134
134
 
135
+ /**
136
+ * This computer for a FREE TRIAL (#305) — or null. Only the OS machine id counts here: the fallback
137
+ * thisDevice() uses when it is missing (~/.gonext/instance) changes with `gu clear`, which would make
138
+ * the trial once-per-install instead of once-per-computer. No stable id, no trial.
139
+ */
140
+ export async function trialDevice(opts = {}) {
141
+ const raw = opts.machineId ?? (await osMachineId({ platform: opts.platform }));
142
+ if (!raw) return null;
143
+ return thisDevice({ ...opts, machineId: raw });
144
+ }
145
+
146
+ /**
147
+ * Ask the gu service for this computer's free trial (#305). → one of:
148
+ * { ok: true, token, endsAt, created } — a new trial, or the one this computer already has
149
+ * { ok: false, why: "used", endedAt } — this computer's trial has ended; never a second one
150
+ * { ok: false, why: "no-device" | "unreachable" | "http-NNN" | "bad-response" }
151
+ * Never throws.
152
+ */
153
+ export async function claimTrial({ hostedBase, device, fetchImpl = fetch, timeoutMs = 8000 }) {
154
+ if (!device) return { ok: false, why: "no-device" };
155
+ const base = String(hostedBase ?? "").replace(/\/+$/, "");
156
+ if (!base) return { ok: false, why: "unreachable" };
157
+ try {
158
+ const res = await fetchImpl(`${base}/api/billing/cli-token/trial`, {
159
+ method: "POST",
160
+ headers: { "content-type": "application/json" },
161
+ body: JSON.stringify({ device }),
162
+ signal: AbortSignal.timeout(timeoutMs),
163
+ });
164
+ const data = await res.json().catch(() => null);
165
+ if (res.status === 409) return { ok: false, why: "used", endedAt: data?.endedAt ?? null };
166
+ if (!res.ok) return { ok: false, why: `http-${res.status}` };
167
+ return typeof data?.token === "string"
168
+ ? { ok: true, token: data.token, endsAt: data.endsAt ?? null, created: data.created === true }
169
+ : { ok: false, why: "bad-response" };
170
+ } catch {
171
+ return { ok: false, why: "unreachable" };
172
+ }
173
+ }
174
+
175
+ /** Whole days left until an ISO date (rounded up; 0 when past). */
176
+ export function daysLeft(iso, now = new Date()) {
177
+ const ms = Date.parse(iso ?? "") - now.getTime();
178
+ return Number.isFinite(ms) && ms > 0 ? Math.ceil(ms / 86_400_000) : 0;
179
+ }
180
+
181
+ /** The one line a first install sees instead of a token question (#305). */
182
+ export function trialStartedLine(endsAt, site = DEFAULT_SITE) {
183
+ const accountUrl = `${String(site).replace(/^https?:\/\//, "")}/account`;
184
+ return `Free trial on this computer: 10 days, until ${shortDate(endsAt)}. ` +
185
+ `Then get a free token for 1 computer at ${accountUrl} — gu token <paste> links it.`;
186
+ }
187
+
135
188
  /** What renewals send about this computer. The raw OS id never leaves the machine — only a salted hash. */
136
189
  export async function thisDevice({ platform = process.platform, hostName = osHostname(), machineId, instanceId } = {}) {
137
190
  const raw = machineId ?? (await osMachineId({ platform }));
@@ -289,6 +342,15 @@ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempt
289
342
  if (check.ok) {
290
343
  const p = check.payload;
291
344
  const ended = p.paidUntil && Date.parse(p.paidUntil) <= now.getTime();
345
+ // THE FREE TRIAL (#305): its days, and the way on — a FREE account token covers one computer
346
+ // (the owner's wording: "go to <site> to get a free token for your 1 free machine"). Every start,
347
+ // quietly; urgent only once the trial has ended.
348
+ if (p.plan === "trial") {
349
+ const way = `free token for 1 computer at ${accountUrl}`;
350
+ if (ended) return { kind: "trial-ended", urgent: true, text: `free trial ended · ${way}` };
351
+ const left = daysLeft(p.paidUntil, now);
352
+ return { kind: "trial", urgent: false, text: `trial · ${left} day${left === 1 ? "" : "s"} left · ${way}` };
353
+ }
292
354
  // SHORT AND CALM (#292: "make it simpler … seems so serious"): the fact, then the one thing to
293
355
  // do. `urgent` is what the bar colours by — only these two need the user to act.
294
356
  if (ended) return { kind: "plan-ended", urgent: true, text: `Pro ended ${shortDate(p.paidUntil)} · renew at ${pricingUrl}` };
@@ -324,7 +386,7 @@ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempt
324
386
  }
325
387
 
326
388
  /** "Free plan" / "Pro plan" — the name a person uses. */
327
- const planName = (plan) => (plan === "pro" ? "Pro plan" : "Free plan");
389
+ const planName = (plan) => (plan === "pro" ? "Pro plan" : plan === "trial" ? "free trial" : "Free plan");
328
390
  /** "1 computer" / "4 computers" — "computer", not "device": a device is also a phone. */
329
391
  const computers = (n) => `${n} ${n === 1 ? "computer" : "computers"}`;
330
392
 
@@ -351,6 +413,10 @@ export function accountLine({ payload = null, settingsEmail = "", now = new Date
351
413
  if (payload.plan === "pro" && Number.isFinite(until) && until > now.getTime()) {
352
414
  plan = `Pro until ${shortDate(payload.paidUntil)}`;
353
415
  pro = true;
416
+ } else if (payload.plan === "trial") {
417
+ plan = Number.isFinite(until) && until > now.getTime()
418
+ ? `Free trial until ${shortDate(payload.paidUntil)}`
419
+ : `Free trial ended ${shortDate(payload.paidUntil)}`;
354
420
  } else if (payload.paidUntil) {
355
421
  plan = `Free — Pro ended ${shortDate(payload.paidUntil)}`;
356
422
  } else {
@@ -364,6 +430,10 @@ export function tokenSummary(payload, now = new Date()) {
364
430
  const lines = [];
365
431
  if (payload.plan === "pro" && payload.paidUntil && Date.parse(payload.paidUntil) > now.getTime()) {
366
432
  lines.push(`Pro until ${shortDate(payload.paidUntil)}.`);
433
+ } else if (payload.plan === "trial") {
434
+ lines.push(Date.parse(payload.paidUntil) > now.getTime()
435
+ ? `Free trial on this computer — ${daysLeft(payload.paidUntil, now)} day(s) left, until ${shortDate(payload.paidUntil)}.`
436
+ : `The free trial on this computer ended on ${shortDate(payload.paidUntil)}.`);
367
437
  } else if (payload.paidUntil) {
368
438
  lines.push(`Free — the Pro plan ended on ${shortDate(payload.paidUntil)}.`);
369
439
  } else {
@@ -410,11 +480,14 @@ export function enforcementDecision({ token, publicKeyB64, now = new Date(), sit
410
480
  const pricingUrl = `${host}/pricing`;
411
481
  const accountUrl = `${host}/account`;
412
482
  const ended = p.paidUntil && Date.parse(p.paidUntil) <= now.getTime();
413
- if (p.plan !== "pro" || ended) {
483
+ // A running free trial (#305) is entitled like Pro, for its ten days.
484
+ if ((p.plan !== "pro" && p.plan !== "trial") || ended) {
414
485
  return {
415
486
  enforced: true,
416
487
  allowed: false,
417
- why: ended
488
+ why: ended && p.plan === "trial"
489
+ ? `The free trial on this computer ended on ${shortDate(p.paidUntil)}. Go Pro at ${pricingUrl} to keep using the agent.`
490
+ : ended
418
491
  ? `Your gu Pro plan ended on ${shortDate(p.paidUntil)}. Renew at ${pricingUrl} to keep using the agent.`
419
492
  : `gu needs a Pro plan on this account. See ${pricingUrl} — the rest of gu still works.`,
420
493
  };
@@ -193,8 +193,15 @@ export async function probeMongo() {
193
193
  // both `pgrep` and `mongosh`, so setup declared it missing and refused to continue on a
194
194
  // machine where it worked perfectly. Found exactly that way, in a container, on Linux.
195
195
  const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
196
+ // A HANDSHAKE, NOT AN OPEN PORT (task #304). "The port accepts a connection" also passes when
197
+ // Docker Desktop is stuck: its forwarder keeps 27017 open while nothing behind it answers, so
198
+ // setup used to call that "running" and every API call then timed out. A MongoDB `hello` is
199
+ // answered with or without a password; silence means nothing usable is there.
196
200
  if (await tcpReachable(host, port)) {
197
- return { running: true, how: `listening at ${host}:${port}` };
201
+ const { mongoAnswers, portHolder } = await import("./mongo-native.mjs");
202
+ const state = await mongoAnswers(host, port);
203
+ if (state === "answers") return { running: true, how: `answering at ${host}:${port}` };
204
+ return { running: false, how: null, silentPort: true, holder: await portHolder(port), installed: false };
198
205
  }
199
206
 
200
207
  if (process.platform === "win32") {
@@ -272,6 +279,58 @@ export async function probeDockerForMongo(name = MONGO_DOCKER_NAME) {
272
279
  };
273
280
  }
274
281
 
282
+ /**
283
+ * macOS: make a native MongoDB answer on 27017 (task #304). → { ok, explained }.
284
+ * `explained` = it printed its own guidance, so the caller adds none.
285
+ */
286
+ export async function ensureNativeMongoMac(ask, mongo, { log = (l) => console.log(l) } = {}) {
287
+ const mn = await import("./mongo-native.mjs");
288
+ const brew = mn.findBrew();
289
+ const { action, holder } = mn.macMongoAction({
290
+ state: mongo.silentPort ? "silent" : "closed",
291
+ holder: mongo.holder,
292
+ serverInstalled: Boolean(mongo.installed),
293
+ brew,
294
+ });
295
+ if (action === "docker-stuck" || action === "port-silent") {
296
+ log("");
297
+ for (const l of mn.silentPortLines({ action, holder })) log(" " + (l.startsWith(" ") ? l : dim(l)));
298
+ return { ok: false, explained: true };
299
+ }
300
+ if (action === "no-brew") {
301
+ log("");
302
+ for (const l of mn.noBrewLines()) log(dim(" " + l));
303
+ return { ok: false, explained: true };
304
+ }
305
+ const installing = action === "brew-install";
306
+ const yes = await ask(
307
+ installing
308
+ ? " Install MongoDB Community with Homebrew now? [Y/n]: "
309
+ : " MongoDB is installed but not running. Start it now (brew services)? [Y/n]: "
310
+ );
311
+ if (String(yes ?? "").trim().toLowerCase().startsWith("n")) {
312
+ log(dim("\n Not installed. To do it yourself:"));
313
+ for (const s of installing ? mn.BREW_INSTALL_STEPS : mn.BREW_START_STEPS) log(dim(` brew ${s.join(" ")}`));
314
+ return { ok: false, explained: true };
315
+ }
316
+ log("\n " + bold(installing ? "Installing MongoDB Community" : "Starting MongoDB") + dim(" Homebrew, a minute or two"));
317
+ const done = await mn.runBrewSteps(brew, installing ? mn.BREW_INSTALL_STEPS : mn.BREW_START_STEPS, {
318
+ onLine: (l) => l.length < 200 && log(dim(" " + l)),
319
+ });
320
+ if (!done) {
321
+ log("\n " + red("✗ ") + "Homebrew did not finish — the lines above say why.");
322
+ return { ok: false, explained: true };
323
+ }
324
+ const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
325
+ if (!(await mn.waitForMongo(host, port, { timeoutMs: 60000 }))) {
326
+ log("\n " + red("✗ ") + `MongoDB was started but is not answering at ${host}:${port} yet.`);
327
+ log(dim(" Its log: /opt/homebrew/var/log/mongodb/mongo.log — then run gu again."));
328
+ return { ok: false, explained: true };
329
+ }
330
+ log(" " + cyan("✓ MongoDB Community is running") + dim(` at ${host}:${port} (brew services — it starts at login)`));
331
+ return { ok: true, explained: true };
332
+ }
333
+
275
334
  /**
276
335
  * Start MongoDB in a container and wait for it to answer. → true when it is up.
277
336
  *
@@ -684,6 +743,19 @@ export async function runClientExpertSetup({
684
743
  console.log("\n " + bold("MongoDB is not running on this machine."));
685
744
  console.log(dim(" Local mode keeps every conversation and token count in a database"));
686
745
  console.log(dim(" on THIS machine, so it needs one before setup can go further."));
746
+ // A MAC GETS NATIVE MONGODB, NOT DOCKER (task #304): Homebrew's mongodb-community as a brew
747
+ // service. Docker on a Mac puts every call through its VM, and a stuck Docker Desktop keeps
748
+ // 27017 open while nothing answers — the failure that stopped this project's own Mac. The
749
+ // Docker branch below stays for Linux and Windows.
750
+ let explained = false;
751
+ if (machine.platform === "darwin") {
752
+ const r = await ensureNativeMongoMac(ask, mongo);
753
+ if (r.ok) {
754
+ mongo.running = true;
755
+ probe.mongoRunning = true;
756
+ }
757
+ explained = r.explained;
758
+ } else
687
759
  // DOCKER FIRST, WHERE IT EXISTS. This is the one branch here that can actually finish the
688
760
  // job rather than print homework: one container, no tap, no system service, and the same
689
761
  // mongo:8 the project's own end-to-end tests run against. Only offered when `docker ps`
@@ -693,8 +765,8 @@ export async function runClientExpertSetup({
693
765
  mongo.running = true;
694
766
  probe.mongoRunning = true;
695
767
  }
696
- if (probe.mongoRunning) {
697
- // fall through to the rest of setup below — nothing left to instruct.
768
+ if (probe.mongoRunning || explained) {
769
+ // fall through — running, or the Mac branch has already said what to do.
698
770
  } else if (mongo.installed) {
699
771
  console.log(dim("\n It looks installed but is not answering. Start it with:\n"));
700
772
  console.log(
@@ -1004,6 +1004,13 @@ class _AgentOffline(_AgentConfigError):
1004
1004
  which would need the network anyway and would answer as if the work had been done."""
1005
1005
 
1006
1006
 
1007
+ class _StreamStalled(RuntimeError):
1008
+ """A streamed reply STOPPED mid-way: tokens had started, then nothing arrived for the stall
1009
+ window (task #306). What a connection that died silently looks like — the internet dropped
1010
+ during the reply and the dead TCP connection never errored. Handled as a lost connection
1011
+ (_is_offline): the same step is sent again on a fresh connection."""
1012
+
1013
+
1007
1014
  class _AgentBackendOverloaded(RuntimeError):
1008
1015
  """The coding backend refused every attempt with 429/rate-limit (task #114). Carries
1009
1016
  the user-facing explanation; like a gateway outage it must NOT degrade to a plain
@@ -3572,7 +3579,16 @@ def _is_offline(err, host: str = "") -> bool:
3572
3579
 
3573
3580
  Something that answered with a status is never offline, and a timeout is its own class
3574
3581
  (openai's APITimeoutError subclasses APIConnectionError, so it is excluded by name)."""
3575
- if err is None or _http_status_of(err) is not None or _is_request_timeout(err):
3582
+ if err is None:
3583
+ return False
3584
+ # A stream that died mid-reply (#306) — whatever the host: the reply had started, so the
3585
+ # server was up, and only the connection can have gone.
3586
+ cur, depth = err, 0
3587
+ while cur is not None and depth < 6:
3588
+ if isinstance(cur, _StreamStalled):
3589
+ return True
3590
+ cur, depth = (getattr(cur, "__cause__", None) or getattr(cur, "__context__", None)), depth + 1
3591
+ if _http_status_of(err) is not None or _is_request_timeout(err):
3576
3592
  return False
3577
3593
  local = _host_locality_is_local(host)
3578
3594
  cur, depth = err, 0
@@ -3604,6 +3620,68 @@ def _host_locality_is_local(host: str) -> bool:
3604
3620
  return "." not in h or h.endswith((".local", ".lan", ".internal", ".home"))
3605
3621
 
3606
3622
 
3623
+ def _stream_stall_seconds() -> float:
3624
+ """How long a STARTED stream may go silent before it is treated as a dead connection
3625
+ (GONEXT_STREAM_STALL_S, default 60s). Only after the first token: before it, a slow box may
3626
+ legitimately spend many minutes in prompt-eval (see _code_request_timeout)."""
3627
+ try:
3628
+ v = float(os.environ.get("GONEXT_STREAM_STALL_S", "") or 60)
3629
+ except ValueError:
3630
+ v = 60.0
3631
+ return max(5.0, v)
3632
+
3633
+
3634
+ def _iter_with_stall(stream, started, stall_s: float, on_data=None, poll: float = 0.5):
3635
+ """Yield the stream's chunks, raising _StreamStalled when it goes quiet mid-reply (#306).
3636
+
3637
+ WHY A READER THREAD. `for chunk in stream` blocks inside a socket read; when the connection
3638
+ died silently that read never returns, and closing the socket from another thread does not
3639
+ reliably wake it on every OS. So a helper thread does the blocking reads and hands chunks over
3640
+ a queue, and this side waits on the queue with a timeout it controls. On a stall the stream is
3641
+ closed best-effort and the reader is abandoned (a daemon thread on a dead socket, gone with the
3642
+ process) — the step is then sent again on a new connection.
3643
+
3644
+ `started()` says whether the reply has begun; silence before that is prompt-eval, not a stall."""
3645
+ import queue as _queue
3646
+ import threading as _threading
3647
+ q = _queue.Queue()
3648
+
3649
+ def _reader():
3650
+ try:
3651
+ for c in stream:
3652
+ q.put(("chunk", c))
3653
+ q.put(("end", None))
3654
+ except BaseException as e: # noqa: BLE001
3655
+ q.put(("error", e))
3656
+
3657
+ _threading.Thread(target=_reader, name="gu-stream-reader", daemon=True).start()
3658
+ last = time.monotonic()
3659
+ while True:
3660
+ try:
3661
+ kind, val = q.get(timeout=poll)
3662
+ except _queue.Empty:
3663
+ if started() and time.monotonic() - last > stall_s:
3664
+ try:
3665
+ stream.close()
3666
+ except Exception: # noqa: BLE001
3667
+ pass
3668
+ raise _StreamStalled(
3669
+ f"the reply stopped: no data for {stall_s:.0f}s after it had started")
3670
+ continue
3671
+ last = time.monotonic()
3672
+ if on_data is not None:
3673
+ try:
3674
+ on_data()
3675
+ except Exception: # noqa: BLE001
3676
+ pass
3677
+ if kind == "chunk":
3678
+ yield val
3679
+ elif kind == "end":
3680
+ return
3681
+ else:
3682
+ raise val
3683
+
3684
+
3607
3685
  def _offline_wait_budget() -> float:
3608
3686
  """How long a turn waits for the network before giving up (GONEXT_OFFLINE_WAIT_S, default 10 min)."""
3609
3687
  try:
@@ -10086,7 +10164,16 @@ def run_agent_chat(cfg):
10086
10164
  # Separate this step's live thinking from the previous step summary.
10087
10165
  _emit({"type": "stream", "text": "\n"})
10088
10166
 
10089
- for chunk in stream:
10167
+ # #306: the stream goes through the stall watchdog — a reply that stops mid-way (the
10168
+ # connection died silently) raises _StreamStalled instead of hanging for the hour-long
10169
+ # request budget. `_last_data_at` feeds the heartbeat's "no data for Ns".
10170
+ self._last_data_at = time.monotonic()
10171
+ for chunk in _iter_with_stall(
10172
+ stream,
10173
+ started=lambda: first_token_at is not None,
10174
+ stall_s=_stream_stall_seconds(),
10175
+ on_data=lambda: setattr(self, "_last_data_at", time.monotonic()),
10176
+ ):
10090
10177
  usage = getattr(chunk, "usage", None)
10091
10178
  if usage is not None:
10092
10179
  self._last_usage_seen = True
@@ -10488,8 +10575,17 @@ def run_agent_chat(cfg):
10488
10575
  # and the call is in its finishing phase, not stuck waiting.
10489
10576
  word = (_STATUS_ALMOST_DONE if getattr(self, "_first_token_seen", False)
10490
10577
  else f"{_thinking_word()}…")
10491
- _emit({"type": "step", "text": f"{word} ({waited}s)"})
10492
- _log(f"heartbeat: model call in flight {waited}s")
10578
+ # #306: after the first token, SAY when nothing is arriving. "Almost done"
10579
+ # kept counting for 20 minutes over a stream that had died.
10580
+ gap = 0.0
10581
+ if getattr(self, "_first_token_seen", False):
10582
+ gap = time.monotonic() - getattr(self, "_last_data_at", time.monotonic())
10583
+ if gap >= 20:
10584
+ _emit({"type": "step", "text": f"No data from the model for {gap:.0f}s — watching the connection…"})
10585
+ _log(f"heartbeat: model call in flight {waited}s — no data for {gap:.0f}s")
10586
+ else:
10587
+ _emit({"type": "step", "text": f"{word} ({waited}s)"})
10588
+ _log(f"heartbeat: model call in flight {waited}s")
10493
10589
 
10494
10590
  hb = threading.Thread(target=_heartbeat, daemon=True)
10495
10591
  hb.start()
@@ -10586,7 +10682,10 @@ def run_agent_chat(cfg):
10586
10682
  now = time.monotonic()
10587
10683
  if offline["since"] is None:
10588
10684
  offline.update(since=now, n=0, said=now)
10685
+ _stalled = isinstance(e, _StreamStalled)
10589
10686
  _emit({"type": "step", "text": (
10687
+ f"The connection to {_host} stopped mid-reply — reconnecting…"
10688
+ if _stalled else
10590
10689
  f"Lost the connection to {_host} — waiting for it to come back…")})
10591
10690
  waited = now - offline["since"]
10592
10691
  budget = _offline_wait_budget()
package/gu-cli.mjs CHANGED
@@ -132,6 +132,7 @@ Usage:
132
132
  # terminal's update row to notice
133
133
  gu-cli rag # test the embedder end-to-end + list knowledge bases
134
134
  gu-cli workspace revert [runId] # undo an agent run's file edits from its backups
135
+ gu-cli mongo migrate-from-docker # macOS: move a Docker MongoDB to native (Homebrew)
135
136
  gu-cli clear [--scratch|--keep-history] [--yes] # back to a fresh machine (asks first)
136
137
  gu-cli fix-powershell [--check] # Windows: drop PowerShell's built-in \`gu\` alias
137
138
  # (it is Get-Unique and shadows this CLI)
@@ -1480,7 +1481,151 @@ if (args[0] === "rag") {
1480
1481
  process.exit(0);
1481
1482
  }
1482
1483
  // Task #127, Phase 3: ensure the base chat model is downloaded + running (no GoTerminal).
1484
+ /**
1485
+ * gu-cli mongo migrate-from-docker [--container <name>] — move a Docker MongoDB's data to a
1486
+ * native Homebrew MongoDB on this Mac (task #304). The steps done by hand on 2026-09-30, made
1487
+ * repeatable: nothing about the Docker container changes until the copy has been VERIFIED
1488
+ * collection by collection, and even then it is stopped, never deleted.
1489
+ */
1490
+ if (args[0] === "mongo") {
1491
+ const mn = await import("./mongo-native.mjs");
1492
+ const sub = args[1] || "";
1493
+ if (sub !== "migrate-from-docker") {
1494
+ console.log("\n Usage: gu-cli mongo migrate-from-docker [--container <name>]\n");
1495
+ process.exit(sub ? 1 : 0);
1496
+ }
1497
+ // The container publishing 27017, detected — setup's name has changed over time, and a machine
1498
+ // keeps the name it was set up with.
1499
+ const ci = args.indexOf("--container");
1500
+ const container = ci >= 0 && args[ci + 1] ? args[ci + 1] : ((await mn.dockerContainerOnPort(27017)) || "");
1501
+ const TEMP = 27018;
1502
+ const DBPATH = "/opt/homebrew/var/mongodb";
1503
+ const plan = mn.migrationPlan({ container, tempPort: TEMP, dbPath: DBPATH });
1504
+ const say = (id, ok, extra = "") => {
1505
+ const step = plan.find((p) => p.id === id);
1506
+ console.log(` ${ok ? "✓" : "✗"} ${step.says}${extra ? ` — ${extra}` : ""}`);
1507
+ };
1508
+ const fail = (id, why) => {
1509
+ say(id, false, why);
1510
+ console.log("\n Nothing about the Docker container was changed.\n");
1511
+ process.exit(1);
1512
+ };
1513
+ if (process.platform !== "darwin") fail("install", "this command is for macOS (Homebrew)");
1514
+ console.log("");
1515
+ // 1) the source answers
1516
+ if ((await mn.mongoAnswers("127.0.0.1", 27017)) !== "answers") {
1517
+ fail("source", "nothing answers on 27017 — start Docker Desktop (or restart it if stuck) first");
1518
+ }
1519
+ const holder = await mn.portHolder(27017);
1520
+ if (!mn.isDockerHolder(holder)) fail("source", `27017 is served by ${holder || "something"} — not Docker, nothing to migrate`);
1521
+ if (!container) fail("source", "no running Docker container publishes 27017 — name it with --container");
1522
+ say("source", true, container);
1523
+ // 2) Homebrew MongoDB installed
1524
+ const brew = mn.findBrew();
1525
+ if (!brew) fail("install", "Homebrew is not installed (https://brew.sh)");
1526
+ const hasMongod = (await mn.captureOutput("sh", ["-c", "command -v mongod || true"])).trim();
1527
+ if (!hasMongod) {
1528
+ const ok = await mn.runBrewSteps(brew, mn.BREW_INSTALL_STEPS.slice(0, 2), { onLine: (l) => console.log(` ${l}`) });
1529
+ if (!ok) fail("install", "brew install failed (see above)");
1530
+ }
1531
+ say("install", true);
1532
+ // 3) native mongod on the temp port, its own process (MongoDB 9 refuses --fork on macOS)
1533
+ const { mkdirSync } = await import("node:fs");
1534
+ mkdirSync(DBPATH, { recursive: true });
1535
+ const temp = spawn("mongod", ["--dbpath", DBPATH, "--port", String(TEMP), "--bind_ip", "127.0.0.1",
1536
+ "--logpath", "/opt/homebrew/var/log/mongodb/mongo-migrate.log"], { stdio: "ignore", detached: true });
1537
+ temp.unref();
1538
+ const stopTemp = async () => {
1539
+ await mn.captureOutput("mongosh", ["--quiet", "--port", String(TEMP), "admin", "--eval", "try{db.adminCommand({shutdown:1})}catch(e){}"], 15000);
1540
+ };
1541
+ if (!(await mn.waitForMongo("127.0.0.1", TEMP, { timeoutMs: 60000 }))) fail("temp", "it did not start (log: /opt/homebrew/var/log/mongodb/mongo-migrate.log)");
1542
+ say("temp", true);
1543
+ const counts = async (port) => {
1544
+ try {
1545
+ return JSON.parse((await mn.captureOutput("mongosh", ["--quiet", "--port", String(port), "--eval", mn.COUNTS_SCRIPT], 120000)).trim() || "{}");
1546
+ } catch {
1547
+ return null;
1548
+ }
1549
+ };
1550
+ // 4) the native one must be empty — never merge into data we did not put there
1551
+ const already = await counts(TEMP);
1552
+ if (already === null || Object.keys(already).length) {
1553
+ await stopTemp();
1554
+ fail("empty", already === null ? "could not read it" : `it already holds ${Object.keys(already).length} collection(s) — move them away first`);
1555
+ }
1556
+ say("empty", true);
1557
+ // 5) pause the API
1558
+ const self = process.argv[1];
1559
+ await mn.runStreaming(process.execPath, [self, "api", "stop"]);
1560
+ say("api-stop", true);
1561
+ // 6) copy: mongodump | mongorestore
1562
+ const copied = await new Promise((resolve) => {
1563
+ const dump = spawn("mongodump", ["--uri", "mongodb://127.0.0.1:27017", "--archive", "--quiet"], { stdio: ["ignore", "pipe", "inherit"] });
1564
+ const restore = spawn("mongorestore", ["--uri", `mongodb://127.0.0.1:${TEMP}`, "--archive", "--nsExclude", "admin.system.version", "--quiet"], { stdio: ["pipe", "inherit", "inherit"] });
1565
+ dump.stdout.pipe(restore.stdin);
1566
+ let codes = [];
1567
+ const end = () => codes.length === 2 && resolve(codes.every((c) => c === 0));
1568
+ dump.on("close", (c) => { codes.push(c); end(); });
1569
+ restore.on("close", (c) => { codes.push(c); end(); });
1570
+ dump.on("error", () => resolve(false));
1571
+ restore.on("error", () => resolve(false));
1572
+ });
1573
+ if (!copied) {
1574
+ await stopTemp();
1575
+ await mn.runStreaming(process.execPath, [self, "api", "start"]);
1576
+ fail("copy", "mongodump / mongorestore failed (see above)");
1577
+ }
1578
+ say("copy", true);
1579
+ // 7) verify
1580
+ const before = await counts(27017);
1581
+ const after = await counts(TEMP);
1582
+ const diff = before && after ? mn.compareCounts(before, after) : [{ ns: "(could not read counts)" }];
1583
+ if (diff.length) {
1584
+ await stopTemp();
1585
+ await mn.runStreaming(process.execPath, [self, "api", "start"]);
1586
+ for (const d of diff.slice(0, 10)) console.log(` ${d.ns}: ${d.before} → ${d.after}`);
1587
+ fail("verify", `${diff.length} collection(s) differ — the Docker MongoDB stays in use`);
1588
+ }
1589
+ say("verify", true, `${Object.keys(before).length} collections`);
1590
+ // 8) the container: stopped, not restarting, KEPT
1591
+ await mn.captureOutput("docker", ["update", "--restart=no", container], 30000);
1592
+ await mn.captureOutput("docker", ["stop", container], 60000);
1593
+ say("container", true);
1594
+ // 9) switch: temp down, the brew service on 27017
1595
+ await stopTemp();
1596
+ const started = await mn.runBrewSteps(brew, mn.BREW_START_STEPS, { onLine: (l) => console.log(` ${l}`) });
1597
+ if (!started || !(await mn.waitForMongo("127.0.0.1", 27017, { timeoutMs: 60000 }))) {
1598
+ console.log(` ✗ ${plan.find((p) => p.id === "switch").says}`);
1599
+ console.log(`\n The data is copied, but the native MongoDB is not answering on 27017.`);
1600
+ console.log(` Put Docker back while you look: docker start ${container}\n`);
1601
+ process.exit(1);
1602
+ }
1603
+ say("switch", true);
1604
+ // 10) API back
1605
+ await mn.runStreaming(process.execPath, [self, "api", "start"]);
1606
+ say("api-start", true);
1607
+ console.log(`\n Done. The old container ${container} is stopped and kept as a backup —`);
1608
+ console.log(` remove it when you are happy: docker rm ${container}\n`);
1609
+ process.exit(0);
1610
+ }
1611
+
1483
1612
  if (args[0] === "doctor") {
1613
+ // Task #304: a Mac whose MongoDB runs in Docker is told the native option, once, up front —
1614
+ // a working Docker MongoDB is left alone (it is a choice), a silent one is named as stuck.
1615
+ if (process.platform === "darwin") {
1616
+ try {
1617
+ const mn = await import("./mongo-native.mjs");
1618
+ const holder = await mn.portHolder(27017);
1619
+ if (mn.isDockerHolder(holder)) {
1620
+ const state = await mn.mongoAnswers("127.0.0.1", 27017);
1621
+ console.log(state === "answers"
1622
+ ? "\n MongoDB runs in Docker here. A native one is faster and needs no Docker Desktop:\n gu-cli mongo migrate-from-docker\n"
1623
+ : "\n ✗ Port 27017 is Docker's, but no MongoDB answers — Docker Desktop looks stuck.\n Restart it (killall Docker && open -a Docker), then: gu-cli mongo migrate-from-docker\n");
1624
+ }
1625
+ } catch {
1626
+ /* a note, never a reason for doctor to fail */
1627
+ }
1628
+ }
1484
1629
  /**
1485
1630
  * Should doctor set up MODELS on this machine? → true | false | undefined (decide by hardware)
1486
1631
  *
package/gu-repl.mjs CHANGED
@@ -1004,7 +1004,43 @@ const regionUi = (seq) => {
1004
1004
  * parked — on the frozen input line, where the very next paintBar() erases it. That is how a
1005
1005
  * failed turn came out as `>> hi` followed by nothing at all.
1006
1006
  */
1007
+ /**
1008
+ * THE QUESTION IS COMMITTED ON THE TURN'S FIRST OUTPUT (task #307, the user's design).
1009
+ *
1010
+ * A submitted question used to be printed into the transcript the moment Enter was pressed — so a
1011
+ * change of mind a second later (Ctrl+C, edit, resend) left the abandoned ">> eys" in the
1012
+ * scrollback, above the corrected question, where nothing can take it back out. Now the echo is
1013
+ * HELD (pendingEcho) and shown in the live block above the status line, and it is written for real
1014
+ * only when the turn prints something — the first line through contentWrite below, so it always
1015
+ * lands above that output, never after it.
1016
+ *
1017
+ * · Ctrl+C BEFORE any output: nothing reached the transcript. The question goes back into the
1018
+ * input to be edited, and there is no trace to clean up.
1019
+ * · Ctrl+C AFTER output: the question is committed — it happened, and what the turn did is below
1020
+ * it. A plain stop: no editing, the question stands, "continue" resumes it (#247).
1021
+ */
1022
+ let pendingEcho = null;
1023
+ let echoCommitted = true;
1024
+ /** The held question as live rows: at most 3 lines of it, each clipped to the width, then a blank. */
1025
+ function pendingEchoRows() {
1026
+ if (pendingEcho === null) return [];
1027
+ const width = Math.max(20, process.stdout.columns || 80);
1028
+ const lines = String(pendingEcho).replace(/\n+$/, "").split("\n");
1029
+ const shown = lines.slice(0, 3).map((l) => clipVisible(l, width - 1));
1030
+ if (lines.length > 3) shown.push(dim(`${GUTTER}… ${lines.length - 3} more line(s)`));
1031
+ return [...shown, ""];
1032
+ }
1033
+ function flushPendingEcho() {
1034
+ if (pendingEcho === null) return;
1035
+ const text = pendingEcho;
1036
+ pendingEcho = null;
1037
+ echoCommitted = true;
1038
+ process.stdout.write(text);
1039
+ }
1040
+
1007
1041
  const contentWrite = (raw, chunk, enc, cb) => {
1042
+ // Any real output commits a held question first (see pendingEcho above).
1043
+ if (_uiDepth === 0 && pendingEcho !== null) flushPendingEcho();
1008
1044
  if (_uiDepth === 0 && (typeof chunk === "string" || Buffer.isBuffer(chunk))) {
1009
1045
  transcript.append(String(chunk));
1010
1046
  // ALWAYS FOLLOW THE BOTTOM. New output means there is something newer to see, so a view
@@ -1538,6 +1574,21 @@ const missingNoticeText = () => {
1538
1574
  return parts.length ? "⚠ " + parts.join(" · ") : "";
1539
1575
  };
1540
1576
 
1577
+ // #307: the calm note after a quick change of mind — dim, not the ⚠ orange of a warning, and in the
1578
+ // bar rather than the transcript (which the whole point is to leave untouched).
1579
+ let stopNotice = "";
1580
+ let stopNoticeTimer = null;
1581
+ function showStopNotice(text) {
1582
+ stopNotice = text;
1583
+ if (stopNoticeTimer) clearTimeout(stopNoticeTimer);
1584
+ stopNoticeTimer = setTimeout(() => {
1585
+ stopNotice = "";
1586
+ stopNoticeTimer = null;
1587
+ if (barPinned()) paintBar();
1588
+ }, 15_000);
1589
+ stopNoticeTimer.unref?.();
1590
+ if (barPinned()) paintBar();
1591
+ }
1541
1592
  function showImageNotice(text) {
1542
1593
  imageNotice = text;
1543
1594
  if (imageNoticeTimer) clearTimeout(imageNoticeTimer);
@@ -2134,6 +2185,32 @@ let startupRenewDone = false;
2134
2185
  */
2135
2186
  let licenceBlock = null;
2136
2187
  let licenceDecided = false;
2188
+
2189
+ /**
2190
+ * THE FREE TRIAL (#305): a first install gets ten days on this computer instead of a "paste your
2191
+ * token" stop. Once per computer — the gu service decides, keyed on the OS machine id's hash. → true
2192
+ * when a trial token is now saved. Tried at most once per start by the startup refresh, and again
2193
+ * right before the token question (a first start may have been offline).
2194
+ */
2195
+ let trialTried = false;
2196
+ let trialEndedAt = ""; // this computer's trial has ended — the token question says so
2197
+ async function tryTrialToken(ct, dir, { announce = true } = {}) {
2198
+ const device = await ct.trialDevice().catch(() => null);
2199
+ if (!device) return false; // no stable machine id → no trial (see trialDevice)
2200
+ const { DEFAULT_API_BASE } = await import("./device-login.mjs");
2201
+ const hostedBase = String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE;
2202
+ const r = await ct.claimTrial({ hostedBase, device });
2203
+ if (r.ok && ct.verifyCliToken(r.token, ct.publicKey()).ok) {
2204
+ await ct.writeState(dir, { token: r.token, lastAttemptAt: new Date().toISOString() });
2205
+ if (announce) {
2206
+ console.log(GUTTER + accent(r.created ? ct.trialStartedLine(r.endsAt, ct.siteUrl()) : `Free trial on this computer until ${ct.shortDate(r.endsAt)}.`));
2207
+ }
2208
+ return true;
2209
+ }
2210
+ if (r.why === "used" && r.endedAt) trialEndedAt = r.endedAt;
2211
+ return false;
2212
+ }
2213
+
2137
2214
  async function refreshCliToken() {
2138
2215
  if (tokenBusy) return;
2139
2216
  tokenBusy = true;
@@ -2148,6 +2225,11 @@ async function refreshCliToken() {
2148
2225
  const mode = isClientExpert() ? "expert" : "client";
2149
2226
  const site = ct.siteUrl();
2150
2227
  let state = await ct.readState(dir);
2228
+ // #305: private mode with no usable token → this computer's free trial, before anything asks.
2229
+ if (mode === "expert" && !trialTried && !(state.token && ct.verifyCliToken(state.token, pk).ok)) {
2230
+ trialTried = true;
2231
+ if (await tryTrialToken(ct, dir)) state = await ct.readState(dir);
2232
+ }
2151
2233
  const now = new Date();
2152
2234
  const noticeNow = () =>
2153
2235
  ct.barNotice({ token: state.token, publicKeyB64: pk, mode, now: new Date(), attempted: Boolean(state.lastAttemptAt), site });
@@ -2423,7 +2505,9 @@ const footerRows = () =>
2423
2505
  // Its own row (#54), sized to the window so the fix it names is never the part cut off.
2424
2506
  notice: imageNotice
2425
2507
  ? orange("⚠ " + fitImageNotice(imageNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
2426
- : "",
2508
+ : stopNotice
2509
+ ? dim(clipVisible(stopNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
2510
+ : "",
2427
2511
  // Task #14: the skipped-setup warning — persistent (not a timed notice), red, its own row.
2428
2512
  missing: missingNoticeText() ? red(missingNoticeText()) : "",
2429
2513
  });
@@ -2510,7 +2594,9 @@ function frozenZone() {
2510
2594
  // (reported: "the input line somehow now too high, mostly 2 lines"). Rather than trust every
2511
2595
  // teardown path to have cleared it — there are four, and clearStatus early-returns when
2512
2596
  // statusShown is already false — the prompt simply refuses to render any.
2513
- const status = footerActive ? [] : liveStatusRows;
2597
+ // #307: a question held until its turn prints anything sits on top of the status block — where
2598
+ // it will land once committed, so nothing moves when it does.
2599
+ const status = footerActive ? [] : [...pendingEchoRows(), ...liveStatusRows];
2514
2600
  // DECIDED BEFORE THE LAYOUT, because the head's height depends on it: the blank under the
2515
2601
  // separator exists to lift a CONTROL off the typed line, so with no control there is nothing
2516
2602
  // to lift. The same three conditions the rows[sep] writes below test — kept adjacent so they
@@ -6689,7 +6775,7 @@ async function runSavePresetCommand(line) {
6689
6775
  *
6690
6776
  * What the turn printed stays on screen: it is the record of what it did.
6691
6777
  */
6692
- async function afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stoppedCommand = "", typed = "" }) {
6778
+ async function afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stoppedCommand = "", typed = "", committed = true }) {
6693
6779
  let files = [];
6694
6780
  try {
6695
6781
  const ws = await import("./workspace-revert.mjs");
@@ -6702,13 +6788,25 @@ async function afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stopped
6702
6788
  } catch {
6703
6789
  /* no backups readable — nothing to name */
6704
6790
  }
6705
- // A bare "continue" was the typed line: giving THAT back is no help, so only the note.
6706
- const canRestore = Boolean(submittedInput?.raw) && !isResumeWord(typed);
6707
- for (const l of stopNoteLines({ files, command: stoppedCommand, restored: canRestore })) {
6708
- console.log(GUTTER + dim(l));
6791
+ // #307 — THE USER'S RULE: the question can be edited again only if it was never committed (the
6792
+ // turn printed nothing). A turn that changed files had something to say, so it counts as
6793
+ // committed; its held question is written now, above the note about those files.
6794
+ if (!committed && files.length) {
6795
+ flushPendingEcho();
6796
+ committed = true;
6709
6797
  }
6710
- console.log("");
6798
+ if (committed) {
6799
+ for (const l of stopNoteLines({ files, command: stoppedCommand, restored: false })) console.log(GUTTER + dim(l));
6800
+ console.log("");
6801
+ return;
6802
+ }
6803
+ // Never committed: drop the held echo — it never reached the transcript — and give it back.
6804
+ pendingEcho = null;
6805
+ echoCommitted = true;
6806
+ // A bare "continue" was the typed line: giving THAT back is no help.
6807
+ const canRestore = Boolean(submittedInput?.raw) && !isResumeWord(typed);
6711
6808
  if (!canRestore) return;
6809
+ showStopNotice("stopped — your question is back in the input; edit it and press Enter, or Ctrl+C to clear it");
6712
6810
  const text = restoreInput(submittedInput, { pasteStore, imageStore });
6713
6811
  rl.line = text;
6714
6812
  rl.cursor = text.length;
@@ -9661,6 +9759,12 @@ async function ensureTokenForTurn() {
9661
9759
  const state = await ct.readState(dir);
9662
9760
  if (!ct.turnNeedsToken({ privateMode: true, token: state.token })) return true;
9663
9761
  console.log("");
9762
+ // #305: the free trial first — a first start that was offline gets it here, with no question.
9763
+ if (await tryTrialToken(ct, dir)) {
9764
+ console.log("");
9765
+ return true;
9766
+ }
9767
+ if (trialEndedAt) console.log(GUTTER + dim(`The free trial on this computer ended on ${ct.shortDate(trialEndedAt)}.`));
9664
9768
  for (const l of ct.tokenAskLines()) console.log(GUTTER + (l.startsWith(" ") ? dim(l) : yellow(l)));
9665
9769
  for (let attempt = 0; attempt < 2; attempt++) {
9666
9770
  const pasted = String((await ask(" gu token: ")) ?? "").trim();
@@ -10940,8 +11044,10 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
10940
11044
  // killed the Python agent. Print a quiet note and return an empty, non-persisted
10941
11045
  // turn (main() drops it from history).
10942
11046
  clearStatus();
10943
- process.stdout.write("\n" + GUTTER + dim("(cancelled — the agent was stopped)") + "\n");
10944
- return { text: "", shown: false, cancelled: true, started: reachedModel, stoppedCommand: runningCmd || "" };
11047
+ // #307: `committed` — did the question reach the transcript (the turn printed something)?
11048
+ const committed = pendingEcho === null;
11049
+ if (committed) process.stdout.write("\n" + GUTTER + dim("(cancelled — the agent was stopped)") + "\n");
11050
+ return { text: "", shown: false, cancelled: true, started: reachedModel, stoppedCommand: runningCmd || "", committed };
10945
11051
  }
10946
11052
  if (job.jobStatus === "failed") {
10947
11053
  clearStatus();
@@ -11827,7 +11933,9 @@ async function main() {
11827
11933
  // agent; the poll loop then returns cleanly with the "(cancelled)" note.
11828
11934
  cancelRequested = true;
11829
11935
  const jobId = currentJobId;
11830
- process.stdout.write("\n" + GUTTER + red("^C ") + dim("stopping the agent…\n"));
11936
+ // #307: while the question is still held (nothing printed yet), nothing goes to the
11937
+ // transcript either — a quick change of mind leaves no trace.
11938
+ if (pendingEcho === null) process.stdout.write("\n" + GUTTER + red("^C ") + dim("stopping the agent…\n"));
11831
11939
  fetch(`${apiBase}/api/worker/jobs/cancel`, {
11832
11940
  method: "POST",
11833
11941
  headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
@@ -11839,7 +11947,7 @@ async function main() {
11839
11947
  // Second Ctrl+C (or no jobId yet): give up waiting locally. The worker keeps
11840
11948
  // trying to stop, but we stop following.
11841
11949
  followAborted = true;
11842
- process.stdout.write("\n" + GUTTER + red("^C "));
11950
+ if (pendingEcho === null) process.stdout.write("\n" + GUTTER + red("^C ")); // #307
11843
11951
  }
11844
11952
  } else {
11845
11953
  // Ctrl+C at a prompt with the slash overlay open = dismiss the overlay first (#109).
@@ -11880,6 +11988,9 @@ async function main() {
11880
11988
  }
11881
11989
 
11882
11990
  for (;;) {
11991
+ // #307: a held question whose turn ended without printing anything (and was not a quick
11992
+ // change of mind) is written now — it was asked, and the transcript should say so.
11993
+ if (pendingEcho !== null) flushPendingEcho();
11883
11994
  stopPendingStatus(); // nothing from a previous turn may still be animating (task #140)
11884
11995
  // The bar and the input line live in the frozen rows at the bottom of the window (task
11885
11996
  // #141); `footerActive` only governs the fallback drawing used when the window is too
@@ -11962,7 +12073,20 @@ async function main() {
11962
12073
  // TRANSCRIPT: the echo is written exactly once per submitted line and never repainted,
11963
12074
  // so there is nothing here for a blank to accumulate against.
11964
12075
  // As typed, not as sent: "[Image #1]", never the file path the agent needs (#51).
11965
- process.stdout.write(PROMPT_PAST + imageMarkersForDisplay(line) + "\n\n");
12076
+ const echo = PROMPT_PAST + imageMarkersForDisplay(line) + "\n\n";
12077
+ if (stopNotice) {
12078
+ stopNotice = "";
12079
+ if (stopNoticeTimer) clearTimeout(stopNoticeTimer);
12080
+ }
12081
+ // #307: a QUESTION (not a /command or !shell line) is held until the turn prints something;
12082
+ // unpinned there is no live block to hold it in, so it is written at once, as before.
12083
+ if (barPinned() && !line.startsWith("/") && !line.startsWith("!")) {
12084
+ pendingEcho = echo;
12085
+ echoCommitted = false;
12086
+ paintBar();
12087
+ } else {
12088
+ process.stdout.write(echo);
12089
+ }
11966
12090
  }
11967
12091
  // ESC AT ANY QUESTION LANDS HERE (see PromptCancelled). Every command below is wrapped as
11968
12092
  // one block rather than each flow catching for itself, because the request was to be able to
@@ -12287,7 +12411,8 @@ async function main() {
12287
12411
  const block = answerBlock({
12288
12412
  elapsedMs, answer: plain, worked: (turnOutputTokens ?? 0) > 0,
12289
12413
  gutter: GUTTER, dim, render: renderAnswer,
12290
- inputLabel: inputTokens > 0 ? fmtTokens(inputTokens) : "",
12414
+ // No "(↑ N)" here (task #310, the user's call): it was this turn's input total —
12415
+ // the very number the bar's "sent" shows one row below.
12291
12416
  });
12292
12417
  console.log(`${LINE_SPACING}${block.text}\n`);
12293
12418
  registerCopyBlock(block.rows, plain, 1);
@@ -12365,7 +12490,7 @@ async function main() {
12365
12490
  continue;
12366
12491
  }
12367
12492
  const { text: answer, shown, cancelled, started, detached, inputTokens, outputTokens,
12368
- turnOutputTokens, elapsedMs, stoppedCommand } = turn;
12493
+ turnOutputTokens, elapsedMs, stoppedCommand, committed } = turn;
12369
12494
  // Detached: the agent keeps running on the worker, so the question is neither answered
12370
12495
  // nor stopped — leave it untagged, and say nothing contradicting "the job keeps running".
12371
12496
  if (detached) continue;
@@ -12430,7 +12555,7 @@ async function main() {
12430
12555
  // two must come from one place, and for the empty line under "thought for …".
12431
12556
  const block = answerBlock({
12432
12557
  elapsedMs, answer: plain, worked, gutter: GUTTER, dim, render: renderAnswer,
12433
- inputLabel: inputTokens > 0 ? fmtTokens(inputTokens) : "",
12558
+ // No "(↑ N)" (task #310): the bar's "sent" already shows this turn's input.
12434
12559
  });
12435
12560
  console.log(`${LINE_SPACING}${block.text}\n`);
12436
12561
  // Register the SOURCE, not the rendering (task #158): renderAnswer hard-wraps the
@@ -12462,7 +12587,7 @@ async function main() {
12462
12587
  // Stopped AFTER the model was called = too slow or stuck, never a change of mind: the
12463
12588
  // request still stands (task #247, the user's rule). Before that, nothing had begun.
12464
12589
  await markInterrupted(cwd, history, taskLine, started ? "stopped" : "cancelled");
12465
- await afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stoppedCommand, typed: line });
12590
+ await afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stoppedCommand, typed: line, committed: committed !== false });
12466
12591
  } else {
12467
12592
  await markInterrupted(cwd, history, taskLine, "failed");
12468
12593
  // The turn ended cleanly but empty — no exception to summarize, so say exactly that.
@@ -0,0 +1,275 @@
1
+ /**
2
+ * MONGODB ON A MAC: NATIVE, NOT DOCKER (task #304).
3
+ *
4
+ * Client Expert setup used to start MongoDB in Docker first wherever Docker answered. On a Mac that
5
+ * costs twice: every database call crosses Docker Desktop's VM and port forwarder, and when Docker
6
+ * Desktop hangs (seen 2026-09-29, after the disk filled) its forwarder keeps 27017 OPEN while
7
+ * nothing behind it answers — so the API timed out on every request instead of failing fast, gu
8
+ * said "Worker key lookup failed", and Docker did not recover by itself. The fix that worked was
9
+ * Homebrew's mongodb-community as a brew service, the data copied across. This module is that fix,
10
+ * made the default:
11
+ *
12
+ * · probe with a real MongoDB handshake, never "the port accepts a connection"
13
+ * · a Mac with no answering MongoDB gets Homebrew's mongodb-community (asked once), and setup
14
+ * continues in the same run
15
+ * · a Mac whose 27017 belongs to a stuck Docker is TOLD so, and `gu-cli mongo migrate-from-docker`
16
+ * moves a working Docker MongoDB's data to the native one (verified collection by collection;
17
+ * the container is stopped, never deleted)
18
+ */
19
+ import { spawn } from "node:child_process";
20
+ import { existsSync } from "node:fs";
21
+
22
+ // ---------------------------------------------------------------------------------------------
23
+ // Does something MongoDB answer — not merely accept a TCP connection?
24
+ // ---------------------------------------------------------------------------------------------
25
+
26
+ /**
27
+ * An OP_MSG `{ hello: 1, $db: "admin" }` — the first thing every driver sends, answered without
28
+ * authentication, so it tells a live server (with or without a password) from a port that only
29
+ * accepts connections. Pure: the exact bytes, built by hand to need no driver.
30
+ */
31
+ export function helloMessage(requestId = 1) {
32
+ const cstr = (s) => Buffer.concat([Buffer.from(s, "utf8"), Buffer.from([0])]);
33
+ const int32 = (n) => {
34
+ const b = Buffer.alloc(4);
35
+ b.writeInt32LE(n);
36
+ return b;
37
+ };
38
+ const db = "admin";
39
+ const elements = Buffer.concat([
40
+ Buffer.from([0x10]), cstr("hello"), int32(1),
41
+ Buffer.from([0x02]), cstr("$db"), int32(Buffer.byteLength(db) + 1), cstr(db),
42
+ ]);
43
+ const doc = Buffer.concat([int32(4 + elements.length + 1), elements, Buffer.from([0])]);
44
+ const body = Buffer.concat([int32(0) /* flagBits */, Buffer.from([0]) /* section kind 0 */, doc]);
45
+ const header = Buffer.concat([int32(16 + body.length), int32(requestId), int32(0), int32(2013)]);
46
+ return Buffer.concat([header, body]);
47
+ }
48
+
49
+ /**
50
+ * → "answers" | "silent" (the port accepts, nothing replies — a stuck forwarder) | "closed".
51
+ * A reply is any well-formed message header coming back.
52
+ */
53
+ export async function mongoAnswers(host = "127.0.0.1", port = 27017, timeoutMs = 3000) {
54
+ const net = await import("node:net");
55
+ return new Promise((resolve) => {
56
+ let connected = false;
57
+ let got = Buffer.alloc(0);
58
+ const socket = net.connect({ host, port });
59
+ const done = (v) => {
60
+ socket.destroy();
61
+ resolve(v);
62
+ };
63
+ socket.setTimeout(timeoutMs);
64
+ socket.once("connect", () => {
65
+ connected = true;
66
+ socket.write(helloMessage());
67
+ });
68
+ socket.on("data", (d) => {
69
+ got = Buffer.concat([got, d]);
70
+ if (got.length >= 16) done(got.readInt32LE(12) === 2013 || got.readInt32LE(12) === 1 ? "answers" : "silent");
71
+ });
72
+ socket.once("timeout", () => done(connected ? "silent" : "closed"));
73
+ socket.once("error", () => done(connected ? "silent" : "closed"));
74
+ socket.once("close", () => done(connected ? (got.length ? "answers" : "silent") : "closed"));
75
+ });
76
+ }
77
+
78
+ /** Wait until MongoDB answers, or give up. → true when it answered. */
79
+ export async function waitForMongo(host, port, { timeoutMs = 60000, check = mongoAnswers, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
80
+ const until = Date.now() + timeoutMs;
81
+ while (Date.now() < until) {
82
+ if ((await check(host, port, 2000)) === "answers") return true;
83
+ await sleep(1000);
84
+ }
85
+ return false;
86
+ }
87
+
88
+ /**
89
+ * The process holding a TCP port, by its program's name — null when unknown or none. Through
90
+ * platform-tools (pidOnPort / processCommand), where the per-OS lookups live.
91
+ */
92
+ export async function portHolder(port, { pidOf, commandOf } = {}) {
93
+ const pt = await import("./platform-tools.mjs");
94
+ const pid = await (pidOf ?? pt.pidOnPort)(port);
95
+ if (!pid) return null;
96
+ const cmd = await (commandOf ?? pt.processCommand)(pid);
97
+ const exe = String(cmd || "").trim().split(/\s+/)[0] || "";
98
+ return exe ? exe.split(/[\\/]/).pop() : null;
99
+ }
100
+
101
+ /**
102
+ * The Docker container publishing a port — detected, not assumed: the container setup creates has
103
+ * been renamed over time, and a machine keeps the name it was set up with. null when none.
104
+ */
105
+ export async function dockerContainerOnPort(port, { runCapture = captureOutput } = {}) {
106
+ const out = await runCapture("docker", ["ps", "--filter", `publish=${port}`, "--format", "{{.Names}}"], 15000);
107
+ return String(out || "").trim().split("\n")[0].trim() || null;
108
+ }
109
+
110
+ /** Is that holder Docker's port forwarder? (Docker Desktop: com.docker.backend / vpnkit.) */
111
+ export function isDockerHolder(name) {
112
+ return /docker|vpnkit|com\.docker/i.test(String(name ?? ""));
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------------------------
116
+ // What setup should do on a Mac — pure
117
+ // ---------------------------------------------------------------------------------------------
118
+
119
+ /**
120
+ * @param state mongoAnswers() at the database's address
121
+ * @param holder portHolder() — who owns the port when it is silent
122
+ * @param serverInstalled a `mongod` is on PATH
123
+ * @param brew the Homebrew binary, or ""
124
+ * → { action, ... }:
125
+ * use a MongoDB answers — use it (a working Docker one included: that is a choice)
126
+ * docker-stuck Docker holds the port and nothing answers — say so; never install over it
127
+ * port-silent something else holds the port and nothing answers
128
+ * brew-start mongod is installed (via brew) but not running — start the service
129
+ * brew-install nothing is there — install mongodb-community with Homebrew
130
+ * no-brew nothing is there and there is no Homebrew — say how to get one
131
+ */
132
+ export function macMongoAction({ state, holder = null, serverInstalled = false, brew = "" }) {
133
+ if (state === "answers") return { action: "use" };
134
+ if (state === "silent") return { action: isDockerHolder(holder) ? "docker-stuck" : "port-silent", holder };
135
+ if (!brew) return { action: "no-brew" };
136
+ if (serverInstalled) return { action: "brew-start" };
137
+ return { action: "brew-install" };
138
+ }
139
+
140
+ export const BREW_INSTALL_STEPS = [
141
+ ["tap", "mongodb/brew"],
142
+ ["install", "mongodb/brew/mongodb-community"],
143
+ ["services", "start", "mongodb/brew/mongodb-community"],
144
+ ];
145
+ export const BREW_START_STEPS = [["services", "start", "mongodb/brew/mongodb-community"]];
146
+
147
+ /** Where Homebrew is, or "". Apple Silicon first, then Intel. */
148
+ export function findBrew({ exists = existsSync } = {}) {
149
+ for (const p of ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"]) if (exists(p)) return p;
150
+ return "";
151
+ }
152
+
153
+ /** The lines a Mac with no Homebrew is shown. */
154
+ export function noBrewLines() {
155
+ return [
156
+ "MongoDB is installed with Homebrew on a Mac, and Homebrew is not here.",
157
+ "Install Homebrew (https://brew.sh), then run gu again — setup installs MongoDB from there.",
158
+ "Or install MongoDB Community yourself: https://www.mongodb.com/try/download/community",
159
+ ];
160
+ }
161
+
162
+ /** The lines a Mac whose 27017 is a stuck Docker (or another silent program) is shown. */
163
+ export function silentPortLines({ action, holder, port = 27017 }) {
164
+ if (action === "docker-stuck") {
165
+ return [
166
+ `Port ${port} belongs to Docker (${holder}), but no MongoDB answers there — Docker Desktop looks stuck.`,
167
+ "Restart it: quit Docker Desktop (or run: killall Docker && open -a Docker).",
168
+ "Once its MongoDB answers, move the data to a native MongoDB (faster, and no Docker needed):",
169
+ " gu-cli mongo migrate-from-docker",
170
+ ];
171
+ }
172
+ return [
173
+ `Port ${port} is held by ${holder || "another program"}, but no MongoDB answers there.`,
174
+ "Stop that program (or free the port), then run gu again.",
175
+ ];
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------------------------
179
+ // Running commands with their output shown
180
+ // ---------------------------------------------------------------------------------------------
181
+
182
+ /** Run a command, streaming each output line to onLine. → exit code (null if it could not start). */
183
+ export function runStreaming(cmd, args, { onLine = () => {}, env = process.env } = {}) {
184
+ return new Promise((resolve) => {
185
+ let child;
186
+ try {
187
+ child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env });
188
+ } catch {
189
+ resolve(null);
190
+ return;
191
+ }
192
+ const lines = (b) => String(b).split("\n").map((l) => l.trim()).filter(Boolean).forEach(onLine);
193
+ child.stdout.on("data", lines);
194
+ child.stderr.on("data", lines);
195
+ child.on("error", () => resolve(null));
196
+ child.on("close", (code) => resolve(code));
197
+ });
198
+ }
199
+
200
+ /** Run a command and return its stdout, or "" — for lookups. */
201
+ export function captureOutput(cmd, args, timeoutMs = 10000) {
202
+ return new Promise((resolve) => {
203
+ let out = "";
204
+ let child;
205
+ try {
206
+ child = spawn(cmd, args, { stdio: ["ignore", "pipe", "ignore"] });
207
+ } catch {
208
+ resolve("");
209
+ return;
210
+ }
211
+ const t = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
212
+ child.stdout.on("data", (b) => (out += b));
213
+ child.on("error", () => {
214
+ clearTimeout(t);
215
+ resolve("");
216
+ });
217
+ child.on("close", () => {
218
+ clearTimeout(t);
219
+ resolve(out);
220
+ });
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Homebrew steps, one after another, output shown. → true when all succeeded. HOMEBREW_NO_AUTO_UPDATE
226
+ * keeps `brew install` from first updating every formula on the machine — minutes nobody asked for.
227
+ */
228
+ export async function runBrewSteps(brew, steps, { onLine = () => {}, run = runStreaming } = {}) {
229
+ for (const s of steps) {
230
+ onLine(`$ brew ${s.join(" ")}`);
231
+ const code = await run(brew, s, { onLine, env: { ...process.env, HOMEBREW_NO_AUTO_UPDATE: "1" } });
232
+ if (code !== 0) return false;
233
+ }
234
+ return true;
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------------------------
238
+ // Moving a Docker MongoDB's data to the native one
239
+ // ---------------------------------------------------------------------------------------------
240
+
241
+ /**
242
+ * Per collection "docs/indexes", old vs new → the ones that differ. Pure. A migration is only
243
+ * finished when this is empty — the old one is not touched until then.
244
+ */
245
+ export function compareCounts(before, after) {
246
+ const keys = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])].sort();
247
+ return keys.filter((k) => (before ?? {})[k] !== (after ?? {})[k]).map((k) => ({ ns: k, before: before?.[k] ?? null, after: after?.[k] ?? null }));
248
+ }
249
+
250
+ /** The mongosh script that prints every user collection's "documents/indexes" as JSON. */
251
+ export const COUNTS_SCRIPT =
252
+ 'const o={};db.adminCommand({listDatabases:1}).databases.map(d=>d.name)' +
253
+ '.filter(n=>!["admin","config","local"].includes(n)).forEach(n=>{const d=db.getSiblingDB(n);' +
254
+ 'd.getCollectionNames().forEach(c=>{o[n+"."+c]=d[c].countDocuments({})+"/"+d[c].getIndexes().length})});' +
255
+ "print(JSON.stringify(o))";
256
+
257
+ /**
258
+ * The migration, as named steps (`gu-cli mongo migrate-from-docker`). Pure: the orchestration lives
259
+ * in the CLI, which runs these in order and stops at the first that fails — before the Docker
260
+ * container is touched, if the copy did not verify.
261
+ */
262
+ export function migrationPlan({ container = "the MongoDB container", tempPort = 27018, dbPath = "/opt/homebrew/var/mongodb" } = {}) {
263
+ return [
264
+ { id: "source", says: "the Docker MongoDB answers on 27017" },
265
+ { id: "install", says: "MongoDB Community installed with Homebrew" },
266
+ { id: "temp", says: `the native MongoDB started on ${tempPort} (data in ${dbPath})` },
267
+ { id: "empty", says: "the native MongoDB holds no user databases yet" },
268
+ { id: "api-stop", says: "gu's local API paused — nothing writes during the copy" },
269
+ { id: "copy", says: "every database copied (mongodump | mongorestore)" },
270
+ { id: "verify", says: "every collection's documents and indexes match" },
271
+ { id: "container", says: `the Docker container ${container} stopped and set not to restart (kept, not deleted)` },
272
+ { id: "switch", says: "the native MongoDB serving 27017 as a brew service" },
273
+ { id: "api-start", says: "gu's local API started again" },
274
+ ];
275
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.771",
3
+ "version": "1.0.772",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,6 +54,7 @@
54
54
  "setup-prompts.mjs",
55
55
  "turn-stop.mjs",
56
56
  "workspace-revert.mjs",
57
+ "mongo-native.mjs",
57
58
  "terminal-hover.mjs",
58
59
  "terminal-layout.mjs",
59
60
  "terminal-viewport.mjs",
@@ -472,7 +472,7 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
472
472
  * HOW TO PASTE, for this platform (issue #67). Two different things share the word: TEXT is pasted
473
473
  * by the terminal app (gu only sees the result), an IMAGE by gu's own keys (#284).
474
474
  *
475
- * macOS text ⌘V image ctrl+v — a terminal's ⌘V sends nothing for an image
475
+ * macOS text ⌘+V image ctrl+v — a terminal's ⌘+V sends nothing for an image
476
476
  * Windows text ctrl+v image alt+v — Windows Terminal keeps ctrl+v for itself
477
477
  * Linux text ctrl+shift+v image ctrl+v
478
478
  *
@@ -480,7 +480,7 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
480
480
  * answers "images need a Claude coder" would be a hint that leads to a warning.
481
481
  */
482
482
  export function pasteHint({ platform = process.platform, images = false } = {}) {
483
- const text = platform === "darwin" ? "⌘V" : platform === "win32" ? "ctrl+v" : "ctrl+shift+v";
483
+ const text = platform === "darwin" ? "⌘+V" : platform === "win32" ? "ctrl+v" : "ctrl+shift+v";
484
484
  if (!images) return `${text} to paste`;
485
485
  const image = platform === "win32" ? "alt+v" : "ctrl+v";
486
486
  return `${text} paste · ${image} image`;