pikiclaw 0.2.70 → 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.
package/README.md CHANGED
@@ -142,6 +142,7 @@ npx pikiclaw@latest --doctor # check environment only
142
142
 
143
143
  - Streaming preview with continuous message updates
144
144
  - Session switching, resume, and multi-turn conversations
145
+ - Task queue with **Steer** — interrupt the running task and let a queued message jump ahead
145
146
  - Working directory browsing and switching
146
147
  - File attachments automatically enter the session workspace
147
148
  - Long-task sleep prevention, watchdog, and auto-restart
@@ -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();
@@ -78,7 +78,7 @@ export async function getSessionsPageData(bot, chatId, page, pageSize = 5) {
78
78
  runState: status.isRunning ? 'running' : s.runState,
79
79
  runDetail: s.runDetail,
80
80
  });
81
- const title = s.title ? s.title.replace(/\n/g, ' ').slice(0, 10) : sessionKey.slice(0, 10);
81
+ const title = s.title ? s.title.replace(/\n/g, ' ').slice(0, 20) : sessionKey.slice(0, 20);
82
82
  const time = s.createdAt
83
83
  ? new Date(s.createdAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
84
84
  : '?';
@@ -179,10 +179,14 @@ export function buildHumanLoopPromptMarkdown(prompt) {
179
179
  // ---------------------------------------------------------------------------
180
180
  // LivePreview renderer — produces Markdown for Feishu card elements
181
181
  // ---------------------------------------------------------------------------
182
- export function buildInitialPreviewMarkdown(agent, model, effort, waiting = false) {
182
+ export function buildInitialPreviewMarkdown(agent, model, effort, waiting = false, queuePosition = 0) {
183
183
  const parts = [];
184
- if (waiting)
185
- parts.push('Waiting in queue...');
184
+ if (waiting) {
185
+ const queueLabel = queuePosition > 0
186
+ ? `Queued · ${queuePosition} ${queuePosition === 1 ? 'task' : 'tasks'} ahead`
187
+ : 'Waiting in queue...';
188
+ parts.push(queueLabel);
189
+ }
186
190
  if (model)
187
191
  parts.push(model);
188
192
  else
@@ -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,21 +186,30 @@ 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);
201
203
  }
202
204
  resolveIncomingSession(ctx, text, files) {
203
205
  const cs = this.chat(ctx.chatId);
204
- // TODO: Feishu doesn't expose reply_to in the event easily; for now use active session
206
+ const replyMessageId = ctx.replyToMessageId || null;
207
+ const repliedSession = this.sessionFromMessage(ctx.chatId, replyMessageId);
208
+ if (repliedSession) {
209
+ this.log(`[resolveSession] reply matched session=${repliedSession.sessionId} chat=${ctx.chatId}`);
210
+ this.applySessionSelection(cs, repliedSession);
211
+ return repliedSession;
212
+ }
205
213
  const selected = this.getSelectedSession(cs);
206
214
  if (selected)
207
215
  return selected;
@@ -257,9 +265,27 @@ export class FeishuBot extends Bot {
257
265
  }
258
266
  }
259
267
  sessionsPageSize = 5;
260
- buildStopKeyboard(actionId) {
268
+ buildStopKeyboard(actionId, opts) {
261
269
  if (!actionId)
262
270
  return undefined;
271
+ if (opts?.queued) {
272
+ return {
273
+ rows: [{
274
+ actions: [
275
+ {
276
+ tag: 'button',
277
+ text: { tag: 'plain_text', content: 'Recall' },
278
+ value: { action: `tsk:stop:${actionId}` },
279
+ },
280
+ {
281
+ tag: 'button',
282
+ text: { tag: 'plain_text', content: 'Steer' },
283
+ value: { action: `tsk:steer:${actionId}` },
284
+ },
285
+ ],
286
+ }],
287
+ };
288
+ }
263
289
  return {
264
290
  rows: [{
265
291
  actions: [{
@@ -509,10 +535,10 @@ export class FeishuBot extends Bot {
509
535
  }
510
536
  const staged = stageSessionFiles({
511
537
  agent: session.agent,
512
- workdir: this.workdir,
538
+ workdir: session.workdir,
513
539
  files: msg.files,
514
540
  sessionId: session.sessionId,
515
- title: msg.files[0],
541
+ title: undefined,
516
542
  });
517
543
  session.workspacePath = staged.workspacePath;
518
544
  this.syncSelectedChats(session);
@@ -547,15 +573,17 @@ export class FeishuBot extends Bot {
547
573
  agent: session.agent,
548
574
  sessionKey: session.key,
549
575
  prompt,
576
+ attachments: files,
550
577
  startedAt: start,
551
578
  sourceMessageId: ctx.messageId,
552
579
  });
553
- const stopKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId));
580
+ const queuePosition = waiting ? this.getQueuePosition(session.key, taskId) : 0;
581
+ const placeholderKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId), { queued: waiting });
554
582
  const model = session.modelId || this.modelForAgent(session.agent);
555
583
  const effort = this.effortForAgent(session.agent);
556
- const placeholderId = await this.channel.sendStreamingCard(ctx.chatId, buildInitialPreviewMarkdown(session.agent, model, effort, waiting), {
584
+ const placeholderId = await this.channel.sendStreamingCard(ctx.chatId, buildInitialPreviewMarkdown(session.agent, model, effort, waiting, queuePosition), {
557
585
  replyTo: ctx.messageId || undefined,
558
- keyboard: stopKeyboard,
586
+ keyboard: placeholderKeyboard,
559
587
  });
560
588
  if (placeholderId) {
561
589
  this.registerSessionMessage(ctx.chatId, placeholderId, session);
@@ -563,9 +591,10 @@ export class FeishuBot extends Bot {
563
591
  this.registerTaskPlaceholders(taskId, [placeholderId]);
564
592
  void this.queueSessionTask(session, async () => {
565
593
  let livePreview = null;
594
+ let task = null;
566
595
  const abortController = new AbortController();
567
596
  try {
568
- const task = this.markTaskRunning(taskId, () => abortController.abort());
597
+ task = this.markTaskRunning(taskId, () => abortController.abort());
569
598
  if (!task || task.cancelled) {
570
599
  if (placeholderId) {
571
600
  try {
@@ -576,6 +605,14 @@ export class FeishuBot extends Bot {
576
605
  this.log(`[handleMessage] skipped cancelled queued task chat=${ctx.chatId} msg=${ctx.messageId}`);
577
606
  return;
578
607
  }
608
+ // Task is now running — update keyboard from Recall/Steer to Stop
609
+ const runningKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId));
610
+ if (placeholderId && waiting) {
611
+ try {
612
+ await this.channel.editMessage(ctx.chatId, placeholderId, buildInitialPreviewMarkdown(session.agent, model, effort, false), { keyboard: runningKeyboard });
613
+ }
614
+ catch { }
615
+ }
579
616
  if (placeholderId) {
580
617
  const renderer = this.channel.isStreamingCard(placeholderId)
581
618
  ? feishuStreamingPreviewRenderer
@@ -591,7 +628,7 @@ export class FeishuBot extends Bot {
591
628
  canEditMessages: supportsChannelCapability(this.channel, 'editMessages'),
592
629
  canSendTyping: false,
593
630
  parseMode: 'Markdown',
594
- keyboard: stopKeyboard,
631
+ keyboard: runningKeyboard,
595
632
  log: (message) => this.log(message),
596
633
  });
597
634
  livePreview.start();
@@ -600,8 +637,19 @@ export class FeishuBot extends Bot {
600
637
  const mcpSendFile = this.createMcpSendFileCallback(ctx);
601
638
  const result = await this.runStream(prompt, session, files, (nextText, nextThinking, nextActivity = '', meta, plan) => {
602
639
  livePreview?.update(nextText, nextThinking, nextActivity, meta, plan);
603
- }, 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
+ });
604
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
+ }
605
653
  const finalReplyIds = await this.sendFinalReply(ctx, placeholderId, session.agent, result);
606
654
  this.registerSessionMessages(ctx.chatId, finalReplyIds, session);
607
655
  this.log(`[handleMessage] end chat=${ctx.chatId} agent=${session.agent} ok=${result.ok} session=${result.sessionId || session.sessionId || '(new)'} ` +
@@ -609,6 +657,12 @@ export class FeishuBot extends Bot {
609
657
  `tools=${formatToolLog(result.activity)}`);
610
658
  }
611
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
+ }
612
666
  const msgText = String(e?.message || e || 'Unknown error');
613
667
  this.log(`[handleMessage] end chat=${ctx.chatId} agent=${session.agent} ok=false session=${session.sessionId || '(new)'} ` +
614
668
  `elapsed=${((Date.now() - start) / 1000).toFixed(1)}s error="${msgText.slice(0, 240)}" tools=-`);
@@ -638,6 +692,25 @@ export class FeishuBot extends Bot {
638
692
  this.finishTask(taskId);
639
693
  });
640
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
+ }
641
714
  async sendFinalReply(ctx, placeholderId, agent, result) {
642
715
  const rendered = buildFinalReplyRender(agent, result);
643
716
  const messageIds = [];
@@ -787,6 +860,7 @@ export class FeishuBot extends Bot {
787
860
  messageId: ctx.messageId,
788
861
  from: ctx.from,
789
862
  chatType: 'p2p',
863
+ replyToMessageId: null,
790
864
  reply: (text, opts) => ctx.channel.send(ctx.chatId, text, opts),
791
865
  editReply: (msgId, text, opts) => ctx.channel.editMessage(ctx.chatId, msgId, text, opts),
792
866
  channel: ctx.channel,
@@ -800,6 +874,8 @@ export class FeishuBot extends Bot {
800
874
  return;
801
875
  if (await this.handleTaskStopCallback(data, ctx))
802
876
  return;
877
+ if (await this.handleTaskSteerCallback(data, ctx))
878
+ return;
803
879
  if (await this.handleSwitchNavigateCallback(data, ctx))
804
880
  return;
805
881
  if (await this.handleSwitchSelectCallback(data, ctx))
@@ -877,6 +953,16 @@ export class FeishuBot extends Bot {
877
953
  }
878
954
  return true;
879
955
  }
956
+ async handleTaskSteerCallback(data, ctx) {
957
+ if (!data.startsWith('tsk:steer:'))
958
+ return false;
959
+ const actionId = data.slice('tsk:steer:'.length).trim();
960
+ const result = await this.steerTaskByActionId(actionId);
961
+ if (!result.task)
962
+ return true;
963
+ // The queued task will naturally run next after the running task is interrupted
964
+ return true;
965
+ }
880
966
  async handleSwitchNavigateCallback(data, ctx) {
881
967
  if (!data.startsWith('sw:n:'))
882
968
  return false;
@@ -16,10 +16,10 @@ 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
- title: files[0],
22
+ title: undefined,
23
23
  });
24
24
  session.workspacePath = staged.workspacePath;
25
25
  return {
@@ -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);
@@ -258,10 +258,14 @@ function formatFinalFooterHtml(status, agent, elapsedMs, contextPercent) {
258
258
  export function formatProviderUsageLines(usage) {
259
259
  return buildProviderUsageLines(usage).map(line => line.bold ? `<b>${escapeHtml(line.text)}</b>` : escapeHtml(line.text));
260
260
  }
261
- export function buildInitialPreviewHtml(agent, waiting = false) {
262
- return waiting
263
- ? `<i>Waiting in queue...</i>\n\n${formatPreviewFooterHtml(agent, 0)}`
264
- : formatPreviewFooterHtml(agent, 0);
261
+ export function buildInitialPreviewHtml(agent, waiting = false, queuePosition = 0) {
262
+ if (waiting) {
263
+ const queueLabel = queuePosition > 0
264
+ ? `Queued · ${queuePosition} ${queuePosition === 1 ? 'task' : 'tasks'} ahead`
265
+ : 'Waiting in queue...';
266
+ return `<i>${escapeHtml(queueLabel)}</i>\n\n${formatPreviewFooterHtml(agent, 0)}`;
267
+ }
268
+ return formatPreviewFooterHtml(agent, 0);
265
269
  }
266
270
  export function buildStreamPreviewHtml(input) {
267
271
  const data = extractStreamPreviewData(input);
@@ -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);
@@ -178,6 +180,7 @@ export class TelegramBot extends Bot {
178
180
  : null;
179
181
  const repliedSession = this.sessionFromMessage(ctx.chatId, replyMessageId);
180
182
  if (repliedSession) {
183
+ this.log(`[resolveSession] reply matched session=${repliedSession.sessionId} chat=${ctx.chatId}`);
181
184
  this.applySessionSelection(cs, repliedSession);
182
185
  return repliedSession;
183
186
  }
@@ -262,9 +265,17 @@ export class TelegramBot extends Bot {
262
265
  }
263
266
  }
264
267
  sessionsPageSize = 5;
265
- buildStopKeyboard(actionId) {
268
+ buildStopKeyboard(actionId, opts) {
266
269
  if (!actionId)
267
270
  return undefined;
271
+ if (opts?.queued) {
272
+ return {
273
+ inline_keyboard: [[
274
+ { text: 'Recall', callback_data: `tsk:stop:${actionId}` },
275
+ { text: 'Steer', callback_data: `tsk:steer:${actionId}` },
276
+ ]],
277
+ };
278
+ }
268
279
  return {
269
280
  inline_keyboard: [[
270
281
  { text: 'Stop', callback_data: `tsk:stop:${actionId}` },
@@ -459,10 +470,10 @@ export class TelegramBot extends Bot {
459
470
  }
460
471
  const staged = stageSessionFiles({
461
472
  agent: session.agent,
462
- workdir: this.workdir,
473
+ workdir: session.workdir,
463
474
  files: msg.files,
464
475
  sessionId: session.sessionId,
465
- title: msg.files[0],
476
+ title: undefined,
466
477
  });
467
478
  session.workspacePath = staged.workspacePath;
468
479
  this.syncSelectedChats(session);
@@ -498,14 +509,16 @@ export class TelegramBot extends Bot {
498
509
  agent: session.agent,
499
510
  sessionKey: session.key,
500
511
  prompt,
512
+ attachments: files,
501
513
  startedAt: start,
502
514
  sourceMessageId: ctx.messageId,
503
515
  });
504
- const stopKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId));
505
516
  const waiting = this.sessionHasPendingWork(session);
517
+ const queuePosition = waiting ? this.getQueuePosition(session.key, taskId) : 0;
518
+ const placeholderKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId), { queued: waiting });
506
519
  let phId = null;
507
520
  if (canEditMessages) {
508
- const placeholderId = await ctx.reply(buildInitialPreviewHtml(session.agent, waiting), { parseMode: 'HTML', messageThreadId, keyboard: stopKeyboard });
521
+ const placeholderId = await ctx.reply(buildInitialPreviewHtml(session.agent, waiting, queuePosition), { parseMode: 'HTML', messageThreadId, keyboard: placeholderKeyboard });
509
522
  phId = typeof placeholderId === 'number' ? placeholderId : null;
510
523
  if (phId != null) {
511
524
  this.registerSessionMessage(ctx.chatId, phId, session);
@@ -521,9 +534,10 @@ export class TelegramBot extends Bot {
521
534
  this.registerTaskPlaceholders(taskId, [phId]);
522
535
  void this.queueSessionTask(session, async () => {
523
536
  let livePreview = null;
537
+ let task = null;
524
538
  const abortController = new AbortController();
525
539
  try {
526
- const task = this.markTaskRunning(taskId, () => abortController.abort());
540
+ task = this.markTaskRunning(taskId, () => abortController.abort());
527
541
  if (!task || task.cancelled) {
528
542
  if (phId != null) {
529
543
  try {
@@ -534,6 +548,14 @@ export class TelegramBot extends Bot {
534
548
  this.log(`[handleMessage] skipped cancelled queued task chat=${ctx.chatId} msg=${ctx.messageId}`);
535
549
  return;
536
550
  }
551
+ // Task is now running — update keyboard from Recall/Steer to Stop
552
+ const runningKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId));
553
+ if (phId != null && waiting) {
554
+ try {
555
+ await this.channel.editMessage(ctx.chatId, phId, buildInitialPreviewHtml(session.agent, false), { parseMode: 'HTML', keyboard: runningKeyboard });
556
+ }
557
+ catch { }
558
+ }
537
559
  if (phId != null || canSendTyping) {
538
560
  livePreview = new LivePreview({
539
561
  agent: session.agent,
@@ -546,7 +568,7 @@ export class TelegramBot extends Bot {
546
568
  canEditMessages,
547
569
  canSendTyping,
548
570
  messageThreadId,
549
- keyboard: stopKeyboard,
571
+ keyboard: runningKeyboard,
550
572
  log: (message) => this.log(message),
551
573
  });
552
574
  livePreview.start();
@@ -555,8 +577,19 @@ export class TelegramBot extends Bot {
555
577
  const mcpSendFile = this.createMcpSendFileCallback(ctx, messageThreadId);
556
578
  const result = await this.runStream(prompt, session, files, (nextText, nextThinking, nextActivity = '', meta, plan) => {
557
579
  livePreview?.update(nextText, nextThinking, nextActivity, meta, plan);
558
- }, 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
+ });
559
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
+ }
560
593
  this.log(`[handleMessage] done agent=${session.agent} ok=${result.ok} session=${result.sessionId || '?'} elapsed=${result.elapsedS.toFixed(1)}s edits=${livePreview?.getEditCount() || 0} ` +
561
594
  `tokens=in:${fmtTokens(result.inputTokens)}/cached:${fmtTokens(result.cachedInputTokens)}/out:${fmtTokens(result.outputTokens)}`);
562
595
  this.log(`[handleMessage] response preview: "${result.message.slice(0, 150)}"`);
@@ -565,6 +598,12 @@ export class TelegramBot extends Bot {
565
598
  this.log(`[handleMessage] final reply sent to chat=${ctx.chatId}`);
566
599
  }
567
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
+ }
568
607
  const msgText = String(e?.message || e || 'Unknown error');
569
608
  this.log(`[handleMessage] task failed chat=${ctx.chatId} session=${session.sessionId} error=${msgText}`);
570
609
  const errorHtml = `<b>Error</b>\n\n<code>${escapeHtml(msgText.slice(0, 500))}</code>`;
@@ -594,6 +633,23 @@ export class TelegramBot extends Bot {
594
633
  this.finishTask(taskId);
595
634
  });
596
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
+ }
597
653
  /** Create an MCP sendFile callback bound to a Telegram chat context. */
598
654
  createMcpSendFileCallback(ctx, messageThreadId) {
599
655
  return async (filePath, opts) => {
@@ -758,6 +814,22 @@ export class TelegramBot extends Bot {
758
814
  await ctx.answerCallback('Nothing to stop.');
759
815
  return true;
760
816
  }
817
+ async handleTaskSteerCallback(data, ctx) {
818
+ if (!data.startsWith('tsk:steer:'))
819
+ return false;
820
+ const actionId = data.slice('tsk:steer:'.length).trim();
821
+ const result = await this.steerTaskByActionId(actionId);
822
+ if (!result.task) {
823
+ await ctx.answerCallback('This task already finished.');
824
+ return true;
825
+ }
826
+ if (result.task.status !== 'queued') {
827
+ await ctx.answerCallback('Task is already running.');
828
+ return true;
829
+ }
830
+ await ctx.answerCallback(result.interrupted ? 'Steering — switching to the queued reply...' : 'No running task to interrupt.');
831
+ return true;
832
+ }
761
833
  async handleHumanLoopCallback(data, ctx) {
762
834
  if (!data.startsWith('hl:'))
763
835
  return false;
@@ -824,6 +896,8 @@ export class TelegramBot extends Bot {
824
896
  return;
825
897
  if (await this.handleTaskStopCallback(data, ctx))
826
898
  return;
899
+ if (await this.handleTaskSteerCallback(data, ctx))
900
+ return;
827
901
  if (await this.handleSwitchNavigateCallback(data, ctx))
828
902
  return;
829
903
  if (await this.handleSwitchSelectCallback(data, ctx))