@semalt-ai/code 1.8.3 → 1.8.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/index.js +14 -7
- package/lib/agent.js +189 -58
- package/lib/api.js +11 -34
- package/lib/commands.js +206 -121
- package/lib/config.js +1 -0
- package/lib/constants.js +1 -1
- package/lib/permissions.js +9 -8
- package/lib/prompts.js +4 -7
- package/lib/tools.js +14 -7
- package/lib/ui/chat-history.js +19 -8
- package/lib/ui/create-ui.js +63 -38
- package/lib/ui/diff.js +4 -3
- package/lib/ui/format.js +247 -0
- package/lib/ui/input-field.js +134 -59
- package/lib/ui/layout.js +0 -2
- package/lib/ui/messages.js +44 -0
- package/lib/ui/select.js +114 -0
- package/lib/ui/status-bar.js +135 -28
- package/lib/ui/stream.js +8 -12
- package/lib/ui/terminal.js +2 -0
- package/lib/ui/theme.js +25 -4
- package/lib/ui/utils.js +94 -27
- package/lib/ui/writer.js +393 -45
- package/lib/ui.js +6 -6
- package/package.json +1 -1
- package/lib/ui/legacy.js +0 -130
package/index.js
CHANGED
|
@@ -18,6 +18,7 @@ const { createCommands } = require('./lib/commands');
|
|
|
18
18
|
const { parseArgs } = require('./lib/args');
|
|
19
19
|
const { CONFIG_PATH } = require('./lib/constants');
|
|
20
20
|
const { AUDIT_LOG } = require('./lib/audit');
|
|
21
|
+
const writer = require('./lib/ui/writer');
|
|
21
22
|
|
|
22
23
|
// Install process-wide signal handlers so every exit path (normal, SIGINT,
|
|
23
24
|
// SIGHUP, SIGTERM, uncaught exception) restores the terminal. Safe to call
|
|
@@ -90,7 +91,7 @@ async function main() {
|
|
|
90
91
|
const command = rawArgs[0];
|
|
91
92
|
|
|
92
93
|
if (command === '--help' || command === '-h') {
|
|
93
|
-
|
|
94
|
+
writer.scrollback(`
|
|
94
95
|
Semalt.AI — Self-hosted AI Coding Assistant
|
|
95
96
|
|
|
96
97
|
Usage: semalt-code [command] [options]
|
|
@@ -130,11 +131,13 @@ Options:
|
|
|
130
131
|
|
|
131
132
|
Config: ${CONFIG_PATH}
|
|
132
133
|
`);
|
|
134
|
+
await writer.flush();
|
|
133
135
|
return;
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
if (command === '--version' || command === '-v') {
|
|
137
|
-
|
|
139
|
+
writer.scrollback(PACKAGE_JSON.version);
|
|
140
|
+
await writer.flush();
|
|
138
141
|
return;
|
|
139
142
|
}
|
|
140
143
|
|
|
@@ -170,20 +173,22 @@ Config: ${CONFIG_PATH}
|
|
|
170
173
|
try {
|
|
171
174
|
const entry = JSON.parse(line);
|
|
172
175
|
const icon = entry.approved ? `${ui.FG_GREEN}✓${ui.RST}` : `${ui.FG_RED}✗${ui.RST}`;
|
|
173
|
-
|
|
176
|
+
writer.scrollback(`${icon} ${line}`);
|
|
174
177
|
} catch {
|
|
175
|
-
|
|
178
|
+
writer.scrollback(line);
|
|
176
179
|
}
|
|
177
180
|
}
|
|
178
181
|
} catch {
|
|
179
|
-
|
|
182
|
+
writer.scrollback('No audit log found.');
|
|
180
183
|
}
|
|
184
|
+
await writer.flush();
|
|
181
185
|
} else if (command === 'config') {
|
|
182
186
|
const sub = rawArgs[1];
|
|
183
187
|
if (sub === 'set') {
|
|
184
188
|
const key = rawArgs[2];
|
|
185
189
|
const value = rawArgs[3];
|
|
186
190
|
if (!key || value === undefined) {
|
|
191
|
+
// audit: allowed — pre-UI argparse usage error to stderr; exits immediately.
|
|
187
192
|
process.stderr.write(`Usage: semalt-code config set <key> <value>\n`);
|
|
188
193
|
process.exit(1);
|
|
189
194
|
}
|
|
@@ -194,11 +199,12 @@ Config: ${CONFIG_PATH}
|
|
|
194
199
|
parsed = value;
|
|
195
200
|
}
|
|
196
201
|
configSet(key, parsed);
|
|
197
|
-
|
|
202
|
+
writer.scrollback(`Set ${key} = ${JSON.stringify(parsed)}`);
|
|
198
203
|
} else {
|
|
199
204
|
// default: "show" or bare "config"
|
|
200
|
-
|
|
205
|
+
writer.scrollback(configShow());
|
|
201
206
|
}
|
|
207
|
+
await writer.flush();
|
|
202
208
|
} else {
|
|
203
209
|
const { opts } = parseArgs(rawArgs);
|
|
204
210
|
await commands.cmdChat(opts);
|
|
@@ -209,6 +215,7 @@ main().catch((error) => {
|
|
|
209
215
|
// Tear down the TUI synchronously so the error message lands below the
|
|
210
216
|
// last scrollback line, not on top of a still-rendered live region.
|
|
211
217
|
try { ui.teardownTerminal(); } catch {}
|
|
218
|
+
// audit: allowed — fatal error message to stderr after writer teardown.
|
|
212
219
|
process.stderr.write(`\n ${ui.FG_RED}✗ Fatal: ${error.message}${ui.RST}\n\n`);
|
|
213
220
|
process.exit(1);
|
|
214
221
|
});
|
package/lib/agent.js
CHANGED
|
@@ -8,6 +8,8 @@ const { mapInvokeToCall } = require('./tools');
|
|
|
8
8
|
const { UI_THEME } = require('./ui/theme');
|
|
9
9
|
const { RST } = require('./ui/ansi');
|
|
10
10
|
const { getCols: _getCols, repeatToWidth } = require('./ui/utils');
|
|
11
|
+
const writer = require('./ui/writer');
|
|
12
|
+
const messages = require('./ui/messages');
|
|
11
13
|
|
|
12
14
|
class StreamParser {
|
|
13
15
|
constructor(onToken, onTagOpen, onTagContent, onTagClose) {
|
|
@@ -153,6 +155,31 @@ function estimateTokens(text) {
|
|
|
153
155
|
return Math.floor((text || '').length / 4);
|
|
154
156
|
}
|
|
155
157
|
|
|
158
|
+
// User-initiated aborts surface through several shapes depending on where in
|
|
159
|
+
// the Node http stack the signal fires: `new Error('Aborted')` from our own
|
|
160
|
+
// abort paths in api.js, or AbortError/ABORT_ERR from Node's built-ins. The
|
|
161
|
+
// authoritative check is the signal itself — this helper is the fallback.
|
|
162
|
+
function isAbortError(err) {
|
|
163
|
+
if (!err) return false;
|
|
164
|
+
if (err.name === 'AbortError') return true;
|
|
165
|
+
if (err.code === 'ABORT_ERR' || err.code === 'ERR_ABORTED') return true;
|
|
166
|
+
if (typeof err.message === 'string' && /^Aborted$/i.test(err.message)) return true;
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function abortableSleep(ms, signal) {
|
|
171
|
+
return new Promise((resolve) => {
|
|
172
|
+
if (signal && signal.aborted) { resolve(); return; }
|
|
173
|
+
const t = setTimeout(resolve, ms);
|
|
174
|
+
if (signal) {
|
|
175
|
+
signal.addEventListener('abort', () => {
|
|
176
|
+
clearTimeout(t);
|
|
177
|
+
resolve();
|
|
178
|
+
}, { once: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
156
183
|
function detectFormat(reply, toolCalls) {
|
|
157
184
|
if (!reply || !reply.trim()) return 'empty';
|
|
158
185
|
if (/<(minimax:tool_call|qwen:tool_call|tool_call|function_call)\b/i.test(reply)) return 'tool_call';
|
|
@@ -281,6 +308,116 @@ function truncateForDebug(text, maxLines = 40, maxChars = 2000) {
|
|
|
281
308
|
return s;
|
|
282
309
|
}
|
|
283
310
|
|
|
311
|
+
// Per-tag meta extractor. Converts a tool-executor return value into the
|
|
312
|
+
// compact meta object consumed by the tool-line formatter — exit codes for
|
|
313
|
+
// shell, byte counts for file ops, status_code + bytes for HTTP, etc. A
|
|
314
|
+
// pure function by design: no UI state, no config reads. The callback
|
|
315
|
+
// layer (commands.js) feeds the meta into formatToolLine together with
|
|
316
|
+
// the tag, so the formatter can produce the 4-segment line in either the
|
|
317
|
+
// pending (live region) or final (scrollback) context.
|
|
318
|
+
function _metaForTool(tag, result) {
|
|
319
|
+
if (!result || result.error) return null;
|
|
320
|
+
switch (tag) {
|
|
321
|
+
case 'shell':
|
|
322
|
+
case 'exec':
|
|
323
|
+
return { exit_code: result.exit_code };
|
|
324
|
+
case 'read':
|
|
325
|
+
case 'read_file':
|
|
326
|
+
return {
|
|
327
|
+
bytes: typeof result.bytes === 'number'
|
|
328
|
+
? result.bytes
|
|
329
|
+
: (result.content ? Buffer.byteLength(String(result.content), 'utf8') : 0),
|
|
330
|
+
};
|
|
331
|
+
case 'write':
|
|
332
|
+
case 'write_file':
|
|
333
|
+
case 'create_file':
|
|
334
|
+
case 'append':
|
|
335
|
+
case 'append_file':
|
|
336
|
+
case 'upload':
|
|
337
|
+
return { bytes: typeof result.bytes === 'number' ? result.bytes : 0 };
|
|
338
|
+
case 'list_dir':
|
|
339
|
+
return { count: Array.isArray(result.items) ? result.items.length : 0 };
|
|
340
|
+
case 'search_files':
|
|
341
|
+
return { count: Array.isArray(result.files) ? result.files.length : 0 };
|
|
342
|
+
case 'search_in_file':
|
|
343
|
+
return { count: Array.isArray(result.matches) ? result.matches.length : 0 };
|
|
344
|
+
case 'replace_in_file':
|
|
345
|
+
return { count: typeof result.count === 'number' ? result.count : 0 };
|
|
346
|
+
case 'http_get':
|
|
347
|
+
case 'download':
|
|
348
|
+
return {
|
|
349
|
+
status_code: result.status_code,
|
|
350
|
+
bytes: typeof result.bytes === 'number'
|
|
351
|
+
? result.bytes
|
|
352
|
+
: (result.body ? Buffer.byteLength(String(result.body), 'utf8') : 0),
|
|
353
|
+
};
|
|
354
|
+
case 'file_stat':
|
|
355
|
+
return {
|
|
356
|
+
bytes: result.size_kb ? Math.round(parseFloat(result.size_kb) * 1024) : 0,
|
|
357
|
+
kind: result.type || null,
|
|
358
|
+
};
|
|
359
|
+
default:
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Turn a [action, arg1, arg2, …] call tuple into the `attrs` bag that
|
|
365
|
+
// formatToolLine looks up when building the operation string. Centralized
|
|
366
|
+
// here so the per-tag positional-arg contract is written down in exactly
|
|
367
|
+
// one place — any new tool added to the agent-loop tuple schema also gets
|
|
368
|
+
// its attrs mapping here.
|
|
369
|
+
function _attrsFromCall(call) {
|
|
370
|
+
if (!Array.isArray(call) || call.length === 0) return {};
|
|
371
|
+
const [tag, ...args] = call;
|
|
372
|
+
switch (tag) {
|
|
373
|
+
case 'shell':
|
|
374
|
+
case 'exec':
|
|
375
|
+
return { command: args[0] || '' };
|
|
376
|
+
case 'read':
|
|
377
|
+
case 'read_file':
|
|
378
|
+
case 'list_dir':
|
|
379
|
+
case 'delete_file':
|
|
380
|
+
case 'make_dir':
|
|
381
|
+
case 'remove_dir':
|
|
382
|
+
case 'file_stat':
|
|
383
|
+
return { path: args[0] || '' };
|
|
384
|
+
case 'write':
|
|
385
|
+
case 'write_file':
|
|
386
|
+
case 'create_file':
|
|
387
|
+
case 'append':
|
|
388
|
+
case 'append_file':
|
|
389
|
+
return { path: args[0] || '', content: args[1] || '' };
|
|
390
|
+
case 'upload':
|
|
391
|
+
return { path: args[0] || '' };
|
|
392
|
+
case 'move_file':
|
|
393
|
+
case 'copy_file':
|
|
394
|
+
return { src: args[0] || '', dst: args[1] || '' };
|
|
395
|
+
case 'edit_file':
|
|
396
|
+
return { path: args[0] || '', line: args[1], content: args[2] || '' };
|
|
397
|
+
case 'search_files':
|
|
398
|
+
return { pattern: args[0] || '', dir: args[1] || '.' };
|
|
399
|
+
case 'search_in_file':
|
|
400
|
+
return { path: args[0] || '', pattern: args[1] || '' };
|
|
401
|
+
case 'replace_in_file':
|
|
402
|
+
return { path: args[0] || '', search: args[1] || '', replace: args[2] || '', flags: args[3] || '' };
|
|
403
|
+
case 'get_env':
|
|
404
|
+
return { name: args[0] || '' };
|
|
405
|
+
case 'set_env':
|
|
406
|
+
return { name: args[0] || '', value: args[1] || '' };
|
|
407
|
+
case 'download':
|
|
408
|
+
case 'http_get':
|
|
409
|
+
return { url: args[0] || '' };
|
|
410
|
+
case 'ask_user':
|
|
411
|
+
return { question: args[0] || '' };
|
|
412
|
+
case 'store_memory':
|
|
413
|
+
return { key: args[0] || '', value: args[1] || '' };
|
|
414
|
+
case 'recall_memory':
|
|
415
|
+
return { key: args[0] || '' };
|
|
416
|
+
default:
|
|
417
|
+
return {};
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
284
421
|
function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agentExecFile, ui, getConfig }) {
|
|
285
422
|
const { BOLD, FG_DARK, FG_GRAY, FG_TEAL, FG_YELLOW, RST, THEME, getCols } = ui;
|
|
286
423
|
|
|
@@ -466,6 +603,7 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
|
|
|
466
603
|
// for one-shot/non-TTY flows where there's no UI to host the block.
|
|
467
604
|
const emitDebug = (block) => {
|
|
468
605
|
if (typeof cb.onDebug === 'function') cb.onDebug(block);
|
|
606
|
+
// audit: allowed — stderr debug under --debug flag (no UI hosting available).
|
|
469
607
|
else process.stderr.write('\n' + block + '\n');
|
|
470
608
|
};
|
|
471
609
|
|
|
@@ -479,12 +617,6 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
|
|
|
479
617
|
|
|
480
618
|
const activeSystemPrompt = overrideSystemPrompt !== null ? overrideSystemPrompt : getSystemPrompt(nativeTools);
|
|
481
619
|
|
|
482
|
-
// Response contract: every model response must end with a tool call or
|
|
483
|
-
// <final_answer>...</final_answer>. Anything else is degraded — push a
|
|
484
|
-
// synthetic nudge and retry, capped to prevent runaway loops.
|
|
485
|
-
const MAX_DEGRADED_RETRIES = 2;
|
|
486
|
-
let degradedRetries = 0;
|
|
487
|
-
|
|
488
620
|
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
489
621
|
if (isAborted()) break;
|
|
490
622
|
const linePrefix = `${FG_TEAL}${BOLD}◆ ${RST}`;
|
|
@@ -582,6 +714,14 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
|
|
|
582
714
|
lastApiErr = null;
|
|
583
715
|
break;
|
|
584
716
|
} catch (err) {
|
|
717
|
+
// User-initiated abort: not a transient failure. Skip the retry
|
|
718
|
+
// counter, the "Retrying (N/M)..." status update, the debug dump,
|
|
719
|
+
// and the post-loop error surface. The "Interrupted." feedback is
|
|
720
|
+
// already shown by the input-field abort listener.
|
|
721
|
+
if (controller.signal.aborted || isAborted() || isAbortError(err)) {
|
|
722
|
+
lastApiErr = null;
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
585
725
|
lastApiErr = err;
|
|
586
726
|
if (debug) {
|
|
587
727
|
const status = err.statusCode ? `HTTP ${err.statusCode}` : 'network error';
|
|
@@ -621,13 +761,25 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
|
|
|
621
761
|
}
|
|
622
762
|
}
|
|
623
763
|
cb.onRetry?.(attempt + 1, MAX_RETRIES);
|
|
624
|
-
await
|
|
764
|
+
await abortableSleep(delayMs, controller.signal);
|
|
765
|
+
// Ctrl+C pressed during backoff: bail without the next attempt.
|
|
766
|
+
if (controller.signal.aborted || isAborted()) {
|
|
767
|
+
lastApiErr = null;
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
625
770
|
}
|
|
626
771
|
}
|
|
627
772
|
} finally {
|
|
628
773
|
clearInterval(abortWatcher);
|
|
629
774
|
}
|
|
630
775
|
|
|
776
|
+
// User-initiated abort: exit the turn quietly. Skip the empty-reply
|
|
777
|
+
// "connection dropped" warning below — the abort listener already
|
|
778
|
+
// surfaced "Interrupted." and the outer prompt will return.
|
|
779
|
+
if (controller.signal.aborted || isAborted()) {
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
|
|
631
783
|
if (lastApiErr) {
|
|
632
784
|
if (cb.onError) cb.onError(lastApiErr);
|
|
633
785
|
break;
|
|
@@ -652,13 +804,7 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
|
|
|
652
804
|
if (cb.onError) {
|
|
653
805
|
cb.onError({ message: warnMsg, isWarning: true });
|
|
654
806
|
} else {
|
|
655
|
-
|
|
656
|
-
// commands like `cmdCode`, which don't run the shared live-region
|
|
657
|
-
// writer). Direct stdout write is safe here: no status-bar timer
|
|
658
|
-
// or bubble renderer is competing for stdout.
|
|
659
|
-
process.stdout.write(
|
|
660
|
-
`\n ${THEME.warn}⚠ ${warnMsg}${THEME.reset}\n`
|
|
661
|
-
);
|
|
807
|
+
messages.sysWarn(warnMsg);
|
|
662
808
|
}
|
|
663
809
|
}
|
|
664
810
|
|
|
@@ -729,9 +875,6 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
|
|
|
729
875
|
}
|
|
730
876
|
const isNativeCall = nativeToolCalls.length > 0;
|
|
731
877
|
const cleanedReply = cleanAssistantContent(reply);
|
|
732
|
-
// Protocol contract: a valid response ends with a tool call OR a
|
|
733
|
-
// <final_answer>...</final_answer> block. Anything else is degraded.
|
|
734
|
-
const hasFinal = /<final_answer\b[\s\S]*?<\/final_answer>/i.test(reply);
|
|
735
878
|
|
|
736
879
|
if (debug && result) {
|
|
737
880
|
const lastUserMsg = [...messagesWithSystem].reverse().find((m) => m.role === 'user');
|
|
@@ -864,46 +1007,25 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
|
|
|
864
1007
|
continue;
|
|
865
1008
|
}
|
|
866
1009
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
degradedRetries = 0;
|
|
872
|
-
break;
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// Protocol violation: neither a tool call nor a <final_answer>. Nudge
|
|
876
|
-
// the model to restate in-protocol, capped to prevent runaway loops.
|
|
877
|
-
if (degradedRetries >= MAX_DEGRADED_RETRIES) {
|
|
878
|
-
if (cb.onError) {
|
|
879
|
-
cb.onError({ message: `Agent violated the response contract after ${MAX_DEGRADED_RETRIES} retries — no tool call or <final_answer> block emitted. Stopping.`, isWarning: false });
|
|
880
|
-
}
|
|
881
|
-
break;
|
|
882
|
-
}
|
|
883
|
-
degradedRetries++;
|
|
884
|
-
if (cb.onError) {
|
|
885
|
-
cb.onError({ message: 'Response missing tool call or <final_answer> — nudging model to retry in-protocol.', isWarning: true });
|
|
886
|
-
}
|
|
887
|
-
messages.push({
|
|
888
|
-
role: 'user',
|
|
889
|
-
content: 'Your previous response contained neither a tool call nor a <final_answer> block, which violates the response contract. If you need to perform an action, emit the appropriate tool tag now. If you are done, wrap your reply in <final_answer>...</final_answer>. Do not describe intended actions in prose.',
|
|
890
|
-
});
|
|
891
|
-
continue;
|
|
1010
|
+
// No tool calls and non-empty content (the empty case was already
|
|
1011
|
+
// handled by the `!reply` guard above). This is the model's final
|
|
1012
|
+
// answer for this turn — end the loop and return control to the user.
|
|
1013
|
+
break;
|
|
892
1014
|
}
|
|
893
|
-
// Non-degraded response (has tool calls) — reset the retry counter.
|
|
894
|
-
degradedRetries = 0;
|
|
895
1015
|
if (isAborted()) break;
|
|
896
1016
|
|
|
897
1017
|
if (!cb.onToolStart) {
|
|
898
|
-
|
|
899
|
-
// unset. The shared live-region writer isn't running, so a direct
|
|
900
|
-
// write here can't interleave with a bubble/status redraw.
|
|
901
|
-
process.stdout.write(`\n ${FG_TEAL}◆${RST} ${FG_GRAY}Found ${toolCalls.length} action(s) to execute${RST}\n`);
|
|
1018
|
+
writer.scrollback(`\n ${FG_TEAL}◆${RST} ${FG_GRAY}Found ${toolCalls.length} action(s) to execute${RST}`);
|
|
902
1019
|
}
|
|
903
1020
|
|
|
904
1021
|
const results = [];
|
|
905
1022
|
const debugEntries = debug ? [] : null;
|
|
906
1023
|
let aborted = false;
|
|
1024
|
+
// Per-invocation id. Paired across onToolStart/onToolEnd so the UI
|
|
1025
|
+
// layer can track each concurrent tool's activity-region slot and
|
|
1026
|
+
// commit its final line atomically via endActivity. Monotonic —
|
|
1027
|
+
// never reused even if the agent runs the same tag twice.
|
|
1028
|
+
let invocationCounter = 0;
|
|
907
1029
|
|
|
908
1030
|
for (const call of toolCalls) {
|
|
909
1031
|
if (isAborted()) { aborted = true; break; }
|
|
@@ -911,8 +1033,11 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
|
|
|
911
1033
|
const tag = call[0] || 'unknown';
|
|
912
1034
|
const arg = call[1] || '';
|
|
913
1035
|
const toolStart = Date.now();
|
|
1036
|
+
const invocationId = `tool-${iteration}-${invocationCounter++}-${tag}`;
|
|
1037
|
+
const attrs = _attrsFromCall(call);
|
|
1038
|
+
const startCtx = { id: invocationId, call, attrs, startedAt: toolStart };
|
|
914
1039
|
|
|
915
|
-
if (cb.onToolStart) cb.onToolStart(tag, arg);
|
|
1040
|
+
if (cb.onToolStart) cb.onToolStart(tag, arg, startCtx);
|
|
916
1041
|
|
|
917
1042
|
try {
|
|
918
1043
|
if (tag === 'shell') {
|
|
@@ -920,7 +1045,7 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
|
|
|
920
1045
|
const ms = Date.now() - toolStart;
|
|
921
1046
|
if (shellResult.stderr === 'Permission denied by user') {
|
|
922
1047
|
const resultStr = `Command \`${arg}\`: Permission denied by user.`;
|
|
923
|
-
if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
|
|
1048
|
+
if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms, { id: invocationId, call, attrs, meta: null, error: { message: 'denied' }, denied: true });
|
|
924
1049
|
results.push(resultStr);
|
|
925
1050
|
if (debugEntries) debugEntries.push({ tag, call, ms, status: 'denied', exitCode: null, result: resultStr });
|
|
926
1051
|
aborted = true;
|
|
@@ -929,7 +1054,11 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
|
|
|
929
1054
|
let out = shellResult.stdout;
|
|
930
1055
|
if (shellResult.stderr) out += `\nSTDERR: ${shellResult.stderr}`;
|
|
931
1056
|
const resultStr = `Command \`${arg}\`:\nExit code: ${shellResult.exit_code}\n${out}`;
|
|
932
|
-
|
|
1057
|
+
const meta = _metaForTool(tag, shellResult);
|
|
1058
|
+
const error = shellResult.exit_code !== 0
|
|
1059
|
+
? { message: `exit ${shellResult.exit_code}`, code: shellResult.exit_code }
|
|
1060
|
+
: null;
|
|
1061
|
+
if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms, { id: invocationId, call, attrs, meta, error });
|
|
933
1062
|
results.push(resultStr);
|
|
934
1063
|
if (debugEntries) debugEntries.push({
|
|
935
1064
|
tag,
|
|
@@ -948,14 +1077,18 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
|
|
|
948
1077
|
|
|
949
1078
|
if (fileResult.error === 'Permission denied') {
|
|
950
1079
|
const resultStr = `${tag} ${call[1] || ''}: Permission denied by user.`;
|
|
951
|
-
if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms);
|
|
1080
|
+
if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms, { id: invocationId, call, attrs, meta: null, error: { message: 'denied' }, denied: true });
|
|
952
1081
|
results.push(resultStr);
|
|
953
1082
|
if (debugEntries) debugEntries.push({ tag, call, ms, status: 'denied', exitCode: null, result: resultStr });
|
|
954
1083
|
aborted = true;
|
|
955
1084
|
break;
|
|
956
1085
|
} else {
|
|
957
1086
|
const resultStr = formatFileResult(call, fileResult);
|
|
958
|
-
|
|
1087
|
+
const meta = _metaForTool(tag, fileResult);
|
|
1088
|
+
const error = fileResult.error
|
|
1089
|
+
? { message: fileResult.error, code: fileResult.error_code || null }
|
|
1090
|
+
: null;
|
|
1091
|
+
if (cb.onToolEnd) cb.onToolEnd(tag, resultStr, ms, { id: invocationId, call, attrs, meta, error });
|
|
959
1092
|
results.push(resultStr);
|
|
960
1093
|
if (debugEntries) debugEntries.push({
|
|
961
1094
|
tag,
|
|
@@ -968,12 +1101,11 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
|
|
|
968
1101
|
}
|
|
969
1102
|
} catch (err) {
|
|
970
1103
|
const ms = Date.now() - toolStart;
|
|
971
|
-
if (cb.onToolEnd) cb.onToolEnd(tag, `Error: ${err.message}`, ms);
|
|
1104
|
+
if (cb.onToolEnd) cb.onToolEnd(tag, `Error: ${err.message}`, ms, { id: invocationId, call, attrs, meta: null, error: err });
|
|
972
1105
|
if (cb.onError) {
|
|
973
1106
|
cb.onError({ message: `Tool error (${tag}): ${err.message}`, isWarning: true });
|
|
974
1107
|
} else {
|
|
975
|
-
|
|
976
|
-
process.stdout.write(`\n ${THEME.warn}⚠ Tool error (${tag}): ${err.message}${THEME.reset}\n`);
|
|
1108
|
+
messages.toolError(tag, err.message);
|
|
977
1109
|
}
|
|
978
1110
|
logToolCall(tag, { args: call.slice(1) }, false, 'error');
|
|
979
1111
|
results.push(`${tag}: Error — ${err.message}`);
|
|
@@ -1033,8 +1165,7 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
|
|
|
1033
1165
|
if (cb.onError) {
|
|
1034
1166
|
cb.onError({ message: warnMsg, isWarning: true });
|
|
1035
1167
|
} else {
|
|
1036
|
-
|
|
1037
|
-
process.stdout.write(`\n ${FG_YELLOW}⚠${RST} ${FG_GRAY}${warnMsg}${RST}`);
|
|
1168
|
+
messages.sysWarn(warnMsg);
|
|
1038
1169
|
}
|
|
1039
1170
|
// Push whatever results accumulated before the denial so the LLM has
|
|
1040
1171
|
// context if the user asks to continue.
|
package/lib/api.js
CHANGED
|
@@ -6,6 +6,8 @@ const { URL } = require('url');
|
|
|
6
6
|
|
|
7
7
|
const { buildToolsSchema, isUIActive } = require('./tools');
|
|
8
8
|
const { TOOL_SPECS } = require('./tool_specs');
|
|
9
|
+
const writer = require('./ui/writer');
|
|
10
|
+
const messages = require('./ui/messages');
|
|
9
11
|
|
|
10
12
|
function createApiClient({ getConfig, saveConfig, ui }) {
|
|
11
13
|
const {
|
|
@@ -17,7 +19,6 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
17
19
|
FG_RED,
|
|
18
20
|
FG_TEAL,
|
|
19
21
|
RST,
|
|
20
|
-
StatusBar,
|
|
21
22
|
StreamRenderer,
|
|
22
23
|
} = ui;
|
|
23
24
|
|
|
@@ -472,10 +473,10 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
472
473
|
const toolCallAcc = [];
|
|
473
474
|
const renderer = new StreamRenderer({ firstLinePrefix: linePrefix, showThink });
|
|
474
475
|
if (!silent) {
|
|
476
|
+
// audit: allowed — non-TUI streaming setup, must interleave with StreamRenderer sync writes.
|
|
475
477
|
process.stdout.write('\n');
|
|
476
478
|
renderer._linesWritten = 1;
|
|
477
479
|
}
|
|
478
|
-
let firstContentToken = true;
|
|
479
480
|
let lineBuffer = '';
|
|
480
481
|
|
|
481
482
|
function escapeXml(s) {
|
|
@@ -517,14 +518,6 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
517
518
|
}));
|
|
518
519
|
if (!nativeTools) appendToolCallsXml();
|
|
519
520
|
if (!silent) renderer.flush();
|
|
520
|
-
const elapsed = (Date.now() - startTime) / 1000;
|
|
521
|
-
const tps = tokenCount / (elapsed || 1);
|
|
522
|
-
if (StatusBar.current) {
|
|
523
|
-
let latency = `${Math.round(tps)} tok/s · ${elapsed.toFixed(1)}s`;
|
|
524
|
-
if (reasoningText) latency += ` · ${estimateTokens(reasoningText)} think`;
|
|
525
|
-
StatusBar.current.liveUpdate({ tokens: `${tokenCount} tok`, latency });
|
|
526
|
-
StatusBar.current.render();
|
|
527
|
-
}
|
|
528
521
|
// Fallback for endpoints that don't honor stream_options.include_usage:
|
|
529
522
|
// estimate prompt/completion tokens locally so the status bar still updates.
|
|
530
523
|
let usage = streamUsage;
|
|
@@ -613,6 +606,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
613
606
|
if (!inReasoning) {
|
|
614
607
|
inReasoning = true;
|
|
615
608
|
if (showThink && !uiActive) {
|
|
609
|
+
// audit: allowed — non-TUI thinking output, interleaves with StreamRenderer sync writes.
|
|
616
610
|
process.stdout.write(`\n ${FG_DARK}${DIM}⟨thinking⟩${RST}`);
|
|
617
611
|
renderer._linesWritten++;
|
|
618
612
|
}
|
|
@@ -620,6 +614,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
620
614
|
reasoningText += reasoning;
|
|
621
615
|
tokenCount++;
|
|
622
616
|
if (showThink && !uiActive) {
|
|
617
|
+
// audit: allowed — non-TUI thinking output, interleaves with StreamRenderer sync writes.
|
|
623
618
|
process.stdout.write(`${FG_DARK}${DIM}${reasoning}${RST}`);
|
|
624
619
|
}
|
|
625
620
|
}
|
|
@@ -633,14 +628,6 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
633
628
|
if (tc.id) toolCallAcc[idx].id = tc.id;
|
|
634
629
|
if (tc.function?.name) toolCallAcc[idx].name += tc.function.name;
|
|
635
630
|
if (tc.function?.arguments) toolCallAcc[idx].arguments += tc.function.arguments;
|
|
636
|
-
// When the model streams purely via delta.tool_calls (no
|
|
637
|
-
// delta.content), firstContentToken never flips, so the status
|
|
638
|
-
// bar stays on "Thinking…" for the entire tool-call stream.
|
|
639
|
-
// Surface each new tool slot the moment its name is known so
|
|
640
|
-
// the user sees "Using tool: <name>" instead of a frozen UI.
|
|
641
|
-
if (isNew && StatusBar.current && toolCallAcc[idx].name) {
|
|
642
|
-
StatusBar.current.update('tool', `Using tool: ${toolCallAcc[idx].name}`);
|
|
643
|
-
}
|
|
644
631
|
}
|
|
645
632
|
}
|
|
646
633
|
|
|
@@ -649,28 +636,18 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
649
636
|
if (inReasoning) {
|
|
650
637
|
inReasoning = false;
|
|
651
638
|
if (showThink && !silent) {
|
|
639
|
+
// audit: allowed — non-TUI thinking output, interleaves with StreamRenderer sync writes.
|
|
652
640
|
process.stdout.write(`${FG_DARK}⟨/thinking⟩${RST}\n`);
|
|
653
641
|
renderer._linesWritten++;
|
|
654
642
|
}
|
|
655
643
|
}
|
|
656
644
|
if (onToken) {
|
|
657
|
-
if (firstContentToken) {
|
|
658
|
-
firstContentToken = false;
|
|
659
|
-
if (StatusBar.current) StatusBar.current.update({ status: 'streaming' });
|
|
660
|
-
}
|
|
661
645
|
onToken(content);
|
|
662
646
|
} else {
|
|
663
647
|
renderer.feed(content);
|
|
664
648
|
}
|
|
665
649
|
fullText += content;
|
|
666
650
|
tokenCount++;
|
|
667
|
-
if (tokenCount % 20 === 0 && StatusBar.current) {
|
|
668
|
-
const elapsedSec = (Date.now() - startTime) / 1000 || 0.001;
|
|
669
|
-
StatusBar.current.liveUpdate({
|
|
670
|
-
tokens: `${tokenCount} tok`,
|
|
671
|
-
latency: `${Math.round(tokenCount / elapsedSec)} tok/s`,
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
651
|
}
|
|
675
652
|
} catch {}
|
|
676
653
|
}
|
|
@@ -712,7 +689,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
712
689
|
},
|
|
713
690
|
}, body);
|
|
714
691
|
} catch (error) {
|
|
715
|
-
|
|
692
|
+
messages.netError(error.message);
|
|
716
693
|
return '';
|
|
717
694
|
}
|
|
718
695
|
|
|
@@ -724,7 +701,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
724
701
|
});
|
|
725
702
|
res.on('end', () => {
|
|
726
703
|
if (res.statusCode !== 200) {
|
|
727
|
-
|
|
704
|
+
messages.netError(`HTTP ${res.statusCode} — ${data}`);
|
|
728
705
|
resolve('');
|
|
729
706
|
return;
|
|
730
707
|
}
|
|
@@ -732,15 +709,15 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
732
709
|
try {
|
|
733
710
|
const parsed = JSON.parse(data);
|
|
734
711
|
const content = parsed.choices[0].message.content;
|
|
735
|
-
|
|
712
|
+
writer.scrollback(content);
|
|
736
713
|
resolve(content);
|
|
737
714
|
} catch (error) {
|
|
738
|
-
|
|
715
|
+
messages.netError(`Parse error: ${error.message}`);
|
|
739
716
|
resolve('');
|
|
740
717
|
}
|
|
741
718
|
});
|
|
742
719
|
res.on('error', (error) => {
|
|
743
|
-
|
|
720
|
+
messages.netError(error.message);
|
|
744
721
|
resolve('');
|
|
745
722
|
});
|
|
746
723
|
});
|