@foxden-app/foxclaw 0.13.4 → 0.13.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
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.5 - 2026-10-06
6
+
7
+ ### 中文
8
+
9
+ - 修复 DSH `/threads` 只显示会话 ID:补充读取 DSH 原生持久化标题,旧会话无标题时使用首条用户消息;空会话显示未命名、工作目录和短编号。
10
+ - 标题读取不恢复会话、不调用模型,单个历史会话读取失败不会影响列表;保留分页及原会话 ID 的打开行为。
11
+
12
+ ### English
13
+
14
+ - 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.
15
+ - 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.
16
+
5
17
  ## 0.13.4 - 2026-10-06
6
18
 
7
19
  ### 中文
@@ -40,7 +40,22 @@ 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
+ }>;
44
59
  isBusy(scopeId: string): boolean;
45
60
  executeTurn(request: EngineTurnRequest): EngineTurnExecution;
46
61
  stop(): Promise<void>;
@@ -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,23 @@ 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;
46
63
  closeSession(sessionId: string): Promise<void>;
47
64
  stop(): Promise<void>;
48
65
  private dispose;
@@ -4,7 +4,7 @@ import { EventEmitter } from 'node:events';
4
4
  import fs from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
- import { createHash } from 'node:crypto';
7
+ import { createHash, randomUUID } from 'node:crypto';
8
8
  import { createRequire } from 'node:module';
