@tiens.nguyen/gu-cli 1.0.821 → 1.0.823

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.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `gu` without the bin path in the macOS window title (#358) — see relaunchPlan in
3
+ * terminal-title.mjs. gu-repl.mjs imports this FIRST, so the process is replaced before any other
4
+ * module has loaded or anything has been drawn.
5
+ */
6
+ import { fileURLToPath } from "node:url";
7
+ import { relaunchPlan, restoreAfterRelaunch } from "./terminal-title.mjs";
8
+
9
+ export const RELAUNCHED = restoreAfterRelaunch(process);
10
+
11
+ if (!RELAUNCHED) {
12
+ const plan = relaunchPlan({
13
+ platform: process.platform,
14
+ hasExecve: typeof process.execve === "function",
15
+ env: process.env,
16
+ argv: process.argv,
17
+ execPath: process.execPath,
18
+ script: fileURLToPath(new URL("./gu-repl.mjs", import.meta.url)),
19
+ stdinTTY: !!process.stdin.isTTY,
20
+ stdoutTTY: !!process.stdout.isTTY,
21
+ });
22
+ if (plan) {
23
+ // Node 23–24 flag execve as experimental; that warning would be gu's first line.
24
+ const emitWarning = process.emitWarning;
25
+ process.emitWarning = (w, ...rest) =>
26
+ /execve/i.test(String(w?.message ?? w)) ? undefined : emitWarning.call(process, w, ...rest);
27
+ try {
28
+ process.execve(plan.file, plan.args, plan.env); // does not return when it works
29
+ } catch {
30
+ // not allowed here (sandbox, odd node build): start the normal way, path and all
31
+ } finally {
32
+ process.emitWarning = emitWarning;
33
+ }
34
+ }
35
+ }
package/gu-repl.mjs CHANGED
@@ -23,6 +23,7 @@
23
23
  * Requires the worker daemon to be running (it claims the job).
24
24
  * Commands: /exit /model /new /help Ctrl-C stops following the current run.
25
25
  */
26
+ import { RELAUNCHED } from "./gu-relaunch.mjs"; // FIRST: may replace this process (#358)
26
27
  import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
27
28
  import { readStepTokens, readTurnCache } from "./step-tokens.mjs";
28
29
  import { snapshotInput, restoreInput, replaceStoppedQuestion, restoredQuestionLetGo, stopNoteLines } from "./turn-stop.mjs";
@@ -154,7 +155,7 @@ import {
154
155
  parseModelFallback,
155
156
  } from "./model-fallback.mjs";
156
157
  import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
157
- import { TITLE_POP, TITLE_PUSH, idleTitle, needsSummary, summariseForTitle, titleSeq, trimTitle } from "./terminal-title.mjs";
158
+ import { TITLE_POP, TITLE_PUSH, idleTitle, needsSummary, summariseForTitle, titleModel, titleSeq, trimTitle } from "./terminal-title.mjs";
158
159
  import { IMAGE_NEEDS_CLAUDE, coderTakesImagesFor, clipboardImageToFile, fitImageNotice, imageMarkersForDisplay, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
159
160
  import {
160
161
  isDoubleClick,
@@ -1633,7 +1634,17 @@ function showQuestionInTitle(question) {
1633
1634
  void (async () => {
1634
1635
  const settings = await fetchWorkerSettings();
1635
1636
  if (!settings || mine !== _titleSeq) return;
1636
- const title = await summariseForTitle(question, settings, { fetchImpl: doctorFetch });
1637
+ // #358: the coder this turn runs on, with the key bound to its server — not the agent-model
1638
+ // setting, which can name a model the turns never use, called with no key at all.
1639
+ const target = titleModel(settings, { url: activeCodingUrl(), model: activeCodingModelId() });
1640
+ if (!target) return;
1641
+ const key = target.backendId ? await fetchKeyForBackend(target.backendId) : null;
1642
+ if (mine !== _titleSeq) return;
1643
+ const title = await summariseForTitle(question, settings, {
1644
+ fetchImpl: doctorFetch,
1645
+ target,
1646
+ apiKey: key?.apiKey ?? "",
1647
+ });
1637
1648
  if (title && mine === _titleSeq) writeTitle(title);
1638
1649
  })().catch(() => {});
1639
1650
  }
@@ -14561,3 +14572,10 @@ function releaseScreenAtExit() {
14561
14572
  }
14562
14573
 
14563
14574
  main();
14575
+ // Relaunched as `gu` (gu-relaunch.mjs), this file is Node's --import preload, and Node opens its
14576
+ // own REPL on the terminal once the preload finishes — so it does not finish. When nothing is
14577
+ // left to do, gu exits with its own code instead of Node's "unsettled top-level await" (13).
14578
+ if (RELAUNCHED) {
14579
+ process.on("beforeExit", () => process.exit(process.exitCode ?? 0));
14580
+ await new Promise(() => {});
14581
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.821",
3
+ "version": "1.0.823",
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",
@@ -116,6 +116,7 @@
116
116
  "coding-key-prompt.mjs",
117
117
  "model-fallback.mjs",
118
118
  "terminal-title.mjs",
119
+ "gu-relaunch.mjs",
119
120
  "server-keys.mjs",
120
121
  "add-server.mjs",
121
122
  "powershell-alias.mjs",
@@ -7,8 +7,8 @@
7
7
  * shortened, and keep it there until the next question.
8
8
  *
9
9
  * TWO STEPS, AND THE FIRST NEVER WAITS FOR THE SECOND. The trimmed question goes up the moment
10
- * the turn starts. Only a question too long to fit is then sent to the agent model for a
11
- * few-word summary, which replaces the trimmed one if — and only if — it arrives usable and the
10
+ * the turn starts. Only a question too long to fit is then sent to the coder the turn runs on
11
+ * (with its server's key — see titleModel) for a few-word summary, which replaces the trimmed one if — and only if — it arrives usable and the
12
12
  * user has not asked something else in the meantime. Every way that call can fail (no model
13
13
  * configured, a provider that wants a key this side does not hold, a timeout, a model that
14
14
  * answers with an essay) ends the same way: the trimmed question stays.
@@ -22,6 +22,8 @@
22
22
  * the bytes.
23
23
  */
24
24
 
25
+ import { pathToFileURL } from "node:url";
26
+
25
27
  /** What fits on a tab next to its neighbours. Terminals clip a title; they do not wrap it. */
26
28
  export const TITLE_MAX = 60;
27
29
 
@@ -77,6 +79,56 @@ export function idleTitle(folder) {
77
79
  return name ? `gu · ${trimTitle(name, TITLE_MAX - 5)}` : "gu";
78
80
  }
79
81
 
82
+ /**
83
+ * THE PATH AFTER THE TITLE (#358). macOS Terminal (and iTerm) put the running process and its
84
+ * arguments next to the title gu sets: `bb3d — gu · bb3d — node /opt/homebrew/Cellar/node/…/bin/gu`.
85
+ * The kernel builds that argv from the `#!/usr/bin/env node` line, so gu cannot change it in
86
+ * place — `process.title` blanks the argv area and Terminal then shows the first ENVIRONMENT
87
+ * variable instead (`gu TMPDIR=/var/folders/…`), and a child process shows as `gu ◂ node …`.
88
+ *
89
+ * What does work: replace the process (process.execve, Node ≥ 23.11) with node whose whole argv
90
+ * is `gu` and which loads this script as an --import preload. Terminal then shows `gu`. The
91
+ * user's flags travel in an env var, and restoreAfterRelaunch puts process.argv back the way
92
+ * it was, so nothing after it can tell. Node starts its own REPL once a preload finishes, so
93
+ * the relaunched gu-repl.mjs never finishes (see its last lines).
94
+ *
95
+ * Only for the plain interactive `gu` on macOS: a subcommand, --help/--version, a pipe (node
96
+ * would read a PROGRAM from a non-terminal stdin), Windows and Linux keep the old start.
97
+ * GU_NO_RELAUNCH=1 turns it off.
98
+ */
99
+ export const RELAUNCH_ENV = "GU_RELAUNCHED";
100
+
101
+ /** → { file, args, env } for process.execve, or null to start the normal way. */
102
+ export function relaunchPlan({ platform, hasExecve, env = {}, argv = [], execPath, script, stdinTTY, stdoutTTY }) {
103
+ if (platform !== "darwin" || !hasExecve || !stdinTTY || !stdoutTTY || !execPath || !script) return null;
104
+ if (env[RELAUNCH_ENV] != null || env.GU_NO_RELAUNCH === "1") return null;
105
+ const args = argv.slice(2);
106
+ if (args.some((a) => !a.startsWith("-") || a === "--help" || a === "-h" || a === "--version")) return null;
107
+ const state = JSON.stringify({ script, args, nodeOptions: env.NODE_OPTIONS ?? null });
108
+ // A file URL has no spaces (%20) and no quotes, so it is one NODE_OPTIONS word as it stands.
109
+ const nodeOptions = [`--import=${pathToFileURL(script).href}`, env.NODE_OPTIONS].filter(Boolean).join(" ");
110
+ return { file: execPath, args: ["gu"], env: { ...env, [RELAUNCH_ENV]: state, NODE_OPTIONS: nodeOptions } };
111
+ }
112
+
113
+ /**
114
+ * In the relaunched process: put process.argv and NODE_OPTIONS back, and take the marker out of
115
+ * the environment — every process gu starts (the worker, the agent's commands) inherits it, and
116
+ * a NODE_OPTIONS that preloads gu-repl.mjs would start a gu inside each node they run.
117
+ * → true when this process is the relaunched one.
118
+ */
119
+ export function restoreAfterRelaunch(proc) {
120
+ const raw = proc.env[RELAUNCH_ENV];
121
+ if (raw == null) return false;
122
+ delete proc.env[RELAUNCH_ENV];
123
+ let s;
124
+ try { s = JSON.parse(raw); } catch { return false; }
125
+ if (proc.argv.length !== 1 || !s?.script) return false; // a stale marker, not our relaunch
126
+ if (s.nodeOptions == null) delete proc.env.NODE_OPTIONS;
127
+ else proc.env.NODE_OPTIONS = s.nodeOptions;
128
+ proc.argv.splice(0, proc.argv.length, proc.execPath, s.script, ...(s.args ?? []));
129
+ return true;
130
+ }
131
+
80
132
  /** Where the agent model answers chat, from a base that may or may not already end in /v1. */
81
133
  export function chatEndpoint(url) {
82
134
  const root = String(url ?? "").trim().replace(/\/+$/, "");
@@ -98,6 +150,30 @@ export function agentModelFromSettings(settings) {
98
150
  return { url, model: str(settings?.agentModelName) || "default_model" };
99
151
  }
100
152
 
153
+ /** A server URL reduced to what identifies it: no case, no trailing slash, no trailing /v1. */
154
+ function serverOf(url) {
155
+ return String(url ?? "").trim().toLowerCase().replace(/\/+$/, "").replace(/\/v1$/, "");
156
+ }
157
+
158
+ /**
159
+ * WHICH MODEL WRITES THE TITLE (#358): the coder this turn runs on, and the server row its key
160
+ * is bound to. → { url, model, backendId } or null.
161
+ *
162
+ * It used to be the account's agent-model setting, which on a real account was OpenAI o1 —
163
+ * not what the turns ran on (kimi-k3 on ollama.com), and called without a key, so every long
164
+ * question stayed trimmed. The coder is the model the user has already chosen and keyed; the
165
+ * agent-model setting is only the fallback for a session whose coder is not known yet.
166
+ */
167
+ export function titleModel(settings, coder = {}) {
168
+ const rows = Array.isArray(settings?.agentCodingBackends) ? settings.agentCodingBackends : [];
169
+ const rowFor = (url) => rows.find((r) => r?.id && serverOf(r.url) === serverOf(url))?.id ?? "";
170
+ const url = String(coder?.url ?? "").trim();
171
+ const model = String(coder?.model ?? "").trim();
172
+ if (url && model) return { url, model, backendId: rowFor(url) };
173
+ const agent = agentModelFromSettings(settings);
174
+ return agent ? { ...agent, backendId: rowFor(agent.url) } : null;
175
+ }
176
+
101
177
  export function summaryRequest(model, question) {
102
178
  const q = Array.from(cleanTitleText(question)).slice(0, SUMMARY_INPUT_MAX).join("");
103
179
  return {
@@ -140,20 +216,22 @@ export function pickSummary(body, max = TITLE_MAX) {
140
216
  }
141
217
 
142
218
  /**
143
- * Ask the agent model for a title. → the title, or "" for every reason there is not one.
219
+ * Ask a model for a title. → the title, or "" for every reason there is not one.
220
+ * `target` ({ url, model }, see titleModel) picks the model — the agent-model setting when absent —
221
+ * and `apiKey` is sent as a Bearer token when the server needs one.
144
222
  * Never throws: this runs beside a turn and must not be able to disturb it.
145
223
  */
146
224
  export async function summariseForTitle(
147
225
  question,
148
226
  settings,
149
- { fetchImpl = fetch, timeoutMs = 20_000 } = {},
227
+ { fetchImpl = fetch, timeoutMs = 20_000, target = null, apiKey = "" } = {},
150
228
  ) {
151
- const agent = agentModelFromSettings(settings);
229
+ const agent = target?.url && target?.model ? target : agentModelFromSettings(settings);
152
230
  if (!agent || !needsSummary(question)) return "";
153
231
  try {
154
232
  const res = await fetchImpl(chatEndpoint(agent.url), {
155
233
  method: "POST",
156
- headers: { "Content-Type": "application/json" },
234
+ headers: { "Content-Type": "application/json", ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) },
157
235
  body: JSON.stringify(summaryRequest(agent.model, question)),
158
236
  signal: AbortSignal.timeout(timeoutMs),
159
237
  });