@tiens.nguyen/gu-cli 1.0.739 → 1.0.741

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.
@@ -4764,6 +4764,9 @@ _RAG_SKIP_DIRS = {
4764
4764
  # .github, .vscode or .claude — those carry CI config, editor settings and project
4765
4765
  # instructions a coding agent legitimately benefits from reading.
4766
4766
  ".claude-memory", ".specstory", ".aider.tags.cache.v3", ".goose",
4767
+ # gu's OWN memory store (the memory skill's .gu-memory/, task #269): the agent reaches it
4768
+ # through the memory MCP tools, which query it — not by grepping raw checkpoint markdown.
4769
+ ".gu-memory",
4767
4770
  }
4768
4771
  # Machine-generated files that are TEXT but never worth reading. A lockfile is the whole
4769
4772
  # problem in miniature: in the reported Next.js project package-lock.json is 239KB while every
@@ -6695,6 +6698,9 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
6695
6698
  _emit({"type": "step", "text": "Awaiting your choice: compact the context?"})
6696
6699
  elif command.startswith("__S3SETUP__::"):
6697
6700
  _emit({"type": "step", "text": "Awaiting your choice: set up S3, or save on this computer?"})
6701
+ elif command.startswith("__MCPTOOL__::"):
6702
+ _srv, _, _rest = command[len("__MCPTOOL__::"):].partition("\t")
6703
+ _emit({"type": "step", "text": f"Awaiting your approval: MCP tool {_srv}/{_rest.split(chr(9))[0]}"})
6698
6704
  else:
6699
6705
  _emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
6700
6706
  deadline = _t.time() + max(30, int(deadline_s)) # per-call; risky=180s, __MAXSTEP__ long
@@ -7073,6 +7079,12 @@ def run_agent_chat(cfg):
7073
7079
  # risky command and asks the user via the API instead of hard-blocking it.
7074
7080
  _job_id = (cfg.get("jobId") or "").strip()
7075
7081
  _interactive_approval = bool(cfg.get("interactiveApproval"))
7082
+ # Skills and MCP servers (task #269). The worker scans ~/.gonext/skills, ~/.gonext/mcp.json and
7083
+ # this folder's .gonext/skills + .mcp.json for every turn; none present = neither appears
7084
+ # anywhere in the prompt. Names only here — a skill's body is read when it is used.
7085
+ import gu_skills as _skills_mod
7086
+ _skills = _skills_mod.clean_list(cfg.get("skills"))
7087
+ _mcp_cfg = cfg.get("mcp") if isinstance(cfg.get("mcp"), dict) else {}
7076
7088
 
7077
7089
  def _ask_s3_setup() -> bool:
