pikiclaw 0.2.71 → 0.2.72

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.
@@ -134,7 +134,7 @@ export function buildAgentsCommandView(bot, chatId) {
134
134
  const actions = data.agents
135
135
  .filter(agent => agent.installed)
136
136
  .map(agent => ({
137
- label: agent.agent,
137
+ label: agent.version ? `${agent.agent} ${agent.version}` : agent.agent,
138
138
  action: { kind: 'agent.switch', agent: agent.agent },
139
139
  state: buttonStateFromFlags({ isCurrent: agent.isCurrent }),
140
140
  primary: agent.isCurrent,
@@ -143,15 +143,9 @@ export function buildAgentsCommandView(bot, chatId) {
143
143
  kind: 'agents',
144
144
  title: 'Agents',
145
145
  metaLines: [],
146
- items: data.agents.map(agent => ({
147
- label: agent.agent,
148
- detail: agent.installed
149
- ? (agent.version ? `Version ${agent.version}` : 'Installed')
150
- : 'Not installed',
151
- state: buttonStateFromFlags({ isCurrent: agent.isCurrent, unavailable: !agent.installed }),
152
- })),
153
- helperText: 'Use the controls below to switch agents.',
154
- rows: chunkRows(actions, 3),
146
+ items: [],
147
+ emptyText: actions.length ? undefined : 'No installed agents.',
148
+ rows: actions.map(action => [action]),
155
149
  };
156
150
  }
157
151
  const modelsDrafts = new Map();
@@ -134,7 +134,6 @@ export class FeishuBot extends Bot {
134
134
  this.log(`menu: ${commands.length} commands (${skillCount} skills)`);
135
135
  }
136
136
  afterSwitchWorkdir(_oldPath, _newPath) {
137
- this.sessionMessages.clear();
138
137
  if (!this.channel)
139
138
  return;
140
139
  void this.setupMenu().catch(err => this.log(`menu refresh failed: ${err}`));
@@ -187,14 +186,17 @@ export class FeishuBot extends Bot {
187
186
  return buildSessionTaskId(session, this.nextTaskId++);
188
187
  }
189
188
  registerSessionMessage(chatId, messageId, session) {
190
- this.sessionMessages.register(chatId, messageId, session, this.workdir);
189
+ this.sessionMessages.register(chatId, messageId, session, session.workdir);
191
190
  }
192
191
  registerSessionMessages(chatId, messageIds, session) {
193
- this.sessionMessages.registerMany(chatId, messageIds, session, this.workdir);
192
+ this.sessionMessages.registerMany(chatId, messageIds, session, session.workdir);
194
193
  }
195
194
  sessionFromMessage(chatId, messageId) {
196
- const sessionKey = this.sessionMessages.resolve(chatId, messageId);
197
- return this.getSessionRuntimeByKey(sessionKey);
195
+ const sessionRef = this.sessionMessages.resolve(chatId, messageId);
196
+ if (!sessionRef)
197
+ return null;
198
+ return this.getSessionRuntimeByKey(sessionRef.key, { allowAnyWorkdir: true })
199
+ || this.hydrateSessionRuntime(sessionRef);
198
200
  }
199
201
  ensureSession(chatId, title, files) {
200
202
  return this.ensureSessionForChat(chatId, title, files);
@@ -533,7 +535,7 @@ export class FeishuBot extends Bot {
533
535
  }
534
536
  const staged = stageSessionFiles({
535
537
  agent: session.agent,
536
- workdir: this.workdir,
538
+ workdir: session.workdir,
537
539
  files: msg.files,
538
540
  sessionId: session.sessionId,
539
541
  title: undefined,
@@ -571,6 +573,7 @@ export class FeishuBot extends Bot {
571
573
  agent: session.agent,
572
574
  sessionKey: session.key,
573
575
  prompt,
576
+ attachments: files,
574
577
  startedAt: start,
575
578
  sourceMessageId: ctx.messageId,
576
579
  });
@@ -588,9 +591,10 @@ export class FeishuBot extends Bot {
588
591
  this.registerTaskPlaceholders(taskId, [placeholderId]);
589
592
  void this.queueSessionTask(session, async () => {
590
593
  let livePreview = null;
594
+ let task = null;
591
595
  const abortController = new AbortController();
592
596
  try {
593
- const task = this.markTaskRunning(taskId, () => abortController.abort());
597
+ task = this.markTaskRunning(taskId, () => abortController.abort());
594
598
  if (!task || task.cancelled) {
595
599
  if (placeholderId) {
596
600
  try {
@@ -633,8 +637,19 @@ export class FeishuBot extends Bot {
633
637
  const mcpSendFile = this.createMcpSendFileCallback(ctx);
634
638
  const result = await this.runStream(prompt, session, files, (nextText, nextThinking, nextActivity = '', meta, plan) => {
635
639
  livePreview?.update(nextText, nextThinking, nextActivity, meta, plan);
636
- }, undefined, mcpSendFile, abortController.signal, this.createCodexHumanLoopHandler(ctx, taskId));
640
+ }, undefined, mcpSendFile, abortController.signal, this.createCodexHumanLoopHandler(ctx, taskId), (steer) => {
641
+ const currentTask = this.activeTasks.get(taskId);
642
+ if (!currentTask || currentTask.cancelled || currentTask.status !== 'running')
643
+ return;
644
+ currentTask.steer = steer;
645
+ });
637
646
  await livePreview?.settle();
647
+ if (task?.freezePreviewOnAbort && result.stopReason === 'interrupted') {
648
+ const frozenMessageIds = await this.freezeSteerHandoffPreview(ctx, placeholderId, livePreview);
649
+ this.registerSessionMessages(ctx.chatId, frozenMessageIds, session);
650
+ this.log(`[handleMessage] steer handoff preserved previous preview chat=${ctx.chatId} task=${taskId}`);
651
+ return;
652
+ }
638
653
  const finalReplyIds = await this.sendFinalReply(ctx, placeholderId, session.agent, result);
639
654
  this.registerSessionMessages(ctx.chatId, finalReplyIds, session);
640
655
  this.log(`[handleMessage] end chat=${ctx.chatId} agent=${session.agent} ok=${result.ok} session=${result.sessionId || session.sessionId || '(new)'} ` +
@@ -642,6 +657,12 @@ export class FeishuBot extends Bot {
642
657
  `tools=${formatToolLog(result.activity)}`);
643
658
  }
644
659
  catch (e) {
660
+ if (task?.freezePreviewOnAbort && abortController.signal.aborted) {
661
+ const frozenMessageIds = await this.freezeSteerHandoffPreview(ctx, placeholderId, livePreview);
662
+ this.registerSessionMessages(ctx.chatId, frozenMessageIds, session);
663
+ this.log(`[handleMessage] steer handoff preserved preview after abort chat=${ctx.chatId} task=${taskId}`);
664
+ return;
665
+ }
645
666
  const msgText = String(e?.message || e || 'Unknown error');
646
667
  this.log(`[handleMessage] end chat=${ctx.chatId} agent=${session.agent} ok=false session=${session.sessionId || '(new)'} ` +
647
668
  `elapsed=${((Date.now() - start) / 1000).toFixed(1)}s error="${msgText.slice(0, 240)}" tools=-`);
@@ -671,6 +692,25 @@ export class FeishuBot extends Bot {
671
692
  this.finishTask(taskId);
672
693
  });
673
694
  }
695
+ async freezeSteerHandoffPreview(ctx, placeholderId, livePreview) {
696
+ if (!placeholderId)
697
+ return [];
698
+ const previewMarkdown = livePreview?.getRenderedPreview()?.trim() || '';
699
+ if (!previewMarkdown)
700
+ return [placeholderId];
701
+ try {
702
+ if (this.channel.isStreamingCard(placeholderId)) {
703
+ await this.channel.endStreaming(placeholderId, 'Steered to a new reply.');
704
+ }
705
+ await this.channel.editMessage(ctx.chatId, placeholderId, previewMarkdown, {
706
+ keyboard: { rows: [] },
707
+ });
708
+ return [placeholderId];
709
+ }
710
+ catch {
711
+ return [];
712
+ }
713
+ }
674
714
  async sendFinalReply(ctx, placeholderId, agent, result) {
675
715
  const rendered = buildFinalReplyRender(agent, result);
676
716
  const messageIds = [];
@@ -917,7 +957,7 @@ export class FeishuBot extends Bot {
917
957
  if (!data.startsWith('tsk:steer:'))
918
958
  return false;
919
959
  const actionId = data.slice('tsk:steer:'.length).trim();
920
- const result = this.steerTaskByActionId(actionId);
960
+ const result = await this.steerTaskByActionId(actionId);
921
961
  if (!result.task)
922
962
  return true;
923
963
  // The queued task will naturally run next after the running task is interrupted
@@ -16,7 +16,7 @@ import { stageSessionFiles } from './code-agent.js';
16
16
  export async function stageFilesIntoSession(bot, session, files) {
17
17
  const staged = stageSessionFiles({
18
18
  agent: session.agent,
19
- workdir: bot.workdir,
19
+ workdir: session.workdir,
20
20
  files,
21
21
  sessionId: session.sessionId,
22
22
  title: undefined,
@@ -40,7 +40,15 @@ export class SessionMessageRegistry {
40
40
  chatMessages = new Map();
41
41
  this.messages.set(chatId, chatMessages);
42
42
  }
43
- chatMessages.set(messageId, session.key);
43
+ chatMessages.set(messageId, {
44
+ key: session.key,
45
+ workdir: session.workdir,
46
+ agent: session.agent,
47
+ sessionId: session.sessionId,
48
+ workspacePath: session.workspacePath ?? null,
49
+ codexCumulative: session.codexCumulative,
50
+ modelId: session.modelId ?? null,
51
+ });
44
52
  while (chatMessages.size > this.maxPerChat) {
45
53
  const oldest = chatMessages.keys().next();
46
54
  if (oldest.done)
@@ -104,6 +104,9 @@ export class LivePreview {
104
104
  getEditCount() {
105
105
  return this.editCount;
106
106
  }
107
+ getRenderedPreview() {
108
+ return this.lastPreview;
109
+ }
107
110
  stopFeedback() {
108
111
  if (this.heartbeatTimer) {
109
112
  clearInterval(this.heartbeatTimer);
@@ -82,7 +82,6 @@ export class TelegramBot extends Bot {
82
82
  this.log(`menu: ${commands.length} commands (${skillCount} skills)`);
83
83
  }
84
84
  afterSwitchWorkdir(_oldPath, _newPath) {
85
- this.sessionMessages.clear();
86
85
  if (!this.channel)
87
86
  return;
88
87
  void this.setupMenu().catch(err => this.log(`menu refresh failed after workdir switch: ${err}`));
@@ -159,14 +158,17 @@ export class TelegramBot extends Bot {
159
158
  return buildSessionTaskId(session, this.nextTaskId++);
160
159
  }
161
160
  registerSessionMessage(chatId, messageId, session) {
162
- this.sessionMessages.register(chatId, messageId, session, this.workdir);
161
+ this.sessionMessages.register(chatId, messageId, session, session.workdir);
163
162
  }
164
163
  registerSessionMessages(chatId, messageIds, session) {
165
- this.sessionMessages.registerMany(chatId, messageIds, session, this.workdir);
164
+ this.sessionMessages.registerMany(chatId, messageIds, session, session.workdir);
166
165
  }
167
166
  sessionFromMessage(chatId, messageId) {
168
- const sessionKey = this.sessionMessages.resolve(chatId, messageId);
169
- return this.getSessionRuntimeByKey(sessionKey);
167
+ const sessionRef = this.sessionMessages.resolve(chatId, messageId);
168
+ if (!sessionRef)
169
+ return null;
170
+ return this.getSessionRuntimeByKey(sessionRef.key, { allowAnyWorkdir: true })
171
+ || this.hydrateSessionRuntime(sessionRef);
170
172
  }
171
173
  ensureSession(chatId, title, files) {
172
174
  return this.ensureSessionForChat(chatId, title, files);
@@ -468,7 +470,7 @@ export class TelegramBot extends Bot {
468
470
  }
469
471
  const staged = stageSessionFiles({
470
472
  agent: session.agent,
471
- workdir: this.workdir,
473
+ workdir: session.workdir,
472
474
  files: msg.files,
473
475
  sessionId: session.sessionId,
474
476
  title: undefined,
@@ -507,6 +509,7 @@ export class TelegramBot extends Bot {
507
509
  agent: session.agent,
508
510
  sessionKey: session.key,
509
511
  prompt,
512
+ attachments: files,
510
513
  startedAt: start,
511
514
  sourceMessageId: ctx.messageId,
512
515
  });
@@ -531,9 +534,10 @@ export class TelegramBot extends Bot {
531
534
  this.registerTaskPlaceholders(taskId, [phId]);
532
535
  void this.queueSessionTask(session, async () => {
533
536
  let livePreview = null;
537
+ let task = null;
534
538
  const abortController = new AbortController();
535
539
  try {
536
- const task = this.markTaskRunning(taskId, () => abortController.abort());
540
+ task = this.markTaskRunning(taskId, () => abortController.abort());
537
541
  if (!task || task.cancelled) {
538
542
  if (phId != null) {
539
543
  try {
@@ -573,8 +577,19 @@ export class TelegramBot extends Bot {
573
577
  const mcpSendFile = this.createMcpSendFileCallback(ctx, messageThreadId);
574
578
  const result = await this.runStream(prompt, session, files, (nextText, nextThinking, nextActivity = '', meta, plan) => {
575
579
  livePreview?.update(nextText, nextThinking, nextActivity, meta, plan);
576
- }, undefined, mcpSendFile, abortController.signal, this.createCodexHumanLoopHandler(ctx, taskId, messageThreadId));
580
+ }, undefined, mcpSendFile, abortController.signal, this.createCodexHumanLoopHandler(ctx, taskId, messageThreadId), (steer) => {
581
+ const currentTask = this.activeTasks.get(taskId);
582
+ if (!currentTask || currentTask.cancelled || currentTask.status !== 'running')
583
+ return;
584
+ currentTask.steer = steer;
585
+ });
577
586
  await livePreview?.settle();
587
+ if (task?.freezePreviewOnAbort && result.stopReason === 'interrupted') {
588
+ const frozenMessageIds = await this.freezeSteerHandoffPreview(ctx, phId, livePreview);
589
+ this.registerSessionMessages(ctx.chatId, frozenMessageIds, session);
590
+ this.log(`[handleMessage] steer handoff preserved previous preview chat=${ctx.chatId} task=${taskId}`);
591
+ return;
592
+ }
578
593
  this.log(`[handleMessage] done agent=${session.agent} ok=${result.ok} session=${result.sessionId || '?'} elapsed=${result.elapsedS.toFixed(1)}s edits=${livePreview?.getEditCount() || 0} ` +
579
594
  `tokens=in:${fmtTokens(result.inputTokens)}/cached:${fmtTokens(result.cachedInputTokens)}/out:${fmtTokens(result.outputTokens)}`);
580
595
  this.log(`[handleMessage] response preview: "${result.message.slice(0, 150)}"`);
@@ -583,6 +598,12 @@ export class TelegramBot extends Bot {
583
598
  this.log(`[handleMessage] final reply sent to chat=${ctx.chatId}`);
584
599
  }
585
600
  catch (e) {
601
+ if (task?.freezePreviewOnAbort && abortController.signal.aborted) {
602
+ const frozenMessageIds = await this.freezeSteerHandoffPreview(ctx, phId, livePreview);
603
+ this.registerSessionMessages(ctx.chatId, frozenMessageIds, session);
604
+ this.log(`[handleMessage] steer handoff preserved preview after abort chat=${ctx.chatId} task=${taskId}`);
605
+ return;
606
+ }
586
607
  const msgText = String(e?.message || e || 'Unknown error');
587
608
  this.log(`[handleMessage] task failed chat=${ctx.chatId} session=${session.sessionId} error=${msgText}`);
588
609
  const errorHtml = `<b>Error</b>\n\n<code>${escapeHtml(msgText.slice(0, 500))}</code>`;
@@ -612,6 +633,23 @@ export class TelegramBot extends Bot {
612
633
  this.finishTask(taskId);
613
634
  });
614
635
  }
636
+ async freezeSteerHandoffPreview(ctx, phId, livePreview) {
637
+ if (phId == null)
638
+ return [];
639
+ const previewHtml = livePreview?.getRenderedPreview()?.trim() || '';
640
+ if (!previewHtml)
641
+ return [phId];
642
+ try {
643
+ await this.channel.editMessage(ctx.chatId, phId, previewHtml, {
644
+ parseMode: 'HTML',
645
+ keyboard: { inline_keyboard: [] },
646
+ });
647
+ return [phId];
648
+ }
649
+ catch {
650
+ return [];
651
+ }
652
+ }
615
653
  /** Create an MCP sendFile callback bound to a Telegram chat context. */
616
654
  createMcpSendFileCallback(ctx, messageThreadId) {
617
655
  return async (filePath, opts) => {
@@ -780,7 +818,7 @@ export class TelegramBot extends Bot {
780
818
  if (!data.startsWith('tsk:steer:'))
781
819
  return false;
782
820
  const actionId = data.slice('tsk:steer:'.length).trim();
783
- const result = this.steerTaskByActionId(actionId);
821
+ const result = await this.steerTaskByActionId(actionId);
784
822
  if (!result.task) {
785
823
  await ctx.answerCallback('This task already finished.');
786
824
  return true;
@@ -789,7 +827,7 @@ export class TelegramBot extends Bot {
789
827
  await ctx.answerCallback('Task is already running.');
790
828
  return true;
791
829
  }
792
- await ctx.answerCallback(result.interrupted ? 'Steering — interrupting current task...' : 'No running task to interrupt.');
830
+ await ctx.answerCallback(result.interrupted ? 'Steering — switching to the queued reply...' : 'No running task to interrupt.');
793
831
  return true;
794
832
  }
795
833
  async handleHumanLoopCallback(data, ctx) {
package/dist/bot.js CHANGED
@@ -538,7 +538,19 @@ export class Bot {
538
538
  return runtime;
539
539
  }
540
540
  getSelectedSession(cs) {
541
- return this.getSessionRuntimeByKey(cs.activeSessionKey);
541
+ return this.getSessionRuntimeByKey(cs.activeSessionKey, { allowAnyWorkdir: true });
542
+ }
543
+ hydrateSessionRuntime(session) {
544
+ if (!session.sessionId)
545
+ return null;
546
+ return this.upsertSessionRuntime({
547
+ agent: session.agent,
548
+ sessionId: session.sessionId,
549
+ workdir: session.workdir || this.workdir,
550
+ workspacePath: session.workspacePath ?? null,
551
+ codexCumulative: session.codexCumulative,
552
+ modelId: session.modelId ?? null,
553
+ });
542
554
  }
543
555
  upsertSessionRuntime(session) {
544
556
  const workdir = path.resolve(session.workdir || this.workdir);
@@ -570,6 +582,7 @@ export class Bot {
570
582
  return runtime;
571
583
  }
572
584
  applySessionSelection(cs, session) {
585
+ const previousSessionKey = cs.activeSessionKey ?? null;
573
586
  cs.activeSessionKey = session?.key ?? null;
574
587
  if (session) {
575
588
  cs.agent = session.agent;
@@ -577,12 +590,16 @@ export class Bot {
577
590
  cs.workspacePath = session.workspacePath;
578
591
  cs.codexCumulative = session.codexCumulative;
579
592
  cs.modelId = session.modelId ?? null;
593
+ if (previousSessionKey && previousSessionKey !== session.key)
594
+ this.maybeEvictSessionRuntime(previousSessionKey);
580
595
  return;
581
596
  }
582
597
  cs.sessionId = null;
583
598
  cs.workspacePath = null;
584
599
  cs.codexCumulative = undefined;
585
600
  cs.modelId = null;
601
+ if (previousSessionKey)
602
+ this.maybeEvictSessionRuntime(previousSessionKey);
586
603
  }
587
604
  resetChatConversation(cs) {
588
605
  this.applySessionSelection(cs, null);
@@ -592,12 +609,17 @@ export class Bot {
592
609
  this.applySessionSelection(cs, null);
593
610
  return;
594
611
  }
595
- const runtime = this.upsertSessionRuntime({
612
+ const runtime = this.hydrateSessionRuntime({
596
613
  agent: session.agent,
597
614
  sessionId: session.sessionId,
615
+ workdir: 'workdir' in session ? session.workdir : null,
598
616
  workspacePath: session.workspacePath ?? null,
599
617
  modelId: session.model ?? null,
600
618
  });
619
+ if (!runtime) {
620
+ this.applySessionSelection(cs, null);
621
+ return;
622
+ }
601
623
  this.applySessionSelection(cs, runtime);
602
624
  }
603
625
  syncSelectedChats(session) {
@@ -607,6 +629,27 @@ export class Bot {
607
629
  this.applySessionSelection(cs, session);
608
630
  }
609
631
  }
632
+ isSessionSelected(sessionKey) {
633
+ if (!sessionKey)
634
+ return false;
635
+ for (const [, cs] of this.chats) {
636
+ if (cs.activeSessionKey === sessionKey)
637
+ return true;
638
+ }
639
+ return false;
640
+ }
641
+ maybeEvictSessionRuntime(sessionKey) {
642
+ const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
643
+ if (!session)
644
+ return;
645
+ if (session.runningTaskIds.size)
646
+ return;
647
+ if (session.workdir === this.workdir)
648
+ return;
649
+ if (this.isSessionSelected(session.key))
650
+ return;
651
+ this.sessionStates.delete(session.key);
652
+ }
610
653
  ensureSessionForChat(chatId, title, files) {
611
654
  const cs = this.chat(chatId);
612
655
  const selected = this.getSelectedSession(cs);
@@ -661,9 +704,7 @@ export class Bot {
661
704
  if (!session)
662
705
  return;
663
706
  session.runningTaskIds.delete(taskId);
664
- if (!session.runningTaskIds.size && session.workdir !== this.workdir) {
665
- this.sessionStates.delete(session.key);
666
- }
707
+ this.maybeEvictSessionRuntime(session.key);
667
708
  }
668
709
  runningTaskForSession(sessionKey) {
669
710
  const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
@@ -687,6 +728,8 @@ export class Bot {
687
728
  return task;
688
729
  task.status = 'running';
689
730
  task.abort = abort || null;
731
+ task.steer = null;
732
+ task.freezePreviewOnAbort = false;
690
733
  return task;
691
734
  }
692
735
  registerTaskPlaceholders(taskId, messageIds) {
@@ -768,25 +811,25 @@ export class Bot {
768
811
  return { task, interrupted: false, cancelled: false };
769
812
  }
770
813
  /**
771
- * Steer: interrupt the running task so a queued task (identified by actionId) runs next.
772
- * Returns { task, interrupted } where task is the queued task and interrupted indicates
773
- * whether a running task was aborted.
814
+ * Steer hands off to the queued task's own placeholder card. Interrupt the
815
+ * active task so the queued task can run next and the current preview can be
816
+ * frozen in place instead of being rewritten as an error.
774
817
  */
775
- steerTaskByActionId(actionId) {
818
+ async steerTaskByActionId(actionId) {
776
819
  const taskId = this.taskKeysByActionId.get(String(actionId));
777
820
  if (!taskId)
778
- return { task: null, interrupted: false };
821
+ return { task: null, interrupted: false, steered: false };
779
822
  const task = this.activeTasks.get(taskId) || null;
780
823
  if (!task || task.status !== 'queued')
781
- return { task, interrupted: false };
782
- const interrupted = this.interruptRunningTask(task.sessionKey);
783
- return { task, interrupted };
824
+ return { task, interrupted: false, steered: false };
825
+ const interrupted = this.interruptRunningTask(task.sessionKey, { freezePreview: true });
826
+ return { task, interrupted, steered: false };
784
827
  }
785
828
  /**
786
829
  * Interrupt only the currently running task for a session, leaving queued tasks intact.
787
830
  * Used by the "Steer" action to let a queued task run next.
788
831
  */
789
- interruptRunningTask(sessionKey) {
832
+ interruptRunningTask(sessionKey, opts = {}) {
790
833
  const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
791
834
  if (!session)
792
835
  return false;
@@ -794,6 +837,7 @@ export class Bot {
794
837
  const task = this.activeTasks.get(taskId);
795
838
  if (!task || task.status !== 'running')
796
839
  continue;
840
+ task.freezePreviewOnAbort = !!opts.freezePreview;
797
841
  try {
798
842
  task.abort?.();
799
843
  }
@@ -995,8 +1039,8 @@ export class Bot {
995
1039
  fetchSessions(agent) {
996
1040
  return getSessions({ agent, workdir: this.workdir });
997
1041
  }
998
- fetchSessionTail(agent, sessionId, limit) {
999
- return getSessionTail({ agent, sessionId, workdir: this.workdir, limit });
1042
+ fetchSessionTail(agent, sessionId, limit, workdir = this.workdir) {
1043
+ return getSessionTail({ agent, sessionId, workdir, limit });
1000
1044
  }
1001
1045
  fetchAgents(options = {}) {
1002
1046
  return listAgents(options);
@@ -1048,7 +1092,7 @@ export class Bot {
1048
1092
  return {
1049
1093
  version: VERSION, uptime: Date.now() - this.startedAt,
1050
1094
  memRss: mem.rss, memHeap: mem.heapUsed, pid: process.pid,
1051
- workdir: this.workdir, agent: cs.agent, model, sessionId: cs.sessionId,
1095
+ workdir: selectedSession?.workdir || this.workdir, agent: cs.agent, model, sessionId: cs.sessionId,
1052
1096
  workspacePath: cs.workspacePath ?? null,
1053
1097
  running: fallbackTask, activeTasksCount: this.activeTasks.size, stats: this.stats,
1054
1098
  usage: getUsage({ agent: cs.agent, model }),
@@ -1143,12 +1187,15 @@ export class Bot {
1143
1187
  if (!opts.initial)
1144
1188
  this.onManagedConfigChange(config, opts);
1145
1189
  }
1146
- async runStream(prompt, cs, attachments, onText, systemPrompt, mcpSendFile, abortSignal, onCodexInteractionRequest) {
1190
+ async runStream(prompt, cs, attachments, onText, systemPrompt, mcpSendFile, abortSignal, onCodexInteractionRequest, onSteerReady, onCodexTurnReady) {
1147
1191
  const resolvedModel = cs.modelId || this.modelForAgent(cs.agent);
1148
1192
  const agentConfig = this.agentConfigs[cs.agent] || {};
1149
1193
  const extraArgs = agentConfig.extraArgs || [];
1150
1194
  const browserEnabled = resolveGuiIntegrationConfig(getActiveUserConfig()).browserEnabled;
1151
- this.log(`[runStream] agent=${cs.agent} session=${cs.sessionId || '(new)'} workdir=${this.workdir} timeout=${this.runTimeout}s attachments=${attachments.length}`);
1195
+ const sessionWorkdir = 'workdir' in cs && typeof cs.workdir === 'string' && cs.workdir
1196
+ ? path.resolve(cs.workdir)
1197
+ : this.workdir;
1198
+ this.log(`[runStream] agent=${cs.agent} session=${cs.sessionId || '(new)'} workdir=${sessionWorkdir} timeout=${this.runTimeout}s attachments=${attachments.length}`);
1152
1199
  this.log(`[runStream] ${cs.agent} config: model=${resolvedModel} extraArgs=[${extraArgs.join(' ')}]`);
1153
1200
  const isFirstTurnOfSession = !cs.sessionId || isPendingSessionId(cs.sessionId);
1154
1201
  const mcpSystemPrompt = mcpSendFile
@@ -1158,7 +1205,7 @@ export class Bot {
1158
1205
  ? appendExtraPrompt(systemPrompt, mcpSystemPrompt)
1159
1206
  : undefined;
1160
1207
  const opts = {
1161
- agent: cs.agent, prompt, workdir: this.workdir, timeout: this.runTimeout,
1208
+ agent: cs.agent, prompt, workdir: sessionWorkdir, timeout: this.runTimeout,
1162
1209
  sessionId: cs.sessionId, model: null,
1163
1210
  thinkingEffort: agentConfig.reasoningEffort || 'high', onText,
1164
1211
  attachments: attachments.length ? attachments : undefined,
@@ -1183,6 +1230,8 @@ export class Bot {
1183
1230
  mcpSendFile,
1184
1231
  abortSignal,
1185
1232
  onCodexInteractionRequest,
1233
+ onSteerReady,
1234
+ onCodexTurnReady,
1186
1235
  };
1187
1236
  const result = await doStream(opts);
1188
1237
  this.stats.totalTurns++;
@@ -3,16 +3,18 @@
3
3
  */
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { execSync } from 'node:child_process';
6
+ import { execSync, spawn } from 'node:child_process';
7
+ import { createInterface } from 'node:readline';
7
8
  import { registerDriver } from './agent-driver.js';
8
9
  import {
9
10
  // shared helpers
10
- run, agentLog, detectAgentBin, pushRecentActivity, summarizeClaudeToolUse, summarizeClaudeToolResult, IMAGE_EXTS, mimeForExt, listPikiclawSessions, mergeManagedAndNativeSessions, readTailLines, stripInjectedPrompts, roundPercent, modelFamily, normalizeClaudeModelId, emptyUsage, normalizeUsageStatus, } from './code-agent.js';
11
- import { SESSION_RUNNING_THRESHOLD_MS } from './constants.js';
11
+ Q, run, agentLog, detectAgentBin, buildStreamPreviewMeta, pushRecentActivity, summarizeClaudeToolUse, summarizeClaudeToolResult, joinErrorMessages, IMAGE_EXTS, mimeForExt, listPikiclawSessions, mergeManagedAndNativeSessions, readTailLines, stripInjectedPrompts, roundPercent, modelFamily, normalizeClaudeModelId, emptyUsage, normalizeUsageStatus, } from './code-agent.js';
12
+ import { AGENT_STREAM_HARD_KILL_GRACE_MS, SESSION_RUNNING_THRESHOLD_MS } from './constants.js';
13
+ import { terminateProcessTree } from './process-control.js';
12
14
  // ---------------------------------------------------------------------------
13
15
  // Multimodal stdin
14
16
  // ---------------------------------------------------------------------------
15
- function buildClaudeMultimodalStdin(prompt, attachments) {
17
+ function buildClaudeUserMessage(prompt, attachments) {
16
18
  const content = [];
17
19
  for (const filePath of attachments) {
18
20
  const ext = path.extname(filePath).toLowerCase();
@@ -35,6 +37,10 @@ function buildClaudeMultimodalStdin(prompt, attachments) {
35
37
  content.push({ type: 'text', text: prompt });
36
38
  return JSON.stringify({ type: 'user', message: { role: 'user', content } }) + '\n';
37
39
  }
40
+ function claudeUsesStreamJsonInput(o) {
41
+ return !!o.attachments?.length || !!o.onSteerReady;
42
+ }
43
+ const CLAUDE_STEER_IDLE_CLOSE_MS = 1200;
38
44
  // ---------------------------------------------------------------------------
39
45
  // Command & parser
40
46
  // ---------------------------------------------------------------------------
@@ -47,9 +53,12 @@ function claudeCmd(o) {
47
53
  args.push('--permission-mode', o.claudePermissionMode);
48
54
  if (o.sessionId)
49
55
  args.push('--resume', o.sessionId);
50
- if (o.attachments?.length) {
56
+ if (claudeUsesStreamJsonInput(o)) {
51
57
  args.push('--input-format', 'stream-json');
52
- o._stdinOverride = buildClaudeMultimodalStdin(o.prompt, o.attachments);
58
+ if (o.onSteerReady)
59
+ args.push('--replay-user-messages');
60
+ if (o.attachments?.length)
61
+ o._stdinOverride = buildClaudeUserMessage(o.prompt, o.attachments);
53
62
  }
54
63
  if (o.thinkingEffort)
55
64
  args.push('--effort', o.thinkingEffort);
@@ -183,14 +192,304 @@ function claudeParse(ev, s) {
183
192
  }
184
193
  }
185
194
  }
195
+ function createClaudeStreamState(opts) {
196
+ return {
197
+ sessionId: opts.sessionId,
198
+ text: '',
199
+ thinking: '',
200
+ msgs: [],
201
+ thinkParts: [],
202
+ model: opts.model,
203
+ thinkingEffort: opts.thinkingEffort,
204
+ errors: null,
205
+ inputTokens: null,
206
+ outputTokens: null,
207
+ cachedInputTokens: null,
208
+ cacheCreationInputTokens: null,
209
+ contextWindow: null,
210
+ contextUsedTokens: null,
211
+ codexCumulative: null,
212
+ stopReason: null,
213
+ activity: '',
214
+ recentActivity: [],
215
+ claudeToolsById: new Map(),
216
+ seenClaudeToolIds: new Set(),
217
+ };
218
+ }
219
+ function resetClaudeTurnState(s, note) {
220
+ s.text = '';
221
+ s.thinking = '';
222
+ s.msgs = [];
223
+ s.thinkParts = [];
224
+ s.errors = null;
225
+ s.inputTokens = null;
226
+ s.outputTokens = null;
227
+ s.cachedInputTokens = null;
228
+ s.cacheCreationInputTokens = null;
229
+ s.contextUsedTokens = null;
230
+ s.stopReason = null;
231
+ s.activity = '';
232
+ s.recentActivity = [];
233
+ s.claudeToolsById = new Map();
234
+ s.seenClaudeToolIds = new Set();
235
+ if (note) {
236
+ pushRecentActivity(s.recentActivity, note);
237
+ s.activity = s.recentActivity.join('\n');
238
+ }
239
+ }
240
+ async function doClaudeInteractiveStream(opts) {
241
+ const start = Date.now();
242
+ const deadline = start + opts.timeout * 1000;
243
+ let stderr = '';
244
+ let lineCount = 0;
245
+ let timedOut = false;
246
+ let interrupted = false;
247
+ let stdinClosed = false;
248
+ let steerQueued = false;
249
+ let awaitingSteeredResponseStart = false;
250
+ let idleCloseTimer = null;
251
+ const s = createClaudeStreamState(opts);
252
+ const cmd = claudeCmd(opts);
253
+ const shellCmd = cmd.map(Q).join(' ');
254
+ agentLog(`[spawn] full command: cd ${Q(opts.workdir)} && ${shellCmd}`);
255
+ agentLog(`[spawn] timeout: ${opts.timeout}s session: ${opts.sessionId || '(new)'}`);
256
+ agentLog(`[spawn] prompt (stdin): "${opts.prompt.slice(0, 300)}${opts.prompt.length > 300 ? '…' : ''}"`);
257
+ const spawnEnv = { ...process.env, ...(opts.extraEnv || {}) };
258
+ delete spawnEnv.CLAUDECODE;
259
+ const proc = spawn(shellCmd, {
260
+ cwd: opts.workdir,
261
+ env: spawnEnv,
262
+ stdio: ['pipe', 'pipe', 'pipe'],
263
+ shell: true,
264
+ detached: process.platform !== 'win32',
265
+ });
266
+ agentLog(`[spawn] pid=${proc.pid}`);
267
+ const closeInput = () => {
268
+ if (idleCloseTimer) {
269
+ clearTimeout(idleCloseTimer);
270
+ idleCloseTimer = null;
271
+ }
272
+ if (stdinClosed)
273
+ return;
274
+ stdinClosed = true;
275
+ try {
276
+ proc.stdin?.end();
277
+ }
278
+ catch { }
279
+ };
280
+ const emit = () => {
281
+ opts.onText(s.text, s.thinking, s.activity, buildStreamPreviewMeta(s), null);
282
+ };
283
+ const abortStream = () => {
284
+ if (interrupted || proc.killed)
285
+ return;
286
+ interrupted = true;
287
+ s.stopReason = 'interrupted';
288
+ closeInput();
289
+ agentLog(`[abort] user interrupt, killing process tree pid=${proc.pid}`);
290
+ terminateProcessTree(proc, { signal: 'SIGTERM', forceSignal: 'SIGKILL', forceAfterMs: 5000 });
291
+ };
292
+ if (opts.abortSignal?.aborted)
293
+ abortStream();
294
+ opts.abortSignal?.addEventListener('abort', abortStream, { once: true });
295
+ const scheduleIdleClose = () => {
296
+ if (idleCloseTimer)
297
+ clearTimeout(idleCloseTimer);
298
+ idleCloseTimer = setTimeout(() => {
299
+ idleCloseTimer = null;
300
+ if (stdinClosed || interrupted || timedOut || proc.killed || proc.exitCode != null)
301
+ return;
302
+ agentLog(`[stdin] closing Claude input after ${CLAUDE_STEER_IDLE_CLOSE_MS}ms idle result window`);
303
+ closeInput();
304
+ }, CLAUDE_STEER_IDLE_CLOSE_MS);
305
+ };
306
+ const startsClaudeFollowup = (ev) => {
307
+ const evType = ev?.type || '';
308
+ if (evType === 'assistant')
309
+ return true;
310
+ if (evType !== 'stream_event')
311
+ return false;
312
+ const innerType = ev?.event?.type || '';
313
+ return innerType === 'message_start' || innerType === 'content_block_delta';
314
+ };
315
+ const sendInput = (prompt, attachments = [], note, kind = 'steer') => {
316
+ if (stdinClosed || interrupted || timedOut || proc.killed || proc.exitCode != null)
317
+ return false;
318
+ try {
319
+ proc.stdin?.write(buildClaudeUserMessage(prompt, attachments));
320
+ if (kind === 'steer') {
321
+ steerQueued = true;
322
+ if (idleCloseTimer) {
323
+ clearTimeout(idleCloseTimer);
324
+ idleCloseTimer = null;
325
+ }
326
+ }
327
+ if (note) {
328
+ pushRecentActivity(s.recentActivity, note);
329
+ s.activity = s.recentActivity.join('\n');
330
+ emit();
331
+ }
332
+ return true;
333
+ }
334
+ catch (error) {
335
+ agentLog(`[stdin] failed to write Claude input: ${error?.message || error}`);
336
+ return false;
337
+ }
338
+ };
339
+ if (!sendInput(opts.prompt, opts.attachments || [], undefined, 'initial')) {
340
+ closeInput();
341
+ }
342
+ try {
343
+ opts.onSteerReady?.(async (prompt, attachments = []) => {
344
+ if (!sendInput(prompt, attachments, 'Queued steer input', 'steer'))
345
+ return false;
346
+ return true;
347
+ });
348
+ }
349
+ catch (error) {
350
+ agentLog(`[stdin] onSteerReady error: ${error?.message || error}`);
351
+ }
352
+ proc.stderr?.on('data', (c) => {
353
+ const chunk = c.toString();
354
+ stderr += chunk;
355
+ agentLog(`[stderr] ${chunk.trim().slice(0, 200)}`);
356
+ });
357
+ const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
358
+ rl.on('line', raw => {
359
+ if (Date.now() > deadline) {
360
+ timedOut = true;
361
+ s.stopReason = 'timeout';
362
+ closeInput();
363
+ agentLog('[timeout] deadline exceeded, killing process tree');
364
+ terminateProcessTree(proc, { signal: 'SIGKILL' });
365
+ return;
366
+ }
367
+ const line = raw.trim();
368
+ if (!line || line[0] !== '{')
369
+ return;
370
+ lineCount++;
371
+ try {
372
+ const ev = JSON.parse(line);
373
+ const evType = ev.type || '?';
374
+ if (evType !== 'result' && idleCloseTimer) {
375
+ clearTimeout(idleCloseTimer);
376
+ idleCloseTimer = null;
377
+ }
378
+ if (awaitingSteeredResponseStart && startsClaudeFollowup(ev)) {
379
+ awaitingSteeredResponseStart = false;
380
+ steerQueued = false;
381
+ resetClaudeTurnState(s);
382
+ }
383
+ if (evType === 'system' || evType === 'result' || evType === 'assistant') {
384
+ agentLog(`[event] type=${evType} session=${ev.session_id || s.sessionId || '?'} model=${ev.model || s.model || '?'}`);
385
+ }
386
+ if (evType === 'stream_event') {
387
+ const inner = ev.event || {};
388
+ if (inner.type === 'message_start' || inner.type === 'message_delta') {
389
+ agentLog(`[event] stream_event/${inner.type} session=${ev.session_id || s.sessionId || '?'}`);
390
+ }
391
+ }
392
+ claudeParse(ev, s);
393
+ if (evType === 'result') {
394
+ const hasError = !!ev.is_error || (Array.isArray(ev.errors) && ev.errors.length > 0);
395
+ if (hasError) {
396
+ awaitingSteeredResponseStart = false;
397
+ steerQueued = false;
398
+ closeInput();
399
+ }
400
+ else if (steerQueued) {
401
+ awaitingSteeredResponseStart = true;
402
+ scheduleIdleClose();
403
+ }
404
+ else {
405
+ closeInput();
406
+ }
407
+ }
408
+ emit();
409
+ }
410
+ catch { }
411
+ });
412
+ const hardTimer = setTimeout(() => {
413
+ timedOut = true;
414
+ s.stopReason = 'timeout';
415
+ closeInput();
416
+ agentLog(`[timeout] hard deadline reached (${opts.timeout}s), killing process tree pid=${proc.pid}`);
417
+ terminateProcessTree(proc, { signal: 'SIGTERM', forceSignal: 'SIGKILL', forceAfterMs: 5000 });
418
+ }, opts.timeout * 1000 + AGENT_STREAM_HARD_KILL_GRACE_MS);
419
+ const [procOk, code] = await new Promise(resolve => {
420
+ proc.on('close', code => {
421
+ clearTimeout(hardTimer);
422
+ if (idleCloseTimer) {
423
+ clearTimeout(idleCloseTimer);
424
+ idleCloseTimer = null;
425
+ }
426
+ agentLog(`[exit] code=${code} lines_parsed=${lineCount}`);
427
+ resolve([code === 0, code]);
428
+ });
429
+ proc.on('error', e => {
430
+ clearTimeout(hardTimer);
431
+ if (idleCloseTimer) {
432
+ clearTimeout(idleCloseTimer);
433
+ idleCloseTimer = null;
434
+ }
435
+ agentLog(`[error] ${e.message}`);
436
+ stderr += e.message;
437
+ resolve([false, -1]);
438
+ });
439
+ });
440
+ opts.abortSignal?.removeEventListener('abort', abortStream);
441
+ if (!s.text.trim() && s.msgs.length)
442
+ s.text = s.msgs.join('\n\n');
443
+ if (!s.thinking.trim() && s.thinkParts.length)
444
+ s.thinking = s.thinkParts.join('\n\n');
445
+ const errorText = joinErrorMessages(s.errors);
446
+ const ok = procOk && !s.errors && !timedOut && !interrupted;
447
+ const error = errorText
448
+ || (interrupted ? 'Interrupted by user.' : null)
449
+ || (timedOut ? `Timed out after ${opts.timeout}s before the agent reported completion.` : null)
450
+ || (!procOk ? (stderr.trim() || `Failed (exit=${code}).`) : null);
451
+ const incomplete = !ok || s.stopReason === 'max_tokens' || s.stopReason === 'timeout';
452
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
453
+ agentLog(`[result] ok=${ok && !s.errors} elapsed=${elapsed}s text=${s.text.length}chars thinking=${s.thinking.length}chars session=${s.sessionId || '?'}`);
454
+ if (errorText)
455
+ agentLog(`[result] errors: ${errorText}`);
456
+ if (s.stopReason)
457
+ agentLog(`[result] stop_reason=${s.stopReason}`);
458
+ if (stderr.trim() && !procOk)
459
+ agentLog(`[result] stderr: ${stderr.trim().slice(0, 300)}`);
460
+ return {
461
+ ok,
462
+ sessionId: s.sessionId,
463
+ workspacePath: null,
464
+ model: s.model,
465
+ thinkingEffort: s.thinkingEffort,
466
+ message: s.text.trim() || errorText || (procOk ? '(no textual response)' : `Failed (exit=${code}).\n\n${stderr.trim() || '(no output)'}`),
467
+ thinking: s.thinking.trim() || null,
468
+ elapsedS: (Date.now() - start) / 1000,
469
+ inputTokens: s.inputTokens,
470
+ outputTokens: s.outputTokens,
471
+ cachedInputTokens: s.cachedInputTokens,
472
+ cacheCreationInputTokens: s.cacheCreationInputTokens,
473
+ contextWindow: s.contextWindow,
474
+ contextUsedTokens: s.contextUsedTokens,
475
+ contextPercent: roundPercent((s.contextUsedTokens || 0) / (s.contextWindow || 0)),
476
+ codexCumulative: null,
477
+ error,
478
+ stopReason: s.stopReason,
479
+ incomplete,
480
+ activity: s.activity.trim() || null,
481
+ };
482
+ }
186
483
  // ---------------------------------------------------------------------------
187
484
  // Stream
188
485
  // ---------------------------------------------------------------------------
189
486
  export async function doClaudeStream(opts) {
190
- const result = await run(claudeCmd(opts), opts, claudeParse);
487
+ const result = opts.onSteerReady
488
+ ? await doClaudeInteractiveStream(opts)
489
+ : await run(claudeCmd(opts), opts, claudeParse);
191
490
  const retryText = `${result.error || ''}\n${result.message}`;
192
491
  if (!result.ok && opts.sessionId && /no conversation found/i.test(retryText)) {
193
- return run(claudeCmd({ ...opts, sessionId: null }), { ...opts, sessionId: null }, claudeParse);
492
+ return doClaudeStream({ ...opts, sessionId: null });
194
493
  }
195
494
  return result;
196
495
  }
@@ -95,7 +95,15 @@ export class CodexAppServer {
95
95
  }
96
96
  });
97
97
  proc.on('error', () => { clearTimeout(timer); this.ready = false; resolve(false); });
98
- proc.on('close', () => { this.ready = false; this.proc = null; });
98
+ proc.on('close', () => {
99
+ this.ready = false;
100
+ this.proc = null;
101
+ // Resolve any pending RPC calls so callers don't hang forever
102
+ for (const [id, cb] of this.pending) {
103
+ cb({ error: { message: 'process exited before responding' } });
104
+ }
105
+ this.pending.clear();
106
+ });
99
107
  this.call('initialize', { clientInfo: { name: 'pikiclaw', version: '0.2.0' } })
100
108
  .then(resp => {
101
109
  clearTimeout(timer);
@@ -111,14 +119,24 @@ export class CodexAppServer {
111
119
  .catch(() => { clearTimeout(timer); resolve(false); });
112
120
  });
113
121
  }
114
- call(method, params) {
122
+ call(method, params, timeoutMs) {
115
123
  return new Promise((resolve) => {
116
124
  if (!this.proc || this.proc.killed) {
117
125
  resolve({ error: { message: 'not connected' } });
118
126
  return;
119
127
  }
120
128
  const id = this.nextId++;
121
- this.pending.set(id, resolve);
129
+ const wrappedResolve = (result) => {
130
+ if (timer)
131
+ clearTimeout(timer);
132
+ this.pending.delete(id);
133
+ resolve(result);
134
+ };
135
+ const timer = timeoutMs ? setTimeout(() => {
136
+ this.pending.delete(id);
137
+ resolve({ error: { message: `RPC call '${method}' timed out after ${timeoutMs}ms` } });
138
+ }, timeoutMs) : null;
139
+ this.pending.set(id, wrappedResolve);
122
140
  const msg = { jsonrpc: '2.0', id, method };
123
141
  if (params !== undefined)
124
142
  msg.params = params;
@@ -126,6 +144,9 @@ export class CodexAppServer {
126
144
  this.proc.stdin.write(JSON.stringify(msg) + '\n');
127
145
  }
128
146
  catch {
147
+ if (timer)
148
+ clearTimeout(timer);
149
+ this.pending.delete(id);
129
150
  resolve({ error: { message: 'write failed' } });
130
151
  }
131
152
  });
@@ -238,6 +259,34 @@ function summarizeCodexFileChange(item) {
238
259
  return `Updated ${paths.length} files`;
239
260
  return 'Updated files';
240
261
  }
262
+ function summarizeCodexRawResponseItem(item) {
263
+ if (!item || typeof item !== 'object')
264
+ return null;
265
+ switch (item.type) {
266
+ case 'web_search_call': {
267
+ const action = item.action || {};
268
+ if (action.type === 'search') {
269
+ const query = shortValue(action.query, 120);
270
+ return query ? `Search web: ${query}` : 'Search web';
271
+ }
272
+ if (action.type === 'open_page') {
273
+ const url = shortValue(action.url, 120);
274
+ return url ? `Open ${url}` : 'Open web page';
275
+ }
276
+ return 'Search web';
277
+ }
278
+ case 'custom_tool_call': {
279
+ const name = shortValue(item.name, 80);
280
+ return name ? `Use ${name}` : 'Use tool';
281
+ }
282
+ case 'local_shell_call': {
283
+ const command = shortValue(item.action?.command || item.action?.cmd, 120);
284
+ return command ? `Run shell command: ${command}` : 'Run shell command';
285
+ }
286
+ default:
287
+ return null;
288
+ }
289
+ }
241
290
  function buildCodexInteractionRequest(method, params, requestId) {
242
291
  if (method === 'item/tool/requestUserInput') {
243
292
  const questions = Array.isArray(params?.questions) ? params.questions : [];
@@ -423,7 +472,7 @@ function codexErrorResult(error, start, sessionId, model, thinkingEffort) {
423
472
  // ---------------------------------------------------------------------------
424
473
  // Stream notification handler (extracted from doCodexStream)
425
474
  // ---------------------------------------------------------------------------
426
- function handleCodexNotification(method, params, s, opts, deadline, emit, hardTimer, settleTurnDone) {
475
+ function handleCodexNotification(method, params, s, opts, deadline, emit, hardTimer, settleTurnDone, publishTurnControl) {
427
476
  if (Date.now() > deadline)
428
477
  return;
429
478
  if (params.threadId !== s.sessionId) {
@@ -448,6 +497,9 @@ function handleCodexNotification(method, params, s, opts, deadline, emit, hardTi
448
497
  case 'item/completed':
449
498
  handleItemCompleted(params.item || {}, s, emit);
450
499
  return;
500
+ case 'rawResponseItem/completed':
501
+ handleRawResponseItemCompleted(params.item || {}, s, emit);
502
+ return;
451
503
  case 'thread/tokenUsage/updated':
452
504
  applyCodexTokenUsage(s, params.tokenUsage, opts.codexPrevCumulative);
453
505
  emit();
@@ -475,6 +527,7 @@ function handleCodexNotification(method, params, s, opts, deadline, emit, hardTi
475
527
  }
476
528
  case 'turn/started':
477
529
  s.turnId = params.turn?.id ?? null;
530
+ publishTurnControl?.();
478
531
  return;
479
532
  case 'model/rerouted':
480
533
  s.model = params.model ?? s.model;
@@ -537,6 +590,27 @@ function handleItemCompleted(item, s, emit) {
537
590
  emit();
538
591
  }
539
592
  }
593
+ function handleRawResponseItemCompleted(item, s, emit) {
594
+ if (item?.type === 'reasoning') {
595
+ const summary = Array.isArray(item.summary)
596
+ ? item.summary
597
+ .map((entry) => (typeof entry === 'string' ? entry : entry?.text || ''))
598
+ .filter(Boolean)
599
+ .join('\n')
600
+ .trim()
601
+ : '';
602
+ if (summary) {
603
+ s.thinkParts.push(summary);
604
+ emit();
605
+ return;
606
+ }
607
+ }
608
+ const summary = summarizeCodexRawResponseItem(item);
609
+ if (!summary)
610
+ return;
611
+ pushRecentActivity(s.recentNarrative, summary);
612
+ emit();
613
+ }
540
614
  function handleCompletedAgentMessage(item, s, emit) {
541
615
  const phase = item.phase || s.messagePhases.get(item.id) || 'final_answer';
542
616
  if (phase === 'final_answer') {
@@ -620,6 +694,8 @@ export async function doCodexStream(opts) {
620
694
  let unsubscribeNotifications = () => { };
621
695
  let unsubscribeRequests = () => { };
622
696
  let settleTurnDone = null;
697
+ let emitPreview = () => { };
698
+ let publishedTurnControl = false;
623
699
  try {
624
700
  const config = [];
625
701
  if (opts.codexExtraArgs?.length) {
@@ -632,6 +708,45 @@ export async function doCodexStream(opts) {
632
708
  return codexErrorResult('Failed to start codex app-server.', start, opts.sessionId, opts.model, opts.thinkingEffort);
633
709
  }
634
710
  const s = createCodexStreamState(opts);
711
+ const publishTurnControl = () => {
712
+ if (publishedTurnControl || !opts.onCodexTurnReady || !s.sessionId || !s.turnId)
713
+ return;
714
+ publishedTurnControl = true;
715
+ try {
716
+ const control = {
717
+ threadId: s.sessionId,
718
+ turnId: s.turnId,
719
+ steer: async (prompt, attachments = []) => {
720
+ if (!s.sessionId || !s.turnId)
721
+ return false;
722
+ const expectedTurnId = s.turnId;
723
+ const clippedPrompt = prompt.slice(0, 200);
724
+ agentLog(`[codex-rpc] turn/steer turn=${expectedTurnId} prompt="${clippedPrompt}${prompt.length > 200 ? '…' : ''}"`);
725
+ const steerResp = await srv.call('turn/steer', {
726
+ threadId: s.sessionId,
727
+ expectedTurnId,
728
+ input: buildCodexTurnInput(prompt, attachments),
729
+ }, 30_000);
730
+ if (steerResp.error) {
731
+ const errMsg = steerResp.error.message || 'turn/steer failed';
732
+ agentLog(`[codex-rpc] turn/steer error: ${errMsg}`);
733
+ pushRecentActivity(s.recentFailures, `Steer failed: ${shortValue(errMsg, 120)}`, 4);
734
+ emitPreview();
735
+ return false;
736
+ }
737
+ s.turnId = steerResp.result?.turnId ?? s.turnId;
738
+ pushRecentActivity(s.recentNarrative, 'Applied steer input');
739
+ emitPreview();
740
+ return true;
741
+ },
742
+ };
743
+ opts.onSteerReady?.(control.steer);
744
+ opts.onCodexTurnReady?.(control);
745
+ }
746
+ catch (error) {
747
+ agentLog(`[codex-rpc] onCodexTurnReady error: ${error?.message || error}`);
748
+ }
749
+ };
635
750
  // thread/start or thread/resume
636
751
  let threadResp;
637
752
  const threadParams = {
@@ -643,11 +758,11 @@ export async function doCodexStream(opts) {
643
758
  };
644
759
  if (opts.sessionId) {
645
760
  agentLog(`[codex-rpc] thread/resume id=${opts.sessionId}`);
646
- threadResp = await srv.call('thread/resume', { threadId: opts.sessionId, ...threadParams });
761
+ threadResp = await srv.call('thread/resume', { threadId: opts.sessionId, ...threadParams }, 60_000);
647
762
  }
648
763
  else {
649
764
  agentLog(`[codex-rpc] thread/start cwd=${opts.workdir} model=${opts.codexModel || '(default)'}`);
650
- threadResp = await srv.call('thread/start', threadParams);
765
+ threadResp = await srv.call('thread/start', threadParams, 60_000);
651
766
  }
652
767
  if (threadResp.error) {
653
768
  const errMsg = threadResp.error.message || 'thread/start failed';
@@ -689,8 +804,9 @@ export async function doCodexStream(opts) {
689
804
  s.activity = buildCodexActivityPreview(s);
690
805
  opts.onText(s.text, s.thinking, s.activity, buildStreamPreviewMeta(s), s.plan);
691
806
  };
807
+ emitPreview = emit;
692
808
  unsubscribeNotifications = srv.onNotification((method, params) => {
693
- handleCodexNotification(method, params, s, opts, deadline, emit, hardTimer, settleTurnDone);
809
+ handleCodexNotification(method, params, s, opts, deadline, emit, hardTimer, settleTurnDone, publishTurnControl);
694
810
  });
695
811
  unsubscribeRequests = srv.onRequest((method, params, requestId) => {
696
812
  return handleCodexRequest(method, params, requestId, s, opts, emit);
@@ -704,10 +820,16 @@ export async function doCodexStream(opts) {
704
820
  s.turnError = s.turnError || 'Interrupted by user.';
705
821
  agentLog(`[codex-rpc] abort requested thread=${s.sessionId || '?'} turn=${s.turnId || '?'}`);
706
822
  if (s.turnId && s.sessionId) {
707
- srv.call('turn/interrupt', { threadId: s.sessionId, turnId: s.turnId }).catch(() => { });
823
+ // Send turn/interrupt and wait for Codex to acknowledge before settling.
824
+ // Don't kill the process here — let the finally block handle it after
825
+ // Codex has had time to persist the interrupted session state.
826
+ srv.call('turn/interrupt', { threadId: s.sessionId, turnId: s.turnId }, 5_000)
827
+ .finally(() => settleTurnDone?.());
828
+ }
829
+ else {
830
+ srv.kill();
831
+ settleTurnDone?.();
708
832
  }
709
- srv.kill();
710
- settleTurnDone?.();
711
833
  };
712
834
  if (opts.abortSignal?.aborted)
713
835
  abortStream();
@@ -732,7 +854,7 @@ export async function doCodexStream(opts) {
732
854
  threadId: s.sessionId, input,
733
855
  model: opts.codexModel || undefined,
734
856
  effort: mapEffort(opts.thinkingEffort),
735
- });
857
+ }, 60_000);
736
858
  if (turnResp.error) {
737
859
  opts.abortSignal?.removeEventListener('abort', abortStream);
738
860
  unsubscribeNotifications();
@@ -742,6 +864,7 @@ export async function doCodexStream(opts) {
742
864
  return codexErrorResult(errMsg, start, s.sessionId, s.model, s.thinkingEffort);
743
865
  }
744
866
  s.turnId = turnResp.result?.turn?.id ?? null;
867
+ publishTurnControl();
745
868
  await turnDone;
746
869
  opts.abortSignal?.removeEventListener('abort', abortStream);
747
870
  unsubscribeNotifications();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiclaw",
3
- "version": "0.2.71",
3
+ "version": "0.2.72",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {