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 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
- async run(prompt, signal) {
68
- const sdk = await loadPackage("@anthropic-ai/claude-agent-sdk");
69
- const abortController = new AbortController();
70
- signal.addEventListener("abort", () => abortController.abort());
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 (input) => {
74
- const tool = input.tool_name;
75
- const args = (input.tool_input ?? {});
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 (input) => {
90
- const tool = input.tool_name;
91
- const args = (input.tool_input ?? {});
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, input) => {
126
- const ok = await this.hooks.approve({ kind: "tool", name: tool, args: input });
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, input)}` });
129
- return ok ? { behavior: "allow", updatedInput: input } : { behavior: "deny", message: "The user denied this." };
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
- let streamed = "";
134
- for await (const message of query) {
135
- if (message.type === "system" && message.subtype === "init") {
136
- this.claudeSession = message.session_id;
137
- }
138
- else if (message.type === "stream_event" && !message.parent_tool_use_id) {
139
- const event = message.event;
140
- if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
141
- streamed += event.delta.text;
142
- this.hooks.onStream(streamed);
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
- else if (message.type === "assistant" && !message.parent_tool_use_id) {
146
- const text = message.message.content.filter((b) => b.type === "text").map((b) => b.text).join("").trim();
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
- else if (message.type === "result") {
154
- this.claudeSession = message.session_id ?? this.claudeSession;
155
- if (message.subtype !== "success" || message.is_error) {
156
- const detail = message.errors?.join("; ") || message.result || message.subtype;
157
- if (!signal.aborted)
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.16",
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": {