nou-cli 0.5.16 → 0.5.17
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/README.md +1 -1
- package/dist/src/engines/claude.js +153 -43
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -135,7 +135,7 @@ Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]`
|
|
|
135
135
|
|
|
136
136
|
A small typo in a command still works when it only changes the screen (`/termnial` opens the terminal); for others Nou asks "Did you mean...?" instead of guessing.
|
|
137
137
|
|
|
138
|
-
New lines in a message: <kbd>Shift</kbd>/<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Enter</kbd> in terminals that report them (kitty, Ghostty, WezTerm, iTerm2 and others with the kitty keyboard protocol, which Nou checks for before it starts; also xterm's modifyOtherKeys form), or type `\` and press <kbd>Enter</kbd> in any terminal.
|
|
138
|
+
New lines in a message: <kbd>Shift</kbd>/<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Enter</kbd> in terminals that report them (kitty, Ghostty, WezTerm, iTerm2 and others with the kitty keyboard protocol, which Nou checks for before it starts; also xterm's modifyOtherKeys form), or type `\` and press <kbd>Enter</kbd> in any terminal. On Windows, <kbd>Ctrl</kbd>+<kbd>Enter</kbd> works in every Windows Terminal and the console; <kbd>Shift</kbd>+<kbd>Enter</kbd> needs Windows Terminal 1.25 or later (older versions send it as a plain Enter).
|
|
139
139
|
|
|
140
140
|
Conversations are saved per folder after each reply. `/resume` opens a picker (↑↓ and Enter, type to filter, or type its number) and `/resume 2` (or `nou --continue` for the last one, `nou --resume` for the list) picks one back up with the engine it was on: Nou's own engine restores its messages, Claude Code its session and Codex its thread; pi is handed the conversation so far. Sessions you started in Claude Code's or Codex's own CLI in this folder are listed too (marked "CLI"), and resuming one carries it on in that engine inside Nou.
|
|
141
141
|
|
|
@@ -48,6 +48,7 @@ export class ClaudeCodeEngine extends ExternalEngine {
|
|
|
48
48
|
super(choice, hooks, "Claude Code");
|
|
49
49
|
}
|
|
50
50
|
resetConversation() {
|
|
51
|
+
this.closeLive();
|
|
51
52
|
this.claudeSession = undefined;
|
|
52
53
|
}
|
|
53
54
|
snapshot() {
|
|
@@ -57,6 +58,7 @@ export class ClaudeCodeEngine extends ExternalEngine {
|
|
|
57
58
|
restore(state) {
|
|
58
59
|
if (typeof state?.claudeSession !== "string")
|
|
59
60
|
return false;
|
|
61
|
+
this.closeLive();
|
|
60
62
|
this.claudeSession = state.claudeSession;
|
|
61
63
|
return true;
|
|
62
64
|
}
|
|
@@ -64,15 +66,45 @@ export class ClaudeCodeEngine extends ExternalEngine {
|
|
|
64
66
|
setModel(choice) {
|
|
65
67
|
this.choice = choice;
|
|
66
68
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
dispose() {
|
|
70
|
+
this.closeLive();
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* One Claude Code process per conversation, kept running between messages: each message is fed into it
|
|
74
|
+
* and a turn is over at its result. A process per message didn't work: one that left something running
|
|
75
|
+
* (a dev server, a background command) never closed its stream, so the turn never ended, and the next
|
|
76
|
+
* message's process found the session still in use and ended without a word.
|
|
77
|
+
*/
|
|
78
|
+
live = null;
|
|
79
|
+
/** The turn being waited on: settled at its result, or when the process ends. */
|
|
80
|
+
waiting = null;
|
|
81
|
+
streamed = "";
|
|
82
|
+
closeLive() {
|
|
83
|
+
const live = this.live;
|
|
84
|
+
this.live = null;
|
|
85
|
+
live?.close();
|
|
86
|
+
}
|
|
87
|
+
/** What the process was started with; a change (model, effort, project context) means a new one. */
|
|
88
|
+
liveKey() {
|
|
89
|
+
return JSON.stringify([this.choice.provider.id === "anthropic" ? this.choice.model : null, this.effort ?? null, this.extras]);
|
|
90
|
+
}
|
|
91
|
+
openLive(sdk, key) {
|
|
92
|
+
const queue = [];
|
|
93
|
+
let wake = null;
|
|
94
|
+
let closed = false;
|
|
95
|
+
const input = async function* () {
|
|
96
|
+
while (!closed) {
|
|
97
|
+
if (queue.length)
|
|
98
|
+
yield queue.shift();
|
|
99
|
+
else
|
|
100
|
+
await new Promise((resolve) => (wake = resolve));
|
|
101
|
+
}
|
|
102
|
+
};
|
|
71
103
|
const allow = { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" } };
|
|
72
104
|
const deny = (reason) => ({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } });
|
|
73
|
-
const preToolUse = async (
|
|
74
|
-
const tool =
|
|
75
|
-
const args = (
|
|
105
|
+
const preToolUse = async (hookInput) => {
|
|
106
|
+
const tool = hookInput.tool_name;
|
|
107
|
+
const args = (hookInput.tool_input ?? {});
|
|
76
108
|
if (EDIT_TOOLS.has(tool) && args.file_path) {
|
|
77
109
|
const ok = await this.tracker.approve(args.file_path, proposedContent(tool, args), tool);
|
|
78
110
|
return ok ? allow : deny("The user rejected this change. Ask them what they would like instead.");
|
|
@@ -86,9 +118,9 @@ export class ClaudeCodeEngine extends ExternalEngine {
|
|
|
86
118
|
// Reads and searches follow Claude Code's own rules; anything else that needs asking reaches canUseTool.
|
|
87
119
|
return {};
|
|
88
120
|
};
|
|
89
|
-
const postToolUse = async (
|
|
90
|
-
const tool =
|
|
91
|
-
const args = (
|
|
121
|
+
const postToolUse = async (hookInput) => {
|
|
122
|
+
const tool = hookInput.tool_name;
|
|
123
|
+
const args = (hookInput.tool_input ?? {});
|
|
92
124
|
if (EDIT_TOOLS.has(tool) && args.file_path) {
|
|
93
125
|
const problems = await this.tracker.applied(args.file_path);
|
|
94
126
|
return problems
|
|
@@ -109,10 +141,9 @@ export class ClaudeCodeEngine extends ExternalEngine {
|
|
|
109
141
|
return {};
|
|
110
142
|
};
|
|
111
143
|
const query = sdk.query({
|
|
112
|
-
prompt,
|
|
144
|
+
prompt: input(),
|
|
113
145
|
options: {
|
|
114
146
|
cwd: process.cwd(),
|
|
115
|
-
abortController,
|
|
116
147
|
...(this.claudeSession ? { resume: this.claudeSession } : {}),
|
|
117
148
|
...(this.choice.provider.id === "anthropic" ? { model: this.choice.model } : {}),
|
|
118
149
|
...(this.effort && EFFORTS.has(this.effort) ? { effort: this.effort } : {}),
|
|
@@ -122,45 +153,124 @@ export class ClaudeCodeEngine extends ExternalEngine {
|
|
|
122
153
|
settingSources: ["project"],
|
|
123
154
|
systemPrompt: { type: "preset", preset: "claude_code", append: this.extras.join("\n\n") },
|
|
124
155
|
hooks: { PreToolUse: [{ hooks: [preToolUse] }], PostToolUse: [{ hooks: [postToolUse] }] },
|
|
125
|
-
canUseTool: async (tool,
|
|
126
|
-
const ok = await this.hooks.approve({ kind: "tool", name: tool, args:
|
|
156
|
+
canUseTool: async (tool, toolInput) => {
|
|
157
|
+
const ok = await this.hooks.approve({ kind: "tool", name: tool, args: toolInput });
|
|
127
158
|
if (!ok)
|
|
128
|
-
this.hooks.onEntry({ kind: "tool", text: `Denied: ${describeTool(tool,
|
|
129
|
-
return ok ? { behavior: "allow", updatedInput:
|
|
159
|
+
this.hooks.onEntry({ kind: "tool", text: `Denied: ${describeTool(tool, toolInput)}` });
|
|
160
|
+
return ok ? { behavior: "allow", updatedInput: toolInput } : { behavior: "deny", message: "The user denied this." };
|
|
130
161
|
},
|
|
131
162
|
},
|
|
132
163
|
});
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
164
|
+
const live = {
|
|
165
|
+
key,
|
|
166
|
+
query,
|
|
167
|
+
push: (text) => {
|
|
168
|
+
queue.push({ type: "user", message: { role: "user", content: text }, parent_tool_use_id: null, session_id: this.claudeSession ?? "" });
|
|
169
|
+
wake?.();
|
|
170
|
+
wake = null;
|
|
171
|
+
},
|
|
172
|
+
close: () => {
|
|
173
|
+
process.off("exit", live.close);
|
|
174
|
+
closed = true;
|
|
175
|
+
wake?.();
|
|
176
|
+
try {
|
|
177
|
+
query.close?.();
|
|
143
178
|
}
|
|
179
|
+
catch {
|
|
180
|
+
// already gone
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
process.once("exit", live.close); // Claude Code doesn't outlive Nou
|
|
185
|
+
// Reads everything Claude Code says, for as long as the process runs. Between turns that's what a
|
|
186
|
+
// background task it started brings back (it may carry on by itself); it goes to the chat too.
|
|
187
|
+
void (async () => {
|
|
188
|
+
let failure;
|
|
189
|
+
try {
|
|
190
|
+
for await (const message of query)
|
|
191
|
+
this.handle(message, live);
|
|
144
192
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
streamed = "";
|
|
148
|
-
this.hooks.onStream("");
|
|
149
|
-
if (text)
|
|
150
|
-
this.hooks.onEntry({ kind: "assistant", text });
|
|
151
|
-
this.hooks.onBusy("Claude Code is working...");
|
|
193
|
+
catch (error) {
|
|
194
|
+
failure = error instanceof Error ? error : new Error(String(error));
|
|
152
195
|
}
|
|
153
|
-
|
|
154
|
-
this.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
this.hooks.onEntry({ kind: "error", text: `Claude Code: ${detail}` });
|
|
159
|
-
}
|
|
160
|
-
// The turn is over: don't wait for Claude Code's process to close its stream as well. Something it
|
|
161
|
-
// left running (a background command, an MCP server) kept that open, and Nou "working" forever.
|
|
162
|
-
break;
|
|
196
|
+
finally {
|
|
197
|
+
if (this.live === live)
|
|
198
|
+
this.live = null;
|
|
199
|
+
if (this.waiting?.live === live)
|
|
200
|
+
this.waiting.done(failure ?? (closed ? undefined : new Error("Claude Code stopped before it answered.")));
|
|
163
201
|
}
|
|
202
|
+
})();
|
|
203
|
+
return live;
|
|
204
|
+
}
|
|
205
|
+
handle(message, live) {
|
|
206
|
+
if (message.type === "system" && message.subtype === "init") {
|
|
207
|
+
this.claudeSession = message.session_id;
|
|
208
|
+
}
|
|
209
|
+
else if (message.type === "stream_event" && !message.parent_tool_use_id) {
|
|
210
|
+
const event = message.event;
|
|
211
|
+
if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
|
|
212
|
+
this.streamed += event.delta.text;
|
|
213
|
+
this.hooks.onStream(this.streamed);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else if (message.type === "assistant" && !message.parent_tool_use_id) {
|
|
217
|
+
const text = message.message.content.filter((b) => b.type === "text").map((b) => b.text).join("").trim();
|
|
218
|
+
this.streamed = "";
|
|
219
|
+
this.hooks.onStream("");
|
|
220
|
+
if (text)
|
|
221
|
+
this.hooks.onEntry({ kind: "assistant", text });
|
|
222
|
+
if (this.waiting)
|
|
223
|
+
this.hooks.onBusy("Claude Code is working...");
|
|
224
|
+
}
|
|
225
|
+
else if (message.type === "result") {
|
|
226
|
+
this.claudeSession = message.session_id ?? this.claudeSession;
|
|
227
|
+
this.streamed = "";
|
|
228
|
+
this.hooks.onStream("");
|
|
229
|
+
if (message.subtype !== "success" || message.is_error) {
|
|
230
|
+
const detail = message.errors?.join("; ") || message.result || message.subtype;
|
|
231
|
+
// A turn you stopped ends with an error result; that isn't news.
|
|
232
|
+
if (!this.stopping)
|
|
233
|
+
this.hooks.onEntry({ kind: "error", text: `Claude Code: ${detail}` });
|
|
234
|
+
}
|
|
235
|
+
this.stopping = false;
|
|
236
|
+
if (this.waiting?.live === live)
|
|
237
|
+
this.waiting.done();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
stopping = false;
|
|
241
|
+
async run(prompt, signal) {
|
|
242
|
+
const sdk = await loadPackage("@anthropic-ai/claude-agent-sdk");
|
|
243
|
+
const key = this.liveKey();
|
|
244
|
+
if (this.live && this.live.key !== key)
|
|
245
|
+
this.closeLive();
|
|
246
|
+
const live = (this.live ??= this.openLive(sdk, key));
|
|
247
|
+
this.streamed = "";
|
|
248
|
+
let settle;
|
|
249
|
+
const finished = new Promise((resolve, reject) => (settle = (error) => (error ? reject(error) : resolve())));
|
|
250
|
+
const waiting = { live, done: (error) => settle(error) };
|
|
251
|
+
this.waiting = waiting;
|
|
252
|
+
// Stopping: Claude Code interrupts the turn and ends it with a result; if it doesn't promptly, the
|
|
253
|
+
// process goes (the next message starts a new one, resuming the session).
|
|
254
|
+
const onAbort = () => {
|
|
255
|
+
this.stopping = true;
|
|
256
|
+
Promise.resolve(live.query.interrupt?.()).catch(() => { });
|
|
257
|
+
setTimeout(() => {
|
|
258
|
+
if (this.waiting !== waiting)
|
|
259
|
+
return;
|
|
260
|
+
if (this.live === live)
|
|
261
|
+
this.closeLive();
|
|
262
|
+
waiting.done();
|
|
263
|
+
}, 3000);
|
|
264
|
+
};
|
|
265
|
+
signal.addEventListener("abort", onAbort);
|
|
266
|
+
try {
|
|
267
|
+
live.push(prompt);
|
|
268
|
+
await finished;
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
signal.removeEventListener("abort", onAbort);
|
|
272
|
+
if (this.waiting === waiting)
|
|
273
|
+
this.waiting = null;
|
|
164
274
|
}
|
|
165
275
|
if (signal.aborted)
|
|
166
276
|
throw new Error("aborted");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nou-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.17",
|
|
4
4
|
"description": "Nou: an IDE-style AI coding assistant for your terminal. Local (Ollama, LM Studio) and cloud (Claude, OpenAI, Gemini...) models, live diffs, git-aware, built-in editor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|