@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 +26 -0
- package/dist/channels/telegram/telegram_messaging_port.d.ts +2 -0
- package/dist/channels/telegram/telegram_messaging_port.js +17 -0
- package/dist/codex_app/adapter.d.ts +1 -0
- package/dist/codex_app/adapter.js +59 -8
- package/dist/codex_app/client.d.ts +2 -0
- package/dist/codex_app/client.js +14 -2
- package/dist/core/channel_port.d.ts +2 -0
- package/dist/core/engine_spi.d.ts +8 -0
- package/dist/core/orchestrator.d.ts +2 -0
- package/dist/core/orchestrator.js +78 -12
- package/dist/dsh/adapter.d.ts +1 -0
- package/dist/dsh/adapter.js +8 -0
- package/dist/dsh/client.d.ts +2 -0
- package/dist/dsh/client.js +28 -13
- package/dist/dsh/session_titles_plugin.d.ts +9 -0
- package/dist/dsh/session_titles_plugin.js +28 -12
- package/dist/dsh/ui.d.ts +2 -0
- package/dist/dsh/ui.js +51 -4
- package/dist/store/task_journal.d.ts +8 -0
- package/dist/systemd.js +1 -2
- package/docs/dsh.md +1 -1
- package/package.json +1 -1
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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;
|
package/dist/codex_app/client.js
CHANGED
|
@@ -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 <
|
|
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
|
-
},
|
|
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 ?
|
|
361
|
-
: `⚠️ **Task outcome requires confirmation**\nTask: \`${task.id}\`\n${task.error ? `
|
|
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
|
-
|
|
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
|
}
|
package/dist/dsh/adapter.d.ts
CHANGED
|
@@ -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>;
|
package/dist/dsh/adapter.js
CHANGED
|
@@ -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
|
}
|
package/dist/dsh/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/dsh/client.js
CHANGED
|
@@ -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
|
|
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(
|
|
223
|
+
await fs.writeFile(`${requestPath}.tmp`, JSON.stringify(request), { mode: 0o600 });
|
|
204
224
|
await fs.rename(`${requestPath}.tmp`, requestPath);
|
|
205
|
-
const deadline = Date.now() +
|
|
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
|
|
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
|
-
|
|
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
|
|
31
|
+
let response = {};
|
|
32
32
|
try {
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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(
|
|
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() {
|
|
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.
|
|
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=
|
|
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.
|
|
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",
|