loom-agent 1.2.37 → 1.3.2

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
@@ -405,6 +423,19 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
405
423
  this.messages.push(msg);
406
424
  }
407
425
 
426
+ // Remove a trailing empty assistant row (no text, no toolCalls). getResponse
427
+ // appends the assistant message even when the provider returned nothing —
428
+ // Anthropic rejects empty assistant content with a 400 on the next call, so
429
+ // continuation paths drop the row before adding their user nudge.
430
+ _dropTrailingEmptyAssistant() {
431
+ const last = this.messages[this.messages.length - 1];
432
+ if (last && last.role === 'assistant' && !(last.toolCalls || []).length && !String(last.content || '').trim()) {
433
+ this.messages.pop();
434
+ return true;
435
+ }
436
+ return false;
437
+ }
438
+
408
439
  // ─── Todo state ───
409
440
  // The todowrite tool persists into the session so the sidebar shows the
410
441
  // real, up-to-date task list instead of regex-scanning replies.
@@ -568,10 +599,11 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
568
599
  // No tool-use cap: the model decides when a turn is finished; the user can
569
600
  // always Esc-interrupt a runaway loop.
570
601
  let ranTools = false;
571
- let nudged = false;
602
+ let emptyNudges = 0;
572
603
  let toolCallsThisTurn = 0;
573
604
  // Drive-to-completion state (see the no-toolCalls branch below).
574
605
  let driveNudges = 0;
606
+ let truncContinues = 0;
575
607
  let todowroteThisTurn = false;
576
608
  // Transient provider-error retry state.
577
609
  let providerRetries = 0;
@@ -644,30 +676,58 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
644
676
 
645
677
  if (!toolCalls.length) {
646
678
  const text = String(resp.content || '');
679
+
680
+ // Truncation auto-continue: finish_reason=length means the reply was
681
+ // CUT OFF by maxTokens — common with reasoning models (thinking eats
682
+ // the budget, the final answer never streams). Instead of dead-ending
683
+ // with a placeholder (the classic "stops in the middle"), continue the
684
+ // turn. Empty-content truncations also drop the empty assistant row
685
+ // from history — Anthropic 400s on empty assistant messages.
686
+ if (!this.interrupted && resp.finishReason === 'length' && truncContinues < MAX_TRUNCATION_CONTINUES) {
687
+ truncContinues++;
688
+ if (!text.trim()) this._dropTrailingEmptyAssistant();
689
+ const at = text.trim() ? text.replace(/\s+/g, ' ').trim().slice(-160) : null;
690
+ this.addMessage({
691
+ role: 'user',
692
+ content: '(your reply was cut off by the output-token limit' +
693
+ (at ? ' right after: \u2026' + at : ' before any text streamed') +
694
+ ' \u2014 continue exactly from there and finish the task. Keep the remaining reply compact so it fits.)',
695
+ });
696
+ continue;
697
+ }
698
+
647
699
  // 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;
700
+ // reply — right after running tools (truncation / provider hiccup)
701
+ // or even on a plain first reply (reasoning-only stop, content
702
+ // filter). Automatic continuation instead of dead-ending with a
703
+ // placeholder — up to MAX_EMPTY_NUDGES per turn (hiccups repeat).
704
+ // finish_reason=length skips this: the truncation block above already
705
+ // spent its budget, and the placeholder below explains why.
706
+ if (!text.trim() && emptyNudges < MAX_EMPTY_NUDGES && !this.interrupted && resp.finishReason !== 'length') {
707
+ emptyNudges++;
708
+ this._dropTrailingEmptyAssistant();
653
709
  this.addMessage({
654
710
  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)',
711
+ content: ranTools
712
+ ? '(the model returned an empty reply after ' + toolCallsThisTurn +
713
+ ' tool call(s) — continue where you left off and finish the task)'
714
+ : '(your last reply was empty — respond to the user with text, or use tools to do the work)',
657
715
  });
658
716
  continue;
659
717
  }
660
718
  // 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) {
719
+ // middle of tracked work. Two triggers:
720
+ // 1. todos written THIS turn are still pending/in_progress (the
721
+ // strong signal — always nudge), or
722
+ // 2. tools ran this turn, todos exist, and the reply LOOKS
723
+ // unfinished (short / ends on a promise) — catches premature
724
+ // wrap-ups from models that never call todowrite.
725
+ // Up to MAX_DRIVE_NUDGES continuations. Plan/chat modes are excluded
726
+ // (plan drafts are meant to end with pending steps).
727
+ if (!this.interrupted && this.mode === 'build' && ranTools && driveNudges < MAX_DRIVE_NUDGES) {
669
728
  const unfinished = (this.todos || []).filter((t) => t.status === 'pending' || t.status === 'in_progress');
670
- if (unfinished.length) {
729
+ const gated = todowroteThisTurn || looksUnfinished(text);
730
+ if (unfinished.length && gated) {
671
731
  driveNudges++;
672
732
  const list = unfinished.slice(0, 20).map((t) => '- [' + t.status + '] ' + t.content).join('\n');
673
733
  // Quote what the model actually said — a canned nudge alone gives
@@ -685,16 +745,21 @@ ${this.mode === 'build' ? `- You have all tools available: read, write, edit, ba
685
745
  continue;
686
746
  }
687
747
  }
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)
748
+ // Surface WHAT is still open when a build-mode turn engaged the drive
749
+ // guard (or wrote todos) and ends with work remaining, so the UI can
750
+ // show the real leftovers instead of a generic "stopped" placeholder.
751
+ const openTodos = (this.mode === 'build' && (todowroteThisTurn || driveNudges > 0))
693
752
  ? (this.todos || []).filter((t) => t.status === 'pending' || t.status === 'in_progress')
694
753
  : [];
754
+ // Safety net: after all continuation budgets are spent, a
755
+ // finish_reason=length reply still gets the explanatory placeholder
756
+ // instead of mystery silence.
757
+ const truncHint = resp.finishReason === 'length'
758
+ ? '(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)'
759
+ : '(the model ended the turn without any text)';
695
760
  return {
696
761
  type: 'text',
697
- content: text || '(the model ended the turn without any text)',
762
+ content: text || truncHint,
698
763
  openTodos: openTodos.length ? openTodos : undefined,
699
764
  };
700
765
  }
@@ -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 });