pikiclaw 0.2.69 → 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
@@ -31,6 +31,12 @@ export function encodeCommandAction(action) {
31
31
  return `mod:${action.modelId}`;
32
32
  case 'effort.set':
33
33
  return `eff:${action.effort}`;
34
+ case 'models.select.model':
35
+ return `md:${action.modelId}`;
36
+ case 'models.select.effort':
37
+ return `ed:${action.effort}`;
38
+ case 'models.confirm':
39
+ return 'mc';
34
40
  case 'skill.run':
35
41
  return `skr:${action.command}`;
36
42
  }
@@ -70,6 +76,20 @@ export function decodeCommandAction(data) {
70
76
  return null;
71
77
  return { kind: 'effort.set', effort };
72
78
  }
79
+ if (data.startsWith('md:')) {
80
+ const modelId = data.slice(3);
81
+ if (!modelId)
82
+ return null;
83
+ return { kind: 'models.select.model', modelId };
84
+ }
85
+ if (data.startsWith('ed:')) {
86
+ const effort = data.slice(3);
87
+ if (!effort)
88
+ return null;
89
+ return { kind: 'models.select.effort', effort };
90
+ }
91
+ if (data === 'mc')
92
+ return { kind: 'models.confirm' };
73
93
  if (data.startsWith('skr:')) {
74
94
  const command = data.slice(4);
75
95
  if (!command)
@@ -134,24 +154,57 @@ export function buildAgentsCommandView(bot, chatId) {
134
154
  rows: chunkRows(actions, 3),
135
155
  };
136
156
  }
