@tiens.nguyen/gu-cli 1.0.784 → 1.0.785
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/completed-flush.mjs +47 -0
- package/gonext_agent_chat.py +1 -1
- package/gu-repl.mjs +154 -26
- package/gu_hooks.py +102 -4
- package/gu_skills.py +64 -4
- package/hooks-command.mjs +14 -9
- package/hooks-config.mjs +130 -33
- package/mcp-command.mjs +11 -5
- package/mcp-config.mjs +99 -11
- package/package.json +3 -2
- package/skill-source.mjs +60 -0
- package/skills.mjs +24 -12
package/completed-flush.mjs
CHANGED
|
@@ -103,3 +103,50 @@ export function completedFlush({
|
|
|
103
103
|
const steps = tail.slice(0, close);
|
|
104
104
|
return steps ? { kind: "steps", text: steps } : { kind: "none", text: "" };
|
|
105
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* WHILE THE JOB IS STILL RUNNING: which of the newly arrived text is the turn's WORK (step lines
|
|
109
|
+
* and thoughts, inside <think>…</think>) and which is the ANSWER (outside it). Task #326.
|
|
110
|
+
*
|
|
111
|
+
* An agent turn's stream is `<think>steps…</think>answer`. The answer is the caller's to print —
|
|
112
|
+
* once, wrapped, from the completed resultText. completedFlush has always stopped at </think> for
|
|
113
|
+
* that reason, but the RUNNING poll did not: it fed everything to the step printer. So whenever
|
|
114
|
+
* the answer's chunk was polled before the job flipped to "completed" (any answer long enough to
|
|
115
|
+
* be posted on its own), every paragraph of it was printed as a "●" step bullet — and then the
|
|
116
|
+
* whole answer again under "recap:". Reported as the answer appearing twice; short answers escaped
|
|
117
|
+
* only because their chunk and the completion arrived in the same poll.
|
|
118
|
+
*
|
|
119
|
+
* `state` is carried from poll to poll: { inThink, held }. `held` is the start of a tag cut in two
|
|
120
|
+
* by a poll boundary ("</thi" + "nk>"), kept back until the rest arrives.
|
|
121
|
+
* → { parts: [{ inside, text }], state } — the tags themselves are dropped.
|
|
122
|
+
*/
|
|
123
|
+
export function splitLiveStream(fresh, state = {}) {
|
|
124
|
+
let text = String(state.held ?? "") + String(fresh ?? "");
|
|
125
|
+
let inThink = Boolean(state.inThink);
|
|
126
|
+
const parts = [];
|
|
127
|
+
const push = (inside, t) => {
|
|
128
|
+
if (!t) return;
|
|
129
|
+
const last = parts[parts.length - 1];
|
|
130
|
+
if (last && last.inside === inside) last.text += t;
|
|
131
|
+
else parts.push({ inside, text: t });
|
|
132
|
+
};
|
|
133
|
+
// Hold back a tail that could be the beginning of a tag.
|
|
134
|
+
let held = "";
|
|
135
|
+
const lt = text.lastIndexOf("<");
|
|
136
|
+
if (lt >= 0 && lt > text.length - 8) {
|
|
137
|
+
const tail = text.slice(lt).toLowerCase();
|
|
138
|
+
if ("<think>".startsWith(tail) || "</think>".startsWith(tail)) {
|
|
139
|
+
held = text.slice(lt);
|
|
140
|
+
text = text.slice(0, lt);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const re = /<(\/?)think>/gi;
|
|
144
|
+
let at = 0;
|
|
145
|
+
for (let m; (m = re.exec(text)); ) {
|
|
146
|
+
push(inThink, text.slice(at, m.index));
|
|
147
|
+
inThink = m[1] !== "/";
|
|
148
|
+
at = m.index + m[0].length;
|
|
149
|
+
}
|
|
150
|
+
push(inThink, text.slice(at));
|
|
151
|
+
return { parts, state: { inThink, held } };
|
|
152
|
+
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -7782,7 +7782,7 @@ def run_agent_chat(cfg):
|
|
|
7782
7782
|
_skill_call = _skills_mod.invocation(task_text, _skills)
|
|
7783
7783
|
if _skill_call:
|
|
7784
7784
|
_sk, _sk_body, _sk_req = _skill_call
|
|
7785
|
-
task_text = (_skills_mod.render(_sk, _sk_body) + "\n\nThe user's request: "
|
|
7785
|
+
task_text = (_skills_mod.render(_sk, _sk_body, _sk_req) + "\n\nThe user's request: "
|
|
7786
7786
|
+ (_sk_req or "(nothing more — do what the skill describes)"))
|
|
7787
7787
|
_log(f"skill invoked: {_sk['name']}")
|
|
7788
7788
|
|
package/gu-repl.mjs
CHANGED
|
@@ -23,8 +23,6 @@
|
|
|
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 { appendFileSync as __afs } from "node:fs";
|
|
27
|
-
const __dbg = (...a) => { try { __afs("/private/tmp/claude-501/-Users-joseph-Projects-gonext/bf6ac2f6-435f-4150-a01e-cea31d8ff4d2/scratchpad/r321/dbg.log", a.map((x) => JSON.stringify(x)).join(" ") + "\n"); } catch {} };
|
|
28
26
|
import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
|
|
29
27
|
import { readStepTokens, readTurnCache } from "./step-tokens.mjs";
|
|
30
28
|
import { snapshotInput, restoreInput, replaceStoppedQuestion, restoredQuestionLetGo, stopNoteLines } from "./turn-stop.mjs";
|
|
@@ -128,12 +126,13 @@ import {
|
|
|
128
126
|
rankSlashCommands,
|
|
129
127
|
slashMenuWindow,
|
|
130
128
|
} from "./terminal-layout.mjs";
|
|
131
|
-
import { completedFlush } from "./completed-flush.mjs";
|
|
129
|
+
import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
|
|
132
130
|
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
133
131
|
import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
|
|
134
132
|
import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
|
|
135
133
|
import { unapprovedProjectServers } from "./mcp-command.mjs";
|
|
136
134
|
import { readHooks } from "./hooks-config.mjs";
|
|
135
|
+
import { readSkillSource, sourceLabel, SKILL_SOURCE_KEY } from "./skill-source.mjs";
|
|
137
136
|
import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
|
|
138
137
|
import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
|
|
139
138
|
import { IMAGE_NEEDS_CLAUDE, coderTakesImagesFor, clipboardImageToFile, fitImageNotice, imageMarkersForDisplay, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
|
|
@@ -897,6 +896,7 @@ const COMMANDS = [
|
|
|
897
896
|
{ name: "/new", desc: "start a new conversation in this folder — the old one is kept (archived); the folder's settings stay" },
|
|
898
897
|
{ name: "/hooks", usage: "/hooks [list|approve|unapprove]", desc: "hooks: commands gu runs on onStart, onAsk, onToolDone, onTurnEnd — list them, approve this project's" },
|
|
899
898
|
{ 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" },
|
|
899
|
+
{ name: "/switch-skill", usage: "/switch-skill [claude|gu]", desc: "whose skills, MCP servers and hooks this folder uses — Claude Code's (.claude) or gu's own (.gonext)" },
|
|
900
900
|
{ name: "/help", desc: "list these commands" },
|
|
901
901
|
{ name: "/exit", aliases: ["/quit"], desc: "quit" },
|
|
902
902
|
];
|
|
@@ -1004,7 +1004,6 @@ const OUT_RESTORE = "\x1b8";
|
|
|
1004
1004
|
const OUT_SAVE = "\x1b7";
|
|
1005
1005
|
/** Draw in-place UI that lives in the SCROLLING area (status block, pickers, menus). */
|
|
1006
1006
|
const regionUi = (seq) => {
|
|
1007
|
-
if (/\n/.test(seq)) __dbg("regionUi with newline", { nl: (seq.match(/\n/g) || []).length, head: seq.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").slice(0, 60), at: new Error().stack.split("\n").slice(2, 5).map((x) => x.trim().slice(0, 80)) });
|
|
1008
1007
|
if (!barPinned()) {
|
|
1009
1008
|
ui(seq);
|
|
1010
1009
|
return;
|
|
@@ -2598,9 +2597,15 @@ const viewportHeight = () => Math.max(1, viewportRows() - barReserved);
|
|
|
2598
2597
|
* Set by the active turn (it owns `statusShown`/`shownRows`), null between turns.
|
|
2599
2598
|
*/
|
|
2600
2599
|
let liveOverlayRows = null;
|
|
2601
|
-
/**
|
|
2600
|
+
/**
|
|
2601
|
+
* Rows the TRANSCRIPT actually gets: the scrolling area, less any live block under it — and less
|
|
2602
|
+
* the blank rows a closed picker or a shrunk zone left below the content (#321's pad, #324).
|
|
2603
|
+
* Those rows hold nothing, exactly like the live block's: every screen-row → line mapping that
|
|
2604
|
+
* forgot them was off by that many rows. Reported as the question echo landing on the line above
|
|
2605
|
+
* it: the slash menu and the hover put rows BACK from a view that sat `pad` rows too low.
|
|
2606
|
+
*/
|
|
2602
2607
|
const transcriptRows = () =>
|
|
2603
|
-
Math.max(1, viewportHeight() - (liveOverlayRows ? liveOverlayRows() : 0));
|
|
2608
|
+
Math.max(1, viewportHeight() - (liveOverlayRows ? liveOverlayRows() : 0) - livePad());
|
|
2604
2609
|
// Rows the frozen zone occupies: the zone's head (the separator and the blank under it — see
|
|
2605
2610
|
// INPUT_HEAD_ROWS) + the input line, then the bar's own rows (which grow by one under
|
|
2606
2611
|
// --verbose). Everything that reserves, measures or paints the zone MUST use this — reserving
|
|
@@ -2616,10 +2621,23 @@ function pinBar() {
|
|
|
2616
2621
|
const rows = frozenRowCount();
|
|
2617
2622
|
// Re-cutting keeps the OUTPUT position that is already in the save slot (preserveCaret:false);
|
|
2618
2623
|
// paintBar() below puts the caret back on the input line.
|
|
2619
|
-
|
|
2624
|
+
const first = !barPinned();
|
|
2625
|
+
if (!first) ui(scrollRegionSeq(viewportRows(), rows, { preserveCaret: false }));
|
|
2620
2626
|
else ui(reserveRowsSeq(viewportRows(), rows) + OUT_SAVE); // …and remember where output goes
|
|
2621
2627
|
barReserved = rows;
|
|
2622
|
-
|
|
2628
|
+
if (!first) {
|
|
2629
|
+
paintBar();
|
|
2630
|
+
return;
|
|
2631
|
+
}
|
|
2632
|
+
// THE FIRST PIN TAKES THE SCREEN INTO gu's HANDS (#321, #324): one repaint from the transcript,
|
|
2633
|
+
// which also puts the OUTPUT POSITION at the end of the content. reserveRowsSeq leaves it on
|
|
2634
|
+
// the region's LAST row — right when the screen is full, but after a short banner the content
|
|
2635
|
+
// ends near the top, and every later height change is planned from where the content ends
|
|
2636
|
+
// (zoneResizePlan). Left at the bottom, the two disagreed: the region was cut above the saved
|
|
2637
|
+
// position and the next line — the question's echo — was written rows away from its place, on
|
|
2638
|
+
// top of the line printed before it. No pad survives a repaint: the screen IS the model now.
|
|
2639
|
+
padRows = 0;
|
|
2640
|
+
paintLiveTail(); // ends in paintBar
|
|
2623
2641
|
}
|
|
2624
2642
|
/** Give the rows back and clear them — every exit path must reach this. */
|
|
2625
2643
|
function unpinBar() {
|
|
@@ -2765,14 +2783,12 @@ function paintBar() {
|
|
|
2765
2783
|
// re-cut the region before painting, or its top rows would land inside the scrolling area.
|
|
2766
2784
|
const prev = barReserved;
|
|
2767
2785
|
const regrew = zone.rows.length !== prev;
|
|
2768
|
-
if (regrew) { const v = viewportSlice(screenRows(), 0, viewportHeight()).rows; __dbg("MODEL AT REGROW", v.map((r) => r.replace(/\x1b\[[0-9;]*m/g, "").slice(0, 50))); }
|
|
2769
|
-
if (regrew) __dbg("ANY regrow", { prev, now: zone.rows.length, scrolledBack: scrolledBack(), rows: displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1).length, pad: padRows, vp: viewportRows() });
|
|
2770
2786
|
if (regrew) {
|
|
2771
2787
|
// #321: MOVE THE SCREEN LIKE A TERMINAL, never repaint it from the transcript — a repaint put
|
|
2772
2788
|
// lines the terminal had already scrolled away back on screen, and the next output scrolled
|
|
2773
2789
|
// them into the scrollback AGAIN (the banner and the update notice repeated after an update;
|
|
2774
2790
|
// "auto-test off" doubled). See zoneResizePlan.
|
|
2775
|
-
const live =
|
|
2791
|
+
const live = !scrolledBack();
|
|
2776
2792
|
let plan = null;
|
|
2777
2793
|
let rows = 0;
|
|
2778
2794
|
if (live) {
|
|
@@ -2793,7 +2809,6 @@ function paintBar() {
|
|
|
2793
2809
|
ui(clear);
|
|
2794
2810
|
}
|
|
2795
2811
|
if (live) setPad(plan.pad, rows);
|
|
2796
|
-
__dbg("regrow", { prev, now: zone.rows.length, rows, plan, live });
|
|
2797
2812
|
barReserved = zone.rows.length;
|
|
2798
2813
|
}
|
|
2799
2814
|
const top = viewportRows() - zone.rows.length + 1;
|
|
@@ -2820,10 +2835,9 @@ function paintBar() {
|
|
|
2820
2835
|
paintFrozenRowsSeq(zone.rows, viewportRows(), { preserveCaret: false }) +
|
|
2821
2836
|
parkCaretSeq(zone.caretRow, zone.caretCol)
|
|
2822
2837
|
);
|
|
2823
|
-
//
|
|
2824
|
-
//
|
|
2825
|
-
//
|
|
2826
|
-
if (regrew && !scrolledBack() && prev === 0) paintLiveTail();
|
|
2838
|
+
// NO REPAINT HERE: a height change moved the screen above (#321) — repainting it from the
|
|
2839
|
+
// transcript is what filled the scrollback with copies. The one repaint is pinBar's, on the
|
|
2840
|
+
// first pin.
|
|
2827
2841
|
}
|
|
2828
2842
|
/** Bytes that put the caret back on text row `row` (0-based) at 1-based `col`. */
|
|
2829
2843
|
const parkCaretSeq = (row, col) => `\x1b[${inputRowAbs + Math.max(0, row | 0)};${col}H`;
|
|
@@ -3002,6 +3016,9 @@ let liveSelection = null;
|
|
|
3002
3016
|
let padRows = 0;
|
|
3003
3017
|
let padAnchor = 0;
|
|
3004
3018
|
const currentPad = (rowCount) => (padRows > 0 ? Math.max(0, padRows - Math.max(0, rowCount - padAnchor)) : 0);
|
|
3019
|
+
/** The pad right now — free when there is none, which is nearly always. */
|
|
3020
|
+
const livePad = () =>
|
|
3021
|
+
padRows > 0 ? currentPad(displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1).length) : 0;
|
|
3005
3022
|
function setPad(n, rowCount) {
|
|
3006
3023
|
padRows = Math.max(0, n | 0);
|
|
3007
3024
|
padAnchor = rowCount;
|
|
@@ -3420,8 +3437,10 @@ let copyBadgeTimer = null;
|
|
|
3420
3437
|
/** The transcript row currently under the viewport row `row`, as it should look. */
|
|
3421
3438
|
function viewportRowText(row) {
|
|
3422
3439
|
const width = Math.max(20, process.stdout.columns || 80);
|
|
3440
|
+
// The view as it is ON SCREEN — pad rows included (#324), or every row comes back `pad` too low.
|
|
3423
3441
|
const rows = displayRows(transcript.lines(), width - 1);
|
|
3424
|
-
const
|
|
3442
|
+
const pad = currentPad(rows.length);
|
|
3443
|
+
const view = viewportSlice(pad ? [...rows, ...Array(pad).fill("")] : rows, scrollOffset, viewportHeight());
|
|
3425
3444
|
const line = view.rows[row - 1];
|
|
3426
3445
|
return typeof line === "string" ? line : "";
|
|
3427
3446
|
}
|
|
@@ -3632,7 +3651,9 @@ function paintLiveTail() {
|
|
|
3632
3651
|
// painted row down by the block's height so a selection highlight lands N rows away from the
|
|
3633
3652
|
// text it belongs to. Between turns the block is gone, transcriptRows() === viewportHeight(),
|
|
3634
3653
|
// and this is the behaviour it always had.
|
|
3635
|
-
|
|
3654
|
+
// The pad rows are part of the screen (screenRows has them) but not of the transcript's rows.
|
|
3655
|
+
const pad = livePad();
|
|
3656
|
+
const height = transcriptRows() + pad;
|
|
3636
3657
|
const view = viewportSlice(screenRows(), 0, height);
|
|
3637
3658
|
ui(paintViewportSeq(view.rows, height, width, clipVisible, { preserveCaret: false }));
|
|
3638
3659
|
// Park the caret at the end of the last printed line AND SAVE IT: the tail we just repainted
|
|
@@ -3643,8 +3664,9 @@ function paintLiveTail() {
|
|
|
3643
3664
|
// that slot with the caret parked on the INPUT LINE. The next line printed after any scroll
|
|
3644
3665
|
// round trip was then written at the prompt's column inside the frozen zone — on screen, a
|
|
3645
3666
|
// question indented three columns to the right of every other line.
|
|
3646
|
-
|
|
3647
|
-
const
|
|
3667
|
+
// …the last CONTENT row: output continues above the pad, which it then fills (#324).
|
|
3668
|
+
const lastRow = Math.max(1, view.rows.length - pad);
|
|
3669
|
+
const lastCol = Math.min(width, visibleLen(view.rows[lastRow - 1] ?? "") + 1);
|
|
3648
3670
|
ui(`\x1b[${lastRow};${lastCol}H` + OUT_SAVE);
|
|
3649
3671
|
paintBar();
|
|
3650
3672
|
}
|
|
@@ -7159,6 +7181,82 @@ async function afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stopped
|
|
|
7159
7181
|
if (barPinned()) paintBar();
|
|
7160
7182
|
}
|
|
7161
7183
|
|
|
7184
|
+
/**
|
|
7185
|
+
* /switch-skill — whose skills, MCP servers and hooks THIS FOLDER uses (task #325): Claude Code's,
|
|
7186
|
+
* read where Claude keeps them (~/.claude, ./.claude, ~/.claude.json), or gu's own (~/.gonext,
|
|
7187
|
+
* ./.gonext). One choice for all three — a skill names its MCP server's tools and the hooks feed
|
|
7188
|
+
* the same database, so they only make sense together. Stored in the folder's session file, which
|
|
7189
|
+
* the worker reads for every turn (skill-source.mjs): it holds from the next question, no restart.
|
|
7190
|
+
*/
|
|
7191
|
+
async function runSwitchSkillCommand(line) {
|
|
7192
|
+
const cwd = resolve(process.cwd());
|
|
7193
|
+
const current = readSkillSource(cwd);
|
|
7194
|
+
const arg = (line.trim().split(/\s+/)[1] ?? "").toLowerCase();
|
|
7195
|
+
const where = (src) => (src === "claude" ? "~/.claude, ./.claude, ~/.claude.json" : "~/.gonext, ./.gonext");
|
|
7196
|
+
let pick = "";
|
|
7197
|
+
console.log("");
|
|
7198
|
+
if (arg) {
|
|
7199
|
+
pick = /^claude/.test(arg) ? "claude" : arg === "gu" ? "gu" : "";
|
|
7200
|
+
if (!pick) {
|
|
7201
|
+
console.log(GUTTER + yellow(`"${arg}" is not one of them — /switch-skill claude or /switch-skill gu\n`));
|
|
7202
|
+
return;
|
|
7203
|
+
}
|
|
7204
|
+
} else {
|
|
7205
|
+
const options = ["claude", "gu"];
|
|
7206
|
+
console.log(dim(` Whose skills, MCP servers and hooks this folder uses (${PICK_HINT}):`));
|
|
7207
|
+
const idx = await pickFromList(
|
|
7208
|
+
options.map((o) => sourceLabel(o) + dim(` · ${where(o)}`) + (o === current ? dim(" · in use") : "")),
|
|
7209
|
+
Math.max(0, options.indexOf(current))
|
|
7210
|
+
);
|
|
7211
|
+
if (idx < 0) {
|
|
7212
|
+
console.log(dim(" (cancelled)\n"));
|
|
7213
|
+
paintBar();
|
|
7214
|
+
return;
|
|
7215
|
+
}
|
|
7216
|
+
pick = options[idx];
|
|
7217
|
+
}
|
|
7218
|
+
try {
|
|
7219
|
+
await updateSessionFile(cwd, (raw) => {
|
|
7220
|
+
raw[SKILL_SOURCE_KEY] = pick;
|
|
7221
|
+
raw.updatedAt = new Date().toISOString();
|
|
7222
|
+
});
|
|
7223
|
+
} catch (err) {
|
|
7224
|
+
console.log(GUTTER + red(`couldn't save the choice — ${err.message}. Nothing changed.\n`));
|
|
7225
|
+
return;
|
|
7226
|
+
}
|
|
7227
|
+
_skillScan = { at: 0, skills: [] }; // the "/" menu and Tab follow at once
|
|
7228
|
+
const say = (l) => console.log(GUTTER + l);
|
|
7229
|
+
say(accent(`✓ ${pick === current ? "this folder uses" : "this folder now uses"} ${sourceLabel(pick)}`));
|
|
7230
|
+
say(dim(` skills, MCP servers and hooks are read from ${where(pick)}`));
|
|
7231
|
+
// What was found there — and what was not, said rather than shown as an empty list.
|
|
7232
|
+
try {
|
|
7233
|
+
const sk = discoverSkills({ cwd: process.cwd(), reserved: COMMAND_NAMES });
|
|
7234
|
+
const mcp = listMcpServers({ cwd: process.cwd() });
|
|
7235
|
+
const hk = readHooks({ cwd: process.cwd() });
|
|
7236
|
+
const nHooks = (h) => Object.values(h).reduce((a, groups) => a + groups.reduce((b, g) => b + g.hooks.length, 0), 0);
|
|
7237
|
+
const count = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
7238
|
+
const running = mcp.servers.filter((x) => x.runnable).length;
|
|
7239
|
+
say(dim(` ${count(sk.skills.length, "skill")}${sk.skills.length ? ` (${sk.skills.map((x) => "/" + x.name).join(", ")})` : ""} · ${count(running, "MCP server")} · ${count(nHooks(hk.user) + (hk.projectApproved ? nHooks(hk.project) : 0), "hook")}`));
|
|
7240
|
+
// Nothing there at all is a normal state, not a fault: say so, and that gu needs none of it.
|
|
7241
|
+
if (!sk.skills.length && !mcp.servers.length && !nHooks(hk.user) && !nHooks(hk.project) && !hk.notes.length) {
|
|
7242
|
+
say(dim(" Nothing is set up there yet — gu works the same without any of them."));
|
|
7243
|
+
}
|
|
7244
|
+
for (const w of [...sk.warnings, ...mcp.warnings, ...hk.warnings]) say(yellow(" " + w));
|
|
7245
|
+
const unsupported = mcp.servers.filter((x) => x.error);
|
|
7246
|
+
if (unsupported.length) say(yellow(` ${count(unsupported.length, "MCP server")} cannot run here: ${unsupported.map((x) => `${x.name} (${x.error})`).join("; ")}`));
|
|
7247
|
+
const waiting = mcp.servers.filter((x) => x.scope === "project" && !x.approved && !x.error);
|
|
7248
|
+
if (waiting.length) say(yellow(` ${count(waiting.length, "project MCP server")} waiting for your approval: ${waiting.map((x) => x.name).join(", ")} — /mcp approve <name>`));
|
|
7249
|
+
if (hk.projectPending) say(yellow(` this folder's ${hk.projectFile.replace(/^\.\//, "")} has hooks that will not run until you approve them — /hooks approve`));
|
|
7250
|
+
if (hk.notes.length) say(yellow(` ${count(hk.notes.length, "hook")} in Claude's settings ${hk.notes.length === 1 ? "is" : "are"} NOT run by gu — /hooks says which and why`));
|
|
7251
|
+
if (pick === "claude" && (nHooks(hk.user) || nHooks(hk.project))) say(dim(" Claude's hooks get Claude's event and tool names from gu, so they record here as they do there."));
|
|
7252
|
+
} catch (err) {
|
|
7253
|
+
say(yellow(` (could not read it back: ${err.message})`));
|
|
7254
|
+
}
|
|
7255
|
+
say(dim(` It holds from your next question. ${pick === "claude" ? "/switch-skill gu" : "/switch-skill claude"} switches back.`));
|
|
7256
|
+
console.log("");
|
|
7257
|
+
if (barPinned()) paintBar();
|
|
7258
|
+
}
|
|
7259
|
+
|
|
7162
7260
|
/**
|
|
7163
7261
|
* /undo — put back what a stopped turn changed (task #302): the last stopped turn's run, or the
|
|
7164
7262
|
* run named. Shows exactly what it will do and asks first — a file edited by hand since the turn
|
|
@@ -9509,6 +9607,8 @@ let forgetActiveStatus = null;
|
|
|
9509
9607
|
// Ctrl+T reveal the full Thought from the KEYBOARD, so the feature doesn't depend on the mouse
|
|
9510
9608
|
// capture that would block scrolling (see hoverMouse).
|
|
9511
9609
|
let toggleActiveThought = null;
|
|
9610
|
+
/** #324: the screen under the Yes/No picker when it opened — the list picker's bookkeeping (#321). */
|
|
9611
|
+
let approvalOpenedOver = null;
|
|
9512
9612
|
function drawApprovalOptions(redraw) {
|
|
9513
9613
|
// Two option lines, redrawn in place. On redraw the cursor sits one row below "No";
|
|
9514
9614
|
// step up 2 rows, clear+rewrite both, landing back where we started.
|
|
@@ -9518,11 +9618,24 @@ function drawApprovalOptions(redraw) {
|
|
|
9518
9618
|
// each and the \x1b[2A count stays exact — which is why a resize can simply redraw here.
|
|
9519
9619
|
const yes = approvalSel === 0 ? accent("▸ Yes") : dim(" Yes");
|
|
9520
9620
|
const no = approvalSel === 1 ? accent("▸ No") : dim(" No");
|
|
9621
|
+
if (!redraw && barPinned() && !scrolledBack()) {
|
|
9622
|
+
const content = displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1).length;
|
|
9623
|
+
approvalOpenedOver = { content, pad: currentPad(content), height: viewportHeight() };
|
|
9624
|
+
}
|
|
9521
9625
|
regionUi((redraw ? "\x1b[2A" : "") + "\x1b[K" + GUTTER + yes + "\n\x1b[K" + GUTTER + no + "\n");
|
|
9522
9626
|
}
|
|
9523
9627
|
function finishApproval(allow) {
|
|
9524
9628
|
if (!approvalActive) return;
|
|
9525
9629
|
approvalActive = false;
|
|
9630
|
+
// #324: on a full screen the two option rows SCROLLED the conversation up by two, and the one
|
|
9631
|
+
// result line below takes only one of them back — the content now ends a row above the region's
|
|
9632
|
+
// bottom. Unrecorded, every row put back from the transcript afterwards (the slash menu, a
|
|
9633
|
+
// hover) was drawn one row low, over its neighbour. Same account as a closed list picker.
|
|
9634
|
+
const over = approvalOpenedOver;
|
|
9635
|
+
approvalOpenedOver = null;
|
|
9636
|
+
if (over && barPinned() && !scrolledBack() && over.height === viewportHeight()) {
|
|
9637
|
+
setPad(blockClosedPad({ contentRows: over.content, pad: over.pad, height: over.height, blockRows: 2 }), over.content);
|
|
9638
|
+
}
|
|
9526
9639
|
// Settle the picker: overwrite the two option lines with a single result line.
|
|
9527
9640
|
process.stdout.write("\x1b[2A\x1b[J" + GUTTER + (allow ? accent("▸ Yes — running it") : red("▸ No — skipped")) + "\n");
|
|
9528
9641
|
const r = approvalResolve;
|
|
@@ -9771,7 +9884,6 @@ function drawList(redraw) {
|
|
|
9771
9884
|
if (!redraw && barPinned() && !scrolledBack()) {
|
|
9772
9885
|
const content = displayRows(transcript.lines(), width - 1).length;
|
|
9773
9886
|
listOpenedOver = { content, pad: currentPad(content), height: viewportHeight() };
|
|
9774
|
-
__dbg("picker open", listOpenedOver, { lastLines: transcript.lines().slice(-3) });
|
|
9775
9887
|
listMaxRows = 0;
|
|
9776
9888
|
}
|
|
9777
9889
|
listMaxRows = Math.max(listMaxRows, physicalRows(rows, width));
|
|
@@ -9793,8 +9905,6 @@ function finishList(index) {
|
|
|
9793
9905
|
// higher with blank rows under it — remember them, so nothing repaints those lines back on screen.
|
|
9794
9906
|
if (listOpenedOver && barPinned() && !scrolledBack() && listOpenedOver.height === viewportHeight()) {
|
|
9795
9907
|
setPad(blockClosedPad({ contentRows: listOpenedOver.content, pad: listOpenedOver.pad, height: listOpenedOver.height, blockRows: listMaxRows }), listOpenedOver.content);
|
|
9796
|
-
{ const v = viewportSlice(screenRows(), 0, viewportHeight()).rows; __dbg("MODEL VIEW", v.map((r) => r.replace(/\x1b\[[0-9;]*m/g, "").slice(0, 60))); }
|
|
9797
|
-
__dbg("picker closed", { ...listOpenedOver, blockRows: listMaxRows, newPad: padRows, nowRows: displayRows(transcript.lines(), width - 1).length });
|
|
9798
9908
|
}
|
|
9799
9909
|
listOpenedOver = null;
|
|
9800
9910
|
listMaxRows = 0;
|
|
@@ -10677,6 +10787,8 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
10677
10787
|
// completed resultText really EXTENDS what we streamed (an old worker replaces it
|
|
10678
10788
|
// with the shorter bare answer, which must not be sliced by shownChars).
|
|
10679
10789
|
let seenRaw = "";
|
|
10790
|
+
// #326: where the live stream is relative to the <think> fence, carried from poll to poll.
|
|
10791
|
+
let liveSplit = { inThink: false, held: "" };
|
|
10680
10792
|
|
|
10681
10793
|
// --- Live in-progress ("blinking") bullet -------------------------------------------
|
|
10682
10794
|
// The agent's raw Thought/code stream is SUPPRESSED (see the ~~~-fence handling in
|
|
@@ -11660,7 +11772,15 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11660
11772
|
if (seenRaw.length < 4096) {
|
|
11661
11773
|
seenRaw = (seenRaw + text.slice(shownChars)).slice(0, 4096);
|
|
11662
11774
|
}
|
|
11663
|
-
|
|
11775
|
+
// #326: only the turn's WORK is shown live — the step lines and thoughts inside
|
|
11776
|
+
// <think>. What follows the fence is the ANSWER, which the caller prints once from the
|
|
11777
|
+
// completed text; fed to the step printer it came out as "●" bullets and then again
|
|
11778
|
+
// under "recap:". A plain chat reply is the exception: its live stream IS its output.
|
|
11779
|
+
const live = splitLiveStream(text.slice(shownChars), liveSplit);
|
|
11780
|
+
liveSplit = live.state;
|
|
11781
|
+
for (const part of live.parts) {
|
|
11782
|
+
if (part.inside || plainReplyFlow) consume(part.text);
|
|
11783
|
+
}
|
|
11664
11784
|
shownChars = text.length;
|
|
11665
11785
|
}
|
|
11666
11786
|
// Interactive command-approval gate: the agent paused on a risky command. Show the
|
|
@@ -12234,7 +12354,11 @@ async function main() {
|
|
|
12234
12354
|
const nSkills = currentSkills().length;
|
|
12235
12355
|
const nMcp = listMcpServers({ cwd: process.cwd() }).servers.filter((x) => x.runnable).length;
|
|
12236
12356
|
const bits = [nSkills ? `${nSkills} skill${nSkills === 1 ? "" : "s"}` : "", nMcp ? `${nMcp} MCP server${nMcp === 1 ? "" : "s"}` : ""].filter(Boolean);
|
|
12237
|
-
|
|
12357
|
+
// #325: say whose they are when this folder uses Claude Code's — even when there are none.
|
|
12358
|
+
const claudes = readSkillSource(process.cwd()) === "claude";
|
|
12359
|
+
if (bits.length || claudes) {
|
|
12360
|
+
statusRows.push(dim((claudes ? "Claude's: " : "") + (bits.join(" · ") || "no skills or MCP servers") + (claudes ? " (/switch-skill · /mcp)" : " (/help · /mcp)")));
|
|
12361
|
+
}
|
|
12238
12362
|
} catch {
|
|
12239
12363
|
/* a broken file is reported below; the banner is not the place to fail */
|
|
12240
12364
|
}
|
|
@@ -12251,7 +12375,7 @@ async function main() {
|
|
|
12251
12375
|
for (const w of hk.warnings) console.log(GUTTER + yellow(w));
|
|
12252
12376
|
if (hk.projectPending) {
|
|
12253
12377
|
console.log(
|
|
12254
|
-
GUTTER + yellow(
|
|
12378
|
+
GUTTER + yellow(`This folder's ${hk.projectFile.replace(/^\.\//, "")} has hooks that will not run until you approve them.`) +
|
|
12255
12379
|
"\n" + GUTTER + dim(" /hooks shows the commands · /hooks approve")
|
|
12256
12380
|
);
|
|
12257
12381
|
}
|
|
@@ -12890,6 +13014,10 @@ async function main() {
|
|
|
12890
13014
|
await runMcpSlashCommand(line);
|
|
12891
13015
|
continue;
|
|
12892
13016
|
}
|
|
13017
|
+
if (line === "/switch-skill" || line.startsWith("/switch-skill ")) {
|
|
13018
|
+
await runSwitchSkillCommand(line);
|
|
13019
|
+
continue;
|
|
13020
|
+
}
|
|
12893
13021
|
if (line === "/hooks" || line.startsWith("/hooks ")) {
|
|
12894
13022
|
const { runHooksCommand } = await import("./hooks-command.mjs");
|
|
12895
13023
|
const style = { ok: accent, warn: yellow, dim };
|
package/gu_hooks.py
CHANGED
|
@@ -17,6 +17,18 @@ A hook that fails any other way, or runs past its timeout, is a warning — neve
|
|
|
17
17
|
|
|
18
18
|
WHAT A HOOK SEES: its own env on a short whitelist of this process's (the worker's env carries gu's
|
|
19
19
|
credentials), plus GU_PROJECT_DIR and GU_HOOK_EVENT. It runs in the project folder.
|
|
20
|
+
|
|
21
|
+
CLAUDE CODE'S HOOKS (task #325). A folder that chose "Claude's skills" hands over hooks read from
|
|
22
|
+
Claude Code's settings; their groups carry dialect "claude". Those commands were written for Claude
|
|
23
|
+
Code, so gu speaks Claude's vocabulary TO THEM and to nobody else:
|
|
24
|
+
the event SessionStart / UserPromptSubmit / PostToolUse / Stop, as "hook_event_name"
|
|
25
|
+
the tool Edit, Write, Read, Grep, Glob, Bash — for the matcher AND in the input, with
|
|
26
|
+
Claude's argument names (file_path, not path)
|
|
27
|
+
the fields prompt (question), tool_response (tool_result), last_assistant_message (answer)
|
|
28
|
+
the answer {"hookSpecificOutput": {"additionalContext": ...}} is read like gu's own shape
|
|
29
|
+
Measured with a real Claude memory hook: given gu's names it never ran; given Claude's event names
|
|
30
|
+
but gu's tool names and fields it ran and recorded nothing; given all of the above it recorded the
|
|
31
|
+
lookups and edits (with line ranges) and gave the next session its memory back.
|
|
20
32
|
"""
|
|
21
33
|
from __future__ import annotations
|
|
22
34
|
|
|
@@ -36,6 +48,62 @@ _ENV_KEEP = ("PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TEMP", "TM
|
|
|
36
48
|
_PLAIN_MATCHER = re.compile(r"^[A-Za-z0-9_\-| ,]*$")
|
|
37
49
|
|
|
38
50
|
|
|
51
|
+
# ---- Claude Code's vocabulary (#325) -----------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
CLAUDE_EVENT = {"onStart": "SessionStart", "onAsk": "UserPromptSubmit",
|
|
54
|
+
"onToolDone": "PostToolUse", "onTurnEnd": "Stop"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def claude_tool(name: str, args: Any) -> tuple[str, dict]:
|
|
58
|
+
"""gu's tool call as Claude Code would have named it. Unknown tools (mcp__server__tool, which
|
|
59
|
+
both spell the same way) pass through unchanged."""
|
|
60
|
+
a = dict(args) if isinstance(args, dict) else {}
|
|
61
|
+
path = a.get("path")
|
|
62
|
+
if name == "edit_file":
|
|
63
|
+
return "Edit", {"file_path": path, "old_string": a.get("old_string", ""),
|
|
64
|
+
"new_string": a.get("new_string", "")}
|
|
65
|
+
if name == "edit_lines":
|
|
66
|
+
# No old text to give: the new text is what a hook looks for in the file.
|
|
67
|
+
return "Edit", {"file_path": path, "old_string": "", "new_string": a.get("new_content", ""),
|
|
68
|
+
"start_line": a.get("start_line"), "end_line": a.get("end_line")}
|
|
69
|
+
if name == "create_file":
|
|
70
|
+
return "Write", {"file_path": path, "content": a.get("content", "")}
|
|
71
|
+
if name in ("read_file_lines", "read_text_file"):
|
|
72
|
+
out = {"file_path": path}
|
|
73
|
+
if a.get("start_line"):
|
|
74
|
+
out["offset"] = a.get("start_line")
|
|
75
|
+
return "Read", out
|
|
76
|
+
if name == "grep_repo":
|
|
77
|
+
return "Grep", {"pattern": a.get("pattern", ""), "path": path or "", "glob": a.get("glob", "")}
|
|
78
|
+
if name == "symbol_context":
|
|
79
|
+
return "Grep", {"pattern": a.get("name", ""), "path": path or ""}
|
|
80
|
+
if name == "list_dir":
|
|
81
|
+
return "Glob", {"pattern": (str(path or ".").rstrip("/\\") or ".") + "/*", "path": path or ""}
|
|
82
|
+
if name == "run_command":
|
|
83
|
+
return "Bash", {"command": a.get("command", "")}
|
|
84
|
+
return name, a
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def claude_payload(event: str, payload: dict) -> dict:
|
|
88
|
+
"""One gu event's payload with Claude Code's field names."""
|
|
89
|
+
out = dict(payload)
|
|
90
|
+
if event == "onStart":
|
|
91
|
+
out.setdefault("source", "startup")
|
|
92
|
+
elif event == "onAsk":
|
|
93
|
+
out["prompt"] = out.pop("question", "")
|
|
94
|
+
elif event == "onToolDone":
|
|
95
|
+
out["tool_name"], out["tool_input"] = claude_tool(str(out.get("tool_name", "")), out.get("tool_input"))
|
|
96
|
+
out["tool_response"] = out.pop("tool_result", "")
|
|
97
|
+
elif event == "onTurnEnd":
|
|
98
|
+
out["last_assistant_message"] = out.pop("answer", "")
|
|
99
|
+
out["stop_hook_active"] = False
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_claude(group: Any) -> bool:
|
|
104
|
+
return isinstance(group, dict) and group.get("dialect") == "claude"
|
|
105
|
+
|
|
106
|
+
|
|
39
107
|
def matches(matcher: str | None, value: str) -> bool:
|
|
40
108
|
"""Empty or "*" = everything. Letters, digits, _ - and | = an exact list. Anything else = a regex."""
|
|
41
109
|
m = (matcher or "").strip()
|
|
@@ -76,11 +144,24 @@ class HookRunner:
|
|
|
76
144
|
def matcher_union(self, event: str) -> list[str]:
|
|
77
145
|
return [g.get("matcher", "") for g in self.hooks.get(event) or [] if isinstance(g, dict)]
|
|
78
146
|
|
|
147
|
+
def wants_tool(self, name: str) -> bool:
|
|
148
|
+
"""Does any onToolDone group name this tool — in gu's name for gu's hooks, in Claude Code's
|
|
149
|
+
for Claude's (a matcher "Edit|Write" means edit_file and create_file)."""
|
|
150
|
+
for g in self.hooks.get("onToolDone") or []:
|
|
151
|
+
# A group with nothing to run wraps no tool, whatever its matcher says.
|
|
152
|
+
if not isinstance(g, dict) or not any(isinstance(h, dict) and h.get("command") for h in g.get("hooks") or []):
|
|
153
|
+
continue
|
|
154
|
+
value = claude_tool(name, {})[0] if is_claude(g) else name
|
|
155
|
+
if matches(g.get("matcher"), value):
|
|
156
|
+
return True
|
|
157
|
+
return False
|
|
158
|
+
|
|
79
159
|
def _env(self, event: str, extra: dict | None) -> dict:
|
|
80
160
|
env = {k: os.environ[k] for k in _ENV_KEEP if k in os.environ}
|
|
81
161
|
env.setdefault("PYTHONIOENCODING", "utf-8")
|
|
82
162
|
env["GU_PROJECT_DIR"] = self.cwd or ""
|
|
83
163
|
env["GU_HOOK_EVENT"] = event
|
|
164
|
+
env["CLAUDE_PROJECT_DIR"] = self.cwd or "" # what a hook written for Claude Code reads
|
|
84
165
|
for k, v in (extra or {}).items():
|
|
85
166
|
env[str(k)] = str(v)
|
|
86
167
|
return env
|
|
@@ -91,13 +172,27 @@ class HookRunner:
|
|
|
91
172
|
return res
|
|
92
173
|
body = {"event": event, "session_id": self.session_id, "cwd": self.cwd or "", **payload}
|
|
93
174
|
data = json.dumps(body, ensure_ascii=False, default=str)
|
|
175
|
+
claude_data = claude_match = None # built once, and only if a Claude hook is there
|
|
94
176
|
for group in self.hooks.get(event) or []:
|
|
95
|
-
if not isinstance(group, dict)
|
|
177
|
+
if not isinstance(group, dict):
|
|
178
|
+
continue
|
|
179
|
+
if is_claude(group):
|
|
180
|
+
if claude_data is None:
|
|
181
|
+
cbody = claude_payload(event, payload)
|
|
182
|
+
claude_match = str(cbody.get("tool_name", "")) if event == "onToolDone" else match_value
|
|
183
|
+
claude_data = json.dumps({"hook_event_name": CLAUDE_EVENT[event],
|
|
184
|
+
"session_id": self.session_id, "cwd": self.cwd or "",
|
|
185
|
+
"permission_mode": "default", **cbody},
|
|
186
|
+
ensure_ascii=False, default=str)
|
|
187
|
+
value, stdin = claude_match, claude_data
|
|
188
|
+
else:
|
|
189
|
+
value, stdin = match_value, data
|
|
190
|
+
if not matches(group.get("matcher"), value):
|
|
96
191
|
continue
|
|
97
192
|
for h in group.get("hooks") or []:
|
|
98
193
|
if not isinstance(h, dict) or (h.get("type") or "command") != "command" or not h.get("command"):
|
|
99
194
|
continue
|
|
100
|
-
self._one(event, h,
|
|
195
|
+
self._one(event, h, stdin, res)
|
|
101
196
|
return res
|
|
102
197
|
|
|
103
198
|
def _one(self, event: str, h: dict, data: str, res: HookResult) -> None:
|
|
@@ -143,6 +238,10 @@ class HookRunner:
|
|
|
143
238
|
res.blocked = str(obj.get("reason") or err or f"the {event} hook {label} said no")
|
|
144
239
|
return
|
|
145
240
|
ctx = obj.get("additionalContext")
|
|
241
|
+
if not isinstance(ctx, str):
|
|
242
|
+
# Claude Code's shape: {"hookSpecificOutput": {"hookEventName", "additionalContext"}}
|
|
243
|
+
inner = obj.get("hookSpecificOutput")
|
|
244
|
+
ctx = inner.get("additionalContext") if isinstance(inner, dict) else None
|
|
146
245
|
if isinstance(ctx, str) and ctx.strip():
|
|
147
246
|
res.context.append(ctx)
|
|
148
247
|
|
|
@@ -185,11 +284,10 @@ def wrap_tools(tools: list, runner: HookRunner, clip: int = 4000) -> int:
|
|
|
185
284
|
context is appended to the tool's result; a block (exit 2) is appended as a message."""
|
|
186
285
|
if not runner.has("onToolDone"):
|
|
187
286
|
return 0
|
|
188
|
-
matchers = runner.matcher_union("onToolDone")
|
|
189
287
|
n = 0
|
|
190
288
|
for t in tools:
|
|
191
289
|
name = getattr(t, "name", "")
|
|
192
|
-
if not name or name == "final_answer" or not
|
|
290
|
+
if not name or name == "final_answer" or not runner.wants_tool(name):
|
|
193
291
|
continue
|
|
194
292
|
orig = t.forward
|
|
195
293
|
order = list((getattr(t, "inputs", None) or {}).keys())
|