ikie-cli 0.1.33 → 0.1.35

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/dist/agent.js CHANGED
@@ -1,9 +1,14 @@
1
1
  import chalk from 'chalk';
2
2
  import * as readline from 'node:readline';
3
- import { TOOL_DEFS, SAFE_TOOLS, PLAN_TOOLS, formatToolArgs, executeTool, isRestrictedPath } from './tools.js';
3
+ import { TOOL_DEFS, SAFE_TOOLS, PLAN_TOOLS, formatToolArgs, executeTool, isRestrictedPath, getMcpToolDefs } from './tools.js';
4
4
  import { IKIE_PORT } from './config.js';
5
5
  import { renderMarkdown, extractThinkTags } from './renderer.js';
6
+ import { getSkill, renderSkill, mapAllowedTools } from './skills.js';
6
7
  import { c, toolLine, toolSuccessLine, toolErrorLine, toolOutputBlock, toolDiffBlock, InlineSpinner, CH, toolMeta } from './theme.js';
8
+ /** Default per-turn step budget — guards against runaway tool loops. */
9
+ export const DEFAULT_MAX_STEPS = 60;
10
+ /** Result synthesized for a tool call that never produced one (cancel/crash). */
11
+ export const INTERRUPTED_TOOL_RESULT = 'Interrupted: this tool did not run to completion (the turn was cancelled).';
7
12
  export function estimateTokens(chars) {
8
13
  return Math.max(1, Math.round(chars / 4));
9
14
  }
@@ -21,6 +26,59 @@ export function extractUpstreamError(err) {
21
26
  e.message;
22
27
  return upstream || 'Unknown error';
23
28
  }