7078
7090
  """Private mode, a file to hand over, no S3 of the user's own (task #252): ask in the
@@ -7230,6 +7242,40 @@ def run_agent_chat(cfg):
7230
7242
  # below. Otherwise the keyword router matches URLs/"api"/"GET" from prior turns
7231
7243
  # and fires the agent on trivial replies like "thanks" or "good".
7232
7244
  latest_user_text = task_text
7245
+ # `/<skill> request` typed in the terminal (task #269): the skill's instructions go in with
7246
+ # the request, for this turn only — the history keeps the short line the user typed.
7247
+ _skill_call = _skills_mod.invocation(task_text, _skills)
7248
+ if _skill_call:
7249
+ _sk, _sk_body, _sk_req = _skill_call
7250
+ task_text = (_skills_mod.render(_sk, _sk_body) + "\n\nThe user's request: "
7251
+ + (_sk_req or "(nothing more — do what the skill describes)"))
7252
+ _log(f"skill invoked: {_sk['name']}")
7253
+
7254
+ # HOOKS (task #270): onStart on the first turn of a gu session, onAsk on every question. What
7255
+ # they print goes in beside the task; exit 2 from onAsk stops the turn before any model call.
7256
+ import gu_hooks as _hooks_mod
7257
+ _hooks = _hooks_mod.from_cfg(cfg, _log)
7258
+ _hook_context = ""
7259
+ if _hooks:
7260
+ _hr_parts, _hr_warn = [], []
7261
+ if _hooks.has("onStart") and _hooks_mod.is_new_session(_hooks.cwd or "", _hooks.session_id):
7262
+ _r = _hooks.run("onStart", {"source": "startup"})
7263
+ _hr_parts.append(_r.text)
7264
+ _hr_warn += _r.warnings
7265
+ if _hooks.has("onAsk"):
7266
+ _r = _hooks.run("onAsk", {"question": latest_user_text})
7267
+ _hr_warn += _r.warnings
7268
+ if _r.blocked:
7269
+ _emit({"type": "final", "text": f"Stopped by your onAsk hook: {_r.blocked}"})
7270
+ return
7271
+ _hr_parts.append(_r.text)
7272
+ for _w in _hr_warn:
7273
+ _emit({"type": "step", "text": f"⚠ {_w}"})
7274
+ _hook_context = "\n\n".join(p for p in _hr_parts if p.strip())
7275
+ if _hook_context:
7276
+ _log(f"hooks added {len(_hook_context)} chars of context")
7277
+ task_text += ("\n\nCONTEXT FROM YOUR gu HOOKS (onStart/onAsk — background, not the "
7278
+ f"request itself):\n{_hook_context}")
7233
7279
 
7234
7280
  # Walk prior turns newest-first, keeping condensed lines until the budget is
7235
7281
  # spent, then restore chronological (oldest→newest) order.
@@ -7862,6 +7908,11 @@ def run_agent_chat(cfg):
7862
7908
  needs_agent = True
7863
7909
  else:
7864
7910
  needs_agent = _route(latest_user_text, agent_base_url, agent_api_key, agent_model_id)
7911
+ # A skill the user typed is work to DO with tools — plain chat cannot follow one.
7912
+ if not needs_agent and _skill_call:
7913
+ _log("router → YES (skill invoked)")
7914
+ _emit({"type": "step", "text": "→ Agent mode (skill)"})
7915
+ needs_agent = True
7865
7916
  # A pending email awaiting 'confirm' must reach the agent even though a bare
7866
7917
  # 'confirm' / 'send it' is not a network keyword — otherwise the send never fires.
7867
7918
  if not needs_agent and _email_confirm_pending():
@@ -8128,7 +8179,10 @@ def run_agent_chat(cfg):
8128
8179
  # told to manage; a model that believes otherwise turns "own the environment" into
8129
8180
  # "write the user instructions for owning it" — see _machine_context.
8130
8181
  + _machine_context(_sys_platform(), _sys_arch(), _WS_ACTIVE or "")
8131
- + "WORK LIKE A SENIOR DEVELOPER — you OWN the task until it DEMONSTRABLY works:\n"
8182
+ # "the task you were GIVEN": ownership is of the request, not of everything it mentions.
8183
+ # `create task "only keep logo on flash screen"` asks for a RECORD; read as "own the logo
8184
+ # change", the agent saved the task and then went on to do it (scaffold rule 8).
8185
+ + "WORK LIKE A SENIOR DEVELOPER — you OWN the task you were GIVEN until it DEMONSTRABLY works:\n"
8132
8186
  " * OUTCOME over exit code: a command exiting 0 is NOT the goal. After any "
8133
8187
  "state-changing step (start/build/install/edit), VERIFY the intended EFFECT "
8134
8188
  "(server responds, artifact exists, test green, file contains the change) before "
@@ -8270,6 +8324,7 @@ def run_agent_chat(cfg):
8270
8324
  "When you have enough to answer, call final_answer(<your answer>) in a code block. "
8271
8325
  "Prefer FEWER steps, and never repeat a tool call that already succeeded.\n\n"
8272
8326
  "{{TOOL_LIST}}\n" # filled in below, once from the REAL registered tool objects
8327
+ + _skills_mod.prompt_block(_skills)
8273
8328
  + _rag_tool_block + _ws_tool_block + _auto_test_block +
8274
8329
  "\n"
8275
8330
  "http_request RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
@@ -8383,8 +8438,9 @@ def run_agent_chat(cfg):
8383
8438
  # Task #90: ~25-token echo of the autonomy contract at the highest-attention spot
8384
8439
  # (recency — right next to the task), reinforcing the full directive that lives in
8385
8440
  # the cached reference above. Workspace/coding mode only.
8386
- + ("Remember: FINISH it yourself — fix blockers, VERIFY the outcome, then "
8387
- "final_answer with evidence; never hand the task back.\n"
8441
+ + ("Remember: FINISH what was asked yourself — fix blockers, VERIFY the outcome, then "
8442
+ "final_answer with evidence; never hand the task back. If it only asks to RECORD work "
8443
+ "(create a task, a note, a plan), recording it IS the task — do not start the work.\n"
8388
8444
  if _WS_AVAILABLE else "")
8389
8445
  + "\nTASK (answer THIS, choose the tool that fits it):\n"
8390
8446
  f"{task_text}\n"
@@ -12405,6 +12461,67 @@ def run_agent_chat(cfg):
12405
12461
  # (S3 indexing) isn't configured.
12406
12462
  if not _RAG_AVAILABLE:
12407
12463
  agent_tools += [list_dir, read_text_file]
12464
+ # SKILLS (task #269): only offered when at least one exists, like every tool above.
12465
+ if _skills:
12466
+ @tool
12467
+ def use_skill(name: str) -> str:
12468
+ """Load a skill's full instructions by its name from the SKILLS list. Call it FIRST when the task matches a skill, then follow what it returns.
12469
+
12470
+ Args:
12471
+ name: the skill's name, exactly as listed under SKILLS
12472
+ """
12473
+ return _skills_mod.use_skill_text(_skills, name)
12474
+
12475
+ agent_tools.append(use_skill)
12476
+ # MCP SERVERS (task #269): every tool of every server that starts, named
12477
+ # mcp__<server>__<tool>. A server that does not start is a note for this turn, not a
12478
+ # failure of it. Each call passes _mcp_approve first. Not for the between-turns index job,
12479
+ # which calls no model and would start the servers for nothing.
12480
+ _mcp_servers = [s for s in (_mcp_cfg.get("servers") or [])
12481
+ if isinstance(s, dict) and s.get("name") and s.get("command")]
12482
+ if _mcp_servers and not cfg.get("ragIndexOnly"):
12483
+ import atexit as _atexit
12484
+ import gu_mcp as _mcp_mod
12485
+ from smolagents import Tool as _SmolTool
12486
+ _mcp_turn_allowed: set = set()
12487
+
12488
+ def _mcp_approve(server: str, tool_name: str, args: dict):
12489
+ """deny → allow → ask (Claude Code's order). The rules are re-read from the file on
12490
+ every call: an "Always allow" answered a moment ago was written there."""
12491
+ allow, deny = _mcp_mod.read_rules(_mcp_cfg.get("configPath"),
12492
+ _mcp_cfg.get("allow") or [], _mcp_cfg.get("deny") or [])
12493
+ verdict = _mcp_mod.decide(allow, deny, server, tool_name)
12494
+ if verdict == "deny":
12495
+ return False, (f"MCP tool {server}/{tool_name} is blocked by a deny rule in "
12496
+ "~/.gonext/mcp.json. Do not call it again; carry on without it.")
12497
+ if verdict == "allow" or (server, tool_name) in _mcp_turn_allowed:
12498
+ return True, ""
12499
+ if not (_interactive_approval and _job_id and worker_api_base and worker_api_key):
12500
+ return False, (f"MCP tool {server}/{tool_name} needs the user's approval and this "
12501
+ "session cannot ask. Tell the user to allow it in the gu terminal "
12502
+ f"(/mcp allow {server}), then carry on without it.")
12503
+ preview = json.dumps(args, ensure_ascii=False, default=str)
12504
+ if len(preview) > 240:
12505
+ preview = preview[:240] + "…"
12506
+ ok = _ws_request_approval(worker_api_base, worker_api_key, _job_id,
12507
+ f"__MCPTOOL__::{server}\t{tool_name}\t{preview}", "")
12508
+ if ok:
12509
+ _mcp_turn_allowed.add((server, tool_name))
12510
+ return True, ""
12511
+ return False, (f"The user declined MCP tool {server}/{tool_name}. Do not call it again "
12512
+ "this turn; carry on without it or ask the user.")
12513
+
12514
+ _emit({"type": "step", "text": f"Starting {len(_mcp_servers)} MCP server(s)…"})
12515
+ _mcp_conn, _mcp_notes = _mcp_mod.connect_all(_mcp_servers, _WS_ACTIVE or None, log=_log)
12516
+ _atexit.register(lambda: [c.close() for c, _t in _mcp_conn])
12517
+ for _note in _mcp_notes:
12518
+ _emit({"type": "step", "text": _note})
12519
+ agent_tools += _mcp_mod.build_tools(_mcp_conn, _SmolTool, _mcp_approve)
12520
+ # onToolDone (task #270): after every tool the hooks' matchers name — MCP tools included.
12521
+ if _hooks:
12522
+ _n_wrapped = _hooks_mod.wrap_tools(agent_tools, _hooks)
12523
+ if _n_wrapped:
12524
+ _log(f"onToolDone hooks on {_n_wrapped} tool(s)")
12408
12525
  # Fill in the numbered tool list now that the REAL tool objects exist — see
12409
12526
  # _render_numbered_tool_list and the {{TOOL_LIST}} placeholder left in tool_hint.
12410
12527
  task_with_hint = task_with_hint.replace(
@@ -12579,6 +12696,12 @@ def run_agent_chat(cfg):
12579
12696
  final_text = _strip_tool_tags(str(result).strip()) or "[No result]"
12580
12697
  _log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
12581
12698
  _emit({"type": "final", "text": final_text})
12699
+ # onTurnEnd (task #270): after the answer is out, so a slow hook never delays it.
12700
+ if _hooks and _hooks.has("onTurnEnd"):
12701
+ _te = _hooks.run("onTurnEnd", {"question": latest_user_text, "answer": final_text,
12702
+ "partial": bool(_maxsteps_partial.get("hit"))})
12703
+ for _w in _te.warnings:
12704
+ _log(f"onTurnEnd: {_w}")
12582
12705
  # Clear the step checkpoint ONLY on a genuinely complete finish (the model's own
12583
12706
  # final_answer, or a deterministic PDF delivery) — that content is now in the
12584
12707
  # answer pushed to conversation history, so the raw trace is no longer needed.
package/gu-cli.mjs CHANGED
@@ -33,6 +33,40 @@ import { createRequire } from "node:module";
33
33
  import dotenv from "dotenv";
34
34
  import OpenAI from "openai";
35
35
  import { resolvePython } from "./platform-tools.mjs";
36
+ import { skillsForTurn } from "./skills.mjs";
37
+ import { mcpForTurn } from "./mcp-config.mjs";
38
+ import { hooksForTurn, sessionForTurn } from "./hooks-config.mjs";
39
+
40
+ /** The `skills` / `mcp` fields of one job's agent config (task #269). A broken config is a log
41
+ * line, never a failed turn — the turn runs without them. */
42
+ function skillsAndMcpForJob(cwd) {
43
+ const out = {};
44
+ try {
45
+ const skills = skillsForTurn({ cwd });
46
+ if (skills.length) out.skills = skills;
47
+ } catch (e) {
48
+ console.log(`[gu-worker] skills skipped: ${e?.message || e}`);
49
+ }
50
+ try {
51
+ const mcp = mcpForTurn({ cwd });
52
+ if (mcp.servers.length) out.mcp = mcp;
53
+ } catch (e) {
54
+ console.log(`[gu-worker] MCP servers skipped: ${e?.message || e}`);
55
+ }
56
+ // Hooks (task #270): yours, plus the project's once approved; and the terminal session this
57
+ // turn belongs to, so the agent knows when onStart is due.
58
+ try {
59
+ const hooks = hooksForTurn({ cwd });
60
+ if (Object.keys(hooks).length) {
61
+ out.hooks = hooks;
62
+ const session = sessionForTurn(cwd);
63
+ if (session) out.hookSession = session;
64
+ }
65
+ } catch (e) {
66
+ console.log(`[gu-worker] hooks skipped: ${e?.message || e}`);
67
+ }
68
+ return out;
69
+ }
36
70
 
37
71
  const WORKER_DIR = dirname(fileURLToPath(import.meta.url));
38
72
 
@@ -223,6 +257,26 @@ if (args[0] === "token") {
223
257
  process.exit(0);
224
258
  }
225
259
 
260
+ if (args[0] === "mcp") {
261
+ // #269. MCP servers from a shell — `claude mcp add`'s shape. Same code as /mcp in the terminal UI.
262
+ const { runMcpCommand } = await import("./mcp-command.mjs");
263
+ const code = await runMcpCommand(args.slice(1), {
264
+ cwd: process.cwd(),
265
+ print: (line, kind) => (kind === "warn" ? console.error(line) : console.log(line)),
266
+ });
267
+ process.exit(code);
268
+ }
269
+
270
+ if (args[0] === "hooks") {
271
+ // #270. Hooks from a shell — same code as /hooks in the terminal UI.
272
+ const { runHooksCommand } = await import("./hooks-command.mjs");
273
+ const code = await runHooksCommand(args.slice(1), {
274
+ cwd: process.cwd(),
275
+ print: (line, kind) => (kind === "warn" ? console.error(line) : console.log(line)),
276
+ });
277
+ process.exit(code);
278
+ }
279
+
226
280
  if (args[0] === "full-log") {
227
281
  // #250. An administrator switches Enable Full Log (#249) from a terminal — after typing an admin
228
282
  // token minted on the web admin page. Same code as /full-log in the terminal UI.
@@ -3836,6 +3890,10 @@ async function runAgentChatJob(job) {
3836
3890
  // Task #134: set when the terminal's folder is on ANOTHER machine. The agent surfaces
3837
3891
  // it to the user — a silent downgrade to "no file tools" would just look broken.
3838
3892
  ...(remoteWorkspace ? { remoteWorkspace } : {}),
3893
+ // Skills and MCP servers (task #269), read from disk for EVERY job so an edited, added or
3894
+ // deleted SKILL.md / server takes effect on the next turn with no restart. The project's
3895
+ // own ones only when its folder is on this machine. None = the keys are absent.
3896
+ ...skillsAndMcpForJob(terminalActiveWs && wsExistsHere ? terminalActiveWs : null),
3839
3897
  });
3840
3898
  // Agent run time budget (task #81). Default 60 min; user-configurable in web Settings
3841
3899
  // (agentTimeoutMinutes → payload.agentTimeoutMs). Multi-step ReAct on a 14B/31B with a
package/gu-repl.mjs CHANGED
@@ -35,7 +35,7 @@ import { dirname, join, resolve, basename } from "node:path";
35
35
  import { fileURLToPath } from "node:url";
36
36
  import { PassThrough } from "node:stream";
37
37
  import { createInterface } from "node:readline";
38
- import { createHash } from "node:crypto";
38
+ import { createHash, randomUUID } from "node:crypto";
39
39
  import { createRequire } from "node:module";
40
40
  import dotenv from "dotenv";
41
41
  // Pure layout arithmetic for every in-place block (bottom bar, live status, pickers) — kept
@@ -117,8 +117,14 @@ import {
117
117
  identityRow,
118
118
  CANCEL_LINE,
119
119
  shouldCancelPrompt,
120
+ rankSlashCommands,
121
+ slashMenuWindow,
120
122
  } from "./terminal-layout.mjs";
121
123
  import { completedFlush } from "./completed-flush.mjs";
124
+ import { discoverSkills, matchSkillCommand } from "./skills.mjs";
125
+ import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
126
+ import { unapprovedProjectServers } from "./mcp-command.mjs";
127
+ import { readHooks } from "./hooks-config.mjs";
122
128
  import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
123
129
  import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
124
130
  import {
@@ -532,7 +538,7 @@ const argv = process.argv.slice(2);
532
538
  // process.argv and exits inside its own subcommand branch, before it ever starts polling.
533
539
  const CLI_SUBCOMMANDS = [
534
540
  "status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
535
- "clear", "reset", "api", "token-usage", "tokens", "token", "full-log",
541
+ "clear", "reset", "api", "token-usage", "tokens", "token", "full-log", "mcp", "hooks",
536
542
  ];
537
543
  // `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
538
544
  // the list, so it fell through to "no subcommand given" and a full agent session opened in that
@@ -807,6 +813,8 @@ const COMMANDS = [
807
813
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
808
814
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
809
815
  { name: "/full-log", usage: "/full-log [on|off|status] [account]", desc: "administrators: Enable Full Log for an account — asks for an admin token from the web admin page first" },
816
+ { name: "/hooks", usage: "/hooks [list|approve|unapprove]", desc: "hooks: commands gu runs on onStart, onAsk, onToolDone, onTurnEnd — list them, approve this project's" },
817
+ { name: "/mcp", usage: "/mcp [list|tools|add|remove|enable|disable|approve|allow|disallow|reconnect]", desc: "MCP servers: see which connect, add one (asks step by step), choose which tools run without asking" },
810
818
  { name: "/help", desc: "list these commands" },
811
819
  { name: "/exit", aliases: ["/quit"], desc: "quit" },
812
820
  ];
@@ -814,22 +822,50 @@ const COMMANDS = [
814
822
  // terminal-layout.mjs — their handlers are below, above the unknown-command guard. Not
815
823
  // imported here: nothing in the REPL reads the list, the tests do (task #153).
816
824
  const COMMAND_NAMES = COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])]);
825
+ // SKILLS ARE COMMANDS TOO (task #269): each ~/.gonext/skills/<name>/SKILL.md (and this folder's
826
+ // .gonext/skills) is /<name>, in Tab-completion and /help like any other. Scanned from disk — kept
827
+ // for 2s only, because the overlay asks on every keystroke — so a skill added, edited or DELETED
828
+ // while gu runs shows up (or quietly disappears) without a restart. A built-in name always wins.
829
+ let _skillScan = { at: 0, skills: [] };
830
+ function currentSkills() {
831
+ if (Date.now() - _skillScan.at > 2000) {
832
+ try {
833
+ _skillScan = { at: Date.now(), skills: discoverSkills({ cwd: process.cwd(), reserved: COMMAND_NAMES }).skills };
834
+ } catch {
835
+ _skillScan = { at: Date.now(), skills: [] };
836
+ }
837
+ }
838
+ return _skillScan.skills;
839
+ }
840
+ const skillCommands = () =>
841
+ currentSkills()
842
+ .filter((s) => s.slash)
843
+ .map((s) => ({ name: `/${s.name}`, usage: `/${s.name} [request]`, desc: `skill — ${firstSentence(s.description)}`, skill: true }));
844
+ /** A skill's description is written for the MODEL and often runs a paragraph; /help shows one line. */
845
+ function firstSentence(text, max = 100) {
846
+ const one = String(text ?? "").split(/(?<=[.!?])\s/)[0].trim();
847
+ return one.length > max ? one.slice(0, max - 1).trimEnd() + "…" : one;
848
+ }
849
+ const allCommands = () => [...COMMANDS, ...skillCommands()];
850
+ const allCommandNames = () => [...COMMAND_NAMES, ...skillCommands().map((c) => c.name)];
817
851
  // Tab-completion: when the line starts with "/", complete against the command names.
818
852
  function slashCompleter(line) {
819
853
  if (!line.startsWith("/")) return [[], line];
820
- const hits = COMMAND_NAMES.filter((n) => n.startsWith(line));
821
- return [hits.length ? hits : COMMAND_NAMES, line];
854
+ const names = allCommandNames();
855
+ const hits = names.filter((n) => n.startsWith(line));
856
+ return [hits.length ? hits : names, line];
822
857
  }
823
858
  // Commands whose name/alias matches the typed prefix (all when prefix is "" or "/").
824
859
  function filteredCommands(prefix = "") {
825
860
  const p = (prefix ?? "").trim();
826
- const m = COMMANDS.filter(
861
+ const cmds = allCommands();
862
+ const m = cmds.filter(
827
863
  (c) =>
828
864
  !p ||
829
865
  p === "/" ||
830
866
  [c.name, ...(c.aliases ?? [])].some((n) => n.startsWith(p))
831
867
  );
832
- return m.length ? m : COMMANDS;
868
+ return m.length ? m : cmds;
833
869
  }
834
870
  // Display rows for a filtered command set; row `sel` (if >=0) is highlighted (❯ + bold).
835
871
  function hintRows(cmds, sel) {
@@ -1648,14 +1684,28 @@ function slashOverlaySeq(line, cursor, menu, cols) {
1648
1684
  }
1649
1685
 
1650
1686
  // Redraw the input line AND the command menu below it, fully in place.
1687
+ // A short WINDOW over the ranked matches, not the whole registry (task #271): a bare "/" used to
1688
+ // draw every command and skill at once. ↑/↓ move through ALL matches and the window follows.
1689
+ let slashTop = 0;
1690
+ function slashMenuRows(cmds, sel, win, line) {
1691
+ if (!cmds.length) {
1692
+ return [dim(` no command matches ${line.trim().split(/\s+/)[0]} — /help lists them all`)];
1693
+ }
1694
+ const rows = hintRows(cmds.slice(win.from, win.to), sel - win.from);
1695
+ if (win.above) rows.splice(1, 0, dim(` ⋯ ${win.above} more above`));
1696
+ if (win.below) rows.push(dim(` ⋯ ${win.below} more — keep typing to narrow`));
1697
+ return rows;
1698
+ }
1651
1699
  function renderSlashOverlay(sel) {
1652
1700
  const line = rl.line;
1653
- const cmds = filteredCommands(line);
1701
+ const cmds = rankSlashCommands(allCommands(), line);
1654
1702
  slashNames = cmds.map((c) => c.name);
1655
1703
  if (sel >= cmds.length) sel = cmds.length - 1;
1656
1704
  slashSel = sel;
1657
1705
  slashPrefix = line;
1658
- const menu = hintRows(cmds, sel);
1706
+ const win = slashMenuWindow(cmds.length, sel, slashTop);
1707
+ slashTop = win.top;
1708
+ const menu = slashMenuRows(cmds, sel, win, line);
1659
1709
  if (!slashOverlayActive) {
1660
1710
  slashOverlayActive = true;
1661
1711
  slashNeutralizeHistory();
@@ -1680,6 +1730,7 @@ function closeSlashOverlay() {
1680
1730
  slashOverlayActive = false;
1681
1731
  slashHintRows = 0;
1682
1732
  slashSel = -1;
1733
+ slashTop = 0;
1683
1734
  slashNames = [];
1684
1735
  slashRestoreHistory();
1685
1736
  }
@@ -4404,6 +4455,23 @@ async function loadSessionUnfinishedTask(cwd) {
4404
4455
  * `mutate` receives the parsed object and may return a replacement or mutate in place.
4405
4456
  */
4406
4457
  let _sessionWrite = Promise.resolve();
4458
+ /**
4459
+ * Mark the start of a gu session in this folder's session file (task #270). The worker reads the
4460
+ * id back (hooks-config.mjs sessionForTurn) and the agent runs onStart hooks on the first turn it
4461
+ * sees it — which is how "a session started" reaches the agent with nothing added to the API.
4462
+ */
4463
+ async function startHookSession(cwd) {
4464
+ try {
4465
+ await updateSessionFile(cwd, (s) => {
4466
+ s.hookSessionId = randomUUID();
4467
+ s.hookSessionStartedAt = new Date().toISOString();
4468
+ return s;
4469
+ });
4470
+ } catch {
4471
+ /* no session file = no onStart this time, never a failed start */
4472
+ }
4473
+ }
4474
+
4407
4475
  async function updateSessionFile(cwd, mutate) {
4408
4476
  const run = async () => {
4409
4477
  let raw = {};
@@ -7185,6 +7253,21 @@ async function runFullLogCommand(line) {
7185
7253
  console.log("");
7186
7254
  }
7187
7255
 
7256
+ /**
7257
+ * /mcp (#269) — the same code as `gu mcp`. `/mcp add` alone asks step by step; its questions go
7258
+ * through ask(), whose label is drawn in the frozen row (a readline prompt alone is invisible there).
7259
+ */
7260
+ async function runMcpSlashCommand(line) {
7261
+ const { runMcpCommand, splitWords } = await import("./mcp-command.mjs");
7262
+ const style = { ok: accent, warn: yellow, dim };
7263
+ await runMcpCommand(splitWords(line).slice(1), {
7264
+ cwd: process.cwd(),
7265
+ ask: (label) => ask(label + " "),
7266
+ print: (l, kind) => console.log(GUTTER + (style[kind] ?? ((x) => x))(l)),
7267
+ });
7268
+ console.log("");
7269
+ }
7270
+
7188
7271
  async function runProxyCommand(line) {
7189
7272
  const cmd = await import("./proxy-command.mjs");
7190
7273
  const store = await import("./proxy-store.mjs");
@@ -8250,6 +8333,10 @@ const COMPACT_PREFIX = "__COMPACT__::";
8250
8333
  // Private mode, a file to hand over, and no S3 of the user's own (task #252): set it up now, or
8251
8334
  // save the file on this computer. Allow = S3 is now set up AND passed its test.
8252
8335
  const S3SETUP_PREFIX = "__S3SETUP__::";
8336
+ // An MCP tool about to run for the first time (task #269): "<server>\t<tool>\t<args preview>".
8337
+ // "Always" writes an allow rule to ~/.gonext/mcp.json — the agent re-reads that file before every
8338
+ // call, so it counts from the next call of this same turn, and for every turn after.
8339
+ const MCPTOOL_PREFIX = "__MCPTOOL__::";
8253
8340
  async function approvalPrompt(command) {
8254
8341
  // Wipe the live "thinking" status block BEFORE any picker draws, and stop the ticker from
8255
8342
  // redrawing it (the tick early-returns while a picker is active) — otherwise the block
@@ -8307,6 +8394,29 @@ async function approvalPrompt(command) {
8307
8394
  );
8308
8395
  return { allow: idx === 0, dontAsk: false };
8309
8396
  }
8397
+ if (command.startsWith(MCPTOOL_PREFIX)) {
8398
+ const [server = "", tool = "", preview = ""] = command.slice(MCPTOOL_PREFIX.length).split("\t");
8399
+ if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
8400
+ process.stdout.write(
8401
+ "\n" + yellow(`⚙ Allow the MCP tool ${server}/${tool}?`) + dim(" (no answer in ~3 min = No)") + "\n" +
8402
+ (preview ? dim(" " + preview) + "\n" : "")
8403
+ );
8404
+ const idx = await pickFromList(
8405
+ ["Yes", `Yes, and always allow ${server}/${tool}`, `Yes, and always allow every ${server} tool`, "No"],
8406
+ 0,
8407
+ { timeoutMs: 170000, timeoutIndex: 3 }
8408
+ );
8409
+ if (idx === 1 || idx === 2) {
8410
+ const rule = idx === 1 ? `mcp__${server}__${tool}` : `mcp__${server}__*`;
8411
+ try {
8412
+ addAllowRule({ rule });
8413
+ process.stdout.write(dim(` (won't ask again: ${rule} — undo with /mcp disallow ${server})\n`));
8414
+ } catch (e) {
8415
+ process.stdout.write(yellow(` could not save that (${e?.message || e}) — allowed this once\n`));
8416
+ }
8417
+ }
8418
+ return { allow: idx >= 0 && idx <= 2, dontAsk: false };
8419
+ }
8310
8420
  const isMax = command.startsWith(MAXSTEP_PREFIX);
8311
8421
  if (isMax) {
8312
8422
  const label = command.slice(MAXSTEP_PREFIX.length);
@@ -10665,8 +10775,42 @@ async function main() {
10665
10775
  const plan = acct.plan ? (acct.pro ? accent(acct.plan) : dim(acct.plan)) : "";
10666
10776
  statusRows.push(acct.email ? dim(`account ${acct.email}`) + (plan ? dim(" · ") + plan : "") : dim("plan ") + plan);
10667
10777
  }
10778
+ // Skills and MCP servers (task #269) — one row, only when there are any.
10779
+ try {
10780
+ const nSkills = currentSkills().length;
10781
+ const nMcp = listMcpServers({ cwd: process.cwd() }).servers.filter((x) => x.runnable).length;
10782
+ const bits = [nSkills ? `${nSkills} skill${nSkills === 1 ? "" : "s"}` : "", nMcp ? `${nMcp} MCP server${nMcp === 1 ? "" : "s"}` : ""].filter(Boolean);
10783
+ if (bits.length) statusRows.push(dim(bits.join(" · ") + " (/help · /mcp)"));
10784
+ } catch {
10785
+ /* a broken file is reported below; the banner is not the place to fail */
10786
+ }
10668
10787
  console.log(renderBanner(statusRows));
10669
10788
  }
10789
+ // Skills and MCP (task #269): say what is BROKEN or WAITING, and nothing when all is well. A
10790
+ // deleted skill is not broken — it is simply gone, and says nothing.
10791
+ try {
10792
+ for (const w of discoverSkills({ cwd: process.cwd(), reserved: COMMAND_NAMES }).warnings) {
10793
+ console.log(GUTTER + yellow(w));
10794
+ }
10795
+ // Hooks (task #270): a Claude Code event name, a broken file, or project hooks waiting.
10796
+ const hk = readHooks({ cwd: process.cwd() });
10797
+ for (const w of hk.warnings) console.log(GUTTER + yellow(w));
10798
+ if (hk.projectPending) {
10799
+ console.log(
10800
+ GUTTER + yellow("This folder's .gonext/settings.json has hooks that will not run until you approve them.") +
10801
+ "\n" + GUTTER + dim(" /hooks shows the commands · /hooks approve")
10802
+ );
10803
+ }
10804
+ const waiting = unapprovedProjectServers(process.cwd());
10805
+ if (waiting.length) {
10806
+ console.log(
10807
+ GUTTER + yellow(`This folder's .mcp.json names ${waiting.length} MCP server(s) that will not start until you approve them: ${waiting.map((x) => x.name).join(", ")}`) +
10808
+ "\n" + GUTTER + dim(" /mcp shows the command each one runs · /mcp approve <name>")
10809
+ );
10810
+ }
10811
+ } catch {
10812
+ /* never block startup on this */
10813
+ }
10670
10814
  // Task #127, Phase 3: if the base chat model server isn't answering, offer to set it up
10671
10815
  // (download + start mlx_lm.server). BC3: only runs when NOTHING answers — an already-up
10672
10816
  // model is left untouched, and this is skipped entirely. Interactive TTYs only.
@@ -10966,6 +11110,8 @@ async function main() {
10966
11110
 
10967
11111
  const cwd = resolve(process.cwd());
10968
11112
  const history = await loadSession(cwd);
11113
+ // A new gu session (task #270): the agent runs onStart hooks on the first turn it sees this id.
11114
+ await startHookSession(cwd);
10969
11115
  if (history.length > 0) {
10970
11116
  const turns = history.filter((m) => m.role === "user").length;
10971
11117
  console.log(
@@ -11220,6 +11366,20 @@ async function main() {
11220
11366
  await runFullLogCommand(line);
11221
11367
  continue;
11222
11368
  }
11369
+ if (line === "/mcp" || line.startsWith("/mcp ")) {
11370
+ await runMcpSlashCommand(line);
11371
+ continue;
11372
+ }
11373
+ if (line === "/hooks" || line.startsWith("/hooks ")) {
11374
+ const { runHooksCommand } = await import("./hooks-command.mjs");
11375
+ const style = { ok: accent, warn: yellow, dim };
11376
+ await runHooksCommand(line.split(/\s+/).slice(1), {
11377
+ cwd: process.cwd(),
11378
+ print: (l, kind) => console.log(GUTTER + (style[kind] ?? ((x) => x))(l)),
11379
+ });
11380
+ console.log("");
11381
+ continue;
11382
+ }
11223
11383
  if (line === "/proxy" || line.startsWith("/proxy ")) {
11224
11384
  await runProxyCommand(line);
11225
11385
  continue;
@@ -11312,6 +11472,7 @@ async function main() {
11312
11472
  if (line === "/reset" || line === "/new") {
11313
11473
  history.length = 0;
11314
11474
  await clearSession(cwd);
11475
+ await startHookSession(cwd); // a fresh session: onStart hooks run again (#270)
11315
11476
  console.log(GUTTER + dim("Session cleared — starting fresh.\n"));
11316
11477
  continue;
11317
11478
  }
@@ -11381,7 +11542,10 @@ async function main() {
11381
11542
  }
11382
11543
  // Any OTHER "/…" is a typo/unknown command — don't send it to the agent as a
11383
11544
  // question; point the user at /help (all real commands were handled above).
11384
- if (line.startsWith("/")) {
11545
+ // …EXCEPT a skill (task #269): "/<skill> request" goes to the agent as typed, and the agent
11546
+ // loads that skill's SKILL.md for the turn. Checked against the disk NOW, so a skill deleted a
11547
+ // moment ago is an unknown command like any other, not an error.
11548
+ if (line.startsWith("/") && !matchSkillCommand(line, currentSkills())) {
11385
11549
  const cmd = line.split(/\s+/)[0];
11386
11550
  console.log(GUTTER + dim(`unknown command: ${cmd} — type /help (or / then Tab) to list commands\n`));
11387
11551
  continue;