9
9
  import { ClientApp, ndJsonStream, PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
10
10
  /** An owned ACP process; protocol stdout stays separate from diagnostics. */
@@ -21,6 +21,7 @@ export class DshClient extends EventEmitter {
21
21
  policyPath = '';
22
22
  policyWrite = Promise.resolve();
23
23
  stderr = '';
24
+ titlesDirectory = '';
24
25
  permissionHandler;
25
26
  cancelPermissions;
26
27
  constructor(options, scopeId, logger) {
@@ -65,6 +66,15 @@ export class DshClient extends EventEmitter {
65
66
  pluginPath = pluginPath.replace(/\.js$/, '.ts');
66
67
  }
67
68
  await fs.writeFile(patchPath, `- insert:\n - id: foxclaw-permissions\n name: ${JSON.stringify(pathToFileURL(pluginPath).href)}\n config:\n policyPath: ${JSON.stringify(this.policyPath)}\n`, { mode: 0o600 });
69
+ this.titlesDirectory = path.join(path.dirname(this.policyPath), `titles-${randomUUID()}`);
70
+ let titlesPlugin = fileURLToPath(new URL('./session_titles_plugin.js', import.meta.url));
71
+ try {
72
+ await fs.access(titlesPlugin);
73
+ }
74
+ catch {
75
+ titlesPlugin = titlesPlugin.replace(/\.js$/, '.ts');
76
+ }
77
+ await fs.appendFile(patchPath, `- insert:\n - id: foxclaw-session-titles\n name: ${JSON.stringify(pathToFileURL(titlesPlugin).href)}\n config:\n directory: ${JSON.stringify(this.titlesDirectory)}\n`);
68
78
  if (this.options.credentialControl) {
69
79
  let credentialsPlugin = fileURLToPath(new URL('./credentials_plugin.js', import.meta.url));
70
80
  try {
@@ -172,7 +182,50 @@ export class DshClient extends EventEmitter {
172
182
  await this.start();
173
183
  if (!this.capabilities.list)
174
184
  throw new Error('This DSH profile cannot list sessions');
175
- return this.agent.request('session/list', cursor ? { cursor } : {});
185
+ const page = await this.agent.request('session/list', cursor ? { cursor } : {});
186
+ const unnamed = page.sessions.filter(session => !session.title?.trim());
187
+ if (unnamed.length === 0)
188
+ return page;
189
+ const titles = await this.readSessionTitles(unnamed.map(session => session.sessionId));
190
+ return { ...page, sessions: page.sessions.map(session => ({ ...session, title: session.title?.trim() ? session.title : titles[session.sessionId] })) };
191
+ }
192
+ async readSessionTitles(ids) {
193
+ const directory = this.titlesDirectory;
194
+ try {
195
+ await fs.access(path.join(directory, 'ready'));
196
+ }
197
+ catch {
198
+ return {};
199
+ }
200
+ const requestPath = path.join(directory, `${randomUUID()}.request`);
201
+ const responsePath = requestPath.replace(/\.request$/, '.response');
202
+ try {
203
+ await fs.writeFile(`${requestPath}.tmp`, JSON.stringify(ids), { mode: 0o600 });
204
+ await fs.rename(`${requestPath}.tmp`, requestPath);
205
+ const deadline = Date.now() + 5000;
206
+ while (this.connected && Date.now() < deadline) {
207
+ try {
208
+ const result = JSON.parse(await fs.readFile(responsePath, 'utf8'));
209
+ if (result && typeof result === 'object' && !Array.isArray(result)) {
210
+ return Object.fromEntries(Object.entries(result).filter(([id, title]) => ids.includes(id) && typeof title === 'string'));
211
+ }
212
+ return {};
213
+ }
214
+ catch (error) {
215
+ if (error.code !== 'ENOENT')
216
+ throw error;
217
+ }
218
+ await new Promise(resolve => setTimeout(resolve, 50));
219
+ }
220
+ this.logger.warn('dsh.session_titles.timeout', { scopeId: this.scopeId });
221
+ }
222
+ catch {
223
+ this.logger.warn('dsh.session_titles.unavailable', { scopeId: this.scopeId });
224
+ }
225
+ finally {
226
+ await Promise.all([requestPath, responsePath, `${requestPath}.tmp`].map(file => fs.rm(file, { force: true }).catch(() => { })));
227
+ }
228
+ return {};
176
229
  }
177
230
  async closeSession(sessionId) {
178
231
  await this.agent.request('session/close', { sessionId });
@@ -190,6 +243,8 @@ export class DshClient extends EventEmitter {
190
243
  this.optionsBySession.clear();
191
244
  if (!child || !child.pid) {
192
245
  connection?.close();
246
+ if (this.titlesDirectory)
247
+ await fs.rm(this.titlesDirectory, { recursive: true, force: true });
193
248
  return;
194
249
  }
195
250
  const exited = new Promise(resolve => child.once('exit', () => resolve()));
@@ -207,5 +262,7 @@ export class DshClient extends EventEmitter {
207
262
  connection?.close();
208
263
  if (this.process === child)
209
264
  this.process = null;
265
+ if (this.titlesDirectory)
266
+ await fs.rm(this.titlesDirectory, { recursive: true, force: true });
210
267
  }
211
268
  }
@@ -0,0 +1,32 @@
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
+ effect(callback: () => () => Promise<void>): void;
23
+ }
24
+ export declare const name = "foxclaw-session-titles";
25
+ export declare const inject: string[];
26
+ /** Read native log-backed names without resuming sessions or invoking a model. */
27
+ export declare function readSessionTitle(query: SessionQuery, id: string): Promise<string | undefined>;
28
+ /** A private, per-process mailbox supplements ACP versions that omit titles. */
29
+ export declare function apply(ctx: Context, config: {
30
+ directory: string;
31
+ }): Promise<void>;
32
+ export {};
@@ -0,0 +1,62 @@
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'];
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 titles = Object.create(null);
32
+ try {
33
+ const ids = JSON.parse(await fs.readFile(requestPath, 'utf8'));
34
+ if (!Array.isArray(ids) || ids.length > 1000 || !ids.every(id => typeof id === 'string'))
35
+ throw new Error('Invalid session title request');
36
+ const results = await Promise.allSettled(ids.map(async (id) => ({ id, title: await readSessionTitle(ctx.sessionQuery, id) })));
37
+ titles = Object.create(null);
38
+ for (const result of results) {
39
+ if (result.status === 'fulfilled' && result.value.title)
40
+ titles[result.value.id] = result.value.title;
41
+ }
42
+ }
43
+ catch { /* Metadata failure must not break native session listing. */ }
44
+ if (!stopped) {
45
+ await fs.writeFile(`${responsePath}.tmp`, JSON.stringify(titles), { mode: 0o600 });
46
+ await fs.rename(`${responsePath}.tmp`, responsePath);
47
+ }
48
+ await fs.rm(requestPath, { force: true });
49
+ }
50
+ };
51
+ const timer = setInterval(() => {
52
+ if (!active)
53
+ active = poll().catch(() => { }).finally(() => { active = undefined; });
54
+ }, 50);
55
+ ctx.effect(() => async () => {
56
+ stopped = true;
57
+ clearInterval(timer);
58
+ await active;
59
+ await fs.rm(path.join(config.directory, 'ready'), { force: true });
60
+ });
61
+ await fs.writeFile(path.join(config.directory, 'ready'), '', { mode: 0o600 });
62
+ }
package/dist/dsh/ui.js CHANGED
@@ -141,7 +141,12 @@ export class DshUi {
141
141
  }
142
142
  async threads(scopeId, locale, orchestrator, messageId, cursor) {
143
143
  const page = await this.adapter.listSessions(scopeId, cursor);
144
- const rows = page.sessions.map(session => [this.button(scopeId, 'open', session.sessionId, (session.title || session.sessionId).slice(0, 60), session.cwd)]);
144
+ const rows = page.sessions.map(session => {
145
+ const workspace = session.cwd.split(/[\\/]/).filter(Boolean).at(-1) || session.cwd;
146
+ const fallback = `${this.copy(locale, '未命名会话', 'Untitled session')} · ${workspace} · ${session.sessionId.slice(-8)}`;
147
+ 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
+ });
145
150
  if (page.nextCursor)
146
151
  rows.push([this.button(scopeId, 'page', page.nextCursor, this.copy(locale, '下一页', 'Next page'))]);
147
152
  rows.push([{ text: this.copy(locale, '返回设置', 'Back'), callback_data: 'engine:setup:main' }]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.13.4",
3
+ "version": "0.13.5",
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",