nou-cli 0.5.2 → 0.5.3

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
@@ -47,11 +47,11 @@ Needs **Node 22.12+** and an interactive terminal (Windows Terminal, PowerShell,
47
47
  | | |
48
48
  | --- | --- |
49
49
  | 🗂️ **File explorer** | Browse your project with file icons and git status (`M` modified, `?` new). Press <kbd>c</kbd> to see only changed files. |
50
- | 🔀 **Live diffs** | Every edit Nou proposes shows up side by side. <kbd>y</kbd> accepts, <kbd>n</kbd> rejects. Nothing touches disk without you. |
50
+ | 🔀 **Live diffs** | Every edit Nou proposes shows up side by side, and the files it is editing shimmer in the file tree while it works. <kbd>y</kbd> accepts, <kbd>n</kbd> rejects. Nothing touches disk without you. |
51
51
  | ✍️ **Built-in editor** | Press <kbd>e</kbd> to edit the open file yourself, with undo/redo and **auto-save**. |
52
52
  | 📍 **Shows you where things are** | Ask "where is the LiveKit integration?" and Nou finds it and opens it in the middle panel, jumped to the exact lines and highlighted. Any file a reply mentions (`src/a.ts:12`) is a link: click it. `/open name[:line]` opens a file yourself. |
53
- | 🎮 **Games for a break** | Add Pong, Snake and Breakout in settings → Games, then `/games` opens a floating panel you can drag anywhere by its title. A game runs only while the panel has the keyboard: Esc goes back to your code and it waits. |
54
- | 🧠 **A capable engine** | Reads and searches in parallel, numbered reads for exact line references, edits that survive small whitespace mismatches, several edits to a file as one change, a plan for longer tasks, full command output (stderr too) with exit codes and timeouts, retries when a provider is overloaded, and conversation caching with Claude. It follows your repo's `AGENTS.md`, `CLAUDE.md` and `NOU.md` (and `~/.nou/NOU.md`). |
53
+ | 🎮 **Games for a break** | Add Pong, Snake and Breakout in settings → Games, then `/games` opens a panel docked under the chat: it splits that column instead of covering anything. Drag its title onto the files, code or chat to dock it there, and × at its top right closes it. A game runs only while the panel has the keyboard: Esc goes back to your code and it waits. |
54
+ | 🧠 **A capable engine** | Reads and searches in parallel, numbered reads for exact line references, edits that survive small whitespace mismatches, several edits to a file as one change, a plan for longer tasks, full command output (stderr too) with exit codes and timeouts, retries when a provider is overloaded or a connection drops (Codex too: it picks the turn back up instead of stopping at "Reconnecting..."), and conversation caching with Claude. It follows your repo's `AGENTS.md`, `CLAUDE.md` and `NOU.md` (and `~/.nou/NOU.md`). |
55
55
  | 🔎 **Finds its way around** | The agent searches your code (`grep`, `glob`) without asking, reads only what it needs, runs your tests to check its work, and summarizes long conversations to stay within the model's context (`/compact` does it on demand). |
56
56
  | ↶ **Undo anything** | Every change, Nou's or yours, is recorded. <kbd>u</kbd> undoes Nou's last edit, `/rewind` undoes its whole last turn, and your own later edits are kept. |
57
57
  | 🧾 **Session diffs** | `/diff` shows everything that changed this session, file by file (<kbd>[</kbd> <kbd>]</kbd> to step through). `/sessions` lists earlier sessions and `/diff 2` shows one. |
@@ -89,7 +89,7 @@ Every change is reviewed like a code review:
89
89
  | <kbd>Ctrl</kbd>+<kbd>P</kbd> | fuzzy-find a file; the files you've opened are listed first |
90
90
  | <kbd>b</kbd> | back to the previous file (press again to flip back) |
91
91
  | <kbd>Ctrl</kbd>+<kbd>F</kbd> | find in the open file: matches are highlighted and counted; <kbd>Enter</kbd>/<kbd>↓</kbd> next, <kbd>↑</kbd> previous, <kbd>Esc</kbd> close |
92
- | 🗂️ Tabs | every file you open gets a tab over the code: click one to switch, its × to close · <kbd>[</kbd>/<kbd>]</kbd> previous/next · <kbd>x</kbd> close |
92
+ | 🗂️ Tabs | every file you open gets a tab over the code: click one to switch, any tab's × to close it, ‹ › for ones that don't fit · <kbd>[</kbd>/<kbd>]</kbd> previous/next · <kbd>x</kbd> or <kbd>Ctrl</kbd>+<kbd>W</kbd> close |
93
93
  | <kbd>Ctrl</kbd>+click | on a name in the code (or <kbd>Option</kbd>+click, or right-click): go to its definition; an imported name goes to its import, and the import's path opens that file |
94
94
  | <kbd>e</kbd> or a double-click | edit the open file (see the editor keys below) |
95
95
  | <kbd>p</kbd> | Problems panel: every error and warning, <kbd>Enter</kbd> jumps to it |
@@ -98,10 +98,11 @@ Every change is reviewed like a code review:
98
98
  | <kbd>c</kbd> | in the file tree: show only changed files |
99
99
  | <kbd>f</kbd> | follow mode (off by default): watch along as Nou works. Each file it reads (the exact lines, highlighted) is shown a step at a time, a little behind it, and its edits as they happen. The status bar shows how many steps behind it is; set the speed in settings → Preferences (slow · normal · fast · instant) |
100
100
  | <kbd>&lt;</kbd> / <kbd>&gt;</kbd> / <kbd>=</kbd> | make the focused pane narrower / wider / reset the layout |
101
- | <kbd>m</kbd> | mouse on/off, so you can select and copy text in the terminal |
101
+ | <kbd>`</kbd> | a terminal under the code, like VS Code's (also `/terminal`): Enter runs a command, Ctrl+C stops it, ↑↓ history, Esc back to the code, × closes it. Commands run through your shell one at a time, so full-screen programs like vim don't draw there |
102
+ | <kbd>m</kbd> | mouse on/off, so you can select and copy text in the terminal (in the chat, just drag over the text: it's copied when you let go) |
102
103
  | <kbd>s</kbd> | settings: model, preferences, plugins |
103
104
  | <kbd>y</kbd> / <kbd>n</kbd> | accept / reject a proposed edit or command, from any pane; the rest of Nou keeps working while it waits (<kbd>o</kbd> shows the edit) |
104
- | <kbd>Enter</kbd> while Nou works | queue the message: it goes when Nou finishes (<kbd>Alt</kbd>+<kbd>Enter</kbd>: stop Nou and send it now) |
105
+ | <kbd>Enter</kbd> while Nou works | queue the message: it goes when Nou finishes, together with anything else you queued, as one message (<kbd>Alt</kbd>+<kbd>Enter</kbd>: stop Nou and send it now) |
105
106
  | <kbd>Esc</kbd> | stop Nou (anything queued goes back in the input) · close a diff · back to chat |
106
107
  | <kbd>Ctrl</kbd>+<kbd>C</kbd> | stop Nou while it works · press twice to quit |
107
108
 
@@ -119,7 +120,11 @@ In the editor:
119
120
  | <kbd>Ctrl</kbd>+<kbd>K</kbd> · <kbd>Ctrl</kbd>+<kbd>G</kbd> | hover info · go to definition (with a language server) |
120
121
  | <kbd>Esc</kbd> | clear the selection, or leave the editor |
121
122
 
122
- Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/help`
123
+ Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/resume [n|last]` · `/terminal` · `/help`
124
+
125
+ 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), or type `\` and press <kbd>Enter</kbd> in any terminal.
126
+
127
+ Conversations are saved per folder after each reply. `/resume` lists them 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.
123
128
 
124
129
  ## 🧠 Models
125
130
 
@@ -16,10 +16,13 @@ function transient(error) {
16
16
  return true;
17
17
  if (status >= 400)
18
18
  return false;
19
- const text = String(error?.message ?? error);
20
- if (/ECONNREFUSED|ENOTFOUND|EAI_AGAIN|Invalid URL|api key|unauthori[sz]ed/i.test(text))
19
+ return transientMessage(String(error?.message ?? error));
20
+ }
21
+ /** Whether an error's text reads as a passing problem (a dropped stream, a busy or failing server). */
22
+ export function transientMessage(text) {
23
+ if (/ECONNREFUSED|ENOTFOUND|EAI_AGAIN|Invalid URL|api key|unauthori[sz]ed|\b40[13]\b/i.test(text))
21
24
  return false;
22
- return /overloaded|rate.?limit|too many requests|ECONNRESET|socket hang up|ETIMEDOUT|timed? ?out|EPIPE|network|premature close|terminated|\b(429|5\d\d)\b/i.test(text);
25
+ return /overloaded|rate.?limit|too many requests|ECONNRESET|socket hang up|ETIMEDOUT|timed? ?out|EPIPE|network|premature close|terminated|disconnected|server had an error|internal server error|service unavailable|bad gateway|try again|\b(429|5\d\d)\b/i.test(text);
23
26
  }
24
27
  /** Messages kept word for word when older ones are summarized. */
25
28
  const KEEP_RECENT = 8;
@@ -111,6 +114,24 @@ export class Agent {
111
114
  this.messages = [];
112
115
  this.notes = [];
113
116
  }
117
+ /** Forgets the last message you sent and everything after it, as if it was never sent. */
118
+ snapshot() {
119
+ return { messages: this.messages };
120
+ }
121
+ restore(state) {
122
+ if (!Array.isArray(state?.messages))
123
+ return false;
124
+ this.messages = state.messages;
125
+ this.forgetThinking(); // written in another run, maybe with another model
126
+ return true;
127
+ }
128
+ retractLast() {
129
+ const at = this.messages.findLastIndex((m) => m.role === "user");
130
+ if (at < 0)
131
+ return false;
132
+ this.messages = this.messages.slice(0, at);
133
+ return true;
134
+ }
114
135
  /** Something the model should know before the next message (e.g. the user undid its edit). */
115
136
  note(text) {
116
137
  this.notes.push(text);
@@ -254,8 +275,10 @@ export class Agent {
254
275
  await this.compact(false, signal);
255
276
  this.hooks.onBusy(`Pondering with ${describe(this.choice)}...`);
256
277
  let streamed = "";
278
+ let thought = "";
257
279
  const result = await this.withRetries(signal, () => {
258
280
  streamed = "";
281
+ thought = "";
259
282
  return this.choice.provider.chat({
260
283
  model: this.choice.model,
261
284
  system: this.system,
@@ -267,6 +290,13 @@ export class Agent {
267
290
  streamed += delta;
268
291
  this.hooks.onStream(streamed);
269
292
  },
293
+ // Its thinking shows on the one-line progress (the latest of it), so the chat doesn't jump.
294
+ onThinking: (delta) => {
295
+ thought = (thought + delta).slice(-400);
296
+ const latest = thought.split("\n").map((l) => l.trim()).filter(Boolean).at(-1) ?? "";
297
+ if (latest)
298
+ this.hooks.onBusy(`Thinking: ${latest.length > 90 ? `…${latest.slice(-89)}` : latest}`);
299
+ },
270
300
  });
271
301
  });
272
302
  this.hooks.onStream("");
package/dist/src/cli.js CHANGED
@@ -18,6 +18,8 @@ const HELP = `nou ${currentVersion()}: an AI coding assistant for your terminal
18
18
 
19
19
  Usage:
20
20
  nou open Nou in the current folder
21
+ nou --continue pick up the last conversation in this folder (-c)
22
+ nou --resume list earlier conversations here to pick one up (-r)
21
23
  nou update update to the latest version
22
24
  nou --version print the version
23
25
  nou --help show this help
@@ -107,7 +109,7 @@ async function main() {
107
109
  await ensureProjectContext(choice);
108
110
  const permContext = await loadPermanentContext();
109
111
  await installFastOutput();
110
- const app = render(createElement(App, { initialModel: choice, permContext }), RENDER_OPTIONS);
112
+ const app = render(createElement(App, { initialModel: choice, permContext, ...(args.includes("--continue") || args.includes("-c") ? { resume: "last" } : args.includes("--resume") || args.includes("-r") ? { resume: "list" } : {}) }), RENDER_OPTIONS);
111
113
  await app.waitUntilExit();
112
114
  lsp.stopAll();
113
115
  p.outro(chalk.cyan("Bye bye!"));
@@ -93,7 +93,7 @@ const OUTPUT_KEEP = 200_000;
93
93
  const running = new Set();
94
94
  let exitHooked = false;
95
95
  /** Stops a command and everything it started. */
96
- function stopProcess(child) {
96
+ export function stopProcess(child) {
97
97
  try {
98
98
  if (process.platform === "win32") {
99
99
  if (child.pid)
@@ -25,7 +25,7 @@ export const DEFAULT_SETTINGS = {
25
25
  paneWidths: null,
26
26
  trustedFolders: [],
27
27
  games: [],
28
- gamePanel: null,
28
+ gameDock: "chat",
29
29
  plugins: {},
30
30
  trustedPlugins: {},
31
31
  };
@@ -0,0 +1,165 @@
1
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ /** Files looked at per tool: the newest ones. */
5
+ const SCAN = 300;
6
+ const claudeDir = (home) => process.env.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude");
7
+ const codexDir = (home) => process.env.CODEX_HOME ?? path.join(home, ".codex");
8
+ function jsonLines(file, limit = Infinity) {
9
+ const out = [];
10
+ let text;
11
+ try {
12
+ text = readFileSync(file, "utf-8");
13
+ }
14
+ catch {
15
+ return out;
16
+ }
17
+ for (const line of text.split("\n")) {
18
+ if (out.length >= limit)
19
+ break;
20
+ if (!line.trim())
21
+ continue;
22
+ try {
23
+ out.push(JSON.parse(line));
24
+ }
25
+ catch {
26
+ // a line being written
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+ /** The start of a file, for the line that says which folder a Codex session ran in. */
32
+ function head(file, bytes = 8192) {
33
+ const fd = openSync(file, "r");
34
+ try {
35
+ const buf = Buffer.alloc(bytes);
36
+ return buf.subarray(0, readSync(fd, buf, 0, bytes, 0)).toString("utf-8");
37
+ }
38
+ finally {
39
+ closeSync(fd);
40
+ }
41
+ }
42
+ /** Plain text from a message's content (a string, or parts with text). */
43
+ function textOf(content) {
44
+ if (typeof content === "string")
45
+ return content;
46
+ if (!Array.isArray(content))
47
+ return "";
48
+ return content.filter((p) => typeof p?.text === "string" && /^(text|input_text|output_text)$/.test(p.type ?? "text")).map((p) => p.text).join("\n");
49
+ }
50
+ /** What the tools add on their own (context blocks, command echoes): not something you typed. */
51
+ const injected = (text) => /^\s*<(environment_context|user_instructions|command-|local-command|system-reminder)/.test(text) || text.startsWith("Caveat:");
52
+ const titleOf = (text) => text.replace(/\s+/g, " ").trim().slice(0, 80);
53
+ /** Claude Code's sessions for this folder. */
54
+ function claudeSessions(home, cwd) {
55
+ const dir = path.join(claudeDir(home), "projects", cwd.replace(/[^A-Za-z0-9]/g, "-"));
56
+ if (!existsSync(dir))
57
+ return [];
58
+ const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => path.join(dir, f));
59
+ const out = [];
60
+ for (const file of newest(files)) {
61
+ const said = claudeEntries(file);
62
+ const first = said.find((e) => e.kind === "user");
63
+ if (!first)
64
+ continue;
65
+ out.push({ id: path.basename(file, ".jsonl"), engine: "claude-code", title: titleOf(first.text), updated: statSync(file).mtimeMs, messages: said.filter((e) => e.kind === "user").length, file });
66
+ }
67
+ return out;
68
+ }
69
+ function claudeEntries(file) {
70
+ const out = [];
71
+ for (const line of jsonLines(file)) {
72
+ if (line.isSidechain || line.isMeta)
73
+ continue;
74
+ if (line.type === "user" && line.message?.role === "user") {
75
+ const text = textOf(line.message.content);
76
+ if (text.trim() && !injected(text))
77
+ out.push({ kind: "user", text: text.trim() });
78
+ }
79
+ else if (line.type === "assistant") {
80
+ const text = textOf(line.message?.content);
81
+ if (text.trim())
82
+ out.push({ kind: "assistant", text: text.trim() });
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+ /** Codex's sessions for this folder (it keeps them all in one tree, by date). */
88
+ function codexSessions(home, cwd) {
89
+ const root = path.join(codexDir(home), "sessions");
90
+ if (!existsSync(root))
91
+ return [];
92
+ const files = [];
93
+ const walk = (dir, depth) => {
94
+ for (const name of readdirSync(dir).sort().reverse()) {
95
+ if (files.length >= SCAN)
96
+ return;
97
+ const full = path.join(dir, name);
98
+ if (depth < 3)
99
+ walk(full, depth + 1);
100
+ else if (name.startsWith("rollout-") && name.endsWith(".jsonl"))
101
+ files.push(full);
102
+ }
103
+ };
104
+ try {
105
+ walk(root, 0);
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ const out = [];
111
+ for (const file of files) {
112
+ let meta;
113
+ try {
114
+ meta = JSON.parse(head(file).split("\n")[0]);
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ const payload = meta?.type === "session_meta" ? meta.payload : null;
120
+ if (!payload?.id || path.resolve(payload.cwd ?? "") !== path.resolve(cwd))
121
+ continue;
122
+ const said = codexEntries(file);
123
+ const first = said.find((e) => e.kind === "user");
124
+ if (!first)
125
+ continue;
126
+ out.push({ id: payload.id, engine: "codex", title: titleOf(first.text), updated: statSync(file).mtimeMs, messages: said.filter((e) => e.kind === "user").length, file });
127
+ }
128
+ return out;
129
+ }
130
+ function codexEntries(file) {
131
+ const out = [];
132
+ for (const line of jsonLines(file)) {
133
+ const p = line.type === "response_item" ? line.payload : null;
134
+ if (p?.type !== "message")
135
+ continue;
136
+ const text = textOf(p.content).trim();
137
+ if (!text || injected(text))
138
+ continue;
139
+ if (p.role === "user")
140
+ out.push({ kind: "user", text });
141
+ else if (p.role === "assistant")
142
+ out.push({ kind: "assistant", text });
143
+ }
144
+ return out;
145
+ }
146
+ function newest(files) {
147
+ return files.map((f) => ({ f, t: statSync(f).mtimeMs })).sort((a, b) => b.t - a.t).slice(0, SCAN).map((x) => x.f);
148
+ }
149
+ /** Claude Code's and Codex's own sessions in this folder, latest first. */
150
+ export function externalSessions(home = os.homedir(), cwd = process.cwd()) {
151
+ const out = [];
152
+ for (const read of [claudeSessions, codexSessions]) {
153
+ try {
154
+ out.push(...read(home, cwd));
155
+ }
156
+ catch {
157
+ // a tool's files we can't read: skip them
158
+ }
159
+ }
160
+ return out.sort((a, b) => b.updated - a.updated);
161
+ }
162
+ /** The chat of an external session, to show when it's resumed. */
163
+ export function externalEntries(session) {
164
+ return session.engine === "claude-code" ? claudeEntries(session.file) : codexEntries(session.file);
165
+ }
@@ -0,0 +1,71 @@
1
+ import { createHash } from "crypto";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
3
+ import path from "path";
4
+ import { NOU_HOME } from "../config/index.js";
5
+ /** Kept per project: the oldest go once there are more. */
6
+ const KEEP = 50;
7
+ /** Chat entries saved per conversation (the latest ones). */
8
+ const MAX_ENTRIES = 500;
9
+ function folder(cwd = process.cwd()) {
10
+ const name = path.basename(cwd).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 40);
11
+ return path.join(NOU_HOME, "conversations", `${name}-${createHash("sha1").update(cwd).digest("hex").slice(0, 10)}`);
12
+ }
13
+ export function saveConversation(conversation) {
14
+ const dir = folder();
15
+ try {
16
+ if (!existsSync(dir))
17
+ mkdirSync(dir, { recursive: true });
18
+ const entries = conversation.entries.slice(-MAX_ENTRIES);
19
+ writeFileSync(path.join(dir, `${conversation.id}.json`), JSON.stringify({ ...conversation, entries }), { mode: 0o600 });
20
+ const old = listConversations().slice(KEEP);
21
+ for (const c of old)
22
+ unlinkSync(path.join(dir, `${c.id}.json`));
23
+ }
24
+ catch {
25
+ // Saving is a convenience: a read-only home shouldn't break the chat.
26
+ }
27
+ }
28
+ /** This project's conversations, latest first. */
29
+ export function listConversations() {
30
+ const dir = folder();
31
+ if (!existsSync(dir))
32
+ return [];
33
+ const out = [];
34
+ for (const file of readdirSync(dir)) {
35
+ if (!file.endsWith(".json"))
36
+ continue;
37
+ try {
38
+ const c = JSON.parse(readFileSync(path.join(dir, file), "utf-8"));
39
+ const state = c.state;
40
+ const ref = state?.claudeSession ?? state?.codexThread;
41
+ out.push({ id: c.id, title: c.title, updated: c.updated, engine: c.engine, messages: c.entries.filter((e) => e.kind === "user").length, ...(typeof ref === "string" ? { ref } : {}) });
42
+ }
43
+ catch {
44
+ // a damaged file is skipped
45
+ }
46
+ }
47
+ return out.sort((a, b) => b.updated - a.updated);
48
+ }
49
+ export function loadConversation(id) {
50
+ if (!/^[A-Za-z0-9_-]+$/.test(id))
51
+ return null;
52
+ try {
53
+ return JSON.parse(readFileSync(path.join(folder(), `${id}.json`), "utf-8"));
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ /** "5m ago", "yesterday"... */
60
+ export function ago(time, now = Date.now()) {
61
+ const s = Math.max(0, Math.round((now - time) / 1000));
62
+ if (s < 60)
63
+ return "just now";
64
+ if (s < 3600)
65
+ return `${Math.floor(s / 60)}m ago`;
66
+ if (s < 86_400)
67
+ return `${Math.floor(s / 3600)}h ago`;
68
+ if (s < 172_800)
69
+ return "yesterday";
70
+ return `${Math.floor(s / 86_400)}d ago`;
71
+ }
@@ -50,6 +50,16 @@ export class ClaudeCodeEngine extends ExternalEngine {
50
50
  resetConversation() {
51
51
  this.claudeSession = undefined;
52
52
  }
53
+ snapshot() {
54
+ return this.claudeSession ? { claudeSession: this.claudeSession } : undefined;
55
+ }
56
+ /** Claude Code keeps its sessions on disk, so a saved one resumes in a later run too. */
57
+ restore(state) {
58
+ if (typeof state?.claudeSession !== "string")
59
+ return false;
60
+ this.claudeSession = state.claudeSession;
61
+ return true;
62
+ }
53
63
  /** Claude Code can resume its session with another model, so the conversation carries over. */
54
64
  setModel(choice) {
55
65
  this.choice = choice;
@@ -1,11 +1,14 @@
1
1
  import { execFileSync } from "child_process";
2
2
  import { statSync } from "fs";
3
+ import { transientMessage } from "../agent/index.js";
3
4
  import { listAllFiles } from "../ui/QuickOpen.js";
4
5
  import { ExternalEngine } from "./external.js";
5
6
  import { loadPackage } from "./index.js";
6
7
  const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
7
8
  const MAX_FILES = 5000;
8
9
  const MAX_BYTES = 512 * 1024;
10
+ /** Times a turn that failed for a passing reason (a dropped stream, a failing server) is picked up again. */
11
+ const RESUMES = 5;
9
12
  /** Project files small enough to keep a copy of (git's list when there is one, so ignored files are skipped). */
10
13
  function projectFiles() {
11
14
  let files;
@@ -42,34 +45,82 @@ export class CodexEngine extends ExternalEngine {
42
45
  id = "codex";
43
46
  thread = null;
44
47
  introduced = false;
48
+ /** A saved thread to pick back up (/resume): Codex keeps its threads on disk. */
49
+ resumeId;
45
50
  constructor(choice, hooks) {
46
51
  super(choice, hooks, "Codex");
47
52
  }
48
53
  resetConversation() {
49
54
  this.thread = null;
50
55
  this.introduced = false;
56
+ this.resumeId = undefined;
57
+ }
58
+ snapshot() {
59
+ const id = this.thread?.id ?? this.resumeId;
60
+ return id ? { codexThread: id } : undefined;
61
+ }
62
+ restore(state) {
63
+ if (typeof state?.codexThread !== "string")
64
+ return false;
65
+ this.thread = null;
66
+ this.resumeId = state.codexThread;
67
+ this.introduced = true; // it already has the project context
68
+ return true;
51
69
  }
52
70
  async run(prompt, signal) {
53
71
  const { Codex } = await loadPackage("@openai/codex-sdk");
54
72
  // Codex uses its own login (`codex login`); an OpenAI key from Nou's setup works too.
55
73
  const apiKey = process.env.CODEX_API_KEY || process.env.OPENAI_API_KEY;
56
- this.thread ??= new Codex(apiKey ? { apiKey } : {}).startThread({
74
+ const options = {
57
75
  workingDirectory: process.cwd(),
58
76
  skipGitRepoCheck: true,
59
77
  sandboxMode: "workspace-write",
78
+ // Its sandbox blocks the network by default, so npm/pip installs and tests that fetch fail ("can't
79
+ // resolve registry.npmjs.org"); Nou's other engines can reach it, so Codex can too.
80
+ networkAccessEnabled: true,
60
81
  approvalPolicy: "never",
61
82
  ...(this.choice.provider.id === "openai" ? { model: this.choice.model } : {}),
62
83
  ...(this.effort && EFFORTS.has(this.effort) ? { modelReasoningEffort: this.effort } : {}),
63
- });
84
+ };
85
+ const codex = new Codex(apiKey ? { apiKey } : {});
86
+ this.thread ??= this.resumeId ? codex.resumeThread(this.resumeId, options) : codex.startThread(options);
64
87
  // Codex has no system prompt option; Nou's project context goes with the first message.
65
88
  const input = !this.introduced && this.extras.length ? `${this.extras.join("\n\n")}\n\n${prompt}` : prompt;
66
89
  this.introduced = true;
67
90
  this.tracker.remember(projectFiles());
91
+ // Codex retries a dropped stream itself ("Reconnecting... 2/5"); when it gives up, Nou picks the turn up
92
+ // again on the same thread, waiting longer each time, before calling it failed.
93
+ for (let attempt = 0;; attempt++) {
94
+ const failure = await this.runTurn(attempt ? "The connection dropped before you finished. Carry on where you left off." : input, signal);
95
+ if (!failure)
96
+ return;
97
+ if (attempt >= RESUMES || !transientMessage(failure))
98
+ throw new Error(explain(failure));
99
+ const wait = Math.min(30_000, 2000 * 2 ** attempt);
100
+ this.hooks.onStream("");
101
+ this.hooks.onBusy(`Codex: ${failure.split("\n")[0].slice(0, 70)}. Retrying in ${wait / 1000}s (${attempt + 1}/${RESUMES})...`);
102
+ await new Promise((resolve) => {
103
+ const timer = setTimeout(resolve, wait);
104
+ signal.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
105
+ });
106
+ if (signal.aborted)
107
+ throw new Error("aborted");
108
+ }
109
+ }
110
+ /** Runs one Codex turn; returns why it failed, if it did. */
111
+ async runTurn(input, signal) {
68
112
  let failure;
69
113
  try {
70
114
  const { events } = await this.thread.runStreamed(input, { signal });
71
- for await (const event of events)
115
+ for await (const event of events) {
116
+ if (event.type === "turn.completed") {
117
+ // Its turn is over (it may have just asked you something): done now, rather than waiting for the
118
+ // codex process to wind down, which kept "Codex is working..." up under its question.
119
+ failure = undefined; // and it recovered from anything that went wrong on the way
120
+ break;
121
+ }
72
122
  failure = (await this.handle(event)) ?? failure;
123
+ }
73
124
  }
74
125
  catch (error) {
75
126
  // The codex process exiting with an error ends the stream; its first line says why.
@@ -79,8 +130,7 @@ export class CodexEngine extends ExternalEngine {
79
130
  }
80
131
  if (signal.aborted)
81
132
  throw new Error("aborted");
82
- if (failure)
83
- throw new Error(explain(failure));
133
+ return failure;
84
134
  }
85
135
  /** Shows one Codex event; returns the error message if the turn failed. */
86
136
  async handle(event) {
@@ -124,6 +174,11 @@ export class CodexEngine extends ExternalEngine {
124
174
  return event.error.message;
125
175
  }
126
176
  else if (event.type === "error") {
177
+ // "Reconnecting... 1/5 (stream disconnected ...)": Codex is retrying, so it's progress, not the end.
178
+ if (/^Reconnecting\b/i.test(event.message)) {
179
+ this.hooks.onBusy(`Codex: ${event.message.split("\n")[0].slice(0, 100)}`);
180
+ return undefined;
181
+ }
127
182
  return event.message;
128
183
  }
129
184
  return undefined;
@@ -81,7 +81,7 @@ export function anthropicProvider() {
81
81
  apiKey: process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || "",
82
82
  api: "anthropic-messages",
83
83
  }),
84
- async chat({ model, system, messages, tools, onText, signal, effort: requested }) {
84
+ async chat({ model, system, messages, tools, onText, onThinking, signal, effort: requested }) {
85
85
  const fallbacks = FALLBACK_MODELS.test(model);
86
86
  const effort = EFFORT_MODELS.test(model) ? process.env.NOU_EFFORT ?? requested ?? "high" : undefined;
87
87
  const body = {
@@ -105,20 +105,9 @@ export function anthropicProvider() {
105
105
  let text = "";
106
106
  try {
107
107
  const stream = getClient().beta.messages.stream(body, signal ? { signal } : {});
108
- // Thinking streams as a quote above the reply (the chat shows quotes dimmed); only the reply is kept.
109
- let thinking = false;
110
- stream.on("thinking", (delta) => {
111
- const piece = `${thinking ? "" : "> "}${delta.replace(/\n/g, "\n> ")}`;
112
- thinking = true;
113
- text += piece;
114
- onText(piece);
115
- });
108
+ // Thinking goes to the progress line, not the reply: it isn't kept, so it mustn't reflow the chat.
109
+ stream.on("thinking", (delta) => onThinking?.(delta));
116
110
  stream.on("text", (delta) => {
117
- if (thinking) {
118
- thinking = false;
119
- text += "\n\n";
120
- onText("\n\n");
121
- }
122
111
  text += delta;
123
112
  onText(delta);
124
113
  });