@tiens.nguyen/gu-cli 1.0.740 → 1.0.742
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/coding-key-prompt.mjs +21 -6
- package/gonext_agent_chat.py +44 -3
- package/gu-cli.mjs +23 -0
- package/gu-repl.mjs +68 -6
- package/gu_hooks.py +231 -0
- package/hooks-command.mjs +71 -0
- package/hooks-config.mjs +172 -0
- package/package.json +4 -1
- package/terminal-layout.mjs +48 -0
package/coding-key-prompt.mjs
CHANGED
|
@@ -24,6 +24,8 @@ const identity = (s) => s;
|
|
|
24
24
|
* and on Ctrl+K to replace one — and they were two hand-written copies of the same idea. A hint
|
|
25
25
|
* added to one of them would have left the other exactly as confusing.
|
|
26
26
|
*/
|
|
27
|
+
import { keyInUse } from "./server-keys.mjs";
|
|
28
|
+
|
|
27
29
|
export const SECRET_KEY_HINT = "type or paste the key, then press Enter — it stays masked";
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -217,13 +219,26 @@ export function codingKeyFallbackLine(kj, jobId = "") {
|
|
|
217
219
|
* when a choice HAS been made, and when only one key exists — with one there is nothing to
|
|
218
220
|
* choose, and a warning on a correct setup is how warnings stop being read.
|
|
219
221
|
*/
|
|
220
|
-
export function unchosenKeyWarning({ codingKind = "", codingUrl = "", selectedKeyId = "", keys = null } = {}) {
|
|
222
|
+
export function unchosenKeyWarning({ codingKind = "", codingUrl = "", selectedKeyId = "", keys = null, backendId = "" } = {}) {
|
|
221
223
|
const quiet = { warn: false, using: null, why: "" };
|
|
222
224
|
// Local Ollama and MLX need no credential; the Ollama cloud does.
|
|
223
225
|
if (!codingNeedsKey(codingKind, codingUrl)) return quiet;
|
|
224
|
-
if (!Array.isArray(keys)
|
|
226
|
+
if (!Array.isArray(keys)) return quiet;
|
|
225
227
|
if (String(selectedKeyId ?? "").trim()) return quiet;
|
|
226
|
-
|
|
228
|
+
// KEYS BELONG TO A SERVER (#245). Reported: the bar said "key not chosen" beside ollama.com while
|
|
229
|
+
// the key picker showed its one key, hsbc_ollama, "(in use)" — because this counted all seven keys
|
|
230
|
+
// on the ACCOUNT (Anthropic, OpenAI, …) where the API only ever chooses among THIS server's keys.
|
|
231
|
+
// With the server known: its keys, else the keys tied to no server; and a server default IS a
|
|
232
|
+
// choice. Without it (an older caller) the old account-wide count stands.
|
|
233
|
+
if (backendId) {
|
|
234
|
+
const own = keys.filter((k) => k && k.backendId === backendId);
|
|
235
|
+
const pool = own.length ? own : keys.filter((k) => k && !k.backendId);
|
|
236
|
+
if (own.some((k) => k.default)) return quiet;
|
|
237
|
+
if (pool.length < 2) return quiet;
|
|
238
|
+
return { warn: true, using: keyInUse(keys, backendId) ?? pool[0], why: "none-chosen", count: pool.length };
|
|
239
|
+
}
|
|
240
|
+
if (keys.length < 2) return quiet;
|
|
241
|
+
return { warn: true, using: keys[0], why: "none-chosen", count: keys.length };
|
|
227
242
|
}
|
|
228
243
|
|
|
229
244
|
/**
|
|
@@ -245,14 +260,14 @@ export function unchosenKeyWarning({ codingKind = "", codingUrl = "", selectedKe
|
|
|
245
260
|
* qualified ("<kind>::<model>", task #123) stores the bare form and is still a valid pick.
|
|
246
261
|
*/
|
|
247
262
|
/** The folder's coder — its kind AND its URL, which together decide whether it needs a key. */
|
|
248
|
-
export function effectiveCoder({ sessionCodingModel = "", choices = null, defaultKind = "", defaultUrl = "" } = {}) {
|
|
263
|
+
export function effectiveCoder({ sessionCodingModel = "", choices = null, defaultKind = "", defaultUrl = "", defaultBackendId = "" } = {}) {
|
|
249
264
|
const want = String(sessionCodingModel ?? "").trim();
|
|
250
265
|
const list = Array.isArray(choices) ? choices : [];
|
|
251
266
|
if (want) {
|
|
252
267
|
const hit = list.find((c) => c?.id === want || c?.model === want);
|
|
253
|
-
if (hit) return { kind: String(hit.kind ?? ""), url: String(hit.url ?? "") };
|
|
268
|
+
if (hit) return { kind: String(hit.kind ?? ""), url: String(hit.url ?? ""), backendId: String(hit.backendId ?? "") };
|
|
254
269
|
}
|
|
255
|
-
return { kind: String(defaultKind ?? ""), url: String(defaultUrl ?? "") };
|
|
270
|
+
return { kind: String(defaultKind ?? ""), url: String(defaultUrl ?? ""), backendId: String(defaultBackendId ?? "") };
|
|
256
271
|
}
|
|
257
272
|
|
|
258
273
|
export function effectiveCoderKind({
|
package/gonext_agent_chat.py
CHANGED
|
@@ -7251,6 +7251,32 @@ def run_agent_chat(cfg):
|
|
|
7251
7251
|
+ (_sk_req or "(nothing more — do what the skill describes)"))
|
|
7252
7252
|
_log(f"skill invoked: {_sk['name']}")
|
|
7253
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}")
|
|
7279
|
+
|
|
7254
7280
|
# Walk prior turns newest-first, keeping condensed lines until the budget is
|
|
7255
7281
|
# spent, then restore chronological (oldest→newest) order.
|
|
7256
7282
|
history_lines = []
|
|
@@ -8153,7 +8179,10 @@ def run_agent_chat(cfg):
|
|
|
8153
8179
|
# told to manage; a model that believes otherwise turns "own the environment" into
|
|
8154
8180
|
# "write the user instructions for owning it" — see _machine_context.
|
|
8155
8181
|
+ _machine_context(_sys_platform(), _sys_arch(), _WS_ACTIVE or "")
|
|
8156
|
-
|
|
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"
|
|
8157
8186
|
" * OUTCOME over exit code: a command exiting 0 is NOT the goal. After any "
|
|
8158
8187
|
"state-changing step (start/build/install/edit), VERIFY the intended EFFECT "
|
|
8159
8188
|
"(server responds, artifact exists, test green, file contains the change) before "
|
|
@@ -8409,8 +8438,9 @@ def run_agent_chat(cfg):
|
|
|
8409
8438
|
# Task #90: ~25-token echo of the autonomy contract at the highest-attention spot
|
|
8410
8439
|
# (recency — right next to the task), reinforcing the full directive that lives in
|
|
8411
8440
|
# the cached reference above. Workspace/coding mode only.
|
|
8412
|
-
+ ("Remember: FINISH
|
|
8413
|
-
"final_answer with evidence; never hand the task back
|
|
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"
|
|
8414
8444
|
if _WS_AVAILABLE else "")
|
|
8415
8445
|
+ "\nTASK (answer THIS, choose the tool that fits it):\n"
|
|
8416
8446
|
f"{task_text}\n"
|
|
@@ -12487,6 +12517,11 @@ def run_agent_chat(cfg):
|
|
|
12487
12517
|
for _note in _mcp_notes:
|
|
12488
12518
|
_emit({"type": "step", "text": _note})
|
|
12489
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)")
|
|
12490
12525
|
# Fill in the numbered tool list now that the REAL tool objects exist — see
|
|
12491
12526
|
# _render_numbered_tool_list and the {{TOOL_LIST}} placeholder left in tool_hint.
|
|
12492
12527
|
task_with_hint = task_with_hint.replace(
|
|
@@ -12661,6 +12696,12 @@ def run_agent_chat(cfg):
|
|
|
12661
12696
|
final_text = _strip_tool_tags(str(result).strip()) or "[No result]"
|
|
12662
12697
|
_log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
|
|
12663
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}")
|
|
12664
12705
|
# Clear the step checkpoint ONLY on a genuinely complete finish (the model's own
|
|
12665
12706
|
# final_answer, or a deterministic PDF delivery) — that content is now in the
|
|
12666
12707
|
# answer pushed to conversation history, so the raw trace is no longer needed.
|
package/gu-cli.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import OpenAI from "openai";
|
|
|
35
35
|
import { resolvePython } from "./platform-tools.mjs";
|
|
36
36
|
import { skillsForTurn } from "./skills.mjs";
|
|
37
37
|
import { mcpForTurn } from "./mcp-config.mjs";
|
|
38
|
+
import { hooksForTurn, sessionForTurn } from "./hooks-config.mjs";
|
|
38
39
|
|
|
39
40
|
/** The `skills` / `mcp` fields of one job's agent config (task #269). A broken config is a log
|
|
40
41
|
* line, never a failed turn — the turn runs without them. */
|
|
@@ -52,6 +53,18 @@ function skillsAndMcpForJob(cwd) {
|
|
|
52
53
|
} catch (e) {
|
|
53
54
|
console.log(`[gu-worker] MCP servers skipped: ${e?.message || e}`);
|
|
54
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
|
+
}
|
|
55
68
|
return out;
|
|
56
69
|
}
|
|
57
70
|
|
|
@@ -254,6 +267,16 @@ if (args[0] === "mcp") {
|
|
|
254
267
|
process.exit(code);
|
|
255
268
|
}
|
|
256
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
|
+
|
|
257
280
|
if (args[0] === "full-log") {
|
|
258
281
|
// #250. An administrator switches Enable Full Log (#249) from a terminal — after typing an admin
|
|
259
282
|
// token minted on the web admin page. Same code as /full-log in the terminal UI.
|
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,11 +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";
|
|
122
124
|
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
123
125
|
import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
|
|
124
126
|
import { unapprovedProjectServers } from "./mcp-command.mjs";
|
|
127
|
+
import { readHooks } from "./hooks-config.mjs";
|
|
125
128
|
import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
|
|
126
129
|
import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
|
|
127
130
|
import {
|
|
@@ -535,7 +538,7 @@ const argv = process.argv.slice(2);
|
|
|
535
538
|
// process.argv and exits inside its own subcommand branch, before it ever starts polling.
|
|
536
539
|
const CLI_SUBCOMMANDS = [
|
|
537
540
|
"status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
|
|
538
|
-
"clear", "reset", "api", "token-usage", "tokens", "token", "full-log", "mcp",
|
|
541
|
+
"clear", "reset", "api", "token-usage", "tokens", "token", "full-log", "mcp", "hooks",
|
|
539
542
|
];
|
|
540
543
|
// `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
|
|
541
544
|
// the list, so it fell through to "no subcommand given" and a full agent session opened in that
|
|
@@ -810,6 +813,7 @@ const COMMANDS = [
|
|
|
810
813
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
811
814
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
812
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" },
|
|
813
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" },
|
|
814
818
|
{ name: "/help", desc: "list these commands" },
|
|
815
819
|
{ name: "/exit", aliases: ["/quit"], desc: "quit" },
|
|
@@ -1680,14 +1684,28 @@ function slashOverlaySeq(line, cursor, menu, cols) {
|
|
|
1680
1684
|
}
|
|
1681
1685
|
|
|
1682
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
|
+
}
|
|
1683
1699
|
function renderSlashOverlay(sel) {
|
|
1684
1700
|
const line = rl.line;
|
|
1685
|
-
const cmds =
|
|
1701
|
+
const cmds = rankSlashCommands(allCommands(), line);
|
|
1686
1702
|
slashNames = cmds.map((c) => c.name);
|
|
1687
1703
|
if (sel >= cmds.length) sel = cmds.length - 1;
|
|
1688
1704
|
slashSel = sel;
|
|
1689
1705
|
slashPrefix = line;
|
|
1690
|
-
const
|
|
1706
|
+
const win = slashMenuWindow(cmds.length, sel, slashTop);
|
|
1707
|
+
slashTop = win.top;
|
|
1708
|
+
const menu = slashMenuRows(cmds, sel, win, line);
|
|
1691
1709
|
if (!slashOverlayActive) {
|
|
1692
1710
|
slashOverlayActive = true;
|
|
1693
1711
|
slashNeutralizeHistory();
|
|
@@ -1712,6 +1730,7 @@ function closeSlashOverlay() {
|
|
|
1712
1730
|
slashOverlayActive = false;
|
|
1713
1731
|
slashHintRows = 0;
|
|
1714
1732
|
slashSel = -1;
|
|
1733
|
+
slashTop = 0;
|
|
1715
1734
|
slashNames = [];
|
|
1716
1735
|
slashRestoreHistory();
|
|
1717
1736
|
}
|
|
@@ -4436,6 +4455,23 @@ async function loadSessionUnfinishedTask(cwd) {
|
|
|
4436
4455
|
* `mutate` receives the parsed object and may return a replacement or mutate in place.
|
|
4437
4456
|
*/
|
|
4438
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
|
+
|
|
4439
4475
|
async function updateSessionFile(cwd, mutate) {
|
|
4440
4476
|
const run = async () => {
|
|
4441
4477
|
let raw = {};
|
|
@@ -6215,6 +6251,8 @@ async function chooseModel() {
|
|
|
6215
6251
|
codingKind: "openai",
|
|
6216
6252
|
selectedKeyId: sessionCodingKeyId,
|
|
6217
6253
|
keys: keysNow,
|
|
6254
|
+
// Only THIS server's keys are a choice to make (#245) — not every key on the account.
|
|
6255
|
+
backendId: ks.backendId,
|
|
6218
6256
|
}).warn;
|
|
6219
6257
|
const line = codingKeyLine({
|
|
6220
6258
|
keys: keysNow,
|
|
@@ -10756,6 +10794,15 @@ async function main() {
|
|
|
10756
10794
|
for (const w of discoverSkills({ cwd: process.cwd(), reserved: COMMAND_NAMES }).warnings) {
|
|
10757
10795
|
console.log(GUTTER + yellow(w));
|
|
10758
10796
|
}
|
|
10797
|
+
// Hooks (task #270): a Claude Code event name, a broken file, or project hooks waiting.
|
|
10798
|
+
const hk = readHooks({ cwd: process.cwd() });
|
|
10799
|
+
for (const w of hk.warnings) console.log(GUTTER + yellow(w));
|
|
10800
|
+
if (hk.projectPending) {
|
|
10801
|
+
console.log(
|
|
10802
|
+
GUTTER + yellow("This folder's .gonext/settings.json has hooks that will not run until you approve them.") +
|
|
10803
|
+
"\n" + GUTTER + dim(" /hooks shows the commands · /hooks approve")
|
|
10804
|
+
);
|
|
10805
|
+
}
|
|
10759
10806
|
const waiting = unapprovedProjectServers(process.cwd());
|
|
10760
10807
|
if (waiting.length) {
|
|
10761
10808
|
console.log(
|
|
@@ -10931,11 +10978,12 @@ async function main() {
|
|
|
10931
10978
|
// machine an Ollama coder, which needs no key — while the folder was on gpt-5. Reading
|
|
10932
10979
|
// it directly made this check answer about a coder nobody was using, and the mark only
|
|
10933
10980
|
// appeared after /model, which re-probes WITH the override.
|
|
10934
|
-
const { kind, url } = effectiveCoder({
|
|
10981
|
+
const { kind, url, backendId } = effectiveCoder({
|
|
10935
10982
|
sessionCodingModel,
|
|
10936
10983
|
choices: probe?.codingChoices,
|
|
10937
10984
|
defaultKind: p?.codingKind,
|
|
10938
10985
|
defaultUrl: probe?.codingChoices?.find?.((c) => c?.isDefault)?.url ?? "",
|
|
10986
|
+
defaultBackendId: probe?.codingChoices?.find?.((c) => c?.isDefault)?.backendId ?? "",
|
|
10939
10987
|
});
|
|
10940
10988
|
const allKeys =
|
|
10941
10989
|
codingNeedsKey(kind, url) && !sessionCodingKeyId ? await fetchCodingKeys() : null;
|
|
@@ -10944,6 +10992,7 @@ async function main() {
|
|
|
10944
10992
|
codingUrl: url,
|
|
10945
10993
|
selectedKeyId: sessionCodingKeyId,
|
|
10946
10994
|
keys: allKeys,
|
|
10995
|
+
backendId,
|
|
10947
10996
|
});
|
|
10948
10997
|
// THE BAR CARRIES IT TOO, from the same decision — a line printed at startup scrolls
|
|
10949
10998
|
// away within a few turns, and the report was precisely that the warning has to be
|
|
@@ -10951,7 +11000,7 @@ async function main() {
|
|
|
10951
11000
|
keyChoicePending = w.warn;
|
|
10952
11001
|
if (w.warn) {
|
|
10953
11002
|
console.log(
|
|
10954
|
-
GUTTER + orange(` ${
|
|
11003
|
+
GUTTER + orange(` ${w.count} API keys for this server and this folder has not chosen one`) +
|
|
10955
11004
|
dim(` — using ${w.using.name} ····${w.using.tail}.`)
|
|
10956
11005
|
);
|
|
10957
11006
|
console.log(GUTTER + dim(" ctrl+k picks the right one; a wrong key fails mid-task.\n"));
|
|
@@ -11065,6 +11114,8 @@ async function main() {
|
|
|
11065
11114
|
|
|
11066
11115
|
const cwd = resolve(process.cwd());
|
|
11067
11116
|
const history = await loadSession(cwd);
|
|
11117
|
+
// A new gu session (task #270): the agent runs onStart hooks on the first turn it sees this id.
|
|
11118
|
+
await startHookSession(cwd);
|
|
11068
11119
|
if (history.length > 0) {
|
|
11069
11120
|
const turns = history.filter((m) => m.role === "user").length;
|
|
11070
11121
|
console.log(
|
|
@@ -11323,6 +11374,16 @@ async function main() {
|
|
|
11323
11374
|
await runMcpSlashCommand(line);
|
|
11324
11375
|
continue;
|
|
11325
11376
|
}
|
|
11377
|
+
if (line === "/hooks" || line.startsWith("/hooks ")) {
|
|
11378
|
+
const { runHooksCommand } = await import("./hooks-command.mjs");
|
|
11379
|
+
const style = { ok: accent, warn: yellow, dim };
|
|
11380
|
+
await runHooksCommand(line.split(/\s+/).slice(1), {
|
|
11381
|
+
cwd: process.cwd(),
|
|
11382
|
+
print: (l, kind) => console.log(GUTTER + (style[kind] ?? ((x) => x))(l)),
|
|
11383
|
+
});
|
|
11384
|
+
console.log("");
|
|
11385
|
+
continue;
|
|
11386
|
+
}
|
|
11326
11387
|
if (line === "/proxy" || line.startsWith("/proxy ")) {
|
|
11327
11388
|
await runProxyCommand(line);
|
|
11328
11389
|
continue;
|
|
@@ -11415,6 +11476,7 @@ async function main() {
|
|
|
11415
11476
|
if (line === "/reset" || line === "/new") {
|
|
11416
11477
|
history.length = 0;
|
|
11417
11478
|
await clearSession(cwd);
|
|
11479
|
+
await startHookSession(cwd); // a fresh session: onStart hooks run again (#270)
|
|
11418
11480
|
console.log(GUTTER + dim("Session cleared — starting fresh.\n"));
|
|
11419
11481
|
continue;
|
|
11420
11482
|
}
|
package/gu_hooks.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Hooks for the gu agent (task #270): commands gu runs at fixed moments of a turn.
|
|
2
|
+
|
|
3
|
+
onStart the first turn of a gu session (gu started, or /reset)
|
|
4
|
+
onAsk every question, before the agent starts on it
|
|
5
|
+
onToolDone after a tool the matcher names (gu's tool names: edit_file, grep_repo, mcp__…)
|
|
6
|
+
onTurnEnd after the answer has been sent
|
|
7
|
+
|
|
8
|
+
gu's own names and gu's own tool names, deliberately not Claude Code's. The worker hands the agent
|
|
9
|
+
the hooks that apply (hooks-config.mjs: yours, plus a project's only once approved) and the
|
|
10
|
+
session this turn belongs to.
|
|
11
|
+
|
|
12
|
+
A HOOK IS A COMMAND. It gets one JSON object on stdin and may answer on stdout:
|
|
13
|
+
{"additionalContext": "..."} added for the model (onStart, onAsk; onToolDone: after the result)
|
|
14
|
+
plain text the same as additionalContext
|
|
15
|
+
exit code 2 stop: onAsk stops the turn, onToolDone tells the model; stderr is why
|
|
16
|
+
A hook that fails any other way, or runs past its timeout, is a warning — never a failed turn.
|
|
17
|
+
|
|
18
|
+
WHAT A HOOK SEES: its own env on a short whitelist of this process's (the worker's env carries gu's
|
|
19
|
+
credentials), plus GU_PROJECT_DIR and GU_HOOK_EVENT. It runs in the project folder.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any, Callable
|
|
29
|
+
|
|
30
|
+
EVENTS = ("onStart", "onAsk", "onToolDone", "onTurnEnd")
|
|
31
|
+
DEFAULT_TIMEOUT_S = 30
|
|
32
|
+
CONTEXT_CAP_CHARS = 6000
|
|
33
|
+
_ENV_KEEP = ("PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TEMP", "TMP", "SHELL", "USER",
|
|
34
|
+
"USERNAME", "LOGNAME", "TERM", "SYSTEMROOT", "APPDATA", "LOCALAPPDATA", "USERPROFILE",
|
|
35
|
+
"PROGRAMDATA", "PATHEXT", "COMSPEC", "VIRTUAL_ENV", "PYTHONIOENCODING")
|
|
36
|
+
_PLAIN_MATCHER = re.compile(r"^[A-Za-z0-9_\-| ,]*$")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def matches(matcher: str | None, value: str) -> bool:
|
|
40
|
+
"""Empty or "*" = everything. Letters, digits, _ - and | = an exact list. Anything else = a regex."""
|
|
41
|
+
m = (matcher or "").strip()
|
|
42
|
+
if m in ("", "*"):
|
|
43
|
+
return True
|
|
44
|
+
if _PLAIN_MATCHER.match(m):
|
|
45
|
+
return value in {x.strip() for x in re.split(r"[|,]", m) if x.strip()}
|
|
46
|
+
try:
|
|
47
|
+
return re.search(m, value) is not None
|
|
48
|
+
except re.error:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HookResult:
|
|
53
|
+
def __init__(self):
|
|
54
|
+
self.context: list[str] = []
|
|
55
|
+
self.blocked: str | None = None
|
|
56
|
+
self.warnings: list[str] = []
|
|
57
|
+
self.ran = 0
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def text(self) -> str:
|
|
61
|
+
t = "\n\n".join(c for c in self.context if c.strip())
|
|
62
|
+
return t[:CONTEXT_CAP_CHARS] + ("\n… [hook output cut]" if len(t) > CONTEXT_CAP_CHARS else "")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HookRunner:
|
|
66
|
+
def __init__(self, hooks: dict, cwd: str | None, session_id: str,
|
|
67
|
+
log: Callable[[str], None] = lambda s: None):
|
|
68
|
+
self.hooks = hooks if isinstance(hooks, dict) else {}
|
|
69
|
+
self.cwd = cwd if cwd and os.path.isdir(cwd) else None
|
|
70
|
+
self.session_id = session_id or "unknown"
|
|
71
|
+
self.log = log
|
|
72
|
+
|
|
73
|
+
def has(self, event: str) -> bool:
|
|
74
|
+
return bool(self.hooks.get(event))
|
|
75
|
+
|
|
76
|
+
def matcher_union(self, event: str) -> list[str]:
|
|
77
|
+
return [g.get("matcher", "") for g in self.hooks.get(event) or [] if isinstance(g, dict)]
|
|
78
|
+
|
|
79
|
+
def _env(self, event: str, extra: dict | None) -> dict:
|
|
80
|
+
env = {k: os.environ[k] for k in _ENV_KEEP if k in os.environ}
|
|
81
|
+
env.setdefault("PYTHONIOENCODING", "utf-8")
|
|
82
|
+
env["GU_PROJECT_DIR"] = self.cwd or ""
|
|
83
|
+
env["GU_HOOK_EVENT"] = event
|
|
84
|
+
for k, v in (extra or {}).items():
|
|
85
|
+
env[str(k)] = str(v)
|
|
86
|
+
return env
|
|
87
|
+
|
|
88
|
+
def run(self, event: str, payload: dict, match_value: str = "") -> HookResult:
|
|
89
|
+
res = HookResult()
|
|
90
|
+
if event not in EVENTS:
|
|
91
|
+
return res
|
|
92
|
+
body = {"event": event, "session_id": self.session_id, "cwd": self.cwd or "", **payload}
|
|
93
|
+
data = json.dumps(body, ensure_ascii=False, default=str)
|
|
94
|
+
for group in self.hooks.get(event) or []:
|
|
95
|
+
if not isinstance(group, dict) or not matches(group.get("matcher"), match_value):
|
|
96
|
+
continue
|
|
97
|
+
for h in group.get("hooks") or []:
|
|
98
|
+
if not isinstance(h, dict) or (h.get("type") or "command") != "command" or not h.get("command"):
|
|
99
|
+
continue
|
|
100
|
+
self._one(event, h, data, res)
|
|
101
|
+
return res
|
|
102
|
+
|
|
103
|
+
def _one(self, event: str, h: dict, data: str, res: HookResult) -> None:
|
|
104
|
+
cmd = str(h["command"])
|
|
105
|
+
args = [str(a) for a in (h.get("args") or [])]
|
|
106
|
+
timeout = float(h.get("timeout") or DEFAULT_TIMEOUT_S)
|
|
107
|
+
label = os.path.basename(args[-1] if args else cmd.split()[0])
|
|
108
|
+
t0 = time.time()
|
|
109
|
+
res.ran += 1
|
|
110
|
+
try:
|
|
111
|
+
p = subprocess.run([cmd, *args] if args else cmd, shell=not args, input=data,
|
|
112
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
113
|
+
timeout=timeout, cwd=self.cwd, env=self._env(event, h.get("env")))
|
|
114
|
+
except subprocess.TimeoutExpired:
|
|
115
|
+
res.warnings.append(f"{event} hook {label} took longer than {timeout:.0f}s and was stopped")
|
|
116
|
+
self.log(f"hook {event} {label}: timeout {timeout:.0f}s")
|
|
117
|
+
return
|
|
118
|
+
except OSError as e:
|
|
119
|
+
res.warnings.append(f"{event} hook {label} could not start: {e.strerror or e}")
|
|
120
|
+
self.log(f"hook {event} {label}: could not start")
|
|
121
|
+
return
|
|
122
|
+
ms = int((time.time() - t0) * 1000)
|
|
123
|
+
out = (p.stdout or "").strip()
|
|
124
|
+
err = (p.stderr or "").strip()
|
|
125
|
+
# Sizes only: a hook's output is conversation content, and the log is content-free (#249).
|
|
126
|
+
self.log(f"hook {event} {label}: exit {p.returncode} in {ms}ms, {len(out)} chars out")
|
|
127
|
+
if p.returncode == 2:
|
|
128
|
+
res.blocked = err or f"the {event} hook {label} said no"
|
|
129
|
+
return
|
|
130
|
+
if p.returncode != 0:
|
|
131
|
+
tail = err.splitlines()[-1][:200] if err else ""
|
|
132
|
+
res.warnings.append(f"{event} hook {label} failed (exit {p.returncode})" + (f": {tail}" if tail else ""))
|
|
133
|
+
return
|
|
134
|
+
if not out:
|
|
135
|
+
return
|
|
136
|
+
try:
|
|
137
|
+
obj = json.loads(out)
|
|
138
|
+
except ValueError:
|
|
139
|
+
res.context.append(out) # plain text = context
|
|
140
|
+
return
|
|
141
|
+
if isinstance(obj, dict):
|
|
142
|
+
if str(obj.get("decision", "")).lower() == "block":
|
|
143
|
+
res.blocked = str(obj.get("reason") or err or f"the {event} hook {label} said no")
|
|
144
|
+
return
|
|
145
|
+
ctx = obj.get("additionalContext")
|
|
146
|
+
if isinstance(ctx, str) and ctx.strip():
|
|
147
|
+
res.context.append(ctx)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---- onStart: once per session ---------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def _state_path() -> str:
|
|
153
|
+
return os.path.join(os.path.expanduser("~"), ".gonext", "hooks-state.json")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def is_new_session(cwd: str, session_id: str, path: str | None = None) -> bool:
|
|
157
|
+
"""True the first time this session is seen for this folder — and records it."""
|
|
158
|
+
path = path or _state_path()
|
|
159
|
+
key = os.path.realpath(cwd or "")
|
|
160
|
+
try:
|
|
161
|
+
with open(path, encoding="utf-8") as fh:
|
|
162
|
+
state = json.load(fh)
|
|
163
|
+
if not isinstance(state, dict):
|
|
164
|
+
state = {}
|
|
165
|
+
except (OSError, ValueError):
|
|
166
|
+
state = {}
|
|
167
|
+
if state.get(key) == session_id:
|
|
168
|
+
return False
|
|
169
|
+
state[key] = session_id
|
|
170
|
+
try:
|
|
171
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
172
|
+
tmp = f"{path}.{os.getpid()}.tmp"
|
|
173
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
174
|
+
json.dump(state, fh, indent=2)
|
|
175
|
+
os.replace(tmp, path)
|
|
176
|
+
except OSError:
|
|
177
|
+
pass
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---- onToolDone: wrapping the agent's tools ---------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def wrap_tools(tools: list, runner: HookRunner, clip: int = 4000) -> int:
|
|
184
|
+
"""Run onToolDone after every tool a matcher names. Returns how many tools were wrapped. Hook
|
|
185
|
+
context is appended to the tool's result; a block (exit 2) is appended as a message."""
|
|
186
|
+
if not runner.has("onToolDone"):
|
|
187
|
+
return 0
|
|
188
|
+
matchers = runner.matcher_union("onToolDone")
|
|
189
|
+
n = 0
|
|
190
|
+
for t in tools:
|
|
191
|
+
name = getattr(t, "name", "")
|
|
192
|
+
if not name or name == "final_answer" or not any(matches(m, name) for m in matchers):
|
|
193
|
+
continue
|
|
194
|
+
orig = t.forward
|
|
195
|
+
order = list((getattr(t, "inputs", None) or {}).keys())
|
|
196
|
+
|
|
197
|
+
def forward(*pos, _orig=orig, _name=name, _order=order, **kw):
|
|
198
|
+
result = _orig(*pos, **kw)
|
|
199
|
+
args = dict(kw)
|
|
200
|
+
for k, v in zip(_order, pos):
|
|
201
|
+
args.setdefault(k, v)
|
|
202
|
+
text = str(result)
|
|
203
|
+
r = runner.run("onToolDone", {"tool_name": _name, "tool_input": args,
|
|
204
|
+
"tool_result": text[:clip]}, match_value=_name)
|
|
205
|
+
extra = []
|
|
206
|
+
if r.blocked:
|
|
207
|
+
extra.append(f"[onToolDone hook] {r.blocked}")
|
|
208
|
+
if r.text:
|
|
209
|
+
extra.append(f"[onToolDone hook]\n{r.text}")
|
|
210
|
+
return f"{result}\n\n" + "\n".join(extra) if extra and isinstance(result, str) else result
|
|
211
|
+
|
|
212
|
+
t.forward = forward
|
|
213
|
+
n += 1
|
|
214
|
+
return n
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def from_cfg(cfg: dict, log: Callable[[str], None]) -> HookRunner | None:
|
|
218
|
+
hooks = cfg.get("hooks")
|
|
219
|
+
if not isinstance(hooks, dict) or not any(hooks.get(e) for e in EVENTS):
|
|
220
|
+
return None
|
|
221
|
+
sess = cfg.get("hookSession") if isinstance(cfg.get("hookSession"), dict) else {}
|
|
222
|
+
cwd = (cfg.get("activeWorkspace") or "").strip() or None
|
|
223
|
+
if sess.get("id"):
|
|
224
|
+
return HookRunner(hooks, cwd, str(sess["id"]), log)
|
|
225
|
+
# No terminal session (the web, or a terminal older than hooks). The job id would be a NEW
|
|
226
|
+
# "session" every turn — onStart on every question — so onStart is skipped, and the other
|
|
227
|
+
# events share one id per day, which keeps the memory hook's steps together.
|
|
228
|
+
import datetime
|
|
229
|
+
runner = HookRunner(hooks, cwd, "gu-" + datetime.date.today().isoformat(), log)
|
|
230
|
+
runner.hooks = {k: v for k, v in runner.hooks.items() if k != "onStart"}
|
|
231
|
+
return runner
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gu hooks …` and `/hooks …` — ONE code path (task #270).
|
|
3
|
+
*
|
|
4
|
+
* list every hook for this folder: yours, and the project's with whether it is approved
|
|
5
|
+
* approve run this project's hooks (as they are now — an edit needs approving again)
|
|
6
|
+
* unapprove stop running them
|
|
7
|
+
*/
|
|
8
|
+
import { HOOK_EVENTS, describeHook, readHooks, approveProjectHooks, userSettingsPath } from "./hooks-config.mjs";
|
|
9
|
+
|
|
10
|
+
export const HOOKS_USAGE = "gu hooks [list|approve|unapprove]";
|
|
11
|
+
|
|
12
|
+
function printGroups(hooks, print, indent = " ") {
|
|
13
|
+
for (const ev of HOOK_EVENTS) {
|
|
14
|
+
for (const g of hooks[ev] ?? []) {
|
|
15
|
+
const on = g.matcher ? ` (tools: ${g.matcher})` : "";
|
|
16
|
+
for (const h of g.hooks) print(`${indent}${ev.padEnd(11)} ${describeHook(h)}${on}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runHooksCommand(words, { cwd = process.cwd(), print = console.log } = {}) {
|
|
22
|
+
const sub = String((words ?? [])[0] ?? "list").toLowerCase();
|
|
23
|
+
try {
|
|
24
|
+
if (sub === "list" || sub === "ls") {
|
|
25
|
+
const r = readHooks({ cwd });
|
|
26
|
+
for (const w of r.warnings) print(w, "warn");
|
|
27
|
+
const nUser = Object.keys(r.user).length;
|
|
28
|
+
const nProj = Object.keys(r.project).length;
|
|
29
|
+
if (!nUser && !nProj) {
|
|
30
|
+
print("No hooks.");
|
|
31
|
+
print(`Add them under "hooks" in ${userSettingsPath()} — events: ${HOOK_EVENTS.join(", ")}.`, "dim");
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
if (nUser) {
|
|
35
|
+
print(`Yours (${userSettingsPath()}):`, "ok");
|
|
36
|
+
printGroups(r.user, (l) => print(l, "dim"));
|
|
37
|
+
}
|
|
38
|
+
if (nProj) {
|
|
39
|
+
print(
|
|
40
|
+
r.projectApproved
|
|
41
|
+
? "This project's (./.gonext/settings.json) — approved:"
|
|
42
|
+
: "This project's (./.gonext/settings.json) — NOT running until you approve them:",
|
|
43
|
+
r.projectApproved ? "ok" : "warn"
|
|
44
|
+
);
|
|
45
|
+
printGroups(r.project, (l) => print(l, "dim"));
|
|
46
|
+
if (!r.projectApproved) print("Check the commands above, then: /hooks approve", "dim");
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
if (sub === "approve" || sub === "unapprove") {
|
|
51
|
+
const r = readHooks({ cwd });
|
|
52
|
+
if (!Object.keys(r.project).length) {
|
|
53
|
+
print("This folder has no hooks of its own (./.gonext/settings.json).", "warn");
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
approveProjectHooks({ cwd, approve: sub === "approve" });
|
|
57
|
+
print(
|
|
58
|
+
sub === "approve"
|
|
59
|
+
? "Approved: this project's hooks run from the next turn. Editing them needs approving again."
|
|
60
|
+
: "This project's hooks will not run here.",
|
|
61
|
+
"ok"
|
|
62
|
+
);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
print(`unknown "${sub}" — ${HOOKS_USAGE}`, "warn");
|
|
66
|
+
return 2;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
print(e?.message || String(e), "warn");
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
package/hooks-config.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOOKS — commands gu runs at fixed moments of a turn (task #270). Where they are set, and which
|
|
3
|
+
* ones a turn gets.
|
|
4
|
+
*
|
|
5
|
+
* ~/.gonext/settings.json yours, every folder
|
|
6
|
+
* <project>/.gonext/settings.json this project's — run only once you approve them here
|
|
7
|
+
*
|
|
8
|
+
* { "hooks": { "<event>": [ { "matcher": "edit_file|create_file",
|
|
9
|
+
* "hooks": [ { "type": "command", "command": "python3",
|
|
10
|
+
* "args": ["/path/hook.py"], "timeout": 30 } ] } ] } }
|
|
11
|
+
*
|
|
12
|
+
* gu's OWN event names — onStart, onAsk, onToolDone, onTurnEnd — and gu's own tool names in
|
|
13
|
+
* matchers (edit_file, grep_repo, mcp__<server>__<tool>). Claude Code's names (SessionStart, …) are
|
|
14
|
+
* recognised only to say what to write instead: a hook filed under a name gu never fires would
|
|
15
|
+
* otherwise fail in silence.
|
|
16
|
+
*
|
|
17
|
+
* A PROJECT'S HOOKS ARE COMMANDS THAT CAME WITH THE REPOSITORY, so they never run until you approve
|
|
18
|
+
* them for that folder — and the approval is of their exact content (a hash): an edited hooks
|
|
19
|
+
* block needs approving again. The approval lives in YOUR settings file, never the project's.
|
|
20
|
+
*
|
|
21
|
+
* THE SESSION. onStart fires on the first turn of a gu session. The terminal writes a new session
|
|
22
|
+
* id into the folder's session file when it starts (and on /reset); sessionForTurn reads it back so
|
|
23
|
+
* the agent can tell "new session" from "next turn" with nothing added to the API.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { dirname, join, resolve } from "node:path";
|
|
29
|
+
|
|
30
|
+
export const HOOK_EVENTS = ["onStart", "onAsk", "onToolDone", "onTurnEnd"];
|
|
31
|
+
/** Claude Code's names → gu's. Only used to tell the user what to rename. */
|
|
32
|
+
export const CLAUDE_EVENT_NAMES = {
|
|
33
|
+
SessionStart: "onStart",
|
|
34
|
+
UserPromptSubmit: "onAsk",
|
|
35
|
+
PostToolUse: "onToolDone",
|
|
36
|
+
Stop: "onTurnEnd",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const userSettingsPath = (home = homedir()) => join(home, ".gonext", "settings.json");
|
|
40
|
+
export const projectSettingsPath = (cwd) => join(cwd, ".gonext", "settings.json");
|
|
41
|
+
|
|
42
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
43
|
+
|
|
44
|
+
function readJson(file) {
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
text = readFileSync(file, "utf8");
|
|
48
|
+
} catch {
|
|
49
|
+
return { data: null };
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const data = JSON.parse(text);
|
|
53
|
+
return isObj(data) ? { data } : { data: null, error: "not a JSON object" };
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return { data: null, error: `not valid JSON (${e.message})` };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A settings file's hooks, cleaned: only gu events, only command hooks. → { hooks, warnings } */
|
|
60
|
+
export function cleanHooks(raw, where) {
|
|
61
|
+
const hooks = {};
|
|
62
|
+
const warnings = [];
|
|
63
|
+
if (!isObj(raw)) return { hooks, warnings };
|
|
64
|
+
for (const [event, groups] of Object.entries(raw)) {
|
|
65
|
+
if (!HOOK_EVENTS.includes(event)) {
|
|
66
|
+
const gu = CLAUDE_EVENT_NAMES[event];
|
|
67
|
+
warnings.push(
|
|
68
|
+
gu
|
|
69
|
+
? `${where}: "${event}" is Claude Code's name — gu calls it "${gu}". Skipped until renamed.`
|
|
70
|
+
: `${where}: "${event}" is not a gu hook event (${HOOK_EVENTS.join(", ")}). Skipped.`
|
|
71
|
+
);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const kept = [];
|
|
75
|
+
for (const g of Array.isArray(groups) ? groups : []) {
|
|
76
|
+
if (!isObj(g)) continue;
|
|
77
|
+
const hs = (Array.isArray(g.hooks) ? g.hooks : [])
|
|
78
|
+
.filter((h) => isObj(h) && (h.type ?? "command") === "command" && String(h.command ?? "").trim())
|
|
79
|
+
.map((h) => ({
|
|
80
|
+
type: "command",
|
|
81
|
+
command: String(h.command),
|
|
82
|
+
...(Array.isArray(h.args) ? { args: h.args.map(String) } : {}),
|
|
83
|
+
...(Number.isFinite(h.timeout) && h.timeout > 0 ? { timeout: h.timeout } : {}),
|
|
84
|
+
...(isObj(h.env) ? { env: Object.fromEntries(Object.entries(h.env).map(([k, v]) => [k, String(v)])) } : {}),
|
|
85
|
+
}));
|
|
86
|
+
if (hs.length) kept.push({ matcher: String(g.matcher ?? ""), hooks: hs });
|
|
87
|
+
}
|
|
88
|
+
if (kept.length) hooks[event] = kept;
|
|
89
|
+
}
|
|
90
|
+
return { hooks, warnings };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The exact content being approved — any edit changes it. */
|
|
94
|
+
export function hooksFingerprint(hooks) {
|
|
95
|
+
return createHash("sha256").update(JSON.stringify(hooks ?? {})).digest("hex").slice(0, 16);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** → { user, project, projectApproved, projectPending, warnings } */
|
|
99
|
+
export function readHooks({ cwd, home = homedir() } = {}) {
|
|
100
|
+
const warnings = [];
|
|
101
|
+
const u = readJson(userSettingsPath(home));
|
|
102
|
+
if (u.error) warnings.push(`${userSettingsPath(home)} is ${u.error} — its hooks are skipped`);
|
|
103
|
+
const user = cleanHooks(u.data?.hooks, "~/.gonext/settings.json");
|
|
104
|
+
warnings.push(...user.warnings);
|
|
105
|
+
let project = { hooks: {}, warnings: [] };
|
|
106
|
+
let projectApproved = false;
|
|
107
|
+
if (cwd && resolve(projectSettingsPath(cwd)) !== resolve(userSettingsPath(home))) {
|
|
108
|
+
const p = readJson(projectSettingsPath(cwd));
|
|
109
|
+
if (p.error) warnings.push(`${projectSettingsPath(cwd)} is ${p.error} — its hooks are skipped`);
|
|
110
|
+
project = cleanHooks(p.data?.hooks, ".gonext/settings.json");
|
|
111
|
+
warnings.push(...project.warnings);
|
|
112
|
+
const approved = u.data?.approvedProjectHooks?.[resolve(cwd)];
|
|
113
|
+
projectApproved = Object.keys(project.hooks).length > 0 && approved === hooksFingerprint(project.hooks);
|
|
114
|
+
}
|
|
115
|
+
const hasProject = Object.keys(project.hooks).length > 0;
|
|
116
|
+
return {
|
|
117
|
+
user: user.hooks,
|
|
118
|
+
project: project.hooks,
|
|
119
|
+
projectApproved,
|
|
120
|
+
projectPending: hasProject && !projectApproved,
|
|
121
|
+
warnings,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What one turn runs: yours first, then the project's when approved. */
|
|
126
|
+
export function hooksForTurn({ cwd, home = homedir() } = {}) {
|
|
127
|
+
const r = readHooks({ cwd, home });
|
|
128
|
+
const out = {};
|
|
129
|
+
for (const ev of HOOK_EVENTS) {
|
|
130
|
+
const groups = [...(r.user[ev] ?? []), ...(r.projectApproved ? r.project[ev] ?? [] : [])];
|
|
131
|
+
if (groups.length) out[ev] = groups;
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Approve (or withdraw) this folder's hooks, as they are right now. */
|
|
137
|
+
export function approveProjectHooks({ cwd, home = homedir(), approve = true }) {
|
|
138
|
+
const r = readHooks({ cwd, home });
|
|
139
|
+
const file = userSettingsPath(home);
|
|
140
|
+
const { data, error } = readJson(file);
|
|
141
|
+
if (error) throw new Error(`${file} is ${error} — fix or remove it first`);
|
|
142
|
+
const raw = data ?? {};
|
|
143
|
+
raw.approvedProjectHooks = isObj(raw.approvedProjectHooks) ? raw.approvedProjectHooks : {};
|
|
144
|
+
if (approve) raw.approvedProjectHooks[resolve(cwd)] = hooksFingerprint(r.project);
|
|
145
|
+
else delete raw.approvedProjectHooks[resolve(cwd)];
|
|
146
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
147
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
148
|
+
writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
149
|
+
renameSync(tmp, file);
|
|
150
|
+
return r.project;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ---------------------------------------------------------------- the session ------------ */
|
|
154
|
+
|
|
155
|
+
/** The terminal's per-folder session file — the same name gu-repl.mjs uses. */
|
|
156
|
+
export function sessionFileFor(cwd, home = homedir()) {
|
|
157
|
+
const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 32);
|
|
158
|
+
return join(home, ".gonext", "sessions", `${hash}.json`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** → { id, startedAt } | null. null = no terminal session (the web, or an older terminal). */
|
|
162
|
+
export function sessionForTurn(cwd, home = homedir()) {
|
|
163
|
+
if (!cwd) return null;
|
|
164
|
+
const { data } = readJson(sessionFileFor(cwd, home));
|
|
165
|
+
const id = data?.hookSessionId;
|
|
166
|
+
return typeof id === "string" && id ? { id, startedAt: String(data.hookSessionStartedAt ?? "") } : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Describe one hook for a list: the command and its args, as one line. */
|
|
170
|
+
export function describeHook(h) {
|
|
171
|
+
return [h.command, ...(h.args ?? [])].map((w) => (/\s/.test(w) ? JSON.stringify(w) : w)).join(" ");
|
|
172
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.742",
|
|
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",
|
|
@@ -92,10 +92,13 @@
|
|
|
92
92
|
"skills.mjs",
|
|
93
93
|
"mcp-config.mjs",
|
|
94
94
|
"mcp-command.mjs",
|
|
95
|
+
"hooks-config.mjs",
|
|
96
|
+
"hooks-command.mjs",
|
|
95
97
|
"gonext_probe_agent.py",
|
|
96
98
|
"gonext_agent_chat.py",
|
|
97
99
|
"gu_mcp.py",
|
|
98
100
|
"gu_skills.py",
|
|
101
|
+
"gu_hooks.py",
|
|
99
102
|
"prompt_scaffold_check.py",
|
|
100
103
|
"gonext_transcribe.py",
|
|
101
104
|
"gonext_mlx_embed.py",
|
package/terminal-layout.mjs
CHANGED
|
@@ -2653,3 +2653,51 @@ export function shouldCancelPrompt(awaiting, key) {
|
|
|
2653
2653
|
if (key.ctrl) return false;
|
|
2654
2654
|
return key.sequence === "\u001b";
|
|
2655
2655
|
}
|
|
2656
|
+
|
|
2657
|
+
/**
|
|
2658
|
+
* THE SLASH MENU SHOWS A FEW, AND NARROWS AS YOU TYPE (task #271), the way Claude Code's does.
|
|
2659
|
+
*
|
|
2660
|
+
* A bare "/" used to print every command at once: sixteen built-ins plus every skill, most of them
|
|
2661
|
+
* settings nobody touches twice, so the one you wanted was somewhere in a wall of text. Now the
|
|
2662
|
+
* menu is a short window (SLASH_MENU_ROWS) over the ranked matches, ↑/↓ scroll it, and each typed
|
|
2663
|
+
* character narrows it. /help still prints everything.
|
|
2664
|
+
*
|
|
2665
|
+
* MATCHING, best first, so the command whose name you are typing is always on top:
|
|
2666
|
+
* 0 the name starts with it /mo → /model
|
|
2667
|
+
* 1 a word of the name starts with it /model → then /agent-model, /embed-model
|
|
2668
|
+
* 2 the name contains it /del → /model …
|
|
2669
|
+
* 3 the description contains it /coding → /model ("switch the CODING model"); 3+ chars
|
|
2670
|
+
* Only the first word counts: "/proxy set" is /proxy with an argument. No match → [] (the menu
|
|
2671
|
+
* says so), never "everything", which is what it used to fall back to.
|
|
2672
|
+
*/
|
|
2673
|
+
export const SLASH_MENU_ROWS = 6;
|
|
2674
|
+
|
|
2675
|
+
export function rankSlashCommands(cmds, typed) {
|
|
2676
|
+
const list = cmds ?? [];
|
|
2677
|
+
const word = String(typed ?? "").trim().split(/\s+/)[0].toLowerCase().replace(/^\//, "");
|
|
2678
|
+
if (!word) return list.slice();
|
|
2679
|
+
const scored = [];
|
|
2680
|
+
list.forEach((c, i) => {
|
|
2681
|
+
const names = [c.name, ...(c.aliases ?? [])].map((n) => String(n).toLowerCase().replace(/^\//, ""));
|
|
2682
|
+
let score = -1;
|
|
2683
|
+
if (names.some((n) => n.startsWith(word))) score = 0;
|
|
2684
|
+
else if (names.some((n) => n.split(/[-_]/).some((part) => part.startsWith(word)))) score = 1;
|
|
2685
|
+
else if (names.some((n) => n.includes(word))) score = 2;
|
|
2686
|
+
else if (word.length >= 3 && String(c.desc ?? "").toLowerCase().includes(word)) score = 3;
|
|
2687
|
+
if (score >= 0) scored.push({ c, score, i });
|
|
2688
|
+
});
|
|
2689
|
+
scored.sort((a, b) => a.score - b.score || a.i - b.i);
|
|
2690
|
+
return scored.map((s) => s.c);
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
/**
|
|
2694
|
+
* Which slice of the ranked list is on screen. `sel` -1 = nothing highlighted (window at the top).
|
|
2695
|
+
* → { from, to, above, below, top } — above/below are how many are hidden on each side.
|
|
2696
|
+
*/
|
|
2697
|
+
export function slashMenuWindow(total, sel, top, rows = SLASH_MENU_ROWS) {
|
|
2698
|
+
const n = Math.max(0, total);
|
|
2699
|
+
if (n <= rows) return { from: 0, to: n, above: 0, below: 0, top: 0 };
|
|
2700
|
+
// listWindow spends two of its rows on the indicators; give it room for exactly `rows` items.
|
|
2701
|
+
const w = listWindow(n, sel < 0 ? 0 : sel, sel < 0 ? 0 : top, rows + 2);
|
|
2702
|
+
return { from: w.from, to: w.to, above: w.above, below: w.below, top: w.top };
|
|
2703
|
+
}
|