loom-agent 1.2.37 → 1.3.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.
@@ -1,167 +1,245 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const os = require('os');
4
-
5
- const SESSION_SCHEMA_VERSION = 2;
6
-
7
- const LOOM_DIR = path.join(os.homedir(), '.loom');
8
- const SESSIONS_DIR = path.join(LOOM_DIR, 'sessions');
9
-
10
- // Re-evaluated on every call so tests can isolate with LOOM_CONFIG_DIR.
11
- function sessionsDir() {
12
- return path.join(process.env.LOOM_CONFIG_DIR || LOOM_DIR, 'sessions');
13
- }
14
-
15
- function ensureDir() {
16
- const d = sessionsDir();
17
- if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
18
- }
19
-
20
- function convId() {
21
- return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6);
22
- }
23
-
24
- // Coerce whatever came off disk (hand-edited, corrupt, older schema) into the
25
- // guaranteed current shape: { id, createdAt, schemaVersion, messages } with
26
- // every message having { role, content } while preserving any extra fields.
27
- function normalizeSession(data) {
28
- if (!data || typeof data !== 'object') return null;
29
- const raw = Array.isArray(data.messages) ? data.messages : [];
30
- const messages = raw.map((m) => {
31
- if (!m || typeof m !== 'object') return { role: 'user', content: '' };
32
- const { role, content, ...rest } = m;
33
- return { role: role || 'user', content: typeof content === 'undefined' ? '' : content, ...rest };
34
- });
35
- return {
36
- id: typeof data.id === 'string' ? data.id : null,
37
- createdAt: typeof data.createdAt === 'string' ? data.createdAt : null,
38
- schemaVersion: typeof data.schemaVersion === 'number' ? data.schemaVersion : 1,
39
- messages,
40
- };
41
- }
42
-
43
- // Session IDs become file names — keep them strictly alphanumeric to rule out
44
- // path traversal via loadSession/deleteSession (e.g. "../../secrets").
45
- const ID_RX = /^[a-zA-Z0-9_-]{3,128}$/;
46
- function isValidSessionId(id) {
47
- return typeof id === 'string' && ID_RX.test(id);
48
- }
49
-
50
- function saveSession(session) {
51
- ensureDir();
52
- const id = isValidSessionId(session.conversationId) ? session.conversationId : convId();
53
- const now = new Date().toISOString();
54
- // Overwriting an existing session keeps its original createdAt (it is the
55
- // same conversation); updatedAt always reflects this write.
56
- let createdAt = now;
57
- const existingFile = path.join(sessionsDir(), id + '.json');
58
- if (fs.existsSync(existingFile)) {
59
- try {
60
- const prev = JSON.parse(fs.readFileSync(existingFile, 'utf8'));
61
- if (prev && typeof prev.createdAt === 'string') createdAt = prev.createdAt;
62
- } catch {}
63
- }
64
- const data = {
65
- id,
66
- schemaVersion: SESSION_SCHEMA_VERSION,
67
- createdAt,
68
- updatedAt: now,
69
- messages: session.messages || [],
70
- provider: session.config ? session.config.provider : null,
71
- model: session.config && session.config.model ? session.config.model[session.config.provider] : null,
72
- };
73
- const f = path.join(sessionsDir(), id + '.json');
74
- fs.writeFileSync(f, JSON.stringify(data, null, 2));
75
- return { id, file: f };
76
- }
77
-
78
- function listSessions() {
79
- ensureDir();
80
- const files = fs.readdirSync(sessionsDir()).filter((f) => f.endsWith('.json'));
81
- const list = files.map((f) => {
82
- const p = path.join(sessionsDir(), f);
83
- try {
84
- const data = JSON.parse(fs.readFileSync(p, 'utf8'));
85
- let mtime = null;
86
- try { mtime = fs.statSync(p).mtime.toISOString(); } catch {}
87
- return {
88
- id: data.id || f.replace('.json', ''),
89
- createdAt: data.createdAt,
90
- updatedAt: data.updatedAt,
91
- mtime,
92
- messageCount: (data.messages || []).length,
93
- provider: data.provider,
94
- model: data.model,
95
- };
96
- } catch {
97
- let mtime = null;
98
- try { mtime = fs.statSync(p).mtime.toISOString(); } catch {}
99
- return { id: f.replace('.json', ''), createdAt: null, updatedAt: null, mtime, messageCount: 0 };
100
- }
101
- });
102
- // Newest first by the last write time (updatedAt, else createdAt, else the
103
- // file's own mtime). Sorting by file NAME put legacy "share-*" exports on
104
- // top forever ("s" > "m"), burying the real, recent conversations.
105
- const stamp = (s) => s.updatedAt || s.createdAt || s.mtime || '';
106
- return list.filter((s) => isValidSessionId(s.id)).sort((a, b) => (stamp(b) < stamp(a) ? -1 : stamp(b) > stamp(a) ? 1 : 0));
107
- }
108
-
109
- function loadSession(id) {
110
- if (!isValidSessionId(id)) return null;
111
- const f = path.join(sessionsDir(), id + '.json');
112
- if (!fs.existsSync(f)) return null;
113
- try {
114
- const raw = JSON.parse(fs.readFileSync(f, 'utf8'));
115
- const norm = normalizeSession(raw);
116
- if (!norm) return null;
117
- norm.id = norm.id || id;
118
- norm.file = f;
119
- return norm;
120
- } catch {
121
- return null;
122
- }
123
- }
124
-
125
- function deleteSession(id) {
126
- if (!isValidSessionId(id)) return false;
127
- const f = path.join(sessionsDir(), id + '.json');
128
- if (fs.existsSync(f)) {
129
- fs.unlinkSync(f);
130
- return true;
131
- }
132
- return false;
133
- }
134
-
135
- function exportChat(session, format) {
136
- const type = format || 'md';
137
- const msgs = session.messages || [];
138
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
139
- const file = path.join(process.cwd(), 'loom-chat-' + ts + '.' + type);
140
- if (type === 'md') {
141
- const lines = ['# Loom Code Chat Export', '', '**Date:** ' + new Date().toLocaleString(), ''];
142
- for (const m of msgs) {
143
- const role = m.role || 'unknown';
144
- lines.push('### ' + role.charAt(0).toUpperCase() + role.slice(1));
145
- lines.push('');
146
- lines.push(m.content || '(empty)');
147
- lines.push('');
148
- }
149
- fs.writeFileSync(file, lines.join('\n'));
150
- } else {
151
- const exportLines = msgs.map((m) => m.role + ': ' + (m.content || ''));
152
- fs.writeFileSync(file, exportLines.join('\n'));
153
- }
154
- return file;
155
- }
156
-
157
- module.exports = {
158
- saveSession,
159
- listSessions,
160
- loadSession,
161
- deleteSession,
162
- exportChat,
163
- normalizeSession,
164
- isValidSessionId,
165
- SESSIONS_DIR,
166
- SESSION_SCHEMA_VERSION,
167
- };
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const SESSION_SCHEMA_VERSION = 3;
6
+
7
+ const LOOM_DIR = path.join(os.homedir(), '.loom');
8
+
9
+ // Re-evaluated on every call so tests can isolate with LOOM_CONFIG_DIR.
10
+ // Layout is claude-code/opencode style: one folder per session inside the
11
+ // PROJECT's .loom/ directory:
12
+ //
13
+ // <project>/.loom/sessions/<id>/session.json metadata
14
+ // <project>/.loom/sessions/<id>/messages.jsonl one message per line
15
+ //
16
+ // Flat legacy files (~/.loom/sessions/<id>.json, schema v1/v2) stay
17
+ // readable; the next saveSession migrates them into the project folder.
18
+ function baseDir() {
19
+ if (process.env.LOOM_CONFIG_DIR) return path.join(process.env.LOOM_CONFIG_DIR, 'sessions');
20
+ return path.join(process.cwd(), '.loom', 'sessions');
21
+ }
22
+
23
+ // The pre-v3 global store. Same as baseDir() under LOOM_CONFIG_DIR (tests),
24
+ // the user's old ~/.loom/sessions otherwise.
25
+ function legacyDir() {
26
+ if (process.env.LOOM_CONFIG_DIR) return path.join(process.env.LOOM_CONFIG_DIR, 'sessions');
27
+ return path.join(LOOM_DIR, 'sessions');
28
+ }
29
+
30
+ function ensureDir() {
31
+ const d = baseDir();
32
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
33
+ }
34
+
35
+ function convId() {
36
+ return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6);
37
+ }
38
+
39
+ // Session IDs become folder names — keep them strictly alphanumeric to rule
40
+ // out path traversal via loadSession/deleteSession (e.g. "../../secrets").
41
+ const ID_RX = /^[a-zA-Z0-9_-]{3,128}$/;
42
+ function isValidSessionId(id) {
43
+ return typeof id === 'string' && ID_RX.test(id);
44
+ }
45
+
46
+ // Coerce whatever came off disk (hand-edited, corrupt, older schema) into the
47
+ // guaranteed current shape: { id, createdAt, schemaVersion, messages } with
48
+ // every message having { role, content } while preserving any extra fields.
49
+ // Snake-case tool fields from old saves are aliased to camelCase so a resumed
50
+ // session can always rebuild a valid provider payload (toolCallId is sent as
51
+ // tool_call_id / tool_use_id — losing it 400s on strict providers).
52
+ function normalizeSession(data) {
53
+ if (!data || typeof data !== 'object') return null;
54
+ const raw = Array.isArray(data.messages) ? data.messages : [];
55
+ const messages = raw.map((m) => {
56
+ if (!m || typeof m !== 'object') return { role: 'user', content: '' };
57
+ const { role, content, ...rest } = m;
58
+ const out = { role: role || 'user', content: typeof content === 'undefined' ? '' : content, ...rest };
59
+ if (!out.toolCalls && Array.isArray(rest.tool_calls)) out.toolCalls = rest.tool_calls;
60
+ if (!out.toolCallId && rest.tool_call_id) out.toolCallId = rest.tool_call_id;
61
+ return out;
62
+ });
63
+ return {
64
+ id: typeof data.id === 'string' ? data.id : null,
65
+ createdAt: typeof data.createdAt === 'string' ? data.createdAt : null,
66
+ schemaVersion: typeof data.schemaVersion === 'number' ? data.schemaVersion : 1,
67
+ messages,
68
+ };
69
+ }
70
+
71
+ function sessionFolder(id) {
72
+ return path.join(baseDir(), id);
73
+ }
74
+
75
+ function readFolderSession(id) {
76
+ const dir = sessionFolder(id);
77
+ const metaFile = path.join(dir, 'session.json');
78
+ if (!fs.existsSync(metaFile)) return null;
79
+ try {
80
+ const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
81
+ const messages = [];
82
+ const msgFile = path.join(dir, 'messages.jsonl');
83
+ if (fs.existsSync(msgFile)) {
84
+ const lines = fs.readFileSync(msgFile, 'utf8').split('\n');
85
+ for (const line of lines) {
86
+ const t = line.trim();
87
+ if (!t) continue;
88
+ try { messages.push(JSON.parse(t)); } catch {}
89
+ }
90
+ }
91
+ const norm = normalizeSession({ ...meta, messages });
92
+ if (!norm) return null;
93
+ norm.id = norm.id || id;
94
+ norm.file = metaFile;
95
+ norm.dir = dir;
96
+ return norm;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ function readFlatSession(dir, id) {
103
+ const f = path.join(dir, id + '.json');
104
+ if (!fs.existsSync(f)) return null;
105
+ try {
106
+ const raw = JSON.parse(fs.readFileSync(f, 'utf8'));
107
+ const norm = normalizeSession(raw);
108
+ if (!norm) return null;
109
+ norm.id = norm.id || id;
110
+ norm.file = f;
111
+ return norm;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ function saveSession(session) {
118
+ ensureDir();
119
+ const id = isValidSessionId(session.conversationId) ? session.conversationId : convId();
120
+ const now = new Date().toISOString();
121
+ // Overwriting an existing session keeps its original createdAt (it is the
122
+ // same conversation); updatedAt always reflects this write.
123
+ let createdAt = now;
124
+ const prev = readFolderSession(id) || readFlatSession(baseDir(), id) || readFlatSession(legacyDir(), id);
125
+ if (prev && typeof prev.createdAt === 'string') createdAt = prev.createdAt;
126
+ const messages = session.messages || [];
127
+ const data = {
128
+ id,
129
+ schemaVersion: SESSION_SCHEMA_VERSION,
130
+ createdAt,
131
+ updatedAt: now,
132
+ provider: session.config ? session.config.provider : null,
133
+ model: session.config && session.config.model ? session.config.model[session.config.provider] : null,
134
+ messageCount: messages.length,
135
+ };
136
+ const dir = sessionFolder(id);
137
+ fs.mkdirSync(dir, { recursive: true });
138
+ const metaFile = path.join(dir, 'session.json');
139
+ // Write messages first, metadata last: a crash mid-save leaves the old
140
+ // session.json pointing at a complete messages file, never the reverse.
141
+ const msgFile = path.join(dir, 'messages.jsonl');
142
+ const tmp = msgFile + '.tmp';
143
+ fs.writeFileSync(tmp, messages.map((m) => JSON.stringify(m)).join('\n') + (messages.length ? '\n' : ''));
144
+ fs.renameSync(tmp, msgFile);
145
+ fs.writeFileSync(metaFile, JSON.stringify(data, null, 2));
146
+ return { id, file: metaFile, dir };
147
+ }
148
+
149
+ function listDir(dir, fromFolder) {
150
+ let names;
151
+ try { names = fs.readdirSync(dir); } catch { return []; }
152
+ const out = [];
153
+ for (const name of names) {
154
+ if (fromFolder) {
155
+ const entry = readFolderSession(name);
156
+ if (entry && isValidSessionId(entry.id)) out.push(entry);
157
+ } else if (name.endsWith('.json')) {
158
+ const id = name.replace(/\.json$/, '');
159
+ const entry = readFlatSession(dir, id);
160
+ if (entry && isValidSessionId(entry.id)) out.push(entry);
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+
166
+ function listSessions() {
167
+ ensureDir();
168
+ // Newest first by the last write time (updatedAt, else createdAt). Folder
169
+ // sessions shadow same-id flat files (a migrated session must not appear
170
+ // twice). Legacy ~/.loom/sessions files stay listed so old history remains
171
+ // reachable after upgrading to the per-project folder layout.
172
+ const byId = new Map();
173
+ for (const s of listDir(legacyDir(), false)) byId.set(s.id, s);
174
+ for (const s of listDir(baseDir(), false)) byId.set(s.id, s);
175
+ for (const s of listDir(baseDir(), true)) byId.set(s.id, s);
176
+ const list = [...byId.values()].map((data) => {
177
+ let mtime = null;
178
+ try { mtime = fs.statSync(data.file).mtime.toISOString(); } catch {}
179
+ return {
180
+ id: data.id,
181
+ createdAt: data.createdAt,
182
+ updatedAt: data.updatedAt,
183
+ mtime,
184
+ messageCount: data.messageCount || (data.messages || []).length,
185
+ provider: data.provider,
186
+ model: data.model,
187
+ };
188
+ });
189
+ const stamp = (s) => s.updatedAt || s.createdAt || s.mtime || '';
190
+ return list.sort((a, b) => (stamp(b) < stamp(a) ? -1 : stamp(b) > stamp(a) ? 1 : 0));
191
+ }
192
+
193
+ function loadSession(id) {
194
+ if (!isValidSessionId(id)) return null;
195
+ return readFolderSession(id) || readFlatSession(baseDir(), id) || readFlatSession(legacyDir(), id);
196
+ }
197
+
198
+ function deleteSession(id) {
199
+ if (!isValidSessionId(id)) return false;
200
+ let removed = false;
201
+ const dir = sessionFolder(id);
202
+ if (fs.existsSync(dir)) {
203
+ try { fs.rmSync(dir, { recursive: true, force: true }); removed = true; } catch {}
204
+ }
205
+ for (const d of new Set([baseDir(), legacyDir()])) {
206
+ const f = path.join(d, id + '.json');
207
+ if (fs.existsSync(f)) {
208
+ try { fs.unlinkSync(f); removed = true; } catch {}
209
+ }
210
+ }
211
+ return removed;
212
+ }
213
+
214
+ function exportChat(session, format) {
215
+ const type = format || 'md';
216
+ const msgs = session.messages || [];
217
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
218
+ const file = path.join(process.cwd(), 'loom-chat-' + ts + '.' + type);
219
+ if (type === 'md') {
220
+ const lines = ['# Loom Code Chat Export', '', '**Date:** ' + new Date().toLocaleString(), ''];
221
+ for (const m of msgs) {
222
+ const role = m.role || 'unknown';
223
+ lines.push('### ' + role.charAt(0).toUpperCase() + role.slice(1));
224
+ lines.push('');
225
+ lines.push(m.content || '(empty)');
226
+ lines.push('');
227
+ }
228
+ fs.writeFileSync(file, lines.join('\n'));
229
+ } else {
230
+ const exportLines = msgs.map((m) => m.role + ': ' + (m.content || ''));
231
+ fs.writeFileSync(file, exportLines.join('\n'));
232
+ }
233
+ return file;
234
+ }
235
+
236
+ module.exports = {
237
+ saveSession,
238
+ listSessions,
239
+ loadSession,
240
+ deleteSession,
241
+ exportChat,
242
+ normalizeSession,
243
+ isValidSessionId,
244
+ SESSION_SCHEMA_VERSION,
245
+ };
@@ -26,11 +26,29 @@ const COMPACT_MIN_MESSAGES = 6;
26
26
  // Drive-to-completion: how many continuation nudges one turn may spend when
27
27
  // the model stops with todos still unfinished (the "stops between tasks"
28
28
  // failure). Capped so a model that refuses to update its todos cannot spin.
29
- const MAX_DRIVE_NUDGES = 5;
29
+ const MAX_DRIVE_NUDGES = 8;
30
+ // Empty-reply nudges per turn (provider hiccup / reasoning-only stop).
31
+ const MAX_EMPTY_NUDGES = 3;
32
+ // finish_reason=length (output budget exhausted) auto-continues the turn
33
+ // this many times before surfacing the truncation placeholder. This is the
34
+ // #1 cause of "stops in the middle" with reasoning models: thinking eats
35
+ // the whole max_tokens budget and the answer never streams.
36
+ const MAX_TRUNCATION_CONTINUES = 5;
30
37
  // Transient provider failures (network blip, 5xx, stream reset) retry the
31
38
  // model call this many times before the turn surfaces an error.
32
39
  const PROVIDER_RETRIES = 2;
33
40
 
41
+ // Does this reply sound like the model stopped in the MIDDLE of working?
42
+ // Used when todos exist but weren't touched this turn: a short reply, or a
43
+ // long one that ends on a continuation promise ("Now I will…", "Next, …"),
44
+ // an open colon, or an ellipsis is unfinished. A full explanation is not.
45
+ function looksUnfinished(text) {
46
+ const t = String(text || '').trim();
47
+ if (!t) return true;
48
+ if (t.length < 320) return true;
49
+ return /(?:\b(?:now|next|then|let me|i'?ll|going to|proceeding)\b[^.!?]*[.!]?\s*$)|[:…]$|\.\.\.\s*$/i.test(t);
50
+ }
51
+
34
52
  /**
35
53
  * @typedef {Object} SpeedStats
36
54
  * @property {number} _turnStart
@@ -245,6 +263,7 @@ ${this.loadSkills()}
245
263
  ## Mode
246
264
  You are in ${this.mode === 'build' ? 'BUILD MODE (full agent)' : this.mode === 'plan' ? 'PLAN MODE (read-only analysis)' : 'CHAT MODE (conversation only)'}.
247
265
  ${this.mode === 'build' ? `- You have all tools available: read, write, edit, bash, glob, grep, webfetch, and MCP tools.
266
+ - bash runs on the user's machine. Only use the sandbox tool when isolation is genuinely needed (the user asked for it, or the command is risky/Linux-only); never for ordinary commands.
248
267
  - Inspect, edit, and verify the result yourself end-to-end.
249
268
  - When done, summarize in 1-2 sentences.` : ''}${this.mode === 'plan' ? `- Read-only tools only: read, glob, grep, webfetch, todowrite. No MCP tools.
250
269
  - Investigate thoroughly, then output a "## Plan" with ordered steps (exact file path + what changes each step makes). No narration.` : ''}${this.mode === 'chat' ? `- No tools available; answer conversationally.
@@ -405,6 +424,19 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
405
424
  this.messages.push(msg);
406
425
  }
407
426
 
427
+ // Remove a trailing empty assistant row (no text, no toolCalls). getResponse
428
+ // appends the assistant message even when the provider returned nothing —
429
+ // Anthropic rejects empty assistant content with a 400 on the next call, so
430
+ // continuation paths drop the row before adding their user nudge.
431
+ _dropTrailingEmptyAssistant() {
432
+ const last = this.messages[this.messages.length - 1];
433
+ if (last && last.role === 'assistant' && !(last.toolCalls || []).length && !String(last.content || '').trim()) {
434
+ this.messages.pop();
435
+ return true;
436
+ }
437
+ return false;
438
+ }
439
+
408
440
  // ─── Todo state ───
409
441
  // The todowrite tool persists into the session so the sidebar shows the
410
442
  // real, up-to-date task list instead of regex-scanning replies.
@@ -568,10 +600,11 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
568
600
  // No tool-use cap: the model decides when a turn is finished; the user can
569
601
  // always Esc-interrupt a runaway loop.
570
602
  let ranTools = false;
571
- let nudged = false;
603
+ let emptyNudges = 0;
572
604
  let toolCallsThisTurn = 0;
573
605
  // Drive-to-completion state (see the no-toolCalls branch below).
574
606
  let driveNudges = 0;
607
+ let truncContinues = 0;
575
608
  let todowroteThisTurn = false;
576
609
  // Transient provider-error retry state.
577
610
  let providerRetries = 0;
@@ -644,30 +677,58 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
644
677
 
645
678
  if (!toolCalls.length) {
646
679
  const text = String(resp.content || '');
680
+
681
+ // Truncation auto-continue: finish_reason=length means the reply was
682
+ // CUT OFF by maxTokens — common with reasoning models (thinking eats
683
+ // the budget, the final answer never streams). Instead of dead-ending
684
+ // with a placeholder (the classic "stops in the middle"), continue the
685
+ // turn. Empty-content truncations also drop the empty assistant row
686
+ // from history — Anthropic 400s on empty assistant messages.
687
+ if (!this.interrupted && resp.finishReason === 'length' && truncContinues < MAX_TRUNCATION_CONTINUES) {
688
+ truncContinues++;
689
+ if (!text.trim()) this._dropTrailingEmptyAssistant();
690
+ const at = text.trim() ? text.replace(/\s+/g, ' ').trim().slice(-160) : null;
691
+ this.addMessage({
692
+ role: 'user',
693
+ content: '(your reply was cut off by the output-token limit' +
694
+ (at ? ' right after: \u2026' + at : ' before any text streamed') +
695
+ ' \u2014 continue exactly from there and finish the task. Keep the remaining reply compact so it fits.)',
696
+ });
697
+ continue;
698
+ }
699
+
647
700
  // Silent-stop guard: models occasionally end a turn with an EMPTY
648
- // reply right after running tools (truncation / provider hiccup),
649
- // which reads to the user like "the agent just stopped". One
650
- // automatic continuation instead of dead-ending with a placeholder.
651
- if (!text.trim() && ranTools && !nudged && !this.interrupted) {
652
- nudged = true;
701
+ // reply — right after running tools (truncation / provider hiccup)
702
+ // or even on a plain first reply (reasoning-only stop, content
703
+ // filter). Automatic continuation instead of dead-ending with a
704
+ // placeholder — up to MAX_EMPTY_NUDGES per turn (hiccups repeat).
705
+ // finish_reason=length skips this: the truncation block above already
706
+ // spent its budget, and the placeholder below explains why.
707
+ if (!text.trim() && emptyNudges < MAX_EMPTY_NUDGES && !this.interrupted && resp.finishReason !== 'length') {
708
+ emptyNudges++;
709
+ this._dropTrailingEmptyAssistant();
653
710
  this.addMessage({
654
711
  role: 'user',
655
- content: '(the model returned an empty reply after ' + toolCallsThisTurn +
656
- ' tool call(s) — continue where you left off and finish the task)',
712
+ content: ranTools
713
+ ? '(the model returned an empty reply after ' + toolCallsThisTurn +
714
+ ' tool call(s) — continue where you left off and finish the task)'
715
+ : '(your last reply was empty — respond to the user with text, or use tools to do the work)',
657
716
  });
658
717
  continue;
659
718
  }
660
719
  // Drive-to-completion guard: the model stopped calling tools in the
661
- // middle of tracked work (todos written this turn still
662
- // pending/in_progress). Inject one continuation that quotes the
663
- // model's ACTUAL last message and lists the unfinished items — up to
664
- // MAX_DRIVE_NUDGES times — so a premature summary does not strand the
665
- // task. The model can stop cleanly by completing or cancelling the
666
- // items via todowrite. Plan/chat modes are excluded (plan drafts are
667
- // meant to end with pending steps).
668
- if (!this.interrupted && this.mode === 'build' && ranTools && todowroteThisTurn && driveNudges < MAX_DRIVE_NUDGES) {
720
+ // middle of tracked work. Two triggers:
721
+ // 1. todos written THIS turn are still pending/in_progress (the
722
+ // strong signal — always nudge), or
723
+ // 2. tools ran this turn, todos exist, and the reply LOOKS
724
+ // unfinished (short / ends on a promise) — catches premature
725
+ // wrap-ups from models that never call todowrite.
726
+ // Up to MAX_DRIVE_NUDGES continuations. Plan/chat modes are excluded
727
+ // (plan drafts are meant to end with pending steps).
728
+ if (!this.interrupted && this.mode === 'build' && ranTools && driveNudges < MAX_DRIVE_NUDGES) {
669
729
  const unfinished = (this.todos || []).filter((t) => t.status === 'pending' || t.status === 'in_progress');
670
- if (unfinished.length) {
730
+ const gated = todowroteThisTurn || looksUnfinished(text);
731
+ if (unfinished.length && gated) {
671
732
  driveNudges++;
672
733
  const list = unfinished.slice(0, 20).map((t) => '- [' + t.status + '] ' + t.content).join('\n');
673
734
  // Quote what the model actually said — a canned nudge alone gives
@@ -685,16 +746,21 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
685
746
  continue;
686
747
  }
687
748
  }
688
- // Surface WHAT is still open when a build-mode turn that WROTE todos
689
- // ends with work remaining (same signal as the nudge above), so the
690
- // UI can show the real leftovers instead of a generic "stopped"
691
- // placeholder. Stale todos from older turns stay silent.
692
- const openTodos = (this.mode === 'build' && todowroteThisTurn)
749
+ // Surface WHAT is still open when a build-mode turn engaged the drive
750
+ // guard (or wrote todos) and ends with work remaining, so the UI can
751
+ // show the real leftovers instead of a generic "stopped" placeholder.
752
+ const openTodos = (this.mode === 'build' && (todowroteThisTurn || driveNudges > 0))
693
753
  ? (this.todos || []).filter((t) => t.status === 'pending' || t.status === 'in_progress')
694
754
  : [];
755
+ // Safety net: after all continuation budgets are spent, a
756
+ // finish_reason=length reply still gets the explanatory placeholder
757
+ // instead of mystery silence.
758
+ const truncHint = resp.finishReason === 'length'
759
+ ? '(the reply hit the max-tokens limit mid-stream — the model ran out of output budget before writing any text. Raise maxTokens in config, or /think off to free the thinking budget)'
760
+ : '(the model ended the turn without any text)';
695
761
  return {
696
762
  type: 'text',
697
- content: text || '(the model ended the turn without any text)',
763
+ content: text || truncHint,
698
764
  openTodos: openTodos.length ? openTodos : undefined,
699
765
  };
700
766
  }
@@ -50,10 +50,15 @@ function buildBody(messages, options) {
50
50
  continue;
51
51
  }
52
52
  if (m.role === 'tool') {
53
- out.push({
54
- role: 'user',
55
- content: [{ type: 'tool_result', tool_use_id: m.toolCallId, content: String(m.content) }],
56
- });
53
+ // tool_use_id is required by the API — skip orphaned tool rows from
54
+ // resumed/hand-edited sessions rather than failing the whole turn.
55
+ const tcid = m.toolCallId || m.tool_call_id;
56
+ if (tcid) {
57
+ out.push({
58
+ role: 'user',
59
+ content: [{ type: 'tool_result', tool_use_id: tcid, content: String(m.content) }],
60
+ });
61
+ }
57
62
  continue;
58
63
  }
59
64
  if (m.role === 'assistant') {
@@ -70,7 +75,7 @@ function buildBody(messages, options) {
70
75
  } else if (m.content) {
71
76
  blocks.push({ type: 'text', text: m.content });
72
77
  }
73
- for (const tc of m.toolCalls || []) {
78
+ for (const tc of (m.toolCalls || m.tool_calls) || []) {
74
79
  blocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.input || {} });
75
80
  }
76
81
  out.push({ role: 'assistant', content: blocks });