137
- export async function buildModelsCommandView(bot, chatId) {
157
+ const modelsDrafts = new Map();
158
+ async function initModelsDraft(bot, chatId) {
138
159
  const data = await getModelsListData(bot, chatId);
139
- const models = [...data.models].sort((a, b) => Number(b.isCurrent) - Number(a.isCurrent));
160
+ const draft = { modelId: data.currentModel, effort: data.effort?.current ?? null };
161
+ modelsDrafts.set(String(chatId), draft);
162
+ return draft;
163
+ }
164
+ export async function buildModelsCommandView(bot, chatId, draft) {
165
+ const data = await getModelsListData(bot, chatId);
166
+ // Initialize draft from current state or use the provided one
167
+ const d = draft ?? {
168
+ modelId: data.currentModel,
169
+ effort: data.effort?.current ?? null,
170
+ };
171
+ modelsDrafts.set(String(chatId), d);
172
+ const isSelected = (modelId) => modelMatchesSelection(data.agent, modelId, d.modelId);
173
+ const models = [...data.models].sort((a, b) => Number(isSelected(b.id)) - Number(isSelected(a.id)));
140
174
  const modelButtons = models.map(model => ({
141
175
  label: model.alias || model.id,
142
- action: { kind: 'model.switch', modelId: model.id },
143
- state: buttonStateFromFlags({ isCurrent: model.isCurrent }),
144
- primary: model.isCurrent,
176
+ action: { kind: 'models.select.model', modelId: model.id },
177
+ state: buttonStateFromFlags({ isCurrent: isSelected(model.id) }),
178
+ primary: isSelected(model.id),
145
179
  }));
146
- const rows = chunkRows(modelButtons, modelButtons.some(button => button.label.length > 14) ? 1 : 2);
180
+ const rows = chunkRows(modelButtons, 1);
147
181
  if (data.effort) {
148
- rows.push(data.effort.levels.map(level => ({
182
+ const effortButtons = data.effort.levels.map(level => ({
149
183
  label: level.label,
150
- action: { kind: 'effort.set', effort: level.id },
151
- state: buttonStateFromFlags({ isCurrent: level.isCurrent }),
152
- primary: level.isCurrent,
153
- })));
184
+ action: { kind: 'models.select.effort', effort: level.id },
185
+ state: buttonStateFromFlags({ isCurrent: level.id === d.effort }),
186
+ primary: level.id === d.effort,
187
+ }));
188
+ // Section label — clicking it is harmless (triggers confirm, which is noop if nothing changed)
189
+ rows.push([{
190
+ label: '— Thinking Effort —',
191
+ action: { kind: 'models.confirm' },
192
+ state: 'default',
193
+ primary: false,
194
+ }]);
195
+ // ≤3 levels fit in one row; 4+ split into rows of 2 to avoid Feishu truncation
196
+ rows.push(...chunkRows(effortButtons, effortButtons.length <= 3 ? effortButtons.length : 2));
154
197
  }
198
+ // Detect whether draft differs from current live values
199
+ const modelChanged = !modelMatchesSelection(data.agent, d.modelId, data.currentModel);
200
+ const effortChanged = !!(data.effort && d.effort !== data.effort.current);
201
+ const hasChanges = modelChanged || effortChanged;
202
+ rows.push([{
203
+ label: hasChanges ? '✓ Apply' : '✓ OK',
204
+ action: { kind: 'models.confirm' },
205
+ state: 'default',
206
+ primary: hasChanges,
207
+ }]);
155
208
  return {
156
209
  kind: 'models',
157
210
  title: 'Models',
@@ -159,15 +212,15 @@ export async function buildModelsCommandView(bot, chatId) {
159
212
  metaLines: [
160
213
  ...(data.sources.length ? [`Source: ${data.sources.join(', ')}`] : []),
161
214
  ...(data.note ? [data.note] : []),
162
- ...(data.effort ? [`Thinking Effort: ${data.effort.current}`] : []),
215
+ ...(data.effort ? [`Thinking Effort: ${d.effort}`] : []),
163
216
  ],
164
217
  items: models.map(model => ({
165
218
  label: model.alias || model.id,
166
219
  detail: model.alias ? model.id : null,
167
- state: buttonStateFromFlags({ isCurrent: model.isCurrent }),
220
+ state: buttonStateFromFlags({ isCurrent: isSelected(model.id) }),
168
221
  })),
169
222
  emptyText: 'No discoverable models found.',
170
- helperText: data.models.length ? 'Use the controls below to switch models.' : null,
223
+ helperText: data.models.length ? 'Select model and effort, then tap Apply.' : null,
171
224
  rows,
172
225
  };
173
226
  }
@@ -286,6 +339,51 @@ export async function executeCommandAction(bot, chatId, action, opts = {}) {
286
339
  },
287
340
  };
288
341
  }
342
+ case 'models.select.model': {
343
+ const draft = modelsDrafts.get(String(chatId)) ?? await initModelsDraft(bot, chatId);
344
+ draft.modelId = action.modelId;
345
+ return { kind: 'view', view: await buildModelsCommandView(bot, chatId, draft), callbackText: '' };
346
+ }
347
+ case 'models.select.effort': {
348
+ const draft = modelsDrafts.get(String(chatId)) ?? await initModelsDraft(bot, chatId);
349
+ draft.effort = action.effort;
350
+ return { kind: 'view', view: await buildModelsCommandView(bot, chatId, draft), callbackText: '' };
351
+ }
352
+ case 'models.confirm': {
353
+ const chat = bot.chat(chatId);
354
+ const draft = modelsDrafts.get(String(chatId));
355
+ modelsDrafts.delete(String(chatId));
356
+ if (!draft)
357
+ return { kind: 'noop', message: 'No changes' };
358
+ const currentModel = bot.modelForAgent(chat.agent);
359
+ const currentEffort = bot.effortForAgent(chat.agent);
360
+ const modelChanged = !modelMatchesSelection(chat.agent, draft.modelId, currentModel);
361
+ const effortChanged = draft.effort != null && draft.effort !== currentEffort;
362
+ if (!modelChanged && !effortChanged) {
363
+ return { kind: 'noop', message: 'No changes' };
364
+ }
365
+ const parts = [];
366
+ if (modelChanged) {
367
+ bot.switchModelForChat(chatId, draft.modelId);
368
+ parts.push(`Model: ${draft.modelId}`);
369
+ }
370
+ if (effortChanged) {
371
+ bot.switchEffortForChat(chatId, draft.effort);
372
+ parts.push(`Effort: ${draft.effort}`);
373
+ }
374
+ return {
375
+ kind: 'notice',
376
+ callbackText: parts.join(', '),
377
+ notice: {
378
+ title: 'Configuration Updated',
379
+ value: parts.join('\n'),
380
+ detail: modelChanged
381
+ ? `${chat.agent} · session reset`
382
+ : `${chat.agent} · takes effect on next message`,
383
+ valueMode: 'plain',
384
+ },
385
+ };
386
+ }
289
387
  case 'skill.run': {
290
388
  const resolved = resolveSkillPrompt(bot, chatId, action.command, '');
291
389
  if (!resolved)
@@ -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
  : '?';
@@ -100,6 +100,29 @@ export function renderCommandSelectionCard(view) {
100
100
  rows: view.rows.map(row => ({ actions: row.map(actionButton) })),
101
101
  };
102
102
  }
