@foxden-app/foxclaw 0.13.5 → 0.13.7

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/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
4
4
 
5
+ ## 0.13.7 - 2026-10-06
6
+
7
+ ### 中文
8
+
9
+ - 修复短小结在任务结束后看起来未收纳:Telegram 富消息改用默认关闭的 details 展开块,只显示“过程小结”入口,展开后保留完整内容及格式。接口不支持富消息时仍回退到可折叠引用。
10
+
11
+ ### English
12
+
13
+ - Fixed short progress summaries appearing unfolded after completion. Telegram rich messages now use a closed details block with a visible disclosure label, preserving the full formatted content when expanded. Fall back to expandable quotes when rich messages are unavailable.
14
+
15
+ ## 0.13.6 - 2026-10-06
16
+
17
+ ### 中文
18
+
19
+ - DSH `/threads` 每行右侧增加重命名按钮,输入新名称后通过原生标题服务保存并刷新列表,重启后保留;支持取消、过期及会话隔离。
20
+ - 恢复 Codex 的小结与最终回答分离展示:工作中独立推送小结,完成后将之前的小结收纳为 Telegram 可展开消息,最终结论独立发送。长小结使用富消息保留全文,消息编号与收纳状态持久化;DSH 有明确消息边界时采用同样流程,Antigravity 保持现有展示。
21
+ - 修复 Codex 断线后任务可能一直停留在工作中:释放实时任务并保留待确认状态与队列,使用 `/recover` 处理,避免自动重复执行结果未知的工具操作。正确识别原生失败结果及 RPC 结果未知的超时。
22
+ - Codex 本机 WebSocket 启动等待由 10 秒延长到 60 秒,阻止超时握手迟到后污染连接;systemd 保留重启间隔,取消连续启动失败后的永久熔断,避免暂时不可用导致必须手动重启。
23
+
24
+ ### English
25
+
26
+ - Added a rename button beside each DSH `/threads` entry. Save through the native title service and refresh the list, retaining names after restart; support cancellation, expiry, and scope isolation.
27
+ - Restored separate Codex progress summaries and final answers. Send summaries as normal messages while working, fold earlier summaries into expandable Telegram messages at completion, and send the final answer separately. Preserve long summaries through rich messages and persist message IDs and fold state. DSH follows the same flow when message boundaries are available; Antigravity keeps its existing presentation.
28
+ - Settle disconnected Codex executions without leaving a live task stuck. Keep unconfirmed outcomes and queued work for `/recover` instead of replaying tool operations automatically. Recognize native failed turns and RPC timeouts with unknown outcomes.
29
+ - Allow 60 seconds instead of 10 for local Codex WebSocket startup and reject late handshakes from expired attempts. Keep systemd's restart delay while removing the start-limit lockout that required manual recovery after transient startup failures.
30
+
5
31
  ## 0.13.5 - 2026-10-06
6
32
 
7
33
  ### 中文
