@foxden-app/foxclaw 0.13.4 → 0.13.6

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,34 @@
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.6 - 2026-10-06
6
+
7
+ ### 中文
8
+
9
+ - DSH `/threads` 每行右侧增加重命名按钮,输入新名称后通过原生标题服务保存并刷新列表,重启后保留;支持取消、过期及会话隔离。
10
+ - 恢复 Codex 的小结与最终回答分离展示:工作中独立推送小结,完成后将之前的小结收纳为 Telegram 可展开消息,最终结论独立发送。长小结使用富消息保留全文,消息编号与收纳状态持久化;DSH 有明确消息边界时采用同样流程,Antigravity 保持现有展示。
11
+ - 修复 Codex 断线后任务可能一直停留在工作中:释放实时任务并保留待确认状态与队列,使用 `/recover` 处理,避免自动重复执行结果未知的工具操作。正确识别原生失败结果及 RPC 结果未知的超时。
12
+ - Codex 本机 WebSocket 启动等待由 10 秒延长到 60 秒,阻止超时握手迟到后污染连接;systemd 保留重启间隔,取消连续启动失败后的永久熔断,避免暂时不可用导致必须手动重启。
13
+
14
+ ### English
15
+
16
+ - 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.
17
+ - 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.
18
+ - 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.
19
+ - 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.
20
+
21
+ ## 0.13.5 - 2026-10-06
22
+
23
+ ### 中文
24
+
25
+ - 修复 DSH `/threads` 只显示会话 ID:补充读取 DSH 原生持久化标题,旧会话无标题时使用首条用户消息;空会话显示未命名、工作目录和短编号。
26
+ - 标题读取不恢复会话、不调用模型,单个历史会话读取失败不会影响列表;保留分页及原会话 ID 的打开行为。
27
+
28
+ ### English
29
+
30
+ - Fixed DSH `/threads` displaying session IDs instead of readable names. Read native persisted titles, derive legacy names from the first human message, and show a workspace and short identifier for empty sessions.
31
+ - Title reads do not resume sessions or invoke a model. Unreadable histories cannot break the list; pagination and opening by the original session ID are preserved.
32
+
5
33
  ## 0.13.4 - 2026-10-06
6
34
 
7
35
  ### 中文