103
+ /**
104
+ * Strip code-fence markers (``` / ~~~) from text that is not meant to be
105
+ * rendered as full markdown (thinking, activity). Truncation by
106
+ * extractThinkingTail can leave stray fences that open unwanted code blocks.
107
+ */
108
+ function stripCodeFences(text) {
109
+ return text.replace(/^(`{3,}|~{3,}).*$/gm, '');
110
+ }
111
+ /**
112
+ * Ensure code fences in markdown text are balanced. If an odd number of
113
+ * fence markers is detected, append a closing fence so partial code blocks
114
+ * do not swallow the rest of the card.
115
+ */
116
+ function ensureBalancedCodeFences(text) {
117
+ let inCode = false;
118
+ for (const line of text.split('\n')) {
119
+ const trimmed = line.trimStart();
120
+ if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) {
121
+ inCode = !inCode;
122
+ }
123
+ }
124
+ return inCode ? text + '\n```' : text;
125
+ }
103
126
  function escapeFeishuMarkdownText(text) {
104
127
  return text.replace(/([\\`*_{}[\]()#+\-.!|>~])/g, '\\$1');
105
128
  }
@@ -156,10 +179,14 @@ export function buildHumanLoopPromptMarkdown(prompt) {
156
179
  // ---------------------------------------------------------------------------
157
180
  // LivePreview renderer — produces Markdown for Feishu card elements
158
181
  // ---------------------------------------------------------------------------
159
- export function buildInitialPreviewMarkdown(agent, model, effort, waiting = false) {
182
+ export function buildInitialPreviewMarkdown(agent, model, effort, waiting = false, queuePosition = 0) {
160
183
  const parts = [];
161
- if (waiting)
162
- 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
+ }
163
190
  if (model)
164
191
  parts.push(model);
165
192
  else
@@ -172,19 +199,19 @@ function buildPreviewMarkdown(input, options) {
172
199
  const data = extractStreamPreviewData(input);
173
200
  const parts = [];
174
201
  if (data.planDisplay) {
175
- parts.push(`**Plan**\n${data.planDisplay}`);
202
+ parts.push(`**Plan**\n${stripCodeFences(data.planDisplay)}`);
176
203
  }
177
204
  if (data.activityDisplay) {
178
- parts.push(`**Activity**\n${trimActivityForPreview(data.activityDisplay, data.maxActivity)}`);
205
+ parts.push(`**Activity**\n${stripCodeFences(trimActivityForPreview(data.activityDisplay, data.maxActivity))}`);
179
206
  }
180
207
  if (data.thinkDisplay && !data.display) {
181
- parts.push(`**${data.label}**\n${data.thinkDisplay}`);
208
+ parts.push(`**${data.label}**\n${stripCodeFences(data.thinkDisplay)}`);
182
209
  }
183
210
  else if (data.display) {
184
211
  if (data.rawThinking) {
185
- parts.push(`**${data.label}**\n${data.thinkSnippet}`);
212
+ parts.push(`**${data.label}**\n${stripCodeFences(data.thinkSnippet)}`);
186
213
  }
187
- parts.push(data.preview);
214
+ parts.push(ensureBalancedCodeFences(data.preview));
188
215
  }
189
216
  if (options?.includeFooter !== false) {
190
217
  parts.push(formatPreviewFooter(input.agent, input.elapsedMs, input.meta ?? null));
@@ -213,21 +240,21 @@ export function buildFinalReplyRender(agent, result) {
213
240
  let activityText = '';
214
241
  let activityNoteText = '';
215
242
  if (data.activityNarrative) {
216
- activityText = `**Activity**\n${data.activityNarrative}\n\n`;
243
+ activityText = `**Activity**\n${stripCodeFences(data.activityNarrative)}\n\n`;
217
244
  }
218
245
  if (data.activityCommandSummary) {
219
246
  activityNoteText = `*${data.activityCommandSummary}*\n\n`;
220
247
  }
221
248
  let thinkingText = '';
222
249
  if (data.thinkingDisplay) {
223
- thinkingText = `**${data.thinkLabel}**\n${data.thinkingDisplay}\n\n`;
250
+ thinkingText = `**${data.thinkLabel}**\n${stripCodeFences(data.thinkingDisplay)}\n\n`;
224
251
  }
225
252
  let statusText = '';
226
253
  if (data.statusLines) {
227
254
  statusText = `**⚠ Incomplete Response**\n${data.statusLines.join('\n')}\n\n`;
228
255
  }
229
256
  const headerText = `${activityText}${activityNoteText}${statusText}${thinkingText}`;
230
- const bodyText = data.bodyMessage;
257
+ const bodyText = ensureBalancedCodeFences(data.bodyMessage);
231
258
  return {
232
259
  fullText: `${headerText}${bodyText}${footerText}`,
233
260
  headerText,
@@ -583,6 +610,10 @@ function normalizeFeishuMarkdown(lines) {
583
610
  pendingBlankLine = false;
584
611
  out.push(line);
585
612
  }
613
+ // Safety: close any unclosed code block so it doesn't swallow the rest of
614
+ // a Feishu card element (e.g. truncated body text ending mid-fence).
615
+ if (inCodeBlock)
616
+ out.push('```');
586
617
  return out.join('\n');
587
618
  }
588
619
  export function adaptMarkdownForFeishu(markdown) {
@@ -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();
@@ -651,7 +669,8 @@ export class TelegramBot extends Bot {
651
669
  finalMsgId = await replacePreview(rendered.fullHtml);
652
670
  }
653
671
  else {
654
- const maxFirst = 3900 - rendered.headerHtml.length - rendered.footerHtml.length;
672
+ // Split: header on first message, footer on last message
673
+ const maxFirst = 3900 - rendered.headerHtml.length;
655
674
  let firstBody;
656
675
  let remaining;
657
676
  if (maxFirst > 200) {
@@ -665,14 +684,29 @@ export class TelegramBot extends Bot {
665
684
  firstBody = '';
666
685
  remaining = rendered.bodyHtml;
667
686
  }
668
- const firstHtml = `${rendered.headerHtml}${firstBody}${rendered.footerHtml}`;
669
- finalMsgId = await replacePreview(firstHtml);
670
687
  if (remaining.trim()) {
688
+ // Multi-message: header on first, footer on last
689
+ const firstHtml = `${rendered.headerHtml}${firstBody}`;
690
+ finalMsgId = await replacePreview(firstHtml);
671
691
  const chunks = splitText(remaining, 3800);
672
- for (const chunk of chunks) {
673
- remember(await sendFinalText(chunk, finalMsgId ?? phId ?? ctx.messageId));
692
+ for (let i = 0; i < chunks.length; i++) {
693
+ const isLast = i === chunks.length - 1;
694
+ const chunkText = isLast ? `${chunks[i]}${rendered.footerHtml}` : chunks[i];
695
+ remember(await sendFinalText(chunkText, finalMsgId ?? phId ?? ctx.messageId));
696
+ }
697
+ // Safety: re-clear the Stop keyboard on the placeholder in case the first edit silently failed
698
+ if (phId != null) {
699
+ try {
700
+ await this.channel.editMessage(ctx.chatId, phId, firstHtml || '(done)', { parseMode: 'HTML', keyboard: { inline_keyboard: [] } });
701
+ }
702
+ catch { }
674
703
  }
675
704
  }
705
+ else {
706
+ // Body fits on first message; only footer pushes it over — keep together
707
+ const firstHtml = `${rendered.headerHtml}${firstBody}${rendered.footerHtml}`;
708
+ finalMsgId = await replacePreview(firstHtml);
709
+ }
676
710
  }
677
711
  return { primaryMessageId: finalMsgId, messageIds };
678
712
  }
@@ -742,6 +776,22 @@ export class TelegramBot extends Bot {
742
776
  await ctx.answerCallback('Nothing to stop.');
743
777
  return true;
744
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
+ }
745
795
  async handleHumanLoopCallback(data, ctx) {
746
796
  if (!data.startsWith('hl:'))
747
797
  return false;
@@ -808,6 +858,8 @@ export class TelegramBot extends Bot {
808
858
  return;
809
859
  if (await this.handleTaskStopCallback(data, ctx))
810
860
  return;
861
+ if (await this.handleTaskSteerCallback(data, ctx))
862
+ return;
811
863
  if (await this.handleSwitchNavigateCallback(data, ctx))
812
864
  return;
813
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,
package/dist/cli.js CHANGED
@@ -299,10 +299,15 @@ Docs: https://github.com/xiaotonng/pikiclaw
299
299
  */
300
300
  function persistWorkdir(args, userConfig) {
301
301
  if (!process.env.PIKICLAW_DAEMON_CHILD) {
302
- const cliWorkdir = path.resolve(args.workdir || '.');
303
- if (userConfig.workdir !== cliWorkdir) {
304
- updateUserConfig({ workdir: cliWorkdir });
305
- return loadUserConfig();
302
+ // Only overwrite persisted workdir when the user explicitly passed -w.
303
+ // Falling back to cwd when no flag is given can clobber a valid saved
304
+ // workdir with a temp directory (e.g. after a non-daemon restart).
305
+ if (args.workdir) {
306
+ const cliWorkdir = path.resolve(args.workdir);
307
+ if (userConfig.workdir !== cliWorkdir) {
308
+ updateUserConfig({ workdir: cliWorkdir });
309
+ return loadUserConfig();
310
+ }
306
311
  }
307
312
  }
308
313
  return userConfig;
@@ -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));
@@ -223,9 +223,10 @@ function getNativeClaudeSessions(workdir) {
223
223
  const filePath = path.join(projectDir, entry.name);
224
224
  try {
225
225
  const stat = fs.statSync(filePath);
226
- // Read first few KB to extract title and model from first user/assistant messages
226
+ // Read enough bytes to get past the system_prompt line (can be 20KB+) and
227
+ // reach the first user/assistant events for title and model extraction.
227
228
  const fd = fs.openSync(filePath, 'r');
228
- const buf = Buffer.alloc(8192);
229
+ const buf = Buffer.alloc(65536);
229
230
  const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
230
231
  fs.closeSync(fd);
231
232
  const head = buf.toString('utf8', 0, bytesRead);
@@ -168,6 +168,7 @@ function spawnReplacementProcess(bin, args, env, log) {
168
168
  stdio: 'inherit',
169
169
  detached: true,
170
170
  env,
171
+ cwd: process.cwd(),
171
172
  });
172
173
  child.unref();
173
174
  log?.(`restart: new process spawned (PID ${child.pid})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiclaw",
3
- "version": "0.2.69",
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": {