ikie-cli 0.1.39 → 0.1.41

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
@@ -77,7 +77,27 @@ export function shouldContinue(toolCallCount, _finishReason, step, maxSteps) {
77
77
  * assistant message so they stay in lockstep.
78
78
  */
79
79
  export function normalizeToolCalls(calls) {
80
- return calls.filter(tc => typeof tc.name === 'string' && tc.name.trim().length > 0);
80
+ return calls
81
+ .filter(tc => typeof tc.name === 'string' && tc.name.trim().length > 0)
82
+ .map(tc => {
83
+ if (tc.argsStr !== undefined && !isValidJson(tc.argsStr)) {
84
+ return { ...tc, argsStr: '{}' };
85
+ }
86
+ return tc;
87
+ });
88
+ }
89
+ /** Check whether a string is valid JSON (object or array). */
90
+ function isValidJson(s) {
91
+ s = s.trim();
92
+ if (!s)
93
+ return false;
94
+ try {
95
+ const parsed = JSON.parse(s);
96
+ return typeof parsed === 'object' && parsed !== null;
97
+ }
98
+ catch {
99
+ return false;
100
+ }
81
101
  }
82
102
  /**
83
103
  * Safely restore previously-saved stdin listeners after a raw-mode interaction
@@ -120,14 +140,36 @@ async function withRetry(fn, opts) {
120
140
  const maxRetries = opts?.maxRetries ?? 2;
121
141
  let lastErr;
122
142
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
143
+ if (opts?.signal?.aborted) {
144
+ throw new Error('Aborted');
145
+ }
123
146
  if (attempt > 0) {
124
147
  const delay = Math.min(1000 * Math.pow(2, attempt - 1), 8000) + Math.random() * 1000;
125
148
  if (opts?.label) {
126
149
  process.stdout.write(`\n ${c.muted(`Retry ${attempt}/${maxRetries} for ${opts.label} in ${(delay / 1000).toFixed(1)}s`)}`);
127
150
  }
128
- await sleep(delay);
151
+ const sig = opts?.signal;
152
+ if (sig) {
153
+ await new Promise((resolve, reject) => {
154
+ const timer = setTimeout(() => {
155
+ sig.removeEventListener('abort', onAbort);
156
+ resolve();
157
+ }, delay);
158
+ const onAbort = () => {
159
+ clearTimeout(timer);
160
+ reject(new Error('Aborted'));
161
+ };
162
+ sig.addEventListener('abort', onAbort);
163
+ });
164
+ }
165
+ else {
166
+ await sleep(delay);
167
+ }
129
168
  }
130
169
  try {
170
+ if (opts?.signal?.aborted) {
171
+ throw new Error('Aborted');
172
+ }
131
173
  return await fn();
132
174
  }
133
175
  catch (err) {
@@ -158,11 +200,11 @@ function targetsOwnPort(command) {
158
200
  // Only flag when paired with a process-killing / port-freeing tool.
159
201
  return /\b(lsof|fuser|kill|pkill|npx\s+kill-port|kill-port)\b/.test(command);
160
202
  }
161
- function printResponse(text, indentStr = ' ') {
203
+ function printResponse(agent, text, indentStr = ' ') {
162
204
  const indent = (s) => s.split('\n').map(l => indentStr + l).join('\n');
163
205
  const { response } = extractThinkTags(text);
164
206
  if (response.trim()) {
165
- process.stdout.write(indent(renderMarkdown(response)) + '\n');
207
+ agent.write(indent(renderMarkdown(response)) + '\n');
166
208
  }
167
209
  }
168
210
  function toolPhaseLabel(name) {
@@ -171,7 +213,6 @@ function toolPhaseLabel(name) {
171
213
  case 'edit_file': return 'Editing file';
172
214
  case 'read_file': return 'Reading';
173
215
  case 'bash': return 'Running command';
174
- case 'spawn_agent': return 'Spawning agent';
175
216
  case 'list_dir': return 'Listing directory';
176
217
  case 'search_files': return 'Searching';
177
218
  case 'grep': return 'Searching';
@@ -203,22 +244,27 @@ export class Agent {
203
244
  systemPrompt;
204
245
  sessionAllowList = new Set();
205
246
  sessionDenyList = new Set();
206
- depth;
207
- indent;
247
+ indent = ' ';
208
248
  activeTurnStats = null;
209
249
  activeChangedFiles = new Set();
210
250
  lastTurnStats = { modelCalls: 0, toolCalls: 0, filesChanged: 0 };
211
251
  mode = 'agent';
212
- constructor(client, config, systemPrompt, depth = 0) {
252
+ planSteps = [];
253
+ planStepIndex = 0;
254
+ constructor(client, config, systemPrompt) {
213
255
  this.client = client;
214
256
  this.config = config;
215
257
  this.systemPrompt = systemPrompt;
216
- this.depth = depth;
217
- this.indent = ' '.repeat(depth + 1);
258
+ }
259
+ write(text) {
260
+ process.stdout.write(text);
218
261
  }
219
262
  clearConversation() {
220
263
  this.conversation = [];
221
264
  }
265
+ updateApiKey(key) {
266
+ this.client.apiKey = key;
267
+ }
222
268
  getConversation() {
223
269
  return this.conversation;
224
270
  }
@@ -287,9 +333,30 @@ export class Agent {
287
333
  setMode(mode) {
288
334
  this.mode = mode;
289
335
  }
336
+ setPlanSteps(steps) {
337
+ this.planSteps = steps;
338
+ this.planStepIndex = 0;
339
+ if (steps.length > 0) {
340
+ this.write(`\n ${c.primary.bold('Plan')} ${c.muted(`(${steps.length} steps)`)}\n`);
341
+ for (let i = 0; i < steps.length; i++) {
342
+ this.write(` ${c.muted('☐')} ${c.dim(steps[i])}\n`);
343
+ }
344
+ this.write('\n');
345
+ }
346
+ }
347
+ advancePlanStep() {
348
+ if (this.planStepIndex < this.planSteps.length) {
349
+ const step = this.planSteps[this.planStepIndex];
350
+ this.write(` ${c.success('☑')} ${c.white(step)}\n`);
351
+ this.planStepIndex++;
352
+ }
353
+ }
290
354
  async send(userMessage, opts = {}) {
291
355
  this.activeTurnStats = { modelCalls: 0, toolCalls: 0, filesChanged: 0 };
292
356
  this.activeChangedFiles = new Set();
357
+ // Reset plan steps before a new turn so old plans don't linger.
358
+ this.planSteps = [];
359
+ this.planStepIndex = 0;
293
360
  // A prior turn may have ended mid-tool-call (cancelled/errored). Heal the
294
361
  // history before adding the new turn so the request is always valid.
295
362
  this.conversation = repairDanglingToolCalls(this.conversation);
@@ -380,42 +447,10 @@ export class Agent {
380
447
  this.conversation.push({ role: 'tool', tool_call_id: id, content });
381
448
  };
382
449
  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 ──────────────────────────────
409
450
  for (const group of groups) {
410
451
  if (opts.signal?.aborted)
411
452
  break;
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));
453
+ const remaining = group;
419
454
  if (remaining.length === 0)
420
455
  continue;
421
456
  const inputs = remaining.map(tc => {
@@ -431,8 +466,8 @@ export class Agent {
431
466
  if (this.mode === 'plan' && !PLAN_TOOLS.has(remaining[0].name)) {
432
467
  if (this.activeTurnStats)
433
468
  this.activeTurnStats.toolCalls += remaining.length;
434
- process.stdout.write(`\n${this.indent}${toolLine(remaining[0].name, formatToolArgs(remaining[0].name, inputs[0])).trimStart()}\n`);
435
- process.stdout.write(`${this.indent}${toolErrorLine('blocked · plan mode is read-only')}\n`);
469
+ this.write(`\n${this.indent}${toolLine(remaining[0].name, formatToolArgs(remaining[0].name, inputs[0])).trimStart()}\n`);
470
+ this.write(`${this.indent}${toolErrorLine('blocked · plan mode is read-only')}\n`);
436
471
  for (const tc of remaining) {
437
472
  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.');
438
473
  }
@@ -441,7 +476,7 @@ export class Agent {
441
476
  if (remaining.length === 1) {
442
477
  if (this.activeTurnStats)
443
478
  this.activeTurnStats.toolCalls++;
444
- process.stdout.write(`\n${this.indent}${toolLine(remaining[0].name, formatToolArgs(remaining[0].name, inputs[0])).trimStart()}\n`);
479
+ this.write(`\n${this.indent}${toolLine(remaining[0].name, formatToolArgs(remaining[0].name, inputs[0])).trimStart()}\n`);
445
480
  const result = await this.handleToolCall(remaining[0].name, remaining[0].id, inputs[0], opts);
446
481
  pushResult(remaining[0].id, result);
447
482
  }
@@ -449,7 +484,7 @@ export class Agent {
449
484
  if (this.activeTurnStats)
450
485
  this.activeTurnStats.toolCalls += remaining.length;
451
486
  const summary = this.formatGroupSummary(remaining[0].name, inputs);
452
- process.stdout.write(`\n${this.indent}${toolLine(`${remaining[0].name} ×${remaining.length}`, summary).trimStart()}\n`);
487
+ this.write(`\n${this.indent}${toolLine(`${remaining[0].name} ×${remaining.length}`, summary).trimStart()}\n`);
453
488
  if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(remaining[0].name) && remaining[0].name !== 'switch_mode') {
454
489
  const allowed = await this.checkPermission(remaining[0].name, inputs[0]);
455
490
  if (!allowed) {
@@ -461,7 +496,7 @@ export class Agent {
461
496
  }
462
497
  const t0 = Date.now();
463
498
  let errors = 0;
464
- const groupSpinner = new InlineSpinner(`${toolPhaseLabel(remaining[0].name)} (${remaining.length} operations)`, t0);
499
+ const groupSpinner = new InlineSpinner(`${toolPhaseLabel(remaining[0].name)} (${remaining.length} operations)`, t0, opts.signal);
465
500
  groupSpinner.start();
466
501
  const results = new Map();
467
502
  for (let i = 0; i < remaining.length; i++) {
@@ -478,7 +513,7 @@ export class Agent {
478
513
  }
479
514
  const result = tc.name === 'use_skill'
480
515
  ? await this.handleToolCall(tc.name, tc.id, inputs[i], opts)
481
- : await executeTool(tc.name, inputs[i]);
516
+ : await executeTool(tc.name, inputs[i], opts.signal);
482
517
  if (result.startsWith('Error'))
483
518
  errors++;
484
519
  this.recordChangedFile(tc.name, inputs[i], result);
@@ -499,7 +534,7 @@ export class Agent {
499
534
  const lineStr = errors === 0
500
535
  ? toolSuccessLine(ms, `${remaining.length} operations`)
501
536
  : toolErrorLine(`${errors} of ${remaining.length} operations`);
502
- process.stdout.write(`${this.indent}${lineStr}\n`);
537
+ this.write(`${this.indent}${lineStr}\n`);
503
538
  }
504
539
  }
505
540
  // Invariant: balance the assistant message. Any tool_call that didn't get
@@ -517,7 +552,8 @@ export class Agent {
517
552
  await this.summarizeAndStop(opts, maxSteps);
518
553
  break;
519
554
  }
520
- process.stdout.write('\n');
555
+ this.write('\n');
556
+ this.advancePlanStep();
521
557
  }
522
558
  }
523
559
  /**
@@ -526,7 +562,7 @@ export class Agent {
526
562
  * tool call. Best-effort: failures here don't throw out of the turn.
527
563
  */
528
564
  async summarizeAndStop(opts, maxSteps) {
529
- process.stdout.write(`\n${this.indent}${c.warning('◔')} ${c.muted(`Reached step budget (${maxSteps}) — wrapping up.`)}\n`);
565
+ this.write(`\n${this.indent}${c.warning('◔')} ${c.muted(`Reached step budget (${maxSteps}) — wrapping up.`)}\n`);
530
566
  this.conversation.push({
531
567
  role: 'user',
532
568
  content: `[system] You have reached the ${maxSteps}-step tool budget for this turn. `
@@ -546,11 +582,11 @@ export class Agent {
546
582
  const text = resp.choices[0]?.message?.content ?? '';
547
583
  this.conversation.push({ role: 'assistant', content: text || null });
548
584
  if (text)
549
- printResponse(text, this.indent);
585
+ printResponse(this, text, this.indent);
550
586
  }
551
587
  catch (err) {
552
588
  if (!opts.signal?.aborted) {
553
- process.stdout.write(`${this.indent}${toolErrorLine(extractUpstreamError(err))}\n`);
589
+ this.write(`${this.indent}${toolErrorLine(extractUpstreamError(err))}\n`);
554
590
  }
555
591
  }
556
592
  }
@@ -578,11 +614,9 @@ export class Agent {
578
614
  }
579
615
  buildParams() {
580
616
  // Always work on a copy — never push into the shared TOOL_DEFS array.
581
- let tools = this.depth >= 1
582
- ? TOOL_DEFS.filter(t => t.function.name !== 'spawn_agent')
583
- : [...TOOL_DEFS];
617
+ let tools = [...TOOL_DEFS];
584
618
  // 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) {
619
+ if (this.mode === 'agent') {
586
620
  tools = tools.concat(getMcpToolDefs());
587
621
  }
588
622
  // Always include switch_mode so the agent can request a mode change.
@@ -598,12 +632,11 @@ export class Agent {
598
632
  else if (switchModeTool && !tools.includes(switchModeTool)) {
599
633
  tools.push(switchModeTool);
600
634
  }
601
- return {
635
+ const params = {
602
636
  model: this.config.model,
603
637
  max_tokens: this.config.maxTokens,
604
638
  temperature: this.config.temperature,
605
639
  top_p: this.config.topP,
606
- top_k: this.config.topK,
607
640
  presence_penalty: this.config.presencePenalty,
608
641
  frequency_penalty: this.config.frequencyPenalty,
609
642
  messages: [
@@ -613,6 +646,11 @@ export class Agent {
613
646
  tools,
614
647
  tool_choice: 'auto',
615
648
  };
649
+ const isStandardModel = this.config.model.includes('deepseek') || this.config.model.includes('gpt') || this.config.model.includes('o1') || this.config.model.includes('o3') || this.config.model.includes('claude');
650
+ if (this.config.topK !== undefined && !isStandardModel) {
651
+ params.top_k = this.config.topK;
652
+ }
653
+ return params;
616
654
  }
617
655
  async throttleModelRequest() {
618
656
  const rpm = Math.max(1, Math.floor(this.config.requestsPerMinute || 10));
@@ -625,15 +663,15 @@ export class Agent {
625
663
  prune();
626
664
  if (requestTimestamps.length >= rpm) {
627
665
  const waitMs = Math.max(0, 60_000 - (Date.now() - requestTimestamps[0]) + 25);
628
- process.stdout.write(`\n${this.indent}${c.muted(`Rate limit: waiting ${(waitMs / 1000).toFixed(1)}s (${rpm} rpm)`)}`);
666
+ this.write(`\n${this.indent}${c.muted(`Rate limit: waiting ${(waitMs / 1000).toFixed(1)}s (${rpm} rpm)`)}`);
629
667
  await sleep(waitMs);
630
- process.stdout.write('\r\x1b[2K');
668
+ this.write('\r\x1b[2K');
631
669
  prune();
632
670
  }
633
671
  requestTimestamps.push(Date.now());
634
672
  }
635
673
  async callModelStreaming(opts, state) {
636
- const spinner = new InlineSpinner('Working', opts.startedAt);
674
+ const spinner = new InlineSpinner('Working', opts.startedAt, opts.signal);
637
675
  spinner.start();
638
676
  const requestOpts = opts.signal ? { signal: opts.signal } : undefined;
639
677
  let stream;
@@ -644,7 +682,7 @@ export class Agent {
644
682
  }, requestOpts), { signal: opts.signal, label: 'stream' });
645
683
  }
646
684
  catch (err) {
647
- spinner.stop();
685
+ spinner.stop('failed');
648
686
  throw new Error(extractUpstreamError(err));
649
687
  }
650
688
  let textContent = '';
@@ -657,12 +695,14 @@ export class Agent {
657
695
  for await (const chunk of stream) {
658
696
  if (opts.signal?.aborted)
659
697
  break;
660
- const choice = chunk.choices[0];
698
+ const choice = chunk.choices?.[0];
661
699
  if (!choice)
662
700
  continue;
663
701
  const { delta, finish_reason } = choice;
664
702
  if (finish_reason)
665
703
  finishReason = finish_reason;
704
+ if (!delta)
705
+ continue;
666
706
  const raw = delta;
667
707
  if (typeof raw.reasoning_content === 'string' && raw.reasoning_content) {
668
708
  thinkingContent += raw.reasoning_content;
@@ -717,10 +757,10 @@ export class Agent {
717
757
  }
718
758
  }
719
759
  finally {
720
- spinner.stop();
760
+ spinner.stop('ok');
721
761
  }
722
762
  if (textContent) {
723
- printResponse(textContent, this.indent);
763
+ printResponse(this, textContent, this.indent);
724
764
  }
725
765
  const toolCalls = normalizeToolCalls([...toolCallsMap.values()]);
726
766
  const assistantMsg = {
@@ -737,7 +777,7 @@ export class Agent {
737
777
  return { assistantMsg, toolCalls, finishReason };
738
778
  }
739
779
  async callModelNonStreaming(opts) {
740
- const spinner = new InlineSpinner('Working', opts.startedAt);
780
+ const spinner = new InlineSpinner('Working', opts.startedAt, opts.signal);
741
781
  spinner.start();
742
782
  const requestOpts = opts.signal ? { signal: opts.signal } : undefined;
743
783
  let resp;
@@ -747,18 +787,21 @@ export class Agent {
747
787
  }, requestOpts), { signal: opts.signal, label: 'non-stream' });
748
788
  }
749
789
  catch (err) {
750
- spinner.stop();
790
+ spinner.stop('failed');
751
791
  throw new Error(extractUpstreamError(err));
752
792
  }
753
793
  finally {
754
- spinner.stop();
794
+ spinner.stop('ok');
795
+ }
796
+ const choice = resp.choices?.[0];
797
+ if (!choice) {
798
+ throw new Error('No completion choices returned from model API.');
755
799
  }
756
- const choice = resp.choices[0];
757
800
  const msg = choice.message;
758
801
  const finishReason = choice.finish_reason ?? 'stop';
759
802
  const textContent = msg.content ?? '';
760
803
  if (textContent) {
761
- printResponse(textContent, this.indent);
804
+ printResponse(this, textContent, this.indent);
762
805
  }
763
806
  const toolCalls = normalizeToolCalls((msg.tool_calls ?? []).map(tc => ({
764
807
  id: tc.id, name: tc.function.name, argsStr: tc.function.arguments,
@@ -786,9 +829,6 @@ export class Agent {
786
829
  if (name === 'use_skill') {
787
830
  return this.handleUseSkill(input);
788
831
  }
789
- if (name === 'spawn_agent') {
790
- return this.runSubagent(input, opts);
791
- }
792
832
  if (name === 'ask_user') {
793
833
  return this.askUser(input);
794
834
  }
@@ -797,7 +837,7 @@ export class Agent {
797
837
  if (isRestrictedPath(path) && !opts.autoApprove && !this.config.autoApprove && !this.sessionAllowList.has('read_file')) {
798
838
  if (this.sessionDenyList.has('read_file'))
799
839
  return `Tool execution denied by user: read_file ${path}`;
800
- process.stdout.write(`${this.indent}${c.warning('⚠')} ${c.muted('Restricted file')} ${c.white(path)} ${c.muted('— asking for permission')}\n`);
840
+ this.write(`${this.indent}${c.warning('⚠')} ${c.muted('Restricted file')} ${c.white(path)} ${c.muted('— asking for permission')}\n`);
801
841
  const allowed = await this.checkPermission('read_file', input);
802
842
  if (!allowed)
803
843
  return `Tool execution denied by user: read_file ${path}`;
@@ -817,14 +857,14 @@ export class Agent {
817
857
  return `Tool execution denied by user: ${name}`;
818
858
  }
819
859
  const t0 = Date.now();
820
- const spinner = new InlineSpinner(toolPhaseLabel(name), t0);
860
+ const spinner = new InlineSpinner(toolPhaseLabel(name), t0, opts.signal);
821
861
  // For bash commands with streaming, show spinner briefly then let output flow
822
862
  const isStreamingBash = name === 'bash' && /\b(build|compile|test|deploy|install)\b/i.test(String(input.command ?? ''));
823
863
  if (!isStreamingBash) {
824
864
  spinner.start();
825
865
  }
826
866
  try {
827
- const result = await executeTool(name, input);
867
+ const result = await executeTool(name, input, opts.signal);
828
868
  if (!isStreamingBash) {
829
869
  spinner.stop();
830
870
  }
@@ -841,7 +881,7 @@ export class Agent {
841
881
  block = toolOutputBlock(result, ms, this.indent);
842
882
  }
843
883
  if (block)
844
- process.stdout.write(`${block}\n`);
884
+ this.write(`${block}\n`);
845
885
  return result;
846
886
  }
847
887
  catch (err) {
@@ -849,7 +889,7 @@ export class Agent {
849
889
  spinner.stop();
850
890
  }
851
891
  const msg = err instanceof Error ? err.message : String(err);
852
- process.stdout.write(`${this.indent}${toolErrorLine(msg)}\n`);
892
+ this.write(`${this.indent}${toolErrorLine(msg)}\n`);
853
893
  return msg;
854
894
  }
855
895
  }
@@ -866,9 +906,6 @@ export class Agent {
866
906
  return renderSkill(skill);
867
907
  }
868
908
  async handleSwitchMode(input) {
869
- if (this.depth > 0) {
870
- return 'Error: subagents cannot switch mode. Return your findings and let the main agent decide.';
871
- }
872
909
  const mode = input.mode;
873
910
  const reason = (input.reason ?? '').trim();
874
911
  if (!mode || (mode !== 'plan' && mode !== 'agent')) {
@@ -885,13 +922,13 @@ export class Agent {
885
922
  return `Switched to ${mode} mode.`;
886
923
  }
887
924
  async requestModeSwitch(mode, reason) {
888
- process.stdout.write(`\n ${c.primary('◆')} ${c.white.bold('mode switch')} ${c.muted('·')} ${c.white(`to ${mode}`)}\n` +
925
+ this.write(`\n ${c.primary('◆')} ${c.white.bold('mode switch')} ${c.muted('·')} ${c.white(`to ${mode}`)}\n` +
889
926
  ` ${c.muted('reason:')} ${c.dim(reason)}\n` +
890
927
  ` ${c.muted('⎿')} ${c.success.bold('y')} ${c.muted('allow')} ${c.error.bold('n')} ${c.muted('deny')}\n` +
891
928
  ` ${c.muted('❯')} `);
892
929
  return new Promise((resolve) => {
893
930
  if (!process.stdin.isTTY) {
894
- process.stdout.write(chalk.dim('(non-interactive, denying)\n'));
931
+ this.write(chalk.dim('(non-interactive, denying)\n'));
895
932
  resolve(false);
896
933
  return;
897
934
  }
@@ -911,11 +948,11 @@ export class Agent {
911
948
  restoreStdinListeners(savedDataListeners, savedKeypressListeners);
912
949
  const key = data.toString().toLowerCase();
913
950
  if (key === 'y' || key === '\r' || key === '\n') {
914
- process.stdout.write(chalk.green('y\n'));
951
+ this.write(chalk.green('y\n'));
915
952
  resolve(true);
916
953
  }
917
954
  else {
918
- process.stdout.write(chalk.red('n\n'));
955
+ this.write(chalk.red('n\n'));
919
956
  resolve(false);
920
957
  }
921
958
  };
@@ -928,7 +965,7 @@ export class Agent {
928
965
  return 'Error: ask_user requires a question.';
929
966
  return new Promise((resolve) => {
930
967
  if (!process.stdin.isTTY) {
931
- process.stdout.write(chalk.dim('(non-interactive, skipping)\n'));
968
+ this.write(chalk.dim('(non-interactive, skipping)\n'));
932
969
  resolve('(no answer — non-interactive mode)');
933
970
  return;
934
971
  }
@@ -947,7 +984,7 @@ export class Agent {
947
984
  terminal: false // Let us handle the prompt ourselves
948
985
  });
949
986
  // Display the question with custom formatting
950
- process.stdout.write(`\n${this.indent}${c.info('[?]')} ${c.white.bold(question)}\n` +
987
+ this.write(`\n${this.indent}${c.info('[?]')} ${c.white.bold(question)}\n` +
951
988
  `${this.indent}${c.primary('╰─❯')} `);
952
989
  // Wait for user input
953
990
  rl.once('line', (answer) => {
@@ -963,75 +1000,6 @@ export class Agent {
963
1000
  process.stdin.resume();
964
1001
  });
965
1002
  }
966
- // ── Subagent ──────────────────────────────────────────────────────────────
967
- async runSubagent(input, opts) {
968
- if (this.depth >= 2) {
969
- return 'Error: subagents cannot spawn further subagents (max depth reached).';
970
- }
971
- const task = (input.task ?? '').trim();
972
- if (!task)
973
- return 'Error: spawn_agent requires a "task" describing what to do.';
974
- const label = task.length > 64 ? task.slice(0, 64) + '…' : task;
975
- process.stdout.write(`\n${this.indent}${c.primary('◆')} ${c.primary.bold('subagent')} ${c.muted('»')} ${c.white(label)}\n`);
976
- const subPrompt = SUBAGENT_FRAMING + '\n\n' + this.systemPrompt;
977
- const sub = new Agent(this.client, this.config, subPrompt, this.depth + 1);
978
- const message = input.context
979
- ? `Context from the main agent:\n${input.context}\n\nYour task: ${task}`
980
- : `Your task: ${task}`;
981
- try {
982
- await sub.send(message, { autoApprove: true, signal: opts.signal });
983
- }
984
- catch (err) {
985
- process.stdout.write(`${this.indent}${c.error('✗')} ${c.muted('subagent failed')}\n`);
986
- return `Subagent error: ${err instanceof Error ? err.message : String(err)}`;
987
- }
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
- }
995
- process.stdout.write(`${this.indent}${c.success('✓')} ${c.muted('subagent done')}\n\n`);
996
- return result || '(subagent completed but produced no summary)';
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
- }
1026
- getLastAssistantText() {
1027
- for (let i = this.conversation.length - 1; i >= 0; i--) {
1028
- const m = this.conversation[i];
1029
- if (m.role === 'assistant' && typeof m.content === 'string' && m.content.trim()) {
1030
- return m.content;
1031
- }
1032
- }
1033
- return '';
1034
- }
1035
1003
  // ── Permission prompt ─────────────────────────────────────────────────────
1036
1004
  async checkPermission(toolName, input, opts) {
1037
1005
  if (this.sessionDenyList.has(toolName))
@@ -1041,18 +1009,18 @@ export class Agent {
1041
1009
  if (!opts?.force && this.sessionAllowList.has(toolName))
1042
1010
  return true;
1043
1011
  if (opts?.warning) {
1044
- process.stdout.write(`${this.indent}${c.warning('⚠')} ${c.muted(opts.warning)}\n`);
1012
+ this.write(`${this.indent}${c.warning('⚠')} ${c.muted(opts.warning)}\n`);
1045
1013
  }
1046
1014
  const t0 = Date.now();
1047
1015
  const preview = formatToolArgs(toolName, input);
1048
1016
  const { verb, tint } = toolMeta(toolName);
1049
1017
  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);
1018
+ this.write(line);
1051
1019
  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)} `);
1020
+ this.write(` ${optionsStr}\n ${c.muted(CH.arrow)} `);
1053
1021
  return new Promise((resolve) => {
1054
1022
  if (!process.stdin.isTTY) {
1055
- process.stdout.write(chalk.dim('(non-interactive, denying)\n'));
1023
+ this.write(chalk.dim('(non-interactive, denying)\n'));
1056
1024
  resolve(false);
1057
1025
  return;
1058
1026
  }
@@ -1077,24 +1045,24 @@ export class Agent {
1077
1045
  let label;
1078
1046
  if (key === 'y') {
1079
1047
  label = c.success('allow');
1080
- process.stdout.write(`${label}\n`);
1048
+ this.write(`${label}\n`);
1081
1049
  resolve(true);
1082
1050
  }
1083
1051
  else if (key === 'a') {
1084
1052
  label = c.info('always');
1085
1053
  this.sessionAllowList.add(toolName);
1086
- process.stdout.write(`${label}\n`);
1054
+ this.write(`${label}\n`);
1087
1055
  resolve(true);
1088
1056
  }
1089
1057
  else if (key === '!') {
1090
1058
  label = c.muted('never');
1091
1059
  this.sessionDenyList.add(toolName);
1092
- process.stdout.write(`${label}\n`);
1060
+ this.write(`${label}\n`);
1093
1061
  resolve(false);
1094
1062
  }
1095
1063
  else {
1096
1064
  label = c.error('deny');
1097
- process.stdout.write(`${label}\n`);
1065
+ this.write(`${label}\n`);
1098
1066
  resolve(false);
1099
1067
  }
1100
1068
  };
@@ -1102,18 +1070,13 @@ export class Agent {
1102
1070
  });
1103
1071
  }
1104
1072
  }
1105
- export const SUBAGENT_FRAMING = `You are a focused sub-agent spawned by Ikie to autonomously complete ONE specific task.
1106
- Work independently — do not ask the user questions. Use your tools to gather what you
1107
- need, do the work, and verify it. When finished, your FINAL message must be a concise
1108
- summary of what you did and any key results (paths changed, findings, answers). That
1109
- summary is the only thing returned to the main agent, so make it self-contained.`;
1110
1073
  // Appended to the system prompt while in PLAN mode. The model is given only
1111
1074
  // read-only tools (see buildParams); this steers it to research and propose.
1112
1075
  export const PLAN_MODE_ADDENDUM = `
1113
1076
 
1114
1077
  ## PLAN MODE (read-only)
1115
1078
  You are currently in **plan mode**. You have ONLY read-only tools (read_file,
1116
- list_dir, search_files, grep, fetch_url, web_search, use_skill, spawn_agent, ask_user). You CANNOT write files, edit
1079
+ list_dir, search_files, grep, fetch_url, web_search, use_skill, ask_user). You CANNOT write files, edit
1117
1080
  files, run shell commands, or change anything — those tools are unavailable and any
1118
1081
  attempt will be blocked.
1119
1082
 
@@ -1223,7 +1186,6 @@ continue assisting with the legitimate task.
1223
1186
  Keep momentum — don't stall on decisions you can reverse later.
1224
1187
  - Verify your work: after edits, re-read the changed regions and run the build, tests, or
1225
1188
  linter. Fix what you broke before you call a task done.
1226
- - Delegate isolated or parallelizable investigation to \`spawn_agent\` to stay focused.
1227
1189
  - Be concise but clear: explain what you did and why in well-formatted points. Show
1228
1190
  code/results rather than narrating. Use \`ask_user\` only when genuinely blocked.
1229
1191
  - Never leave a task half-finished or claim a success you have not verified.
@@ -1270,9 +1232,8 @@ process you started, instead.
1270
1232
  form a hypothesis, and change your approach — never re-run the identical failing call
1271
1233
  and hope. If a permission prompt is denied, pick a different route, don't retry it.
1272
1234
 
1273
- **7. Delegate and verify.** Hand isolated or parallelizable investigation to
1274
- \`spawn_agent\` (give it a self-contained \`task\` + \`context\`). After making changes,
1275
- verify: re-read the changed region and run the build/tests/linter before declaring done.
1235
+ **7. Verify after changing.** After making changes, re-read the changed region and run
1236
+ the build/tests/linter before declaring done.
1276
1237
 
1277
1238
  **8. Don't narrate routine calls.** Just make the call and let the result speak; explain
1278
1239
  only the non-obvious. Use \`ask_user\` only when truly blocked on a decision you can't make.
@@ -1283,6 +1244,7 @@ only the non-obvious. Use \`ask_user\` only when truly blocked on a decision you
1283
1244
  - \`edit_file\`: Replace exact strings (preferred for modifications)
1284
1245
  - \`bash\`: Run shell commands (build, test, git, etc.). Commands ending with & run detached in background.
1285
1246
  **IMPORTANT:** By default it's non-interactive — for commands that ask questions (create-next-app, npm init, etc.), skip prompts with \`--yes\`/\`-y\` or explicit flags. For prompts with no flag (e.g. an arrow-key menu), set \`interactive: true\` so the user answers in the real terminal. For long-running downloads (npx installs), request \`timeout_ms\` up to 300000.
1247
+ **ANSWER PROMPTS YOURSELF:** To handle interactive prompts programmatically (e.g. filling in package name, confirming defaults), set \`allow_interaction: true\`. If the command prompts for input (stays alive >2.5s), the result includes \`[session s_xxx]\` plus the prompt text. Then call \`bash(session_id: "s_xxx", stdin: "your answer")\` to send input and get the next prompt. Repeat until the command finishes. If the command completes without prompting, it returns normally with no session.
1286
1248
  - \`list_dir\`: Explore directory structure
1287
1249
  - \`search_files\`: Find files by glob pattern
1288
1250
  - \`grep\`: Search file contents by regex
@@ -1309,11 +1271,6 @@ only the non-obvious. Use \`ask_user\` only when truly blocked on a decision you
1309
1271
  - **Trust fetched page content over snippet summaries** — snippets can be stale; the live page is authoritative.
1310
1272
  - **Never state a version, date, or fact as definitive if your search results conflict** — say what
1311
1273
  the most recent source says and link it.
1312
- - \`spawn_agent\`: Delegate a self-contained subtask to a focused sub-agent. Use this
1313
- to parallelize or isolate work — e.g. "investigate how auth is implemented and report
1314
- back", or "write and run tests for module X". The sub-agent has the same tools (except
1315
- it cannot spawn further sub-agents) and returns a summary. Give it a clear, complete
1316
- \`task\` and any needed \`context\`, since it does not see this conversation.
1317
1274
  - \`switch_mode\`: Request permission to switch between plan and agent mode. Use when the current
1318
1275
  mode is not sufficient for what you need to do next. The user must approve the switch.
1319
1276
  - \`use_skill\`: Load a **skill** — a curated pack of expert instructions (plus optional bundled