@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 +28 -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 +17 -1
- package/dist/dsh/adapter.js +8 -0
- package/dist/dsh/client.d.ts +20 -1
- package/dist/dsh/client.js +74 -2
- package/dist/dsh/session_titles_plugin.d.ts +41 -0
- package/dist/dsh/session_titles_plugin.js +78 -0
- package/dist/dsh/ui.d.ts +2 -0
- package/dist/dsh/ui.js +56 -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,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
|
-
|
|
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
|
@@ -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>;
|
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
|
@@ -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;
|
package/dist/dsh/client.js
CHANGED
|
@@ -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
|
-
|
|
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() {
|
|
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 =>
|
|
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.
|
|
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=
|
|
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.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",
|