29
+ /**
30
+ * Guarantee the OpenAI invariant: every `assistant` message that carries
31
+ * `tool_calls` is followed by a `tool` message for each call id. A turn that is
32
+ * cancelled (ESC/Ctrl-C), throws mid-stream, or is restored from a session saved
33
+ * mid-flight can leave "dangling" tool calls with no result — and the provider
34
+ * then rejects the *next* request ("an assistant message with 'tool_calls' must
35
+ * be followed by tool messages"). This splices a synthetic result for any
36
+ * unanswered call so history is always replayable. Pure and idempotent.
37
+ */
38
+ export function repairDanglingToolCalls(messages) {
39
+ const answeredIds = new Set();
40
+ for (const m of messages) {
41
+ if (m.role === 'tool') {
42
+ const id = m.tool_call_id;
43
+ if (typeof id === 'string')
44
+ answeredIds.add(id);
45
+ }
46
+ }
47
+ const out = [];
48
+ for (const m of messages) {
49
+ out.push(m);
50
+ if (m.role !== 'assistant')
51
+ continue;
52
+ const tcs = m.tool_calls;
53
+ if (!Array.isArray(tcs))
54
+ continue;
55
+ for (const tc of tcs) {
56
+ if (tc?.id && !answeredIds.has(tc.id)) {
57
+ out.push({ role: 'tool', tool_call_id: tc.id, content: INTERRUPTED_TOOL_RESULT });
58
+ answeredIds.add(tc.id);
59
+ }
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ /**
65
+ * Whether the agent loop should make another model call. We continue purely on
66
+ * "there were tool calls to answer" — NOT on `finishReason`, because some
67
+ * providers send `finish_reason: 'stop'` (or null) alongside tool calls, which
68
+ * would otherwise silently drop the calls. `maxSteps` caps runaway loops.
69
+ */
70
+ export function shouldContinue(toolCallCount, _finishReason, step, maxSteps) {
71
+ return toolCallCount > 0 && step < maxSteps;
72
+ }
73
+ /**
74
+ * Drop malformed tool calls (no function name) accumulated from a stream. An
75
+ * unnamed call can't be dispatched or answered, so keeping it would create an
76
+ * un-satisfiable `tool_calls` entry. Applied to both the dispatch list and the
77
+ * assistant message so they stay in lockstep.
78
+ */
79
+ export function normalizeToolCalls(calls) {
80
+ return calls.filter(tc => typeof tc.name === 'string' && tc.name.trim().length > 0);
81
+ }
24
82
  /**
25
83
  * Safely restore previously-saved stdin listeners after a raw-mode interaction
26
84
  * (permission prompt, ask_user, theme picker, agent turn).
@@ -45,6 +103,44 @@ const requestTimestamps = [];
45
103
  function sleep(ms) {
46
104
  return new Promise(resolve => setTimeout(resolve, ms));
47
105
  }
106
+ /** Extract HTTP status from an OpenAI/fetch error. */
107
+ function extractStatus(err) {
108
+ const e = err;
109
+ if (typeof e.status === 'number')
110
+ return e.status;
111
+ if (e.response?.status)
112
+ return e.response.status;
113
+ return null;
114
+ }
115
+ /**
116
+ * Retry a function on transient failures (network errors, 5xx, 429) with
117
+ * exponential backoff + jitter. Does NOT retry 4xx (except 429).
118
+ */
119
+ async function withRetry(fn, opts) {
120
+ const maxRetries = opts?.maxRetries ?? 2;
121
+ let lastErr;
122
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
123
+ if (attempt > 0) {
124
+ const delay = Math.min(1000 * Math.pow(2, attempt - 1), 8000) + Math.random() * 1000;
125
+ if (opts?.label) {
126
+ process.stdout.write(`\n ${c.muted(`Retry ${attempt}/${maxRetries} for ${opts.label} in ${(delay / 1000).toFixed(1)}s`)}`);
127
+ }
128
+ await sleep(delay);
129
+ }
130
+ try {
131
+ return await fn();
132
+ }
133
+ catch (err) {
134
+ lastErr = err;
135
+ if (opts?.signal?.aborted)
136
+ throw err;
137
+ const status = extractStatus(err);
138
+ if (status && status >= 400 && status < 500 && status !== 429)
139
+ throw err;
140
+ }
141
+ }
142
+ throw lastErr;
143
+ }
48
144
  /**
49
145
  * True when a bash command tries to kill/free processes by ikie's own host
50
146
  * port — `kill $(lsof -ti:PORT)`, `fuser -k PORT/tcp`, `pkill ... PORT`, etc.
@@ -74,7 +170,7 @@ function toolPhaseLabel(name) {
74
170
  case 'write_file': return 'Writing file';
75
171
  case 'edit_file': return 'Editing file';
76
172
  case 'read_file': return 'Reading';
77
- case 'bash': return 'Preparing command';
173
+ case 'bash': return 'Running command';
78
174
  case 'spawn_agent': return 'Spawning agent';
79
175
  case 'list_dir': return 'Listing directory';
80
176
  case 'search_files': return 'Searching';
@@ -89,7 +185,15 @@ function toolPhaseLabel(name) {
89
185
  case 'use_skill': return 'Loading skill';
90
186
  case 'install_skill': return 'Installing skill';
91
187
  case 'remove_skill': return 'Removing skill';
92
- default: return `Preparing ${name}`;
188
+ case 'mcp_list': return 'Listing MCP servers';
189
+ case 'mcp_add': return 'Adding MCP server';
190
+ default: {
191
+ if (name.startsWith('mcp__')) {
192
+ const parts = name.split('__');
193
+ return `MCP ${parts[1] ?? '?'}`;
194
+ }
195
+ return `Running ${name}`;
196
+ }
93
197
  }
94
198
  }
95
199
  export class Agent {
@@ -119,7 +223,8 @@ export class Agent {
119
223
  return this.conversation;
120
224
  }
121
225
  setConversation(messages) {
122
- this.conversation = [...messages];
226
+ // Loaded sessions may have been saved mid-turn — repair before reuse.
227
+ this.conversation = repairDanglingToolCalls(messages);
123
228
  }
124
229
  getLastTurnStats() {
125
230
  return { ...this.lastTurnStats };
@@ -143,7 +248,7 @@ export class Agent {
143
248
  if (!this.conversation.length)
144
249
  return { before: 0, after: 0 };
145
250
  const before = this.estimateConversationTokens();
146
- const res = await this.client.chat.completions.create({
251
+ const res = await withRetry(() => this.client.chat.completions.create({
147
252
  model: this.config.model,
148
253
  max_tokens: 4096,
149
254
  messages: [
@@ -161,7 +266,7 @@ export class Agent {
161
266
  + 'This summary will replace the full conversation history.',
162
267
  },
163
268
  ],
164
- });
269
+ }), { label: 'compact' });
165
270
  const summary = res.choices[0]?.message?.content ?? '(no summary)';
166
271
  this.conversation = [
167
272
  {
@@ -185,6 +290,9 @@ export class Agent {
185
290
  async send(userMessage, opts = {}) {
186
291
  this.activeTurnStats = { modelCalls: 0, toolCalls: 0, filesChanged: 0 };
187
292
  this.activeChangedFiles = new Set();
293
+ // A prior turn may have ended mid-tool-call (cancelled/errored). Heal the
294
+ // history before adding the new turn so the request is always valid.
295
+ this.conversation = repairDanglingToolCalls(this.conversation);
188
296
  this.conversation.push({ role: 'user', content: userMessage });
189
297
  try {
190
298
  await this.runLoop(opts);
@@ -250,20 +358,67 @@ export class Agent {
250
358
  }
251
359
  // ── Main agent loop ───────────────────────────────────────────────────────
252
360
  async runLoop(opts) {
361
+ const maxSteps = Math.max(1, opts.maxSteps ?? DEFAULT_MAX_STEPS);
362
+ let step = 0;
253
363
  while (true) {
254
364
  if (opts.signal?.aborted)
255
365
  break;
366
+ step++;
256
367
  const { assistantMsg, toolCalls, finishReason } = await this.callModel(opts);
257
368
  this.conversation.push(assistantMsg);
258
- if (opts.signal?.aborted)
259
- break;
260
- if (finishReason !== 'tool_calls' || !toolCalls.length)
369
+ // No tool calls → the model is done talking. Stop (regardless of how the
370
+ // provider labelled finish_reason).
371
+ if (!toolCalls.length)
261
372
  break;
373
+ // Track which tool_call ids we've answered so we can guarantee the
374
+ // assistant message stays balanced even if the turn is cancelled partway.
375
+ const answered = new Set();
376
+ const pushResult = (id, content) => {
377
+ if (answered.has(id))
378
+ return;
379
+ answered.add(id);
380
+ this.conversation.push({ role: 'tool', tool_call_id: id, content });
381
+ };
262
382
  const groups = this.groupToolCalls(toolCalls);
383
+ // ── Pass 1: all spawn_agents in parallel across groups ──────────────
384
+ const spawnResults = new Map(); // tool_call_id → result
385
+ {
386
+ const spawnTasks = [];
387
+ for (const group of groups) {
388
+ for (const tc of group) {
389
+ if (tc.name === 'spawn_agent') {
390
+ let input;
391
+ try {
392
+ input = JSON.parse(tc.argsStr || '{}');
393
+ }
394
+ catch {
395
+ input = {};
396
+ }
397
+ spawnTasks.push({ tc, input });
398
+ }
399
+ }
400
+ }
401
+ if (spawnTasks.length > 0) {
402
+ if (this.activeTurnStats)
403
+ this.activeTurnStats.toolCalls += spawnTasks.length;
404
+ const results = await Promise.all(spawnTasks.map(st => this.runSubagent(st.input, opts)));
405
+ spawnTasks.forEach((st, i) => spawnResults.set(st.tc.id, results[i]));
406
+ }
407
+ }
408
+ // ── Pass 2: remaining groups in order ──────────────────────────────
263
409
  for (const group of groups) {
264
410
  if (opts.signal?.aborted)
265
411
  break;
266
- const inputs = group.map(tc => {
412
+ // Push spawn_agent results for any spawn calls in this group
413
+ for (const tc of group) {
414
+ const r = spawnResults.get(tc.id);
415
+ if (r !== undefined)
416
+ pushResult(tc.id, r);
417
+ }
418
+ const remaining = group.filter(tc => !spawnResults.has(tc.id));
419
+ if (remaining.length === 0)
420
+ continue;
421
+ const inputs = remaining.map(tc => {
267
422
  try {
268
423
  return JSON.parse(tc.argsStr || '{}');
269
424
  }
@@ -273,64 +428,46 @@ export class Agent {
273
428
  });
274
429
  // Plan mode is read-only. The model normally isn't even offered mutating
275
430
  // tools (see buildParams), but refuse here too as defense-in-depth.
276
- if (this.mode === 'plan' && !PLAN_TOOLS.has(group[0].name)) {
431
+ if (this.mode === 'plan' && !PLAN_TOOLS.has(remaining[0].name)) {
277
432
  if (this.activeTurnStats)
278
- this.activeTurnStats.toolCalls += group.length;
279
- process.stdout.write(`\n${this.indent}${toolLine(group[0].name, formatToolArgs(group[0].name, inputs[0])).trimStart()}\n`);
433
+ this.activeTurnStats.toolCalls += remaining.length;
434
+ process.stdout.write(`\n${this.indent}${toolLine(remaining[0].name, formatToolArgs(remaining[0].name, inputs[0])).trimStart()}\n`);
280
435
  process.stdout.write(`${this.indent}${toolErrorLine('blocked · plan mode is read-only')}\n`);
281
- for (const tc of group) {
282
- this.conversation.push({
283
- role: 'tool', tool_call_id: tc.id,
284
- content: 'Blocked: plan mode is read-only. Do not attempt changes — propose a plan instead. The user can switch to agent mode to apply it.',
285
- });
436
+ for (const tc of remaining) {
437
+ pushResult(tc.id, 'Blocked: plan mode is read-only. Do not attempt changes — propose a plan instead. The user can switch to agent mode to apply it.');
286
438
  }
287
439
  continue;
288
440
  }
289
- if (group.length === 1) {
441
+ if (remaining.length === 1) {
290
442
  if (this.activeTurnStats)
291
443
  this.activeTurnStats.toolCalls++;
292
- process.stdout.write(`\n${this.indent}${toolLine(group[0].name, formatToolArgs(group[0].name, inputs[0])).trimStart()}\n`);
293
- const result = await this.handleToolCall(group[0].name, group[0].id, inputs[0], opts);
294
- this.conversation.push({ role: 'tool', tool_call_id: group[0].id, content: result });
444
+ process.stdout.write(`\n${this.indent}${toolLine(remaining[0].name, formatToolArgs(remaining[0].name, inputs[0])).trimStart()}\n`);
445
+ const result = await this.handleToolCall(remaining[0].name, remaining[0].id, inputs[0], opts);
446
+ pushResult(remaining[0].id, result);
295
447
  }
296
448
  else {
297
449
  if (this.activeTurnStats)
298
- this.activeTurnStats.toolCalls += group.length;
299
- const summary = this.formatGroupSummary(group[0].name, inputs);
300
- process.stdout.write(`\n${this.indent}${toolLine(`${group[0].name} ×${group.length}`, summary).trimStart()}\n`);
301
- if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(group[0].name) && group[0].name !== 'switch_mode') {
302
- const allowed = await this.checkPermission(group[0].name, inputs[0]);
450
+ this.activeTurnStats.toolCalls += remaining.length;
451
+ const summary = this.formatGroupSummary(remaining[0].name, inputs);
452
+ process.stdout.write(`\n${this.indent}${toolLine(`${remaining[0].name} ×${remaining.length}`, summary).trimStart()}\n`);
453
+ if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(remaining[0].name) && remaining[0].name !== 'switch_mode') {
454
+ const allowed = await this.checkPermission(remaining[0].name, inputs[0]);
303
455
  if (!allowed) {
304
- for (const tc of group) {
305
- this.conversation.push({
306
- role: 'tool', tool_call_id: tc.id,
307
- content: `Tool execution denied by user: ${tc.name}`,
308
- });
456
+ for (const tc of remaining) {
457
+ pushResult(tc.id, `Tool execution denied by user: ${tc.name}`);
309
458
  }
310
459
  continue;
311
460
  }
312
461
  }
313
462
  const t0 = Date.now();
314
463
  let errors = 0;
315
- // Separate subagents so they can run in parallel; keep other tools sequential
316
- // to avoid races on file mutations.
317
- const spawnIndices = [];
318
- const otherIndices = [];
319
- for (let i = 0; i < group.length; i++) {
320
- if (group[i].name === 'spawn_agent')
321
- spawnIndices.push(i);
322
- else
323
- otherIndices.push(i);
324
- }
464
+ const groupSpinner = new InlineSpinner(`${toolPhaseLabel(remaining[0].name)} (${remaining.length} operations)`, t0);
465
+ groupSpinner.start();
325
466
  const results = new Map();
326
- if (spawnIndices.length > 0) {
327
- const spawnResults = await Promise.all(spawnIndices.map(i => this.runSubagent(inputs[i], opts)));
328
- spawnIndices.forEach((idx, i) => results.set(idx, spawnResults[i]));
329
- }
330
- for (const i of otherIndices) {
467
+ for (let i = 0; i < remaining.length; i++) {
331
468
  if (opts.signal?.aborted)
332
469
  break;
333
- const tc = group[i];
470
+ const tc = remaining[i];
334
471
  try {
335
472
  if (tc.name === 'read_file' && isRestrictedPath(String(inputs[i].path ?? ''))) {
336
473
  const allowed = await this.checkPermission('read_file', inputs[i]);
@@ -339,7 +476,9 @@ export class Agent {
339
476
  continue;
340
477
  }
341
478
  }
342
- const result = await executeTool(tc.name, inputs[i]);
479
+ const result = tc.name === 'use_skill'
480
+ ? await this.handleToolCall(tc.name, tc.id, inputs[i], opts)
481
+ : await executeTool(tc.name, inputs[i]);
343
482
  if (result.startsWith('Error'))
344
483
  errors++;
345
484
  this.recordChangedFile(tc.name, inputs[i], result);
@@ -350,37 +489,102 @@ export class Agent {
350
489
  results.set(i, `Tool error: ${err}`);
351
490
  }
352
491
  }
353
- for (let i = 0; i < group.length; i++) {
492
+ groupSpinner.stop();
493
+ for (let i = 0; i < remaining.length; i++) {
354
494
  const result = results.get(i);
355
- if (result !== undefined) {
356
- this.conversation.push({ role: 'tool', tool_call_id: group[i].id, content: result });
357
- }
495
+ if (result !== undefined)
496
+ pushResult(remaining[i].id, result);
358
497
  }
359
498
  const ms = Date.now() - t0;
360
499
  const lineStr = errors === 0
361
- ? toolSuccessLine(ms, `${group.length} operations`)
362
- : toolErrorLine(`${errors} of ${group.length} operations`);
500
+ ? toolSuccessLine(ms, `${remaining.length} operations`)
501
+ : toolErrorLine(`${errors} of ${remaining.length} operations`);
363
502
  process.stdout.write(`${this.indent}${lineStr}\n`);
364
503
  }
365
504
  }
505
+ // Invariant: balance the assistant message. Any tool_call that didn't get
506
+ // a result (aborted mid-group, an unexpected skip) is answered with a
507
+ // synthetic "interrupted" result so the next request is always valid.
508
+ for (const tc of toolCalls) {
509
+ if (!answered.has(tc.id))
510
+ pushResult(tc.id, INTERRUPTED_TOOL_RESULT);
511
+ }
512
+ if (opts.signal?.aborted)
513
+ break;
514
+ if (!shouldContinue(toolCalls.length, finishReason, step, maxSteps)) {
515
+ // Hit the step budget while still wanting tools — stop cleanly and ask
516
+ // for a final summary (with tools disabled, so it can't dangle).
517
+ await this.summarizeAndStop(opts, maxSteps);
518
+ break;
519
+ }
366
520
  process.stdout.write('\n');
367
521
  }
368
522
  }
523
+ /**
524
+ * Final wrap-up when the per-turn step budget is exhausted. One model call with
525
+ * tools disabled, so it produces a plain summary and can never leave a dangling
526
+ * tool call. Best-effort: failures here don't throw out of the turn.
527
+ */
528
+ async summarizeAndStop(opts, maxSteps) {
529
+ process.stdout.write(`\n${this.indent}${c.warning('◔')} ${c.muted(`Reached step budget (${maxSteps}) — wrapping up.`)}\n`);
530
+ this.conversation.push({
531
+ role: 'user',
532
+ content: `[system] You have reached the ${maxSteps}-step tool budget for this turn. `
533
+ + 'Stop calling tools now. Briefly summarize what you accomplished, what remains, '
534
+ + 'and the exact next step to resume — no tool calls.',
535
+ });
536
+ try {
537
+ if (this.activeTurnStats)
538
+ this.activeTurnStats.modelCalls++;
539
+ await this.throttleModelRequest();
540
+ const params = this.buildParams();
541
+ const resp = await withRetry(() => this.client.chat.completions.create({
542
+ ...params,
543
+ tools: undefined,
544
+ tool_choice: undefined,
545
+ }, (opts.signal ? { signal: opts.signal } : undefined)), { signal: opts.signal, label: 'final-summary' });
546
+ const text = resp.choices[0]?.message?.content ?? '';
547
+ this.conversation.push({ role: 'assistant', content: text || null });
548
+ if (text)
549
+ printResponse(text, this.indent);
550
+ }
551
+ catch (err) {
552
+ if (!opts.signal?.aborted) {
553
+ process.stdout.write(`${this.indent}${toolErrorLine(extractUpstreamError(err))}\n`);
554
+ }
555
+ }
556
+ }
369
557
  // ── Model calls ───────────────────────────────────────────────────────────
370
558
  async callModel(opts) {
559
+ // Count + throttle exactly once per model turn (the non-streaming fallback
560
+ // below is part of the SAME turn, so it must not double-count or double-wait).
561
+ if (this.activeTurnStats)
562
+ this.activeTurnStats.modelCalls++;
563
+ await this.throttleModelRequest();
564
+ const state = { emitted: false };
371
565
  try {
372
- return await this.callModelStreaming(opts);
566
+ return await this.callModelStreaming(opts, state);
373
567
  }
374
568
  catch (err) {
375
569
  if (opts.signal?.aborted)
376
570
  throw err;
571
+ // Only fall back to non-streaming if the stream produced nothing yet.
572
+ // If it already streamed output then failed, replaying would double-print
573
+ // and double-bill — surface the error instead.
574
+ if (state.emitted)
575
+ throw err;
377
576
  return await this.callModelNonStreaming(opts);
378
577
  }
379
578
  }
380
579
  buildParams() {
580
+ // Always work on a copy — never push into the shared TOOL_DEFS array.
381
581
  let tools = this.depth >= 1
382
582
  ? TOOL_DEFS.filter(t => t.function.name !== 'spawn_agent')
383
- : TOOL_DEFS;
583
+ : [...TOOL_DEFS];
584
+ // Append first-class MCP tools in agent mode only (we can't prove they're read-only).
585
+ if (this.mode === 'agent' && this.depth === 0) {
586
+ tools = tools.concat(getMcpToolDefs());
587
+ }
384
588
  // Always include switch_mode so the agent can request a mode change.
385
589
  const switchModeTool = TOOL_DEFS.find(t => t.function.name === 'switch_mode');
386
590
  // Plan mode: only offer read-only tools, and steer toward proposing a plan.
@@ -428,19 +632,16 @@ export class Agent {
428
632
  }
429
633
  requestTimestamps.push(Date.now());
430
634
  }
431
- async callModelStreaming(opts) {
635
+ async callModelStreaming(opts, state) {
432
636
  const spinner = new InlineSpinner('Working', opts.startedAt);
433
637
  spinner.start();
434
638
  const requestOpts = opts.signal ? { signal: opts.signal } : undefined;
435
639
  let stream;
436
640
  try {
437
- if (this.activeTurnStats)
438
- this.activeTurnStats.modelCalls++;
439
- await this.throttleModelRequest();
440
- stream = await this.client.chat.completions.create({
641
+ stream = await withRetry(() => this.client.chat.completions.create({
441
642
  ...this.buildParams(),
442
643
  stream: true,
443
- }, requestOpts);
644
+ }, requestOpts), { signal: opts.signal, label: 'stream' });
444
645
  }
445
646
  catch (err) {
446
647
  spinner.stop();
@@ -470,8 +671,10 @@ export class Agent {
470
671
  if (delta.content) {
471
672
  textContent += delta.content;
472
673
  phase = 'Generating';
674
+ state.emitted = true;
473
675
  }
474
676
  if (delta.tool_calls) {
677
+ state.emitted = true;
475
678
  for (const tc of delta.tool_calls) {
476
679
  const idx = tc.index;
477
680
  if (!toolCallsMap.has(idx)) {
@@ -519,7 +722,7 @@ export class Agent {
519
722
  if (textContent) {
520
723
  printResponse(textContent, this.indent);
521
724
  }
522
- const toolCalls = [...toolCallsMap.values()];
725
+ const toolCalls = normalizeToolCalls([...toolCallsMap.values()]);
523
726
  const assistantMsg = {
524
727
  role: 'assistant',
525
728
  content: textContent || null,
@@ -539,12 +742,9 @@ export class Agent {
539
742
  const requestOpts = opts.signal ? { signal: opts.signal } : undefined;
540
743
  let resp;
541
744
  try {
542
- if (this.activeTurnStats)
543
- this.activeTurnStats.modelCalls++;
544
- await this.throttleModelRequest();
545
- resp = await this.client.chat.completions.create({
745
+ resp = await withRetry(() => this.client.chat.completions.create({
546
746
  ...this.buildParams(),
547
- }, requestOpts);
747
+ }, requestOpts), { signal: opts.signal, label: 'non-stream' });
548
748
  }
549
749
  catch (err) {
550
750
  spinner.stop();
@@ -560,17 +760,19 @@ export class Agent {
560
760
  if (textContent) {
561
761
  printResponse(textContent, this.indent);
562
762
  }
563
- const toolCalls = (msg.tool_calls ?? []).map(tc => ({
763
+ const toolCalls = normalizeToolCalls((msg.tool_calls ?? []).map(tc => ({
564
764
  id: tc.id, name: tc.function.name, argsStr: tc.function.arguments,
565
- }));
765
+ })));
566
766
  const assistantMsg = {
567
767
  role: 'assistant',
568
768
  content: textContent || null,
769
+ // Derive from the normalized list so the message and the dispatch list
770
+ // stay in lockstep (no dangling/un-dispatchable calls).
569
771
  ...(toolCalls.length ? {
570
- tool_calls: (msg.tool_calls ?? []).map(tc => ({
772
+ tool_calls: toolCalls.map(tc => ({
571
773
  id: tc.id,
572
774
  type: 'function',
573
- function: tc.function,
775
+ function: { name: tc.name, arguments: tc.argsStr },
574
776
  })),
575
777
  } : {}),
576
778
  };
@@ -581,6 +783,9 @@ export class Agent {
581
783
  if (name === 'switch_mode') {
582
784
  return this.handleSwitchMode(input);
583
785
  }
786
+ if (name === 'use_skill') {
787
+ return this.handleUseSkill(input);
788
+ }
584
789
  if (name === 'spawn_agent') {
585
790
  return this.runSubagent(input, opts);
586
791
  }
@@ -598,7 +803,7 @@ export class Agent {
598
803
  return `Tool execution denied by user: read_file ${path}`;
599
804
  }
600
805
  }
601
- if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(name)) {
806
+ if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(name) && !this.sessionAllowList.has(name)) {
602
807
  // Self-kill safeguard: a bash command that kills/frees processes by ikie's
603
808
  // own host port (e.g. `kill $(lsof -ti:3000)`) can match ikie's outbound
604
809
  // socket and SIGTERM the session. Force a confirmation even if bash is
@@ -612,8 +817,17 @@ export class Agent {
612
817
  return `Tool execution denied by user: ${name}`;
613
818
  }
614
819
  const t0 = Date.now();
820
+ const spinner = new InlineSpinner(toolPhaseLabel(name), t0);
821
+ // For bash commands with streaming, show spinner briefly then let output flow
822
+ const isStreamingBash = name === 'bash' && /\b(build|compile|test|deploy|install)\b/i.test(String(input.command ?? ''));
823
+ if (!isStreamingBash) {
824
+ spinner.start();
825
+ }
615
826
  try {
616
827
  const result = await executeTool(name, input);
828
+ if (!isStreamingBash) {
829
+ spinner.stop();
830
+ }
617
831
  this.recordChangedFile(name, input, result);
618
832
  const ms = Date.now() - t0;
619
833
  let block;
@@ -626,15 +840,31 @@ export class Agent {
626
840
  else {
627
841
  block = toolOutputBlock(result, ms, this.indent);
628
842
  }
629
- process.stdout.write(`${block}\n`);
843
+ if (block)
844
+ process.stdout.write(`${block}\n`);
630
845
  return result;
631
846
  }
632
847
  catch (err) {
848
+ if (!isStreamingBash) {
849
+ spinner.stop();
850
+ }
633
851
  const msg = err instanceof Error ? err.message : String(err);
634
852
  process.stdout.write(`${this.indent}${toolErrorLine(msg)}\n`);
635
853
  return msg;
636
854
  }
637
855
  }
856
+ async handleUseSkill(input) {
857
+ const name = (input.name ?? '').trim();
858
+ if (!name)
859
+ return 'Error: use_skill requires a name.';
860
+ const skill = getSkill(name);
861
+ if (!skill)
862
+ return `Error: no skill named "${name}".`;
863
+ for (const tool of mapAllowedTools(skill.allowedTools)) {
864
+ this.sessionAllowList.add(tool);
865
+ }
866
+ return renderSkill(skill);
867
+ }
638
868
  async handleSwitchMode(input) {
639
869
  if (this.depth > 0) {
640
870
  return 'Error: subagents cannot switch mode. Return your findings and let the main agent decide.';
@@ -755,10 +985,44 @@ export class Agent {
755
985
  process.stdout.write(`${this.indent}${c.error('✗')} ${c.muted('subagent failed')}\n`);
756
986
  return `Subagent error: ${err instanceof Error ? err.message : String(err)}`;
757
987
  }
758
- const result = sub.getLastAssistantText();
988
+ let result = sub.getLastAssistantText();
989
+ // The subagent's reply IS the only thing returned to the parent. If it ended
990
+ // without a textual summary (e.g. stopped right after a tool call), ask once
991
+ // more for a self-contained summary so we never hand back an empty result.
992
+ if (!result && !opts.signal?.aborted) {
993
+ result = await sub.requestFinalSummary(opts.signal);
994
+ }
759
995
  process.stdout.write(`${this.indent}${c.success('✓')} ${c.muted('subagent done')}\n\n`);
760
996
  return result || '(subagent completed but produced no summary)';
761
997
  }
998
+ /**
999
+ * Best-effort: one tool-less model call asking for a concise, self-contained
1000
+ * summary of the work so far. Used to guarantee a non-empty subagent result.
1001
+ */
1002
+ async requestFinalSummary(signal) {
1003
+ this.conversation.push({
1004
+ role: 'user',
1005
+ content: 'Summarize what you did and any key results (paths changed, findings, answers) '
1006
+ + 'in a few sentences. Do not call any tools.',
1007
+ });
1008
+ try {
1009
+ if (this.activeTurnStats)
1010
+ this.activeTurnStats.modelCalls++;
1011
+ await this.throttleModelRequest();
1012
+ const params = this.buildParams();
1013
+ const resp = await withRetry(() => this.client.chat.completions.create({
1014
+ ...params,
1015
+ tools: undefined,
1016
+ tool_choice: undefined,
1017
+ }, (signal ? { signal } : undefined)), { signal, label: 'sub-summary' });
1018
+ const text = resp.choices[0]?.message?.content ?? '';
1019
+ this.conversation.push({ role: 'assistant', content: text || null });
1020
+ return text;
1021
+ }
1022
+ catch {
1023
+ return '';
1024
+ }
1025
+ }
762
1026
  getLastAssistantText() {
763
1027
  for (let i = this.conversation.length - 1; i >= 0; i--) {
764
1028
  const m = this.conversation[i];
@@ -782,25 +1046,12 @@ export class Agent {
782
1046
  const t0 = Date.now();
783
1047
  const preview = formatToolArgs(toolName, input);
784
1048
  const { verb, tint } = toolMeta(toolName);
785
- const makePrompt = (elapsed) => `\n ${tint('●')} ${c.white.bold('permission')} ${c.muted(`(${elapsed}s)`)} ${c.muted('·')} ${c.white(verb)} ${c.dim(preview)}\n` +
786
- ` ${c.muted('⎿')} ` +
787
- `${c.success.bold('y')} ${c.muted('allow')} ` +
788
- `${c.error.bold('n')} ${c.muted('deny')} ` +
789
- `${c.info.bold('a')} ${c.muted('always')} ` +
790
- `${c.muted.bold('!')} ${c.muted('never')}\n` +
791
- ` ${c.muted(CH.arrow)} `;
792
- process.stdout.write(makePrompt('0.0'));
793
- const timerUpdate = () => {
794
- const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
795
- process.stdout.write(`\x1b[3A\x1b[0J${makePrompt(elapsed)}`);
796
- };
797
- const timerInterval = setInterval(timerUpdate, 100);
798
- const cleanup = () => {
799
- clearInterval(timerInterval);
800
- };
1049
+ const line = `\n ${tint('●')} ${c.white.bold('permission')} ${c.muted(`(${((Date.now() - t0) / 1000).toFixed(1)}s)`)} ${c.muted('·')} ${c.white(verb)} ${c.dim(preview)}\n`;
1050
+ process.stdout.write(line);
1051
+ const optionsStr = `${c.success(' y allow ')} ${c.error(' n deny ')} ${c.info(' a always ')} ${c.muted(' ! never ')}`;
1052
+ process.stdout.write(` ${optionsStr}\n ${c.muted(CH.arrow)} `);
801
1053
  return new Promise((resolve) => {
802
1054
  if (!process.stdin.isTTY) {
803
- cleanup();
804
1055
  process.stdout.write(chalk.dim('(non-interactive, denying)\n'));
805
1056
  resolve(false);
806
1057
  return;
@@ -815,34 +1066,35 @@ export class Agent {
815
1066
  process.stdin.resume();
816
1067
  }
817
1068
  const onData = (data) => {
818
- cleanup();
1069
+ const text = data.toString();
1070
+ const key = text.toLowerCase();
819
1071
  process.stdin.removeListener('data', onData);
820
- // Restore raw mode to what it was (keeps REPL's ESC handler working)
821
- if (process.stdin.isTTY) {
1072
+ if (process.stdin.isTTY)
822
1073
  process.stdin.setRawMode(wasRaw);
823
- }
824
1074
  restoreStdinListeners(savedDataListeners, savedKeypressListeners);
825
- // Only pause if nobody else was listening (no REPL ESC handler)
826
- if (!savedDataListeners.length) {
1075
+ if (!savedDataListeners.length)
827
1076
  process.stdin.pause();
828
- }
829
- const key = data.toString().toLowerCase();
830
- if (key === 'y' || key === '\r' || key === '\n') {
831
- process.stdout.write(chalk.green('y\n'));
1077
+ let label;
1078
+ if (key === 'y') {
1079
+ label = c.success('allow');
1080
+ process.stdout.write(`${label}\n`);
832
1081
  resolve(true);
833
1082
  }
834
1083
  else if (key === 'a') {
835
- process.stdout.write(chalk.blue('a (always)\n'));
1084
+ label = c.info('always');
836
1085
  this.sessionAllowList.add(toolName);
1086
+ process.stdout.write(`${label}\n`);
837
1087
  resolve(true);
838
1088
  }
839
1089
  else if (key === '!') {
840
- process.stdout.write(chalk.dim('! (always deny)\n'));
1090
+ label = c.muted('never');
841
1091
  this.sessionDenyList.add(toolName);
1092
+ process.stdout.write(`${label}\n`);
842
1093
  resolve(false);
843
1094
  }
844
1095
  else {
845
- process.stdout.write(chalk.red('n\n'));
1096
+ label = c.error('deny');
1097
+ process.stdout.write(`${label}\n`);
846
1098
  resolve(false);
847
1099
  }
848
1100
  };
@@ -1081,22 +1333,22 @@ only the non-obvious. Use \`ask_user\` only when truly blocked on a decision you
1081
1333
  unsure. Don't ask for confirmation on safe operations.
1082
1334
 
1083
1335
  ## MCP (Model Context Protocol) System
1084
- - \`mcp_list\`: List all installed MCP servers and their available tools. MCPs extend ikie
1085
- with specialized capabilities like GitHub API, database access, browser automation, etc.
1086
- - \`mcp_install\`: Install a new MCP server from npm, git URL, or local path.
1087
- - \`mcp_start\`: Start an MCP server to make its tools available for use.
1088
- - \`mcp_stop\`: Stop a running MCP server.
1089
- - \`mcp_call\`: Call a tool from a running MCP. Use \`mcp_list\` first to see available tools.
1090
- - \`mcp_uninstall\`: Remove an installed MCP (built-in MCPs cannot be uninstalled).
1091
- - \`mcp_add\`: Add an MCP by specifying the full command directly (Claude/Cline-style). Use when the MCP runs via npx, a script, or any custom command. Example: \`mcp_add(name="magic", commandArgs="npx -y @21st-dev/magic@latest", env={API_KEY: "..."})\`. After adding, you must run \`mcp_start\` to activate it.
1336
+ MCP servers extend ikie with specialized capabilities like GitHub API, database access,
1337
+ browser automation, etc. When a server is configured, each of its tools appears as a
1338
+ first-class tool named \`mcp__<server>__<tool>\`. Call those tools directly there is no
1339
+ meta-tool dance.
1340
+
1341
+ - \`mcp_list\`: List all configured MCP servers and their status/tools.
1342
+ - \`mcp_add\`: Add an MCP server by specifying the command directly (Claude/Cline-style).
1343
+ Example: \`mcp_add(name="magic", commandArgs="npx -y @21st-dev/magic@latest", env={API_KEY: "..."})\`.
1092
1344
 
1093
- **Recognizing MCP config patterns:** When the user says things like "install this MCP", "claude mcp add", "add this MCP", or pastes a Claude-style MCP config, parse it and use \`mcp_add\`. The format is: \`<any-prefix> mcp add <name> [--scope user] [--env KEY=VALUE ...] -- <command> [args...]\`. Everything after \`--\` is the full command string. Translate this to \`mcp_add\` — do NOT try to run it as a bash command.
1345
+ **Recognizing MCP config patterns:** When the user says things like "install this MCP",
1346
+ "claude mcp add", "add this MCP", or pastes a Claude-style MCP config, parse it and use
1347
+ \`mcp_add\`. The format is: \`<any-prefix> mcp add [--scope user|project|local] [--env KEY=VALUE]... [--header "H: v"]... [--transport stdio|http|sse] <name> [url | -- <command> args...]\`. Everything after \`--\` is the full command string. Translate this to \`mcp_add\` — do NOT try to run it as a bash command.
1094
1348
 
1095
- **Built-in MCPs:**
1096
- - **filesystem**: Enhanced file operations (read multiple files, directory trees)
1097
- - **github**: GitHub API operations (search repos, manage issues, read files from repos)
1098
- - **database**: Database operations for SQLite and PostgreSQL
1099
- - **puppeteer**: Browser automation and web scraping
1349
+ You can also configure servers by creating a \`.mcp.json\` file in the project root (or
1350
+ \`.mcp.local.json\` for machine-local overrides, or \`~/.ikie/mcp.json\` for global ones).
1351
+ Each entry has shape \`{ "type": "stdio|http|sse", "command": "...", "args": [...], "env": {...}, "url": "...", "headers": {...}, "enabled": true, "autoStart": true }\`.
1100
1352
 
1101
1353
  **When to use MCPs:** When you need specialized functionality beyond basic tools. For example:
1102
1354
  - Use GitHub MCP to interact with repositories, issues, pull requests