pikiclaw 0.2.70 → 0.2.71

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
@@ -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
@@ -201,7 +201,13 @@ export class FeishuBot extends Bot {
201
201
  }
202
202
  resolveIncomingSession(ctx, text, files) {
203
203
  const cs = this.chat(ctx.chatId);
204
- // TODO: Feishu doesn't expose reply_to in the event easily; for now use active session
204
+ const replyMessageId = ctx.replyToMessageId || null;
205
+ const repliedSession = this.sessionFromMessage(ctx.chatId, replyMessageId);
206
+ if (repliedSession) {
207
+ this.log(`[resolveSession] reply matched session=${repliedSession.sessionId} chat=${ctx.chatId}`);
208
+ this.applySessionSelection(cs, repliedSession);
209
+ return repliedSession;
210
+ }
205
211
  const selected = this.getSelectedSession(cs);
206
212
  if (selected)
207
213
  return selected;
@@ -257,9 +263,27 @@ export class FeishuBot extends Bot {
257
263
  }
258
264
  }
259
265
  sessionsPageSize = 5;
260
- buildStopKeyboard(actionId) {
266
+ buildStopKeyboard(actionId, opts) {
261
267
  if (!actionId)
262
268
  return undefined;
269
+ if (opts?.queued) {
270
+ return {
271
+ rows: [{
272
+ actions: [
273
+ {
274
+ tag: 'button',
275
+ text: { tag: 'plain_text', content: 'Recall' },
276
+ value: { action: `tsk:stop:${actionId}` },
277
+ },
278
+ {
279
+ tag: 'button',
280
+ text: { tag: 'plain_text', content: 'Steer' },
281
+ value: { action: `tsk:steer:${actionId}` },
282
+ },
283
+ ],
284
+ }],
285
+ };
286
+ }
263
287
  return {
264
288
  rows: [{
265
289
  actions: [{
@@ -512,7 +536,7 @@ export class FeishuBot extends Bot {
512
536
  workdir: this.workdir,
513
537
  files: msg.files,
514
538
  sessionId: session.sessionId,
515
- title: msg.files[0],
539
+ title: undefined,
516
540
  });
517
541
  session.workspacePath = staged.workspacePath;
518
542
  this.syncSelectedChats(session);
@@ -550,12 +574,13 @@ export class FeishuBot extends Bot {
550
574
  startedAt: start,
551
575
  sourceMessageId: ctx.messageId,
552
576
  });
553
- const stopKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId));
577
+ const queuePosition = waiting ? this.getQueuePosition(session.key, taskId) : 0;
578
+ const placeholderKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId), { queued: waiting });
554
579
  const model = session.modelId || this.modelForAgent(session.agent);
555
580
  const effort = this.effortForAgent(session.agent);
556
- const placeholderId = await this.channel.sendStreamingCard(ctx.chatId, buildInitialPreviewMarkdown(session.agent, model, effort, waiting), {
581
+ const placeholderId = await this.channel.sendStreamingCard(ctx.chatId, buildInitialPreviewMarkdown(session.agent, model, effort, waiting, queuePosition), {
557
582
  replyTo: ctx.messageId || undefined,
558
- keyboard: stopKeyboard,
583
+ keyboard: placeholderKeyboard,
559
584
  });
560
585
  if (placeholderId) {
561
586
  this.registerSessionMessage(ctx.chatId, placeholderId, session);
@@ -576,6 +601,14 @@ export class FeishuBot extends Bot {
576
601
  this.log(`[handleMessage] skipped cancelled queued task chat=${ctx.chatId} msg=${ctx.messageId}`);
577
602
  return;
578
603
  }
604
+ // Task is now running — update keyboard from Recall/Steer to Stop
605
+ const runningKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId));
606
+ if (placeholderId && waiting) {
607
+ try {
608
+ await this.channel.editMessage(ctx.chatId, placeholderId, buildInitialPreviewMarkdown(session.agent, model, effort, false), { keyboard: runningKeyboard });
609
+ }
610
+ catch { }
611
+ }
579
612
  if (placeholderId) {
580
613
  const renderer = this.channel.isStreamingCard(placeholderId)
581
614
  ? feishuStreamingPreviewRenderer
@@ -591,7 +624,7 @@ export class FeishuBot extends Bot {
591
624
  canEditMessages: supportsChannelCapability(this.channel, 'editMessages'),
592
625
  canSendTyping: false,
593
626
  parseMode: 'Markdown',
594
- keyboard: stopKeyboard,
627
+ keyboard: runningKeyboard,
595
628
  log: (message) => this.log(message),
596
629
  });
597
630
  livePreview.start();
@@ -787,6 +820,7 @@ export class FeishuBot extends Bot {
787
820
  messageId: ctx.messageId,
788
821
  from: ctx.from,
789
822
  chatType: 'p2p',
823
+ replyToMessageId: null,
790
824
  reply: (text, opts) => ctx.channel.send(ctx.chatId, text, opts),
791
825
  editReply: (msgId, text, opts) => ctx.channel.editMessage(ctx.chatId, msgId, text, opts),
792
826
  channel: ctx.channel,
@@ -800,6 +834,8 @@ export class FeishuBot extends Bot {
800
834
  return;
801
835
  if (await this.handleTaskStopCallback(data, ctx))
802
836
  return;
837
+ if (await this.handleTaskSteerCallback(data, ctx))
838
+ return;
803
839
  if (await this.handleSwitchNavigateCallback(data, ctx))
804
840
  return;
805
841
  if (await this.handleSwitchSelectCallback(data, ctx))
@@ -877,6 +913,16 @@ export class FeishuBot extends Bot {
877
913
  }
878
914
  return true;
879
915
  }
916
+ async handleTaskSteerCallback(data, ctx) {
917
+ if (!data.startsWith('tsk:steer:'))
918
+ return false;
919
+ const actionId = data.slice('tsk:steer:'.length).trim();
920
+ const result = this.steerTaskByActionId(actionId);
921
+ if (!result.task)
922
+ return true;
923
+ // The queued task will naturally run next after the running task is interrupted
924
+ return true;
925
+ }
880
926
  async handleSwitchNavigateCallback(data, ctx) {
881
927
  if (!data.startsWith('sw:n:'))
882
928
  return false;
@@ -19,7 +19,7 @@ export async function stageFilesIntoSession(bot, session, files) {
19
19
  workdir: bot.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 {
@@ -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);
@@ -178,6 +178,7 @@ export class TelegramBot extends Bot {
178
178
  : null;
179
179
  const repliedSession = this.sessionFromMessage(ctx.chatId, replyMessageId);
180
180
  if (repliedSession) {
181
+ this.log(`[resolveSession] reply matched session=${repliedSession.sessionId} chat=${ctx.chatId}`);
181
182
  this.applySessionSelection(cs, repliedSession);
182
183
  return repliedSession;
183
184
  }
@@ -262,9 +263,17 @@ export class TelegramBot extends Bot {
262
263
  }
263
264
  }
264
265
  sessionsPageSize = 5;
265
- buildStopKeyboard(actionId) {
266
+ buildStopKeyboard(actionId, opts) {
266
267
  if (!actionId)
267
268
  return undefined;
269
+ if (opts?.queued) {
270
+ return {
271
+ inline_keyboard: [[
272
+ { text: 'Recall', callback_data: `tsk:stop:${actionId}` },
273
+ { text: 'Steer', callback_data: `tsk:steer:${actionId}` },
274
+ ]],
275
+ };
276
+ }
268
277
  return {
269
278
  inline_keyboard: [[
270
279
  { text: 'Stop', callback_data: `tsk:stop:${actionId}` },
@@ -462,7 +471,7 @@ export class TelegramBot extends Bot {
462
471
  workdir: this.workdir,
463
472
  files: msg.files,
464
473
  sessionId: session.sessionId,
465
- title: msg.files[0],
474
+ title: undefined,
466
475
  });
467
476
  session.workspacePath = staged.workspacePath;
468
477
  this.syncSelectedChats(session);
@@ -501,11 +510,12 @@ export class TelegramBot extends Bot {
501
510
  startedAt: start,
502
511
  sourceMessageId: ctx.messageId,
503
512
  });
504
- const stopKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId));
505
513
  const waiting = this.sessionHasPendingWork(session);
514
+ const queuePosition = waiting ? this.getQueuePosition(session.key, taskId) : 0;
515
+ const placeholderKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId), { queued: waiting });
506
516
  let phId = null;
507
517
  if (canEditMessages) {
508
- const placeholderId = await ctx.reply(buildInitialPreviewHtml(session.agent, waiting), { parseMode: 'HTML', messageThreadId, keyboard: stopKeyboard });
518
+ const placeholderId = await ctx.reply(buildInitialPreviewHtml(session.agent, waiting, queuePosition), { parseMode: 'HTML', messageThreadId, keyboard: placeholderKeyboard });
509
519
  phId = typeof placeholderId === 'number' ? placeholderId : null;
510
520
  if (phId != null) {
511
521
  this.registerSessionMessage(ctx.chatId, phId, session);
@@ -534,6 +544,14 @@ export class TelegramBot extends Bot {
534
544
  this.log(`[handleMessage] skipped cancelled queued task chat=${ctx.chatId} msg=${ctx.messageId}`);
535
545
  return;
536
546
  }
547
+ // Task is now running — update keyboard from Recall/Steer to Stop
548
+ const runningKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId));
549
+ if (phId != null && waiting) {
550
+ try {
551
+ await this.channel.editMessage(ctx.chatId, phId, buildInitialPreviewHtml(session.agent, false), { parseMode: 'HTML', keyboard: runningKeyboard });
552
+ }
553
+ catch { }
554
+ }
537
555
  if (phId != null || canSendTyping) {
538
556
  livePreview = new LivePreview({
539
557
  agent: session.agent,
@@ -546,7 +564,7 @@ export class TelegramBot extends Bot {
546
564
  canEditMessages,
547
565
  canSendTyping,
548
566
  messageThreadId,
549
- keyboard: stopKeyboard,
567
+ keyboard: runningKeyboard,
550
568
  log: (message) => this.log(message),
551
569
  });
552
570
  livePreview.start();
@@ -758,6 +776,22 @@ export class TelegramBot extends Bot {
758
776
  await ctx.answerCallback('Nothing to stop.');
759
777
  return true;
760
778
  }
779
+ async handleTaskSteerCallback(data, ctx) {
780
+ if (!data.startsWith('tsk:steer:'))
781
+ return false;
782
+ const actionId = data.slice('tsk:steer:'.length).trim();
783
+ const result = this.steerTaskByActionId(actionId);
784
+ if (!result.task) {
785
+ await ctx.answerCallback('This task already finished.');
786
+ return true;
787
+ }
788
+ if (result.task.status !== 'queued') {
789
+ await ctx.answerCallback('Task is already running.');
790
+ return true;
791
+ }
792
+ await ctx.answerCallback(result.interrupted ? 'Steering — interrupting current task...' : 'No running task to interrupt.');
793
+ return true;
794
+ }
761
795
  async handleHumanLoopCallback(data, ctx) {
762
796
  if (!data.startsWith('hl:'))
763
797
  return false;
@@ -824,6 +858,8 @@ export class TelegramBot extends Bot {
824
858
  return;
825
859
  if (await this.handleTaskStopCallback(data, ctx))
826
860
  return;
861
+ if (await this.handleTaskSteerCallback(data, ctx))
862
+ return;
827
863
  if (await this.handleSwitchNavigateCallback(data, ctx))
828
864
  return;
829
865
  if (await this.handleSwitchSelectCallback(data, ctx))
package/dist/bot.js CHANGED
@@ -617,7 +617,7 @@ export class Bot {
617
617
  workdir: this.workdir,
618
618
  files: [],
619
619
  sessionId: null,
620
- title: title || files[0] || 'New session',
620
+ title: title || 'New session',
621
621
  });
622
622
  const runtime = this.upsertSessionRuntime({
623
623
  agent: cs.agent,
@@ -767,6 +767,61 @@ export class Bot {
767
767
  }
768
768
  return { task, interrupted: false, cancelled: false };
769
769
  }
770
+ /**
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.
774
+ */
775
+ steerTaskByActionId(actionId) {
776
+ const taskId = this.taskKeysByActionId.get(String(actionId));
777
+ if (!taskId)
778
+ return { task: null, interrupted: false };
779
+ const task = this.activeTasks.get(taskId) || null;
780
+ if (!task || task.status !== 'queued')
781
+ return { task, interrupted: false };
782
+ const interrupted = this.interruptRunningTask(task.sessionKey);
783
+ return { task, interrupted };
784
+ }
785
+ /**
786
+ * Interrupt only the currently running task for a session, leaving queued tasks intact.
787
+ * Used by the "Steer" action to let a queued task run next.
788
+ */
789
+ interruptRunningTask(sessionKey) {
790
+ const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
791
+ if (!session)
792
+ return false;
793
+ for (const taskId of session.runningTaskIds) {
794
+ const task = this.activeTasks.get(taskId);
795
+ if (!task || task.status !== 'running')
796
+ continue;
797
+ try {
798
+ task.abort?.();
799
+ }
800
+ catch { }
801
+ return true;
802
+ }
803
+ return false;
804
+ }
805
+ /**
806
+ * Return the number of tasks ahead of the given task in its session queue.
807
+ * Counts running + queued (non-cancelled) tasks that were started before this one.
808
+ */
809
+ getQueuePosition(sessionKey, taskId) {
810
+ const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
811
+ if (!session)
812
+ return 0;
813
+ let ahead = 0;
814
+ for (const otherId of session.runningTaskIds) {
815
+ if (otherId === taskId)
816
+ continue;
817
+ const other = this.activeTasks.get(otherId);
818
+ if (!other || other.cancelled)
819
+ continue;
820
+ if (other.status === 'running' || other.status === 'queued')
821
+ ahead++;
822
+ }
823
+ return ahead;
824
+ }
770
825
  sourceMessageKey(chatId, sourceMessageId) {
771
826
  return `${String(chatId)}:${String(sourceMessageId)}`;
772
827
  }
@@ -423,7 +423,8 @@ class FeishuChannel extends Channel {
423
423
  this._log(`[recv] skipped: not mentioned in group ${chatId}`);
424
424
  return;
425
425
  }
426
- const ctx = this._makeCtx(chatId, messageId, from, chatType, event);
426
+ const parentId = typeof msg.parent_id === 'string' && msg.parent_id ? msg.parent_id : null;
427
+ const ctx = this._makeCtx(chatId, messageId, from, chatType, event, parentId);
427
428
  // Parse message content
428
429
  let text = '';
429
430
  const files = [];
@@ -1031,12 +1032,13 @@ class FeishuChannel extends Channel {
1031
1032
  // ========================================================================
1032
1033
  // Internal helpers
1033
1034
  // ========================================================================
1034
- _makeCtx(chatId, messageId, from, chatType, raw) {
1035
+ _makeCtx(chatId, messageId, from, chatType, raw, replyToMessageId) {
1035
1036
  return {
1036
1037
  chatId,
1037
1038
  messageId,
1038
1039
  from,
1039
1040
  chatType,
1041
+ replyToMessageId: replyToMessageId || null,
1040
1042
  reply: (text, opts) => this.send(chatId, text, { ...opts, replyTo: messageId || opts?.replyTo }),
1041
1043
  editReply: (msgId, text, opts) => this.editMessage(chatId, msgId, text, opts),
1042
1044
  channel: this,
@@ -748,8 +748,7 @@ export function stageSessionFiles(opts) {
748
748
  const importedFiles = importFilesIntoWorkspace(session.workspacePath, opts.files);
749
749
  if (importedFiles.length) {
750
750
  session.record.stagedFiles = dedupeStrings([...session.record.stagedFiles, ...importedFiles]);
751
- if (!session.record.title)
752
- session.record.title = importedFiles[0];
751
+ /* title will be set when the first text prompt arrives */
753
752
  saveSessionRecord(opts.workdir, session.record);
754
753
  }
755
754
  return { sessionId: session.sessionId, workspacePath: session.workspacePath, importedFiles };
@@ -890,7 +889,7 @@ function prepareStreamOpts(opts) {
890
889
  const stagedFiles = [...session.record.stagedFiles];
891
890
  session.record.stagedFiles = [];
892
891
  if (!session.record.title)
893
- session.record.title = summarizePromptTitle(opts.prompt) || importedFiles[0] || null;
892
+ session.record.title = summarizePromptTitle(opts.prompt) || null;
894
893
  setSessionRunState(session.record, 'running', null);
895
894
  saveSessionRecord(opts.workdir, session.record);
896
895
  const attachmentPaths = attachmentRelPaths.map(relPath => path.join(session.workspacePath, relPath));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiclaw",
3
- "version": "0.2.70",
3
+ "version": "0.2.71",
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": {