@@ -17,6 +17,8 @@ export declare class TelegramMessagingPort implements ChannelPort {
17
17
  beginTaskPreview(scopeId: string, taskId: string, text: string, reuseMessageId?: number): Promise<number>;
18
18
  updateTaskPreview(scopeId: string, taskId: string, messageId: number, html: string): Promise<number>;
19
19
  endTaskPreview(scopeId: string, taskId: string): Promise<void>;
20
+ sendTaskCommentary(scopeId: string, text: string): Promise<number>;
21
+ foldTaskCommentary(scopeId: string, messageId: number, text: string): Promise<void>;
20
22
  readonly capabilities: {
21
23
  editableMessages: boolean;
22
24
  inlineActions: boolean;
@@ -3,6 +3,8 @@ import { telegramTaskResult } from '../../telegram/task_result.js';
3
3
  import { parseCommand } from '../../controller/commands.js';
4
4
  import { isDefaultTelegramScope, resolveTelegramAddressing } from '../../telegram/addressing.js';
5
5
  import { parseTelegramTargetFromBridgeScope } from '../../core/bridge_scope.js';
6
+ import { renderTelegramMarkdownRichHtml } from '../../telegram/rich_markdown.js';
7
+ import { escapeTelegramHtml } from '../../telegram/html.js';
6
8
  import { telegramRichHtml, telegramRichMarkdown } from '../../telegram/rich.js';
7
9
  /**
8
10
  * Telegram outbound operations addressed by bridge scope id (`telegram:…`).
@@ -17,6 +19,21 @@ export class TelegramMessagingPort {
17
19
  beginTaskPreview(scopeId, taskId, text, reuseMessageId = 0) { return this.taskPreviews.begin(scopeId, taskId, text, reuseMessageId); }
18
20
  updateTaskPreview(scopeId, taskId, messageId, html) { return this.taskPreviews.update(scopeId, taskId, messageId, html); }
19
21
  endTaskPreview(scopeId, taskId) { return this.taskPreviews.end(scopeId, taskId); }
22
+ async sendTaskCommentary(scopeId, text) {
23
+ return this.sendRichMarkdown(scopeId, text);
24
+ }
25
+ async foldTaskCommentary(scopeId, messageId, text) {
26
+ const target = parseTelegramTargetFromBridgeScope(scopeId);
27
+ try {
28
+ await this.gateway.editRichMessage(target.chatId, messageId, telegramRichHtml(`<details><summary>过程小结</summary>${renderTelegramMarkdownRichHtml(text)}</details>`, { skipEntityDetection: true }), []);
29
+ }
30
+ catch {
31
+ // The plain HTML endpoint has a smaller envelope than rich messages.
32
+ if (text.length > 3000)
33
+ throw new Error('Long commentary requires Telegram rich messages');
34
+ await this.gateway.editHtmlMessage(target.chatId, messageId, `<blockquote expandable>${escapeTelegramHtml(text)}</blockquote>`, []);
35
+ }
36
+ }
20
37
  capabilities = { editableMessages: true, inlineActions: true, maxMessageLength: 4000 };
21
38
  resolveIncoming(event, config, username) {
22
39
  return resolveTelegramAddressing({ text: event.text, attachmentsCount: event.attachments.length,
@@ -3,6 +3,7 @@ import type { CodexAppClient } from './client.js';
3
3
  import type { SandboxModeValue, ApprovalPolicyValue } from '../types.js';
4
4
  export declare class CodexEngineAdapter implements IEngineAdapter {
5
5
  private readonly client;
6
+ readonly supportsCommentary = true;
6
7
  readonly id: string;
7
8
  readonly name: string;
8
9
  private readonly defaultModel;
@@ -4,6 +4,7 @@ import { buildAttachmentPrompt } from '../core/attachment_files.js';
4
4
  import { resolveAccessMode } from '../core/access.js';
5
5
  export class CodexEngineAdapter {
6
6
  client;
7
+ supportsCommentary = true;
7
8
  id;
8
9
  name;
9
10
  defaultModel;
@@ -45,6 +46,8 @@ export class CodexEngineAdapter {
45
46
  promptText = buildAttachmentPrompt(request.prompt, request.stagedAttachments);
46
47
  }
47
48
  let accumulatedResponse = '';
49
+ const messages = new Map();
50
+ const responseText = () => [...messages.values()].filter(message => message.kind !== 'commentary' && message.kind !== 'tool_summary').map(message => message.text).join('\n\n');
48
51
  let finalResult = null;
49
52
  let turnError = null;
50
53
  const resultWaiters = [];
@@ -70,9 +73,31 @@ export class CodexEngineAdapter {
70
73
  return;
71
74
  if (ev.turnId && turnId && ev.turnId !== turnId)
72
75
  return;
73
- if (ev.kind === 'agent_message_delta') {
74
- accumulatedResponse += ev.delta;
75
- emitter.emit('delta', ev.delta);
76
+ if (ev.kind === 'agent_message_started' || ev.kind === 'agent_message_delta' || ev.kind === 'agent_message_completed') {
77
+ const phase = ev.kind === 'agent_message_delta' ? msg.params?.phase ?? msg.params?.item?.phase : ev.phase;
78
+ const knownKind = Boolean(phase) || ev.isPlan;
79
+ let message = messages.get(ev.itemId);
80
+ if (!message) {
81
+ message = { text: '', kind: knownKind || ev.kind === 'agent_message_completed' ? ev.outputKind : 'unknown', sent: false };
82
+ messages.set(ev.itemId, message);
83
+ }
84
+ // Deltas often omit phase; started/completed items carry the authoritative phase.
85
+ if (knownKind || (ev.kind === 'agent_message_completed' && message.kind === 'unknown'))
86
+ message.kind = ev.outputKind;
87
+ if (ev.kind === 'agent_message_delta') {
88
+ message.text += ev.delta;
89
+ if (message.kind !== 'commentary' && message.kind !== 'tool_summary')
90
+ emitter.emit('delta', ev.delta);
91
+ }
92
+ else if (ev.kind === 'agent_message_completed') {
93
+ if (ev.text !== null)
94
+ message.text = ev.text;
95
+ if ((message.kind === 'commentary' || message.kind === 'tool_summary') && !message.sent && message.text.trim()) {
96
+ message.sent = true;
97
+ emitter.emit('commentary', { messageId: ev.itemId, text: message.text });
98
+ }
99
+ }
100
+ accumulatedResponse = responseText();
76
101
  }
77
102
  else if (ev.kind === 'tool_started') {
78
103
  const cmdName = Array.isArray(ev.exec?.command) ? ev.exec.command.join(' ') : 'command';
@@ -90,9 +115,12 @@ export class CodexEngineAdapter {
90
115
  }
91
116
  else if (ev.kind === 'turn_completed') {
92
117
  cleanup();
118
+ const nativeTurn = msg.params?.turn;
119
+ const failure = nativeTurn?.error?.message ?? (nativeTurn?.status === 'failed' ? 'Codex turn failed' : undefined);
93
120
  finalResult = {
94
121
  kind: 'result',
95
- status: ev.state === 'interrupted' ? 'INTERRUPTED' : 'SUCCESS',
122
+ status: failure ? 'ERROR' : nativeTurn?.status === 'interrupted' || ev.state === 'interrupted' ? 'INTERRUPTED' : 'SUCCESS',
123
+ ...(failure ? { error: String(failure) } : {}),
96
124
  response: accumulatedResponse,
97
125
  conversationId: threadId,
98
126
  };
@@ -106,9 +134,20 @@ export class CodexEngineAdapter {
106
134
  };
107
135
  const cleanup = () => {
108
136
  this.client.off('notification', onNotification);
137
+ this.client.off('disconnected', onDisconnected);
109
138
  earlyNotifications.length = 0;
110
139
  };
140
+ const onDisconnected = () => {
141
+ if (finalResult || turnError)
142
+ return;
143
+ cleanup();
144
+ finalResult = { kind: 'result', status: 'ERROR', response: responseText(),
145
+ error: 'Codex connection closed before the task outcome was confirmed.', outcomeUnknown: true, conversationId: threadId };
146
+ emitter.emit('result', finalResult);
147
+ resolveWaiters(finalResult);
148
+ };
111
149
  this.client.on('notification', onNotification);
150
+ this.client.on('disconnected', onDisconnected);
112
151
  const effectiveModel = request.model && request.model !== 'default'
113
152
  ? request.model
114
153
  : (this.defaultModel && this.defaultModel !== 'default' ? this.defaultModel : null);
@@ -136,7 +175,7 @@ export class CodexEngineAdapter {
136
175
  // thread might already be active on server
137
176
  }
138
177
  }
139
- if (cancelled) {
178
+ if (cancelled || finalResult) {
140
179
  cleanup();
141
180
  return;
142
181
  }
@@ -164,7 +203,7 @@ export class CodexEngineAdapter {
164
203
  errMsg.includes('No thread') ||
165
204
  errMsg.includes('stale') ||
166
205
  errMsg.includes('invalid thread')) {
167
- if (cancelled)
206
+ if (cancelled || finalResult)
168
207
  throw turnErr;
169
208
  const session = await this.client.startThread({
170
209
  cwd: request.cwd,
@@ -173,7 +212,7 @@ export class CodexEngineAdapter {
173
212
  sandboxMode: access.sandboxMode,
174
213
  });
175
214
  threadId = session.thread.threadId;
176
- if (cancelled) {
215
+ if (cancelled || finalResult) {
177
216
  cleanup();
178
217
  return;
179
218
  }
@@ -194,13 +233,25 @@ export class CodexEngineAdapter {
194
233
  throw turnErr;
195
234
  }
196
235
  }
236
+ if (finalResult)
237
+ return;
197
238
  turnId = turn.id;
198
239
  for (const notification of earlyNotifications.splice(0))
199
240
  onNotification(notification);
200
241
  }
201
242
  catch (err) {
202
243
  cleanup();
203
- turnError = err instanceof Error ? err : new Error(String(err));
244
+ if (finalResult)
245
+ return;
246
+ const failure = err instanceof Error ? err : new Error(String(err));
247
+ if (failure.message.includes('result is unknown')) {
248
+ finalResult = { kind: 'result', status: 'ERROR', response: responseText(), error: failure.message,
249
+ outcomeUnknown: true, conversationId: threadId };
250
+ emitter.emit('result', finalResult);
251
+ resolveWaiters(finalResult);
252
+ return;
253
+ }
254
+ turnError = failure;
204
255
  emitter.emit('error', turnError);
205
256
  resolveWaiters(null);
206
257
  }
@@ -79,6 +79,8 @@ export declare class CodexAppClient extends EventEmitter {
79
79
  private connected;
80
80
  private userAgent;
81
81
  private readonly requestTimeoutMs;
82
+ private readonly startupTimeoutMs;
83
+ private readonly handshakeTimeoutMs;
82
84
  constructor(codexCliBin: string, launchCommand: string, autolaunch: boolean, serverStatePath: string, serverLogPath: string, logger: Logger, childEnv?: NodeJS.ProcessEnv | null, appServerConfigOverrides?: readonly string[]);
83
85
  isConnected(): boolean;
84
86
  getUserAgent(): string | null;
@@ -34,6 +34,8 @@ export class CodexAppClient extends EventEmitter {
34
34
  connected = false;
35
35
  userAgent = null;
36
36
  requestTimeoutMs = 30_000;
37
+ startupTimeoutMs = 60_000;
38
+ handshakeTimeoutMs = 2000;
37
39
  constructor(codexCliBin, launchCommand, autolaunch, serverStatePath, serverLogPath, logger, childEnv = null, appServerConfigOverrides = []) {
38
40
  super();
39
41
  this.codexCliBin = codexCliBin;
@@ -588,20 +590,30 @@ export class CodexAppClient extends EventEmitter {
588
590
  async connectWebSocket() {
589
591
  const url = `ws://127.0.0.1:${this.port}`;
590
592
  const started = Date.now();
591
- while (Date.now() - started < 10_000) {
593
+ while (Date.now() - started < this.startupTimeoutMs) {
592
594
  try {
593
595
  await new Promise((resolve, reject) => {
594
596
  const ws = new WebSocket(url);
597
+ let settled = false;
595
598
  const timer = setTimeout(() => {
599
+ settled = true;
596
600
  ws.close();
597
601
  reject(new Error('WebSocket handshake timed out'));
598
- }, 2000);
602
+ }, this.handshakeTimeoutMs);
599
603
  const onError = (event) => {
604
+ if (settled)
605
+ return;
606
+ settled = true;
600
607
  clearTimeout(timer);
601
608
  ws.close();
602
609
  reject(new Error(`WebSocket connect failed: ${String(event.type)}`));
603
610
  };
604
611
  ws.addEventListener('open', () => {
612
+ if (settled) {
613
+ ws.close();
614
+ return;
615
+ }
616
+ settled = true;
605
617
  clearTimeout(timer);
606
618
  ws.removeEventListener('error', onError);
607
619
  this.socket = ws;
@@ -32,6 +32,8 @@ export interface ChannelPort extends AttachmentDownloader {
32
32
  }>): Promise<void>;
33
33
  beginTaskPreview?(scopeId: string, taskId: string, text: string, reuseMessageId?: number): Promise<number>;
34
34
  updateTaskPreview?(scopeId: string, taskId: string, messageId: number, html: string): Promise<number>;
35
+ sendTaskCommentary?(scopeId: string, text: string): Promise<number>;
36
+ foldTaskCommentary?(scopeId: string, messageId: number, text: string): Promise<void>;
35
37
  endTaskPreview?(scopeId: string, taskId: string): Promise<void>;
36
38
  resolveAction?(event: ChannelTextEvent): ChannelCallbackEvent | null;
37
39
  editPlain(scopeId: string, messageId: ChannelMessageRef, text: string, keyboard?: ChannelInlineKeyboard): Promise<void>;
@@ -28,11 +28,17 @@ export interface EngineToolEvent {
28
28
  stepIndex?: number | undefined;
29
29
  summary?: string | undefined;
30
30
  }
31
+ export interface EngineCommentaryEvent {
32
+ messageId: string;
33
+ text: string;
34
+ }
31
35
  export interface EngineTurnResult {
32
36
  kind: 'result';
33
37
  status: 'SUCCESS' | 'ERROR' | 'INTERRUPTED';
34
38
  response: string;
35
39
  error?: string | undefined;
40
+ /** The connection ended before a native outcome was confirmed. */
41
+ outcomeUnknown?: boolean;
36
42
  conversationId: string | null;
37
43
  usage?: {
38
44
  inputTokens?: number | undefined;
@@ -47,6 +53,7 @@ export interface EngineTurnExecution {
47
53
  cancel(): void | Promise<void>;
48
54
  waitForResult(): Promise<EngineTurnResult | null>;
49
55
  on(event: 'delta', listener: (text: string) => void): void;
56
+ on(event: 'commentary', listener: (message: EngineCommentaryEvent) => void): void;
50
57
  on(event: 'tool', listener: (tool: EngineToolEvent) => void): void;
51
58
  on(event: 'result', listener: (result: EngineTurnResult) => void): void;
52
59
  on(event: 'error', listener: (error: Error) => void): void;
@@ -64,6 +71,7 @@ export interface EngineTurnErrorContext {
64
71
  export interface IEngineAdapter {
65
72
  readonly id: string;
66
73
  readonly name: string;
74
+ readonly supportsCommentary?: boolean;
67
75
  listModels(scopeId?: string): Promise<EngineModel[]>;
68
76
  executeTurn(request: EngineTurnRequest): EngineTurnExecution;
69
77
  preflightTurn?(request: EngineTurnRequest): Promise<void>;
@@ -28,6 +28,8 @@ export interface UnifiedActiveTurn {
28
28
  queueId?: string | undefined;
29
29
  taskId: string;
30
30
  settling?: boolean;
31
+ separateFinal?: boolean;
32
+ commentaryIds?: Set<string>;
31
33
  }
32
34
  export declare class UnifiedChannelOrchestrator {
33
35
  readonly config: AppConfig;
@@ -357,8 +357,8 @@ export class UnifiedChannelOrchestrator {
357
357
  { text: zh ? '放弃' : 'Cancel', callback_data: `engine:recover:cancel:${task.id}` },
358
358
  ]];
359
359
  await this.sendMessage(task.event.scopeId, zh
360
- ? `⚠️ **任务执行结果待确认**\n任务: \`${task.id}\`\n${task.error ? `恢复失败: ${task.error}。` : '服务重启前可能已执行工具。'}原始输入与附件已保留,队列暂停。\n> ${task.sourcePrompt}\n\n/recover continue · /recover retry · /recover cancel`
361
- : `⚠️ **Task outcome requires confirmation**\nTask: \`${task.id}\`\n${task.error ? `Recovery failed: ${task.error}.` : 'Tools may already have run before the restart.'} Input and attachments are preserved; the queue is paused.\n> ${task.sourcePrompt}\n\n/recover continue · /recover retry · /recover cancel`, actions);
360
+ ? `⚠️ **任务执行结果待确认**\n任务: \`${task.id}\`\n${task.error ? `提示: ${task.error}。` : '服务重启前可能已执行工具。'}原始输入与附件已保留,队列暂停。\n> ${task.sourcePrompt}\n\n/recover continue · /recover retry · /recover cancel`
361
+ : `⚠️ **Task outcome requires confirmation**\nTask: \`${task.id}\`\n${task.error ? `Details: ${task.error}.` : 'Tools may already have run before the restart.'} Input and attachments are preserved; the queue is paused.\n> ${task.sourcePrompt}\n\n/recover continue · /recover retry · /recover cancel`, actions);
362
362
  }
363
363
  async restoreDurableTasks() {
364
364
  const tasks = this.store.taskJournal.listUnfinished().filter(task => this.ownsScope(task.event.scopeId));
@@ -369,8 +369,8 @@ export class UnifiedChannelOrchestrator {
369
369
  }
370
370
  // Claim uncertainty synchronously before recovering messages or starting any queued work.
371
371
  for (const task of tasks)
372
- if (task.state === 'running' && !task.result) {
373
- this.store.taskJournal.update(task.id, 'awaiting_confirmation');
372
+ if (task.state === 'running' && (!task.result || task.result.outcomeUnknown)) {
373
+ this.store.taskJournal.update(task.id, 'awaiting_confirmation', { result: null });
374
374
  }
375
375
  // Import pre-journal previews and processing queues conservatively; their outcome is unknown.
376
376
  const journalScopes = new Set(tasks.filter(task => task.state !== 'queued').map(task => task.event.scopeId));
@@ -503,7 +503,7 @@ export class UnifiedChannelOrchestrator {
503
503
  }
504
504
  if (task.request.threadId)
505
505
  this.store.setBinding(scopeId, task.request.threadId, task.request.cwd);
506
- this.store.taskJournal.update(task.id, 'accepted', { request: { ...task.request, prompt, stagedAttachments }, result: null, delivery: [], deliveredChunks: 0, error: null });
506
+ this.store.taskJournal.update(task.id, 'accepted', { request: { ...task.request, prompt, stagedAttachments }, result: null, delivery: [], deliveredChunks: 0, previewSettled: false, separateFinal: this.getAdapterForBackend(task.backendId).supportsCommentary === true, error: null });
507
507
  await this.executeTurn(task.event, prompt, locale, 0, stagedAttachments, { taskId: task.id, queueId: task.queueId ?? undefined, reuseMessageId: task.previewMessageId || undefined, forcedThreadId: task.request.threadId ?? undefined });
508
508
  }
509
509
  });
@@ -539,7 +539,7 @@ export class UnifiedChannelOrchestrator {
539
539
  const task = this.store.taskJournal.get(turn.taskId);
540
540
  const folded = buildFoldedToolsSummary({ toolLines: turn.toolLines, toolCount: turn.toolCount, stepIndex: turn.stepIndex, startTime: turn.startTime, locale: task.request.locale });
541
541
  const response = `${task.request.locale === 'zh' ? '🛑 已停止任务;排队任务保留。' : '🛑 Task stopped; queued tasks kept.'}${turn.accumulatedText.trim() ? `\n\n${turn.accumulatedText.trim()}` : ''}`;
542
- this.store.taskJournal.update(turn.taskId, 'delivery_pending', { result: { kind: 'result', status: 'INTERRUPTED', response, conversationId: turn.threadId }, delivery: combineSummaryAndResponse(folded, response), deliveredChunks: 0, previewMessageId: turn.messageId });
542
+ this.store.taskJournal.update(turn.taskId, 'delivery_pending', { result: { kind: 'result', status: 'INTERRUPTED', response, conversationId: turn.threadId }, delivery: combineSummaryAndResponse(folded, response), deliveredChunks: 0, previewMessageId: turn.messageId, separateFinal: turn.separateFinal === true });
543
543
  try {
544
544
  await this.deliverTaskChunks(turn.taskId);
545
545
  this.resolveDeliveredTask(turn.taskId);
@@ -622,7 +622,7 @@ export class UnifiedChannelOrchestrator {
622
622
  throw new Error('Delivery interrupted by shutdown');
623
623
  const index = task.deliveredChunks;
624
624
  const chunk = task.delivery[index];
625
- if (index === 0 && task.previewMessageId > 0 && this.messaging.capabilities?.editableMessages !== false) {
625
+ if (!task.separateFinal && index === 0 && task.previewMessageId > 0 && this.messaging.capabilities?.editableMessages !== false) {
626
626
  try {
627
627
  await this.editMessage(task.event.scopeId, task.previewMessageId, chunk, []);
628
628
  }
@@ -634,6 +634,34 @@ export class UnifiedChannelOrchestrator {
634
634
  await this.sendMessage(task.event.scopeId, chunk);
635
635
  task = this.store.taskJournal.update(task.id, 'delivery_pending', { deliveredChunks: index + 1 });
636
636
  }
637
+ if (task.separateFinal) {
638
+ for (let index = 0; index < (task.commentary?.length ?? 0); index++) {
639
+ const item = task.commentary[index];
640
+ if (item.folded || this.messaging.capabilities?.editableMessages === false)
641
+ continue;
642
+ try {
643
+ if (this.messaging.foldTaskCommentary)
644
+ await this.messaging.foldTaskCommentary(task.event.scopeId, item.messageId, item.text);
645
+ else
646
+ await this.editMessage(task.event.scopeId, item.messageId, `<blockquote expandable>${escapeHtml(item.text)}</blockquote>`, []);
647
+ const commentary = [...task.commentary];
648
+ commentary[index] = { ...item, folded: true };
649
+ task = this.store.taskJournal.update(task.id, 'delivery_pending', { commentary });
650
+ }
651
+ catch (error) {
652
+ this.logger.warn('orchestrator.commentary_fold_failed', { taskId, messageId: item.messageId, error: String(error) });
653
+ }
654
+ }
655
+ if (!task.previewSettled) {
656
+ if (task.previewMessageId > 0 && this.messaging.capabilities?.editableMessages !== false) {
657
+ if (task.finalPreviewText)
658
+ await this.editMessage(task.event.scopeId, task.previewMessageId, task.finalPreviewText, []);
659
+ else
660
+ await this.messaging.deleteMessage(task.event.scopeId, task.previewMessageId).catch(() => { });
661
+ }
662
+ task = this.store.taskJournal.update(task.id, 'delivery_pending', { previewSettled: true });
663
+ }
664
+ }
637
665
  }
638
666
  async safeDeliverChunks(scopeId, messageId, chunks) {
639
667
  const active = this.activeTurns.get(scopeId);
@@ -642,7 +670,7 @@ export class UnifiedChannelOrchestrator {
642
670
  await this.messageOperations.run(`${scopeId}:preview:${active.taskId}`, () => this.messaging.endTaskPreview?.(scopeId, active.taskId) ?? Promise.resolve());
643
671
  const task = this.store.taskJournal.get(active.taskId);
644
672
  messageId = active.messageId;
645
- this.store.taskJournal.update(task.id, 'delivery_pending', { delivery: chunks, deliveredChunks: 0, previewMessageId: messageId });
673
+ this.store.taskJournal.update(task.id, 'delivery_pending', { delivery: chunks, deliveredChunks: 0, previewMessageId: messageId, separateFinal: active.separateFinal === true });
646
674
  await this.deliverTaskChunks(task.id);
647
675
  }
648
676
  async safeDeliverMessage(scopeId, messageId, text) {
@@ -927,7 +955,7 @@ export class UnifiedChannelOrchestrator {
927
955
  stagedAttachments, threadId: binding?.threadId || null, cwd: binding?.cwd || this.config.defaultCwd,
928
956
  model: settings?.model || 'default', effort: boost ? 'high' : settings?.reasoningEffort ?? backend.defaults?.reasoningEffort ?? null,
929
957
  serviceTier: boost ? 'boost' : settings?.serviceTier ?? null, locale, accessPreset: settings?.accessPreset ?? 'default' },
930
- queueId, previewMessageId: 0, result: null, delivery: [], deliveredChunks: 0, error: null,
958
+ queueId, previewMessageId: 0, result: null, delivery: [], deliveredChunks: 0, previewSettled: false, separateFinal: this.getBackendDescriptorForScope(event.scopeId).adapter.supportsCommentary === true, error: null,
931
959
  createdAt: Date.now(), updatedAt: Date.now(),
932
960
  };
933
961
  this.store.taskJournal.insert(task);
@@ -1123,7 +1151,7 @@ export class UnifiedChannelOrchestrator {
1123
1151
  throw new Error('Orchestrator is stopped');
1124
1152
  }
1125
1153
  let execution;
1126
- task = this.store.taskJournal.update(task.id, 'running', { result: null, delivery: [], deliveredChunks: 0 });
1154
+ task = this.store.taskJournal.update(task.id, 'running', { result: null, delivery: [], deliveredChunks: 0, previewSettled: false, separateFinal: adapter.supportsCommentary === true });
1127
1155
  try {
1128
1156
  execution = adapter.executeTurn(req);
1129
1157
  }
@@ -1150,6 +1178,8 @@ export class UnifiedChannelOrchestrator {
1150
1178
  previewKey: turnKey,
1151
1179
  queueId: options?.queueId,
1152
1180
  taskId: task.id,
1181
+ separateFinal: adapter.supportsCommentary === true,
1182
+ commentaryIds: new Set(),
1153
1183
  };
1154
1184
  this.activeTurns.set(scopeId, activeTurn);
1155
1185
  this.messaging.sendTypingInScope(scopeId).catch(() => { });
@@ -1174,7 +1204,7 @@ export class UnifiedChannelOrchestrator {
1174
1204
  const elapsedSeconds = Math.max(1, Math.floor((Date.now() - activeTurn.startTime) / 1000));
1175
1205
  const content = renderStreamPreviewContent({
1176
1206
  toolLines: activeTurn.toolLines,
1177
- accumulatedText: activeTurn.accumulatedText,
1207
+ accumulatedText: activeTurn.separateFinal ? '' : activeTurn.accumulatedText,
1178
1208
  isBoost,
1179
1209
  engineName: adapter.name,
1180
1210
  stepIndex: activeTurn.stepIndex,
@@ -1206,6 +1236,28 @@ export class UnifiedChannelOrchestrator {
1206
1236
  activeTurn.accumulatedText += delta;
1207
1237
  scheduleFlush();
1208
1238
  });
1239
+ execution.on('commentary', (message) => {
1240
+ if (!ownsTurn() || activeTurn.settling || !message.text.trim() || activeTurn.commentaryIds?.has(message.messageId))
1241
+ return;
1242
+ activeTurn.commentaryIds?.add(message.messageId);
1243
+ activeTurn.separateFinal = true;
1244
+ activeTurn.accumulatedText = '';
1245
+ void this.messageOperations.run(`${scopeId}:preview:${activeTurn.taskId}`, async () => {
1246
+ if (!ownsTurn())
1247
+ return;
1248
+ const limit = this.messaging.sendTaskCommentary ? 30000 : Math.min(3000, this.messaging.capabilities?.maxMessageLength ?? 3000);
1249
+ for (const text of chunkMessage(message.text, limit)) {
1250
+ const messageId = this.messaging.sendTaskCommentary
1251
+ ? await this.messaging.sendTaskCommentary(scopeId, text)
1252
+ : await this.messaging.sendRichMarkdown(scopeId, text);
1253
+ const current = this.store.taskJournal.get(activeTurn.taskId);
1254
+ this.store.taskJournal.update(current.id, current.state, {
1255
+ commentary: [...(current.commentary ?? []), { messageId, text, folded: false }], separateFinal: true,
1256
+ });
1257
+ }
1258
+ }).catch(error => this.logger.warn('orchestrator.commentary_send_failed', { taskId: activeTurn.taskId, error: String(error) }));
1259
+ scheduleFlush();
1260
+ });
1209
1261
  execution.on('tool', (tool) => {
1210
1262
  if (!ownsTurn() || activeTurn.settling)
1211
1263
  return;
@@ -1271,6 +1323,8 @@ export class UnifiedChannelOrchestrator {
1271
1323
  this.activeTurns.delete(scopeId);
1272
1324
  this.store.removeActiveTurnPreview(turnKey);
1273
1325
  const task = this.store.taskJournal.get(activeTurn.taskId);
1326
+ if (task.state === 'awaiting_confirmation')
1327
+ return;
1274
1328
  if (task.state === 'delivery_pending' && task.deliveredChunks < task.delivery.length) {
1275
1329
  this.logger.warn('orchestrator.delivery_pending', { taskId: task.id, scopeId });
1276
1330
  return;
@@ -1304,6 +1358,14 @@ export class UnifiedChannelOrchestrator {
1304
1358
  totalTokens: res.usage.totalTokens,
1305
1359
  }, adapter.id);
1306
1360
  }
1361
+ if (res.outcomeUnknown) {
1362
+ await this.messageOperations.run(`${scopeId}:preview:${activeTurn.taskId}`, () => this.messaging.endTaskPreview?.(scopeId, activeTurn.taskId) ?? Promise.resolve());
1363
+ const waiting = this.store.taskJournal.update(activeTurn.taskId, 'awaiting_confirmation', { result: null, error: res.error ?? null });
1364
+ if (activeTurn.messageId > 0)
1365
+ await this.editMessage(scopeId, activeTurn.messageId, locale === 'zh' ? '⚠️ 后端连接中断,任务结果尚未确认。使用 /recover 处理;排队任务已保留。' : '⚠️ Backend disconnected; task outcome unconfirmed. Use /recover; queued tasks are kept.', []).catch(() => { });
1366
+ await this.showRecovery(waiting);
1367
+ return;
1368
+ }
1307
1369
  if (res.status === 'SUCCESS') {
1308
1370
  let finalText = (res.response || '').trim();
1309
1371
  if (!finalText && activeTurn.accumulatedText) {
@@ -1317,7 +1379,11 @@ export class UnifiedChannelOrchestrator {
1317
1379
  usage: res.usage,
1318
1380
  locale,
1319
1381
  });
1320
- const chunks = combineSummaryAndResponse(foldedTools, finalText || '(无输出 / No output)');
1382
+ if (activeTurn.separateFinal)
1383
+ this.store.taskJournal.update(activeTurn.taskId, 'running', { finalPreviewText: foldedTools });
1384
+ const chunks = activeTurn.separateFinal
1385
+ ? chunkMessage(finalText || '(无输出 / No output)', this.messaging.capabilities?.maxMessageLength)
1386
+ : combineSummaryAndResponse(foldedTools, finalText || '(无输出 / No output)');
1321
1387
  await this.safeDeliverChunks(scopeId, activeTurn.messageId, chunks);
1322
1388
  return;
1323
1389
  }
@@ -56,6 +56,7 @@ export declare class DshEngineAdapter implements IEngineAdapter {
56
56
  [key: string]: unknown;
57
57
  } | null;
58
58
  }>;
59
+ renameSession(scopeId: string, sessionId: string, cwd: string, title: string): Promise<string>;
59
60
  isBusy(scopeId: string): boolean;
60
61
  executeTurn(request: EngineTurnRequest): EngineTurnExecution;
61
62
  stop(): Promise<void>;
@@ -92,6 +92,12 @@ export class DshEngineAdapter {
92
92
  await state.client.setAccess(access);
93
93
  }
94
94
  async listSessions(scopeId, cursor) { return (await this.sessionForScope(scopeId)).client.listSessions(cursor); }
95
+ async renameSession(scopeId, sessionId, cwd, title) {
96
+ const state = await this.sessionForScope(scopeId);
97
+ if (this.busy.has(scopeId))
98
+ throw new Error('DSH session is busy');
99
+ return state.client.renameSession(sessionId, cwd, title);
100
+ }
95
101
  isBusy(scopeId) { return this.busy.has(scopeId); }
96
102
  executeTurn(request) {
97
103
  const emitter = new EventEmitter();
@@ -112,6 +118,8 @@ export class DshEngineAdapter {
112
118
  const update = notification.update;
113
119
  if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') {
114
120
  if (update.messageId && update.messageId !== finalMessageId) {
121
+ if (finalMessageId && finalMessage.trim())
122
+ emitter.emit('commentary', { messageId: finalMessageId, text: finalMessage });
115
123
  finalMessageId = update.messageId;
116
124
  finalMessage = '';
117
125
  }
@@ -60,6 +60,8 @@ export declare class DshClient extends EventEmitter {
60
60
  } | null;
61
61
  }>;
62
62
  private readSessionTitles;
63
+ renameSession(sessionId: string, cwd: string, title: string): Promise<string>;
64
+ private titleRequest;
63
65
  closeSession(sessionId: string): Promise<void>;
64
66
  stop(): Promise<void>;
65
67
  private dispose;
@@ -190,26 +190,45 @@ export class DshClient extends EventEmitter {
190
190
  return { ...page, sessions: page.sessions.map(session => ({ ...session, title: session.title?.trim() ? session.title : titles[session.sessionId] })) };
191
191
  }
192
192
  async readSessionTitles(ids) {
193
- const directory = this.titlesDirectory;
194
193
  try {
195
- await fs.access(path.join(directory, 'ready'));
194
+ const result = await this.titleRequest(ids);
195
+ return Object.fromEntries(Object.entries(result).filter((entry) => ids.includes(entry[0]) && typeof entry[1] === 'string'));
196
196
  }
197
197
  catch {
198
+ this.logger.warn('dsh.session_titles.unavailable', { scopeId: this.scopeId });
198
199
  return {};
199
200
  }
201
+ }
202
+ async renameSession(sessionId, cwd, title) {
203
+ await this.start();
204
+ const alreadyOpen = this.optionsBySession.has(sessionId);
205
+ await this.session(cwd, sessionId);
206
+ try {
207
+ const result = await this.titleRequest({ operation: 'rename', sessionId, title });
208
+ if (result.ok !== true || typeof result.title !== 'string')
209
+ throw new Error(typeof result.error === 'string' ? result.error : 'DSH rename failed');
210
+ return result.title;
211
+ }
212
+ finally {
213
+ if (!alreadyOpen && this.connected)
214
+ await this.closeSession(sessionId);
215
+ }
216
+ }
217
+ async titleRequest(request) {
218
+ const directory = this.titlesDirectory;
219
+ await fs.access(path.join(directory, 'ready'));
200
220
  const requestPath = path.join(directory, `${randomUUID()}.request`);
201
221
  const responsePath = requestPath.replace(/\.request$/, '.response');
202
222
  try {
203
- await fs.writeFile(`${requestPath}.tmp`, JSON.stringify(ids), { mode: 0o600 });
223
+ await fs.writeFile(`${requestPath}.tmp`, JSON.stringify(request), { mode: 0o600 });
204
224
  await fs.rename(`${requestPath}.tmp`, requestPath);
205
- const deadline = Date.now() + 5000;
225
+ const deadline = Date.now() + 10000;
206
226
  while (this.connected && Date.now() < deadline) {
207
227
  try {
208
228
  const result = JSON.parse(await fs.readFile(responsePath, 'utf8'));
209
- if (result && typeof result === 'object' && !Array.isArray(result)) {
210
- return Object.fromEntries(Object.entries(result).filter(([id, title]) => ids.includes(id) && typeof title === 'string'));
211
- }
212
- return {};
229
+ if (result && typeof result === 'object' && !Array.isArray(result))
230
+ return result;
231
+ throw new Error('Invalid DSH title response');
213
232
  }
214
233
  catch (error) {
215
234
  if (error.code !== 'ENOENT')
@@ -217,15 +236,11 @@ export class DshClient extends EventEmitter {
217
236
  }
218
237
  await new Promise(resolve => setTimeout(resolve, 50));
219
238
  }
220
- this.logger.warn('dsh.session_titles.timeout', { scopeId: this.scopeId });
221
- }
222
- catch {
223
- this.logger.warn('dsh.session_titles.unavailable', { scopeId: this.scopeId });
239
+ throw new Error('DSH title operation timed out or disconnected');
224
240
  }
225
241
  finally {
226
242
  await Promise.all([requestPath, responsePath, `${requestPath}.tmp`].map(file => fs.rm(file, { force: true }).catch(() => { })));
227
243
  }
228
- return {};
229
244
  }
230
245
  async closeSession(sessionId) {
231
246
  await this.agent.request('session/close', { sessionId });
@@ -19,6 +19,15 @@ interface SessionQuery {
19
19
  }
20
20
  interface Context {
21
21
  sessionQuery: SessionQuery;
22
+ sessions?: {
23
+ get(id: string): unknown;
24
+ flush(session: unknown): Promise<unknown>;
25
+ };
26
+ sessionTitle?: {
27
+ rename(session: unknown, title: string): {
28
+ title: string;
29
+ };
30
+ };
22
31
  effect(callback: () => () => Promise<void>): void;
23
32
  }
24
33
  export declare const name = "foxclaw-session-titles";
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  export const name = 'foxclaw-session-titles';
4
- export const inject = ['sessionQuery'];
4
+ export const inject = ['sessionQuery', 'sessions', 'sessionTitle'];
5
5
  /** Read native log-backed names without resuming sessions or invoking a model. */
6
6
  export async function readSessionTitle(query, id) {
7
7
  const title = (await query.readTitle(id))?.title;
@@ -28,21 +28,37 @@ export async function apply(ctx, config) {
28
28
  continue;
29
29
  const requestPath = path.join(config.directory, file);
30
30
  const responsePath = requestPath.replace(/\.request$/, '.response');
31
- let titles = Object.create(null);
31
+ let response = {};
32
32
  try {
33
- const ids = JSON.parse(await fs.readFile(requestPath, 'utf8'));
34
- if (!Array.isArray(ids) || ids.length > 1000 || !ids.every(id => typeof id === 'string'))
35
- throw new Error('Invalid session title request');
36
- const results = await Promise.allSettled(ids.map(async (id) => ({ id, title: await readSessionTitle(ctx.sessionQuery, id) })));
37
- titles = Object.create(null);
38
- for (const result of results) {
39
- if (result.status === 'fulfilled' && result.value.title)
40
- titles[result.value.id] = result.value.title;
33
+ const request = JSON.parse(await fs.readFile(requestPath, 'utf8'));
34
+ if (Array.isArray(request)) {
35
+ if (request.length > 1000 || !request.every(id => typeof id === 'string'))
36
+ throw new Error('Invalid session title request');
37
+ const results = await Promise.allSettled(request.map(async (id) => ({ id, title: await readSessionTitle(ctx.sessionQuery, id) })));
38
+ const titles = Object.create(null);
39
+ for (const result of results) {
40
+ if (result.status === 'fulfilled' && result.value.title)
41
+ titles[result.value.id] = result.value.title;
42
+ }
43
+ response = titles;
41
44
  }
45
+ else {
46
+ const rename = request;
47
+ if (rename?.operation !== 'rename' || typeof rename.sessionId !== 'string' || typeof rename.title !== 'string')
48
+ throw new Error('Invalid rename request');
49
+ const session = ctx.sessions?.get(rename.sessionId);
50
+ if (!session || !ctx.sessionTitle)
51
+ throw new Error('DSH session is unavailable for rename');
52
+ const accepted = ctx.sessionTitle.rename(session, rename.title);
53
+ await ctx.sessions.flush(session);
54
+ response = { ok: true, title: accepted.title };
55
+ }
56
+ }
57
+ catch (error) {
58
+ response = { ok: false, error: error instanceof Error ? error.message : 'DSH title operation failed' };
42
59
  }
43
- catch { /* Metadata failure must not break native session listing. */ }
44
60
  if (!stopped) {
45
- await fs.writeFile(`${responsePath}.tmp`, JSON.stringify(titles), { mode: 0o600 });
61
+ await fs.writeFile(`${responsePath}.tmp`, JSON.stringify(response), { mode: 0o600 });
46
62
  await fs.rename(`${responsePath}.tmp`, responsePath);
47
63
  }
48
64
  await fs.rm(requestPath, { force: true });
package/dist/dsh/ui.d.ts CHANGED
@@ -17,6 +17,7 @@ export declare class DshUi {
17
17
  private readonly approvals;
18
18
  readonly adapter: DshEngineAdapter;
19
19
  private readonly credentials;
20
+ private readonly renames;
20
21
  get pendingApprovals(): number;
21
22
  get pendingOperations(): number;
22
23
  stopPendingOperations(): Promise<void>;
@@ -41,6 +42,7 @@ export declare class DshUi {
41
42
  stop(): Promise<void>;
42
43
  isSensitiveInbound(event: ChannelTextEvent): boolean;
43
44
  inbound(event: ChannelTextEvent, locale: AppLocale, host: BackendUiHost): boolean | Promise<boolean>;
45
+ private saveRename;
44
46
  private saveKey;
45
47
  private clearKeyInput;
46
48
  private keyInput;
package/dist/dsh/ui.js CHANGED
@@ -16,9 +16,10 @@ export class DshUi {
16
16
  approvals = new Map();
17
17
  adapter;
18
18
  credentials;
19
+ renames = new Set();
19
20
  get pendingApprovals() { return this.approvals.size; }
20
- get pendingOperations() { return this.credentials.pendingOperations; }
21
- stopPendingOperations() { return this.credentials.stop(); }
21
+ get pendingOperations() { return this.credentials.pendingOperations + this.renames.size; }
22
+ async stopPendingOperations() { await Promise.allSettled([...this.renames]); await this.credentials.stop(); }
22
23
  constructor(config, store, logger, messaging) {
23
24
  this.config = config;
24
25
  this.store = store;
@@ -145,7 +146,8 @@ export class DshUi {
145
146
  const workspace = session.cwd.split(/[\\/]/).filter(Boolean).at(-1) || session.cwd;
146
147
  const fallback = `${this.copy(locale, '未命名会话', 'Untitled session')} · ${workspace} · ${session.sessionId.slice(-8)}`;
147
148
  const title = session.title?.replace(/\s+/gu, ' ').trim() || fallback;
148
- return [this.button(scopeId, 'open', session.sessionId, Array.from(title).slice(0, 60).join(''), session.cwd)];
149
+ return [this.button(scopeId, 'open', session.sessionId, Array.from(title).slice(0, 60).join(''), session.cwd),
150
+ this.button(scopeId, 'rename', session.sessionId, '✏️', session.cwd)];
149
151
  });
150
152
  if (page.nextCursor)
151
153
  rows.push([this.button(scopeId, 'page', page.nextCursor, this.copy(locale, '下一页', 'Next page'))]);
@@ -194,6 +196,8 @@ export class DshUi {
194
196
  await orchestrator.sendMessage(scopeId, `🧩 **DSH ${this.copy(locale, '插件配置', 'plugin configuration')}**\n\nProfile: \`${dsh.profile}\`\n${Object.entries(dependencies).map(([name, version]) => `• ${name}: ${String(version)}`).join('\n') || this.copy(locale, 'Profile 尚未初始化或没有显式依赖。', 'Profile is not initialized or has no explicit dependencies.')}\n• foxclaw-permissions\n\n${this.copy(locale, '已配置补丁', 'Configured patches')}:\n${dsh.patches.map(patch => `• ${patch}`).join('\n') || '—'}\n\n${this.copy(locale, '安装/移除插件请在终端执行,随后重启 foxclaw', 'Install/remove plugins in a terminal, then restart foxclaw')}:\n\`dsh plugin --profile ${dsh.profile} add <package>\`\n\`dsh plugin --profile ${dsh.profile} remove <package>\``);
195
197
  }
196
198
  async command(scopeId, command, args, locale, orchestrator) {
199
+ this.clearKeyInput(scopeId);
200
+ this.store.setServiceInteraction(scopeId, 'dsh:rename', null);
197
201
  try {
198
202
  switch (command) {
199
203
  case 'cancel':
@@ -277,6 +281,7 @@ export class DshUi {
277
281
  }
278
282
  async callback(scopeId, data, locale, orchestrator, event) {
279
283
  this.clearKeyInput(scopeId);
284
+ this.store.setServiceInteraction(scopeId, 'dsh:rename', null);
280
285
  const answer = (text = '') => this.messaging.answerCallback(event?.callbackQueryId ?? '', text);
281
286
  if (!data.startsWith('dsh:')) {
282
287
  if (orchestrator.getBackendDescriptorForScope(scopeId).engineType === 'dsh' && data.startsWith('engine:') && !['engine:setup:main', 'engine:setup:models', 'engine:setup:active_mode', 'engine:setup:new', 'engine:setup:backend'].includes(data) && !data.startsWith('engine:backend:')) {
@@ -341,6 +346,13 @@ export class DshUi {
341
346
  await this.selectAccess(scopeId, action.value, orchestrator);
342
347
  else if (action.kind === 'open')
343
348
  await this.open(scopeId, action.value, action.cwd, orchestrator);
349
+ else if (action.kind === 'rename') {
350
+ if (orchestrator.hasActiveTurn(scopeId))
351
+ throw new Error(this.copy(locale, '请先结束当前任务再重命名。', 'Finish the active task before renaming.'));
352
+ this.store.setServiceInteraction(scopeId, 'dsh:rename', JSON.stringify({ sessionId: action.value, cwd: action.cwd, expires: Date.now() + 300000 }));
353
+ await orchestrator.sendMessage(scopeId, this.copy(locale, '✏️ 发送新的会话名称,或发送 /cancel 取消。', '✏️ Send the new session name, or /cancel to cancel.'));
354
+ return true;
355
+ }
344
356
  else if (action.kind === 'page') {
345
357
  await this.threads(scopeId, locale, orchestrator, event?.messageId, action.value);
346
358
  return true;
@@ -383,7 +395,7 @@ export class DshUi {
383
395
  for (const pending of this.approvals.values())
384
396
  pending.resolve({ outcome: { outcome: 'cancelled' } });
385
397
  this.actions.clear();
386
- await this.credentials.stop();
398
+ await this.stopPendingOperations();
387
399
  await this.adapter.stop();
388
400
  }
389
401
  isSensitiveInbound(event) {
@@ -396,10 +408,45 @@ export class DshUi {
396
408
  return true;
397
409
  }
398
410
  inbound(event, locale, host) {
411
+ const rename = this.store.getServiceInteraction(event.scopeId, 'dsh:rename');
412
+ if (rename) {
413
+ if (event.text.trim().startsWith('/')) {
414
+ this.store.setServiceInteraction(event.scopeId, 'dsh:rename', null);
415
+ return false;
416
+ }
417
+ return this.saveRename(event, locale, host, rename);
418
+ }
399
419
  if (!this.isSensitiveInbound(event))
400
420
  return false;
401
421
  return this.saveKey(event, locale, host);
402
422
  }
423
+ async saveRename(event, locale, host, state) {
424
+ this.store.setServiceInteraction(event.scopeId, 'dsh:rename', null);
425
+ try {
426
+ const pending = JSON.parse(state);
427
+ if (pending.expires < Date.now())
428
+ throw new Error(this.copy(locale, '重命名已过期,请重新点击按钮。', 'Rename expired; select the button again.'));
429
+ if (event.attachments.length || !event.text.trim() || Array.from(event.text.trim()).length > 120)
430
+ throw new Error(this.copy(locale, '名称须为 1–120 个字符的文本。', 'Enter a text name of 1–120 characters.'));
431
+ if (host.hasActiveTurn(event.scopeId))
432
+ throw new Error(this.copy(locale, '请先结束当前任务。', 'Finish the active task first.'));
433
+ const operation = this.adapter.renameSession(event.scopeId, pending.sessionId, pending.cwd, event.text.trim());
434
+ this.renames.add(operation);
435
+ let title;
436
+ try {
437
+ title = await operation;
438
+ }
439
+ finally {
440
+ this.renames.delete(operation);
441
+ }
442
+ await host.sendMessage(event.scopeId, this.copy(locale, `✅ 已重命名:${title}`, `✅ Renamed: ${title}`));
443
+ await this.threads(event.scopeId, locale, host);
444
+ }
445
+ catch (error) {
446
+ await host.sendMessage(event.scopeId, `⚠️ ${error instanceof Error ? error.message : String(error)}`);
447
+ }
448
+ return true;
449
+ }
403
450
  async saveKey(event, locale, host) {
404
451
  const saved = this.keyInput(event.scopeId);
405
452
  this.clearKeyInput(event.scopeId);
@@ -12,6 +12,14 @@ export interface JournalTask {
12
12
  queueId: string | null;
13
13
  previewMessageId: number;
14
14
  result: EngineTurnResult | null;
15
+ commentary?: Array<{
16
+ messageId: number;
17
+ text: string;
18
+ folded: boolean;
19
+ }>;
20
+ separateFinal?: boolean;
21
+ finalPreviewText?: string;
22
+ previewSettled?: boolean;
15
23
  delivery: string[];
16
24
  deliveredChunks: number;
17
25
  error: string | null;
package/dist/systemd.js CHANGED
@@ -7,8 +7,7 @@ Description=FoxClaw local Codex execution bridge
7
7
  Documentation=https://github.com/foxden-app/foxclaw
8
8
  After=network-online.target
9
9
  Wants=network-online.target
10
- StartLimitIntervalSec=300
11
- StartLimitBurst=5
10
+ StartLimitIntervalSec=0
12
11
 
13
12
  [Service]
14
13
  Type=simple
package/docs/dsh.md CHANGED
@@ -57,7 +57,7 @@ Use `/models` → **Configure API Key** to add or update a DeepSeek key in a pri
57
57
  | `/models`, `/model <provider/model>` | Native model selection; `/model default` restores the session's initial model |
58
58
  | `/effort`, `/effort <level>` | Reasoning choices advertised by the current model; changing models resets reasoning to the new model's default |
59
59
  | `/permissions read-only\|default\|full-access` | Read-only, workspace-write, or full-access, also available as buttons |
60
- | `/threads`, `/open <session ID>` | List and resume persisted DSH sessions |
60
+ | `/threads`, `/open <session ID>` | List and resume persisted DSH sessions; use ✏️ beside a session to rename it, or `/cancel` to cancel name entry |
61
61
  | `/new [directory]` | New session with an optional working directory |
62
62
  | `/active queue\|steer`, `/queue`, `/steer <message>`, `/interrupt` | Queueing, interruption, and takeover |
63
63
  | `/plugins` | Profile dependencies and configured patch paths |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.13.5",
3
+ "version": "0.13.7",
4
4
  "description": "Foxden local execution claw for controlling Codex, OpenCode, Antigravity, and DeepSeek Harness from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",