@@ -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(`<blockquote expandable>${renderTelegramMarkdownRichHtml(text)}</blockquote>`, { 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
  }
@@ -40,7 +40,23 @@ export declare class DshEngineAdapter implements IEngineAdapter {
40
40
  listEfforts(scopeId: string): Promise<SessionConfigSelectOption[]>;
41
41
  selectEffort(scopeId: string, effort: string | null): Promise<void>;
42
42
  setAccess(scopeId: string, access: AccessPresetValue): Promise<void>;
43
- listSessions(scopeId: string, cursor?: string): Promise<import("@agentclientprotocol/sdk").ListSessionsResponse>;
43
+ listSessions(scopeId: string, cursor?: string): Promise<import("@agentclientprotocol/sdk").ListSessionsResponse | {
44
+ sessions: {
45
+ title: string | undefined;
46
+ sessionId: import("@agentclientprotocol/sdk").SessionId;
47
+ cwd: string;
48
+ additionalDirectories?: Array<string>;
49
+ updatedAt?: string | null;
50
+ _meta?: {
51
+ [key: string]: unknown;
52
+ } | null;
53
+ }[];
54
+ nextCursor?: string | null;
55
+ _meta?: {
56
+ [key: string]: unknown;
57
+ } | null;
58
+ }>;
59
+ renameSession(scopeId: string, sessionId: string, cwd: string, title: string): Promise<string>;
44
60
  isBusy(scopeId: string): boolean;
45
61
  executeTurn(request: EngineTurnRequest): EngineTurnExecution;
46
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
  }
@@ -29,6 +29,7 @@ export declare class DshClient extends EventEmitter {
29
29
  private policyPath;
30
30
  private policyWrite;
31
31
  private stderr;
32
+ private titlesDirectory;
32
33
  permissionHandler?: (request: RequestPermissionRequest, signal: AbortSignal) => Promise<RequestPermissionResponse>;
33
34
  cancelPermissions?: () => void;
34
35
  constructor(options: DshClientOptions, scopeId: string, logger: Logger);
@@ -42,7 +43,25 @@ export declare class DshClient extends EventEmitter {
42
43
  session(cwd: string, sessionId?: string | null): Promise<string>;
43
44
  configOptions(sessionId: string): SessionConfigOption[];
44
45
  setConfig(sessionId: string, configId: string, value: string): Promise<void>;
45
- listSessions(cursor?: string): Promise<import("@agentclientprotocol/sdk").ListSessionsResponse>;
46
+ listSessions(cursor?: string): Promise<import("@agentclientprotocol/sdk").ListSessionsResponse | {
47
+ sessions: {
48
+ title: string | undefined;
49
+ sessionId: import("@agentclientprotocol/sdk").SessionId;
50
+ cwd: string;
51
+ additionalDirectories?: Array<string>;
52
+ updatedAt?: string | null;
53
+ _meta?: {
54
+ [key: string]: unknown;
55
+ } | null;
56
+ }[];
57
+ nextCursor?: string | null;
58
+ _meta?: {
59
+ [key: string]: unknown;
60
+ } | null;
61
+ }>;
62
+ private readSessionTitles;
63
+ renameSession(sessionId: string, cwd: string, title: string): Promise<string>;
64
+ private titleRequest;
46
65
  closeSession(sessionId: string): Promise<void>;
47
66
  stop(): Promise<void>;
48
67
  private dispose;
@@ -4,7 +4,7 @@ import { EventEmitter } from 'node:events';
4
4
  import fs from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
- import { createHash } from 'node:crypto';
7
+ import { createHash, randomUUID } from 'node:crypto';
8
8
  import { createRequire } from 'node:module';
9
9
  import { ClientApp, ndJsonStream, PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
10
10
  /** An owned ACP process; protocol stdout stays separate from diagnostics. */
@@ -21,6 +21,7 @@ export class DshClient extends EventEmitter {
21
21
  policyPath = '';
22
22
  policyWrite = Promise.resolve();
23
23
  stderr = '';
24
+ titlesDirectory = '';
24
25
  permissionHandler;
25
26
  cancelPermissions;
26
27
  constructor(options, scopeId, logger) {
@@ -65,6 +66,15 @@ export class DshClient extends EventEmitter {
65
66
  pluginPath = pluginPath.replace(/\.js$/, '.ts');
66
67
  }
67
68
  await fs.writeFile(patchPath, `- insert:\n - id: foxclaw-permissions\n name: ${JSON.stringify(pathToFileURL(pluginPath).href)}\n config:\n policyPath: ${JSON.stringify(this.policyPath)}\n`, { mode: 0o600 });
69
+ this.titlesDirectory = path.join(path.dirname(this.policyPath), `titles-${randomUUID()}`);
70
+ let titlesPlugin = fileURLToPath(new URL('./session_titles_plugin.js', import.meta.url));
71
+ try {
72
+ await fs.access(titlesPlugin);
73
+ }
74
+ catch {
75
+ titlesPlugin = titlesPlugin.replace(/\.js$/, '.ts');
76
+ }
77
+ await fs.appendFile(patchPath, `- insert:\n - id: foxclaw-session-titles\n name: ${JSON.stringify(pathToFileURL(titlesPlugin).href)}\n config:\n directory: ${JSON.stringify(this.titlesDirectory)}\n`);
68
78
  if (this.options.credentialControl) {
69
79
  let credentialsPlugin = fileURLToPath(new URL('./credentials_plugin.js', import.meta.url));
70
80
  try {
@@ -172,7 +182,65 @@ export class DshClient extends EventEmitter {
172
182
  await this.start();
173
183
  if (!this.capabilities.list)
174
184
  throw new Error('This DSH profile cannot list sessions');
175
- return this.agent.request('session/list', cursor ? { cursor } : {});
185
+ const page = await this.agent.request('session/list', cursor ? { cursor } : {});
186
+ const unnamed = page.sessions.filter(session => !session.title?.trim());
187
+ if (unnamed.length === 0)
188
+ return page;
189
+ const titles = await this.readSessionTitles(unnamed.map(session => session.sessionId));
190
+ return { ...page, sessions: page.sessions.map(session => ({ ...session, title: session.title?.trim() ? session.title : titles[session.sessionId] })) };
191
+ }
192
+ async readSessionTitles(ids) {
193
+ try {
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
+ }
197
+ catch {
198
+ this.logger.warn('dsh.session_titles.unavailable', { scopeId: this.scopeId });
199
+ return {};
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'));
220
+ const requestPath = path.join(directory, `${randomUUID()}.request`);
221
+ const responsePath = requestPath.replace(/\.request$/, '.response');
222
+ try {
223
+ await fs.writeFile(`${requestPath}.tmp`, JSON.stringify(request), { mode: 0o600 });
224
+ await fs.rename(`${requestPath}.tmp`, requestPath);
225
+ const deadline = Date.now() + 10000;
226
+ while (this.connected && Date.now() < deadline) {
227
+ try {
228
+ const result = JSON.parse(await fs.readFile(responsePath, 'utf8'));
229
+ if (result && typeof result === 'object' && !Array.isArray(result))
230
+ return result;
231
+ throw new Error('Invalid DSH title response');
232
+ }
233
+ catch (error) {
234
+ if (error.code !== 'ENOENT')
235
+ throw error;
236
+ }
237
+ await new Promise(resolve => setTimeout(resolve, 50));
238
+ }
239
+ throw new Error('DSH title operation timed out or disconnected');
240
+ }
241
+ finally {
242
+ await Promise.all([requestPath, responsePath, `${requestPath}.tmp`].map(file => fs.rm(file, { force: true }).catch(() => { })));
243
+ }
176
244
  }
177
245
  async closeSession(sessionId) {
178
246
  await this.agent.request('session/close', { sessionId });
@@ -190,6 +258,8 @@ export class DshClient extends EventEmitter {
190
258
  this.optionsBySession.clear();
191
259
  if (!child || !child.pid) {
192
260
  connection?.close();
261
+ if (this.titlesDirectory)
262
+ await fs.rm(this.titlesDirectory, { recursive: true, force: true });
193
263
  return;
194
264
  }
195
265
  const exited = new Promise(resolve => child.once('exit', () => resolve()));
@@ -207,5 +277,7 @@ export class DshClient extends EventEmitter {
207
277
  connection?.close();
208
278
  if (this.process === child)
209
279
  this.process = null;
280
+ if (this.titlesDirectory)
281
+ await fs.rm(this.titlesDirectory, { recursive: true, force: true });
210
282
  }
211
283
  }
@@ -0,0 +1,41 @@
1
+ interface SessionQuery {
2
+ readTitle(id: string): Promise<{
3
+ title: string;
4
+ } | undefined>;
5
+ readSession(id: string): Promise<{
6
+ events: Array<{
7
+ type: string;
8
+ data: {
9
+ source?: {
10
+ kind: string;
11
+ };
12
+ content?: Array<{
13
+ type: string;
14
+ text?: string;
15
+ }>;
16
+ };
17
+ }>;
18
+ }>;
19
+ }
20
+ interface Context {
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
+ };
31
+ effect(callback: () => () => Promise<void>): void;
32
+ }
33
+ export declare const name = "foxclaw-session-titles";
34
+ export declare const inject: string[];
35
+ /** Read native log-backed names without resuming sessions or invoking a model. */
36
+ export declare function readSessionTitle(query: SessionQuery, id: string): Promise<string | undefined>;
37
+ /** A private, per-process mailbox supplements ACP versions that omit titles. */
38
+ export declare function apply(ctx: Context, config: {
39
+ directory: string;
40
+ }): Promise<void>;
41
+ export {};
@@ -0,0 +1,78 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export const name = 'foxclaw-session-titles';
4
+ export const inject = ['sessionQuery', 'sessions', 'sessionTitle'];
5
+ /** Read native log-backed names without resuming sessions or invoking a model. */
6
+ export async function readSessionTitle(query, id) {
7
+ const title = (await query.readTitle(id))?.title;
8
+ if (title?.trim())
9
+ return title;
10
+ const { events } = await query.readSession(id);
11
+ for (const event of events) {
12
+ if (event.type !== 'user/message' || event.data.source?.kind !== 'user')
13
+ continue;
14
+ const text = event.data.content?.filter(block => block.type === 'text').map(block => block.text ?? '').join(' ');
15
+ if (text?.trim())
16
+ return Array.from(text.replace(/\s+/gu, ' ').trim()).slice(0, 120).join('');
17
+ }
18
+ return undefined;
19
+ }
20
+ /** A private, per-process mailbox supplements ACP versions that omit titles. */
21
+ export async function apply(ctx, config) {
22
+ await fs.mkdir(config.directory, { recursive: true, mode: 0o700 });
23
+ let active;
24
+ let stopped = false;
25
+ const poll = async () => {
26
+ for (const file of await fs.readdir(config.directory)) {
27
+ if (!/^[a-f0-9-]+\.request$/.test(file))
28
+ continue;
29
+ const requestPath = path.join(config.directory, file);
30
+ const responsePath = requestPath.replace(/\.request$/, '.response');
31
+ let response = {};
32
+ try {
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;
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' };
59
+ }
60
+ if (!stopped) {
61
+ await fs.writeFile(`${responsePath}.tmp`, JSON.stringify(response), { mode: 0o600 });
62
+ await fs.rename(`${responsePath}.tmp`, responsePath);
63
+ }
64
+ await fs.rm(requestPath, { force: true });
65
+ }
66
+ };
67
+ const timer = setInterval(() => {
68
+ if (!active)
69
+ active = poll().catch(() => { }).finally(() => { active = undefined; });
70
+ }, 50);
71
+ ctx.effect(() => async () => {
72
+ stopped = true;
73
+ clearInterval(timer);
74
+ await active;
75
+ await fs.rm(path.join(config.directory, 'ready'), { force: true });
76
+ });
77
+ await fs.writeFile(path.join(config.directory, 'ready'), '', { mode: 0o600 });
78
+ }
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;
@@ -141,7 +142,13 @@ export class DshUi {
141
142
  }
142
143
  async threads(scopeId, locale, orchestrator, messageId, cursor) {
143
144
  const page = await this.adapter.listSessions(scopeId, cursor);
144
- const rows = page.sessions.map(session => [this.button(scopeId, 'open', session.sessionId, (session.title || session.sessionId).slice(0, 60), session.cwd)]);
145
+ const rows = page.sessions.map(session => {
146
+ const workspace = session.cwd.split(/[\\/]/).filter(Boolean).at(-1) || session.cwd;
147
+ const fallback = `${this.copy(locale, '未命名会话', 'Untitled session')} · ${workspace} · ${session.sessionId.slice(-8)}`;
148
+ const title = session.title?.replace(/\s+/gu, ' ').trim() || fallback;
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)];
151
+ });
145
152
  if (page.nextCursor)
146
153
  rows.push([this.button(scopeId, 'page', page.nextCursor, this.copy(locale, '下一页', 'Next page'))]);
147
154
  rows.push([{ text: this.copy(locale, '返回设置', 'Back'), callback_data: 'engine:setup:main' }]);
@@ -189,6 +196,8 @@ export class DshUi {
189
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>\``);
190
197
  }
191
198
  async command(scopeId, command, args, locale, orchestrator) {
199
+ this.clearKeyInput(scopeId);
200
+ this.store.setServiceInteraction(scopeId, 'dsh:rename', null);
192
201
  try {
193
202
  switch (command) {
194
203
  case 'cancel':
@@ -272,6 +281,7 @@ export class DshUi {
272
281
  }
273
282
  async callback(scopeId, data, locale, orchestrator, event) {
274
283
  this.clearKeyInput(scopeId);
284
+ this.store.setServiceInteraction(scopeId, 'dsh:rename', null);
275
285
  const answer = (text = '') => this.messaging.answerCallback(event?.callbackQueryId ?? '', text);
276
286
  if (!data.startsWith('dsh:')) {
277
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:')) {
@@ -336,6 +346,13 @@ export class DshUi {
336
346
  await this.selectAccess(scopeId, action.value, orchestrator);
337
347
  else if (action.kind === 'open')
338
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
+ }
339
356
  else if (action.kind === 'page') {
340
357
  await this.threads(scopeId, locale, orchestrator, event?.messageId, action.value);
341
358
  return true;
@@ -378,7 +395,7 @@ export class DshUi {
378
395
  for (const pending of this.approvals.values())
379
396
  pending.resolve({ outcome: { outcome: 'cancelled' } });
380
397
  this.actions.clear();
381
- await this.credentials.stop();
398
+ await this.stopPendingOperations();
382
399
  await this.adapter.stop();
383
400
  }
384
401
  isSensitiveInbound(event) {
@@ -391,10 +408,45 @@ export class DshUi {
391
408
  return true;
392
409
  }
393
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
+ }
394
419
  if (!this.isSensitiveInbound(event))
395
420
  return false;
396
421
  return this.saveKey(event, locale, host);
397
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
+ }
398
450
  async saveKey(event, locale, host) {
399
451
  const saved = this.keyInput(event.scopeId);
400
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.4",
3
+ "version": "0.13.6",
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",