nou-cli 0.5.2 → 0.5.4

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,13 @@ 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
+ | 🗂️ **Several agents at once** | `/new` (or **+** in the chat's title) opens another conversation tab while the first keeps working. Each tab has its own agent and history; a working tab shows ●, one waiting for your OK shows !, and a click (or `/tab 2`) switches. `/close` closes one; it stays in `/resume`. |
54
+ | 🧑‍🏫 **Show me how** | `/showme add a --verbose flag` and Nou finds where the change goes, then walks you through typing it yourself: each step opens the file at the right line, says why, and shows the code to type dim ahead of your cursor, like autocomplete (<kbd>Tab</kbd> fills in a line). It moves on when what you typed matches, and saves as it goes. `/showme next` skips a step, `/showme stop` ends it. Works with every engine (Nou's, Claude Code, Codex and pi): any edit one makes anyway is put back. `/teach` does the same. |
55
+ | 🎮 **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. Drag its top edge to make it taller or shorter. A game runs only while the panel has the keyboard: Esc goes back to your code and it waits. |
56
+ | 🧠 **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
57
  | 🔎 **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
58
  | ↶ **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
59
  | 🧾 **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 +91,7 @@ Every change is reviewed like a code review:
89
91
  | <kbd>Ctrl</kbd>+<kbd>P</kbd> | fuzzy-find a file; the files you've opened are listed first |
90
92
  | <kbd>b</kbd> | back to the previous file (press again to flip back) |
91
93
  | <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 |
94
+ | 🗂️ 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
95
  | <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
96
  | <kbd>e</kbd> or a double-click | edit the open file (see the editor keys below) |
95
97
  | <kbd>p</kbd> | Problems panel: every error and warning, <kbd>Enter</kbd> jumps to it |
@@ -98,10 +100,12 @@ Every change is reviewed like a code review:
98
100
  | <kbd>c</kbd> | in the file tree: show only changed files |
99
101
  | <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
102
  | <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 |
103
+ | <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; drag its top border (or <kbd>Alt</kbd>+↑/↓) to make it taller or shorter. Commands run through your shell one at a time, so full-screen programs like vim don't draw there |
104
+ | <kbd>Ctrl</kbd>+<kbd>O</kbd> | in the chat: open or fold every "▸ N steps" line (what the agent read and ran between replies folds into one; click one to open just it) |
105
+ | <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
106
  | <kbd>s</kbd> | settings: model, preferences, plugins |
103
107
  | <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) |
108
+ | <kbd>Enter</kbd> while Nou works | queue the message: it goes when Nou finishes, together with anything else you queued, as one message; commands that only change the screen, like `/terminal`, `/games` or `/open`, run right away (<kbd>Alt</kbd>+<kbd>Enter</kbd>: stop Nou and send it now) |
105
109
  | <kbd>Esc</kbd> | stop Nou (anything queued goes back in the input) · close a diff · back to chat |
106
110
  | <kbd>Ctrl</kbd>+<kbd>C</kbd> | stop Nou while it works · press twice to quit |
107
111
 
@@ -119,7 +123,11 @@ In the editor:
119
123
  | <kbd>Ctrl</kbd>+<kbd>K</kbd> · <kbd>Ctrl</kbd>+<kbd>G</kbd> | hover info · go to definition (with a language server) |
120
124
  | <kbd>Esc</kbd> | clear the selection, or leave the editor |
121
125
 
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`
126
+ 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` · `/showme <what you want>` · `/help`
127
+
128
+ 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.
129
+
130
+ 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
131
 
124
132
  ## 🧠 Models
125
133
 
@@ -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,9 @@ export const DEFAULT_SETTINGS = {
25
25
  paneWidths: null,
26
26
  trustedFolders: [],
27
27
  games: [],
28
- gamePanel: null,
28
+ gameDock: "chat",
29
+ terminalRows: null,
30
+ gameRows: null,
29
31
  plugins: {},
30
32
  trustedPlugins: {},
31
33
  };
@@ -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;
@@ -0,0 +1,100 @@
1
+ import path from "path";
2
+ export const SHOW_STEPS = "show_steps";
3
+ /** The tool the engine hands its steps back with. `onSteps` gets them; the engine is told they're shown. */
4
+ export function showStepsTool(onSteps) {
5
+ return {
6
+ definition: {
7
+ name: SHOW_STEPS,
8
+ description: "Only when the user asked you to show them how ([Show me how] in their message): hand back the change as steps for them to type themselves, instead of editing. Each step adds whole lines of code before a line of a file, with why it's needed. Call it once, with every step in order.",
9
+ parameters: {
10
+ type: "object",
11
+ properties: {
12
+ steps: {
13
+ type: "array",
14
+ items: {
15
+ type: "object",
16
+ properties: {
17
+ file: { type: "string", description: "Project-relative path of the file to add to." },
18
+ line: { type: "number", description: "1-based line the code goes in before (the line after the last one appends)." },
19
+ code: { type: "string", description: "The lines to type, indented as they'll be in the file." },
20
+ why: { type: "string", description: "One or two plain sentences: what this does and why it's needed." },
21
+ },
22
+ required: ["file", "line", "code", "why"],
23
+ },
24
+ },
25
+ },
26
+ required: ["steps"],
27
+ },
28
+ },
29
+ readOnly: true,
30
+ source: "nou",
31
+ run: (args) => {
32
+ const steps = tidySteps(args.steps);
33
+ if (!steps.length)
34
+ return "No usable steps: each needs a file, a line and the code to type. Call show_steps again.";
35
+ onSteps(steps);
36
+ return `Shown to the user as ${steps.length} step${steps.length > 1 ? "s" : ""} to type. Don't edit the files yourself; finish with a one-line summary.`;
37
+ },
38
+ };
39
+ }
40
+ /** Steps as the model gave them, kept when usable: a file, a line, code to type. */
41
+ function tidySteps(raw) {
42
+ return (Array.isArray(raw) ? raw : [])
43
+ .filter((s) => typeof s?.file === "string" && typeof s?.code === "string" && s.code.trim())
44
+ .map((s) => ({ file: path.relative(process.cwd(), path.resolve(s.file)).split(path.sep).join("/"), line: Math.max(1, Math.floor(Number(s.line) || 1)), code: s.code.replace(/\r\n/g, "\n").replace(/\n+$/, ""), why: String(s.why ?? "") }));
45
+ }
46
+ /** The fenced block other engines (which can't call Nou's tools) put their steps in. */
47
+ const BLOCK = /```nou-steps\s*\n([\s\S]*?)\n```/;
48
+ /**
49
+ * The message that asks for steps. Nou's own engine hands them back with the show_steps tool; Claude
50
+ * Code, Codex and pi can't call Nou's tools, so they end their reply with a ```nou-steps block instead.
51
+ */
52
+ export function showMePrompt(task, withTool) {
53
+ const how = withTool
54
+ ? `call ${SHOW_STEPS} once with the steps in order`
55
+ : "end your reply with the steps in order as a JSON array in a fenced block marked nou-steps, like:\n```nou-steps\n[{\"file\": \"src/cli.ts\", \"line\": 12, \"code\": \"const verbose = args.includes(\\\"--verbose\\\");\", \"why\": \"Reads the new flag.\"}]\n```";
56
+ return `[Show me how] ${task}\n\nDon't edit any files: I want to type it myself. Read what you need to find where it goes, then ${how} (each step: file, the line to add the code before, the exact lines of code to type, and why). Keep each step small, a few lines, so it's easy to follow.`;
57
+ }
58
+ /** Steps from a reply's ```nou-steps block, and the reply without it; null when there's none. */
59
+ export function stepsFromReply(text) {
60
+ const m = BLOCK.exec(text);
61
+ if (!m)
62
+ return null;
63
+ try {
64
+ const steps = tidySteps(JSON.parse(m[1]));
65
+ return steps.length ? { steps, rest: text.replace(m[0], "").trim() } : null;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ /**
72
+ * How far the text typed so far (`typed`, from where the step starts to the cursor) gets through
73
+ * `expected`: the index reached in `expected`, or -1 when it doesn't match. Leading indentation on each
74
+ * line is ignored either way, since the editor indents new lines itself.
75
+ */
76
+ export function guideProgress(typed, expected) {
77
+ let i = 0;
78
+ let j = 0;
79
+ const skipIndent = (s, k) => {
80
+ while (k < s.length && (s[k] === " " || s[k] === "\t"))
81
+ k++;
82
+ return k;
83
+ };
84
+ i = skipIndent(typed, i);
85
+ j = skipIndent(expected, j);
86
+ while (i < typed.length) {
87
+ if (j >= expected.length || typed[i] !== expected[j])
88
+ return -1;
89
+ const newline = typed[i] === "\n";
90
+ i++;
91
+ j++;
92
+ if (newline) {
93
+ i = skipIndent(typed, i);
94
+ // The rest of the expected indentation counts as typed only once something follows it.
95
+ if (i < typed.length)
96
+ j = skipIndent(expected, j);
97
+ }
98
+ }
99
+ return j;
100
+ }