lark-remote 0.5.12 → 0.5.13
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 +13 -2
- package/README.en.md +1 -14
- package/README.md +1 -14
- package/dist/bridge/approval-coordinator.d.ts +1 -0
- package/dist/bridge/approval-coordinator.js +12 -24
- package/dist/bridge/index.d.ts +7 -0
- package/dist/bridge/index.js +77 -227
- package/dist/bridge/run-approval.d.ts +15 -0
- package/dist/bridge/run-approval.js +41 -0
- package/dist/bridge/run-usage.d.ts +46 -0
- package/dist/bridge/run-usage.js +96 -0
- package/dist/bridge/session-writeback.d.ts +18 -0
- package/dist/bridge/session-writeback.js +36 -0
- package/dist/card/bash-card-session.js +2 -5
- package/dist/card/card-session.d.ts +2 -0
- package/dist/card/card-session.js +20 -22
- package/dist/card/card-shared.d.ts +4 -4
- package/dist/card/card-shared.js +3 -9
- package/dist/card/collapsible.js +3 -2
- package/dist/card/final-answer-overflow.d.ts +7 -0
- package/dist/card/final-answer-overflow.js +46 -0
- package/dist/card/run-card-session.js +2 -7
- package/dist/card/run-renderer.d.ts +14 -0
- package/dist/card/run-renderer.js +119 -1
- package/dist/card/run-state.d.ts +12 -1
- package/dist/card/run-state.js +73 -34
- package/dist/config/codex-config.js +4 -4
- package/dist/config/index.d.ts +4 -2
- package/dist/config/index.js +6 -4
- package/dist/inbound/turn-assembler.js +6 -0
- package/dist/index.js +15 -409
- package/dist/message-handlers.d.ts +26 -0
- package/dist/message-handlers.js +275 -0
- package/dist/router/card-action-payload.d.ts +1 -1
- package/dist/router/card-action-payload.js +1 -1
- package/dist/router/commands/ls.d.ts +9 -0
- package/dist/router/commands/ls.js +243 -0
- package/dist/router/commands/order.d.ts +12 -0
- package/dist/router/commands/order.js +178 -0
- package/dist/router/commands/resume.d.ts +47 -0
- package/dist/router/commands/resume.js +246 -0
- package/dist/router/commands/ws.d.ts +15 -0
- package/dist/router/commands/ws.js +189 -0
- package/dist/router/config/claude.js +3 -3
- package/dist/router/config/codex.js +2 -6
- package/dist/router/config/common/turn-idle.d.ts +2 -0
- package/dist/router/config/common/turn-idle.js +9 -0
- package/dist/router/config/kimi.js +2 -6
- package/dist/router/config/opencode.js +2 -6
- package/dist/router/index.d.ts +14 -22
- package/dist/router/index.js +70 -1152
- package/dist/router/paging.d.ts +13 -0
- package/dist/router/paging.js +20 -0
- package/dist/router/path-validation.d.ts +27 -0
- package/dist/router/path-validation.js +45 -0
- package/dist/runner/claude/runner.d.ts +3 -4
- package/dist/runner/claude/runner.js +4 -5
- package/dist/runner/claude/session.js +1 -1
- package/dist/runner/codex/app-server/translator.d.ts +2 -0
- package/dist/runner/codex/app-server/translator.js +65 -9
- package/dist/runner/common/acp/base-translator.d.ts +3 -8
- package/dist/runner/common/acp/base-translator.js +16 -7
- package/dist/runner/common/spawning-runner.js +10 -4
- package/dist/runner/common/turn-started.d.ts +2 -0
- package/dist/runner/common/turn-started.js +10 -0
- package/dist/runner/dsh/auth.js +2 -3
- package/dist/runner/dsh/client.d.ts +1 -6
- package/dist/runner/dsh/client.js +11 -165
- package/dist/runner/dsh/daemon.d.ts +0 -2
- package/dist/runner/dsh/daemon.js +1 -6
- package/dist/runner/dsh/errors.d.ts +5 -0
- package/dist/runner/dsh/errors.js +11 -0
- package/dist/runner/dsh/exit-cancel.d.ts +0 -2
- package/dist/runner/dsh/exit-cancel.js +1 -1
- package/dist/runner/dsh/home.d.ts +2 -0
- package/dist/runner/dsh/home.js +8 -0
- package/dist/runner/dsh/inflight.d.ts +0 -2
- package/dist/runner/dsh/inflight.js +1 -1
- package/dist/runner/dsh/mux-stream.d.ts +26 -0
- package/dist/runner/dsh/mux-stream.js +157 -0
- package/dist/runner/dsh/translator.d.ts +0 -10
- package/dist/runner/dsh/translator.js +1 -1
- package/dist/runner/index.d.ts +1 -1
- package/dist/runner/kimi/acp/translator.js +1 -12
- package/dist/runner/opencode/acp/translator.js +1 -12
- package/dist/runner/pi/rpc/translator.d.ts +2 -8
- package/dist/runner/pi/rpc/translator.js +2 -7
- package/dist/runner/registry.d.ts +0 -19
- package/dist/runner/registry.js +0 -32
- package/dist/runner/types.d.ts +17 -1
- package/dist/session/claude/session-index.js +4 -2
- package/dist/session/kimi/sessions.js +2 -2
- package/dist/session/opencode/sessions.js +2 -1
- package/dist/session/pi/sessions.js +3 -2
- package/package.json +1 -1
package/dist/router/index.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
1
|
import os from 'node:os';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import YAML from 'yaml';
|
|
@@ -14,7 +13,7 @@ import { getLogger } from '../logger/index.js';
|
|
|
14
13
|
import { stripPlaceholders } from '../inbound/placeholder.js';
|
|
15
14
|
import { clampInt } from '../common/clamp.js';
|
|
16
15
|
import { activeRunUsage } from './usage-format.js';
|
|
17
|
-
import { markdownDiv, buildSessionHistoryCard, paginationBar,
|
|
16
|
+
import { markdownDiv, buildSessionHistoryCard, paginationBar, parseJumpOffset, SEARCH_INPUT_NAME, PAGE_JUMP_INVALID_HINT, } from './card-helpers.js';
|
|
18
17
|
import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
|
|
19
18
|
import { cardButton, newSessionButton, resumeCompactButton, agentDisplayName, } from '../card/card-shared.js';
|
|
20
19
|
import { displayName } from '../platform/path.js';
|
|
@@ -24,36 +23,16 @@ import { checkLatestVersion as defaultCheckLatestVersion, compareVersions as def
|
|
|
24
23
|
import { getConfigBuilder, listRegisteredAgents, sortAgentsForDisplay } from './config/index.js';
|
|
25
24
|
import { probeAllAgents, getCachedAvailability } from '../runner/probe.js';
|
|
26
25
|
import { buildConfigCardFromTabs } from './config/common/render.js';
|
|
26
|
+
import { isDirectorySafe, resolveDirectory, statPath } from './path-validation.js';
|
|
27
|
+
import { pageInfo } from './paging.js';
|
|
28
|
+
import { buildLsCommand, LS_PAGE_SIZE } from './commands/ls.js';
|
|
29
|
+
import { buildWsCommand, WS_PAGE_SIZE } from './commands/ws.js';
|
|
30
|
+
import { buildOrderCommand, ORDER_PAGE_SIZE } from './commands/order.js';
|
|
31
|
+
import { buildResumeCommand, AUTO_RESUME_MAX_EVENTS, RESUME_PAGE_SIZE } from './commands/resume.js';
|
|
27
32
|
/** Max events to read for auto-resume and /resume cards.
|
|
28
33
|
* Limits events at the read stage so enforceCardBudget rarely needs to truncate.
|
|
29
34
|
* Chosen to be under the 28KB card budget for typical events (~2KB each). */
|
|
30
|
-
const AUTO_RESUME_MAX_EVENTS = 5;
|
|
31
|
-
/** /resume 列表页大小;`/resume [N]` 的 N clamp 到 [1, RESUME_PAGE_SIZE]。 */
|
|
32
|
-
const RESUME_PAGE_SIZE = 5;
|
|
33
35
|
/** /active 卡片每页显示的最大条目数(agent run + bash run 合计)。 */
|
|
34
|
-
/**
|
|
35
|
-
* 分页算术(原先 5 处内联且 clamp 语义漂移:/ls 不 clamp、/active clamp 到
|
|
36
|
-
* totalCount-1 会把过期 offset 泄漏成残页)。统一 clamp 到最后一页边界。
|
|
37
|
-
*/
|
|
38
|
-
function pageInfo(totalCount, offset, pageSize) {
|
|
39
|
-
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
|
40
|
-
const maxOffset = Math.max(0, (totalPages - 1) * pageSize);
|
|
41
|
-
const safeOffset = clampInt(offset, 0, maxOffset);
|
|
42
|
-
const currentPage = Math.floor(safeOffset / pageSize) + 1;
|
|
43
|
-
return { totalPages, safeOffset, currentPage };
|
|
44
|
-
}
|
|
45
|
-
/** 在 pageInfo 之上切出当前页条目。 */
|
|
46
|
-
function pageSlice(items, offset, pageSize) {
|
|
47
|
-
const { totalPages, safeOffset, currentPage } = pageInfo(items.length, offset, pageSize);
|
|
48
|
-
return {
|
|
49
|
-
totalCount: items.length,
|
|
50
|
-
totalPages,
|
|
51
|
-
safeOffset,
|
|
52
|
-
currentPage,
|
|
53
|
-
pageItems: items.slice(safeOffset, safeOffset + pageSize),
|
|
54
|
-
hasPagination: items.length > pageSize,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
36
|
const ACTIVE_PAGE_SIZE = 20;
|
|
58
37
|
/**
|
|
59
38
|
* 飞书会把极短间隔连发的多条消息合并成一条换行分隔的消息:用户侧看是两条消息,
|
|
@@ -75,15 +54,6 @@ function findMergedCommandTail(message) {
|
|
|
75
54
|
return hasSlashLine ? tail : null;
|
|
76
55
|
}
|
|
77
56
|
/** 人类可读字节数(B/KB/MB/GB);/ls 列表与单文件卡共用。 */
|
|
78
|
-
function formatBytes(bytes) {
|
|
79
|
-
if (bytes < 1024)
|
|
80
|
-
return `${bytes}B`;
|
|
81
|
-
if (bytes < 1024 * 1024)
|
|
82
|
-
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
83
|
-
if (bytes < 1024 * 1024 * 1024)
|
|
84
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
85
|
-
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
|
|
86
|
-
}
|
|
87
57
|
/**
|
|
88
58
|
* /order 列表页大小;指令超过此数量时显示分页导航栏。
|
|
89
59
|
* 2026-08-13 实测:每行 2 个元素(div + column_set,行间 hr),20 行 + 分页栏
|
|
@@ -93,13 +63,11 @@ function formatBytes(bytes) {
|
|
|
93
63
|
* 从 15 降为 8:3*8 - 1 + 2 = 25 个元素,仍在余量内,同时保证手机窄屏
|
|
94
64
|
* 每条指令的操作控件(执行/别名/删除)不被挤压。
|
|
95
65
|
*/
|
|
96
|
-
const ORDER_PAGE_SIZE = 8;
|
|
97
66
|
/**
|
|
98
67
|
* /ws 列表页大小。从 15 降为 5:头部 2 + 5 行 × 3(div + column_set + hr)+
|
|
99
68
|
* 底栏排序 1 + 分页栏 2 ≈ 20 个 body 元素,远低于单卡 60 元素上限(ErrCode 11310),
|
|
100
69
|
* 留足余量。
|
|
101
70
|
*/
|
|
102
|
-
const WS_PAGE_SIZE = 5;
|
|
103
71
|
/**
|
|
104
72
|
* 归一化卡片分页参数:clamp pageSize 到 [1,maxPageSize],offset 归整为
|
|
105
73
|
* 非负整数并对齐到页边界。非数字 payload(如 'abc')不得产生 NaN slice。
|
|
@@ -126,25 +94,17 @@ function normalizePageArgs(value, maxPageSize) {
|
|
|
126
94
|
return { pageSize, alignedOffset: Math.floor(offset / pageSize) * pageSize };
|
|
127
95
|
}
|
|
128
96
|
/** /resume 列表页内全量预取(readSessionContent)的行数上限;其余行用轻量 summary 兜底。 */
|
|
129
|
-
const RESUME_CONTENT_PREFETCH = 5;
|
|
130
97
|
/**
|
|
131
98
|
* Reader summary 占位符(trim 后匹配)。各 reader 在"无 user 消息"时返回的
|
|
132
99
|
* 兜底文案不同(claude/pi `(无摘要)`、codex `(no user message)`、kimi
|
|
133
100
|
* `New Session`、空串),都不是真实输入,不得渲染成行标题。
|
|
134
101
|
*/
|
|
135
|
-
const RESUME_SUMMARY_PLACEHOLDERS = new Set(['', '(no user message)', '(无摘要)', 'New Session']);
|
|
136
|
-
/** 列表卡「最近输入 / 最后输出」单行预览的长度上限(中文按字符,手机约两行)。 */
|
|
137
|
-
const RESUME_PREVIEW_MAX = 60;
|
|
138
102
|
/**
|
|
139
103
|
* 列表卡单行预览:压平空白、去掉反引号,再按字符截断。
|
|
140
104
|
*
|
|
141
105
|
* 为什么去反引号:agent 的输出里 `code` 很常见,截断点落在一对反引号中间时
|
|
142
106
|
* 后半张卡片的 markdown 会被当成代码块渲染(lark_md 不闭合就不回退)。
|
|
143
107
|
*/
|
|
144
|
-
function sessionPreviewLine(text, max) {
|
|
145
|
-
const flat = text.replace(/`/g, '').replace(/\s+/g, ' ').trim();
|
|
146
|
-
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
147
|
-
}
|
|
148
108
|
/**
|
|
149
109
|
* Card-action commands that must run with enqueue({ immediate: true }).
|
|
150
110
|
* These bypass the serial queue and execute immediately.
|
|
@@ -369,20 +329,6 @@ export const COMPACT_ACTION_CMDS = new Set(['compact', 'resume.compact']);
|
|
|
369
329
|
function payloadOffset(value) {
|
|
370
330
|
return Math.max(0, Math.trunc(Number(value.offset) || 0));
|
|
371
331
|
}
|
|
372
|
-
/**
|
|
373
|
-
* existsSync + statSync 的 TOCTOU 安全目录校验:路径在两次 syscalls 之间被删时
|
|
374
|
-
* statSync 会抛错——落进 enqueueImmediate / 队列任务的静默 catch,点击/命令
|
|
375
|
-
* 毫无反馈(违反「点击必有反馈」红线;同族 handleLsRefresh/Page/Filter 均已
|
|
376
|
-
* try/catch 防护)。统一返回 false 走「路径无效」回复。
|
|
377
|
-
*/
|
|
378
|
-
function isDirectorySafe(p) {
|
|
379
|
-
try {
|
|
380
|
-
return fs.existsSync(p) && fs.statSync(p).isDirectory();
|
|
381
|
-
}
|
|
382
|
-
catch {
|
|
383
|
-
return false;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
332
|
/**
|
|
387
333
|
* 搜索框提交值单源读取:`inputValue` 主路(CardKit 2.0 input 提交(键盘回车/
|
|
388
334
|
* 完成键)经 raw `action.input_value` 回传,SDK normalizer 会丢弃
|
|
@@ -1226,26 +1172,17 @@ export class CommandRouter {
|
|
|
1226
1172
|
const resolvedTarget = path.resolve(targetPath);
|
|
1227
1173
|
// 与 ls.browse 对齐:目标只要存在且是目录即可切换(不再做子树/父级限制)。
|
|
1228
1174
|
// 安全边界由 binder owner 认证兜底,见 docs/zh/architecture/design.md §9.7 更新说明。
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
return;
|
|
1232
|
-
}
|
|
1233
|
-
// Canonicalize via realpath; if the target vanished between stat and
|
|
1234
|
-
// realpath (TOCTOU), reject gracefully instead of throwing.
|
|
1235
|
-
let canonical;
|
|
1236
|
-
try {
|
|
1237
|
-
canonical = fs.realpathSync(resolvedTarget);
|
|
1238
|
-
}
|
|
1239
|
-
catch {
|
|
1175
|
+
const resolution = resolveDirectory(resolvedTarget);
|
|
1176
|
+
if (!resolution.ok) {
|
|
1240
1177
|
await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
|
|
1241
1178
|
return;
|
|
1242
1179
|
}
|
|
1243
1180
|
// Set cwd + auto-resume + user notification (shared with /cd and /ws use)
|
|
1244
|
-
const notifyResult = this.switchCwdAndNotify(ctx.userId, canonical, ctx);
|
|
1181
|
+
const notifyResult = this.switchCwdAndNotify(ctx.userId, resolution.canonical, ctx);
|
|
1245
1182
|
// Card channel: refresh ls card in-place (existing behavior preserved)。
|
|
1246
1183
|
// value.root 是本次 /ls 浏览的起点(绝对路径,不受 cwd 变更影响),透传后
|
|
1247
1184
|
// 「返回」仍回到用户最初 /ls 的那个目录。
|
|
1248
|
-
const card = this.cmdLs([canonical], ctx, 0, value.root);
|
|
1185
|
+
const card = this.cmdLs([resolution.canonical], ctx, 0, value.root);
|
|
1249
1186
|
await this.bridge.updateCardInPlace(card.card, ctx);
|
|
1250
1187
|
// Message channel: send switch confirmation / auto-resume card to chat
|
|
1251
1188
|
await this.bridge.sendResult(notifyResult, ctx);
|
|
@@ -1256,21 +1193,11 @@ export class CommandRouter {
|
|
|
1256
1193
|
async handleLsRefresh(value, ctx) {
|
|
1257
1194
|
const targetPath = value.path;
|
|
1258
1195
|
const offset = value.offset ?? 0;
|
|
1259
|
-
//
|
|
1260
|
-
//
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
let card;
|
|
1265
|
-
try {
|
|
1266
|
-
card =
|
|
1267
|
-
targetPath && fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
|
|
1268
|
-
? this.cmdLs([targetPath], ctx, offset, value.root, value.q)
|
|
1269
|
-
: this.cmdLs([], ctx, offset, value.root, value.q);
|
|
1270
|
-
}
|
|
1271
|
-
catch {
|
|
1272
|
-
card = this.cmdLs([], ctx, offset, value.root, value.q);
|
|
1273
|
-
}
|
|
1196
|
+
// Treat a vanished/non-dir path as "use current cwd" instead of crashing
|
|
1197
|
+
// the refresh. inspectPath closes the former existsSync→statSync race.
|
|
1198
|
+
const card = targetPath && isDirectorySafe(targetPath)
|
|
1199
|
+
? this.cmdLs([targetPath], ctx, offset, value.root, value.q)
|
|
1200
|
+
: this.cmdLs([], ctx, offset, value.root, value.q);
|
|
1274
1201
|
await this.bridge.updateCardInPlace(card.card, ctx);
|
|
1275
1202
|
}
|
|
1276
1203
|
/**
|
|
@@ -1281,22 +1208,12 @@ export class CommandRouter {
|
|
|
1281
1208
|
if (!targetPath) {
|
|
1282
1209
|
return { toast: { type: 'error', content: '卡片 payload 缺少 path' } };
|
|
1283
1210
|
}
|
|
1284
|
-
const paging = this.pagingOffset(value,
|
|
1211
|
+
const paging = this.pagingOffset(value, LS_PAGE_SIZE);
|
|
1285
1212
|
if ('error' in paging) {
|
|
1286
1213
|
return { toast: { type: 'error', content: paging.error } };
|
|
1287
1214
|
}
|
|
1288
1215
|
const resolvedTarget = path.resolve(targetPath);
|
|
1289
|
-
|
|
1290
|
-
// try/catch the throw lands in enqueueImmediate's silent .catch and the
|
|
1291
|
-
// paginated card never updates (no visible feedback for the click).
|
|
1292
|
-
let isDir;
|
|
1293
|
-
try {
|
|
1294
|
-
isDir = fs.existsSync(resolvedTarget) && fs.statSync(resolvedTarget).isDirectory();
|
|
1295
|
-
}
|
|
1296
|
-
catch {
|
|
1297
|
-
isDir = false;
|
|
1298
|
-
}
|
|
1299
|
-
if (!isDir) {
|
|
1216
|
+
if (!isDirectorySafe(resolvedTarget)) {
|
|
1300
1217
|
return { toast: { type: 'error', content: `路径无效: ${resolvedTarget}` } };
|
|
1301
1218
|
}
|
|
1302
1219
|
const card = this.cmdLs([resolvedTarget], ctx, paging.offset, value.root, value.q);
|
|
@@ -1318,14 +1235,7 @@ export class CommandRouter {
|
|
|
1318
1235
|
}
|
|
1319
1236
|
const q = searchQueryFrom(value);
|
|
1320
1237
|
const resolvedTarget = path.resolve(targetPath);
|
|
1321
|
-
|
|
1322
|
-
try {
|
|
1323
|
-
isDir = fs.existsSync(resolvedTarget) && fs.statSync(resolvedTarget).isDirectory();
|
|
1324
|
-
}
|
|
1325
|
-
catch {
|
|
1326
|
-
isDir = false;
|
|
1327
|
-
}
|
|
1328
|
-
if (!isDir) {
|
|
1238
|
+
if (!isDirectorySafe(resolvedTarget)) {
|
|
1329
1239
|
return { toast: { type: 'error', content: `路径无效: ${resolvedTarget}` } };
|
|
1330
1240
|
}
|
|
1331
1241
|
const card = this.cmdLs([resolvedTarget], ctx, 0, value.root, q);
|
|
@@ -1856,16 +1766,13 @@ export class CommandRouter {
|
|
|
1856
1766
|
* `/download` 与 `ls.file` 卡片按钮共用,避免两处重复 30MB/存在性校验。
|
|
1857
1767
|
*/
|
|
1858
1768
|
async deliverFileToFeishu(resolved, ctx) {
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
try {
|
|
1864
|
-
stat = fs.statSync(resolved);
|
|
1865
|
-
}
|
|
1866
|
-
catch {
|
|
1769
|
+
const resolution = statPath(resolved);
|
|
1770
|
+
if (!resolution.ok) {
|
|
1771
|
+
if (resolution.reason === 'missing')
|
|
1772
|
+
return { text: `文件不存在: ${resolved}` };
|
|
1867
1773
|
return { text: `无法读取: ${resolved}` };
|
|
1868
1774
|
}
|
|
1775
|
+
const { stat } = resolution;
|
|
1869
1776
|
if (stat.isDirectory()) {
|
|
1870
1777
|
return { text: `这是一个目录,不能作为文件发送: ${resolved}\n用 /ls 查看目录内容` };
|
|
1871
1778
|
}
|
|
@@ -2629,38 +2536,16 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2629
2536
|
const currentCwd = this.sessionStore.getCwd(ctx.userId) ?? process.cwd();
|
|
2630
2537
|
const expanded = target.startsWith('~') ? path.join(os.homedir(), target.slice(1)) : target;
|
|
2631
2538
|
const resolved = path.resolve(currentCwd, expanded);
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
try {
|
|
2639
|
-
stat = fs.statSync(resolved);
|
|
2640
|
-
}
|
|
2641
|
-
catch {
|
|
2642
|
-
return { text: `路径无效: ${resolved}` };
|
|
2643
|
-
}
|
|
2644
|
-
if (!stat.isDirectory()) {
|
|
2645
|
-
return { text: `不是目录: ${resolved}` };
|
|
2646
|
-
}
|
|
2647
|
-
// Canonicalize via realpath so it matches the symlink-resolved cwd that
|
|
2648
|
-
// Claude writes into JSONL (e.g. `/tmp` → `/private/tmp` on macOS).
|
|
2649
|
-
// Without this, a hand-typed unresolved path never matches any JSONL's
|
|
2650
|
-
// cwd field and the user's `/cd /tmp/foo` never finds their sessions.
|
|
2651
|
-
// TOCTOU: the directory may vanish between stat and realpath.
|
|
2652
|
-
// Reject gracefully instead of letting realpathSync throw into the queue
|
|
2653
|
-
// task's .catch (which only logs — the user gets no feedback). Mirrors
|
|
2654
|
-
// handleLsSwitch's realpath guard so /cd and ls.switch behave identically.
|
|
2655
|
-
let canonical;
|
|
2656
|
-
try {
|
|
2657
|
-
canonical = fs.realpathSync(resolved);
|
|
2658
|
-
}
|
|
2659
|
-
catch {
|
|
2539
|
+
const resolution = resolveDirectory(resolved);
|
|
2540
|
+
if (!resolution.ok) {
|
|
2541
|
+
if (resolution.reason === 'missing')
|
|
2542
|
+
return { text: `路径不存在: ${resolved}` };
|
|
2543
|
+
if (resolution.reason === 'not_directory')
|
|
2544
|
+
return { text: `不是目录: ${resolved}` };
|
|
2660
2545
|
return { text: `路径无效: ${resolved}` };
|
|
2661
2546
|
}
|
|
2662
2547
|
// Switch cwd with auto-resume and user feedback (shared with /ws use and ls.switch)
|
|
2663
|
-
return this.switchCwdAndNotify(ctx.userId, canonical, ctx);
|
|
2548
|
+
return this.switchCwdAndNotify(ctx.userId, resolution.canonical, ctx);
|
|
2664
2549
|
}
|
|
2665
2550
|
readSessionDisplayState(sessionId, cwd, opts) {
|
|
2666
2551
|
// Use the appropriate session reader based on agentKind
|
|
@@ -2871,7 +2756,6 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2871
2756
|
}
|
|
2872
2757
|
return actions;
|
|
2873
2758
|
}
|
|
2874
|
-
static LS_PAGE_SIZE = 30;
|
|
2875
2759
|
/**
|
|
2876
2760
|
* `/ls` 实现(public:测试直接调用,替代 as unknown as)。
|
|
2877
2761
|
*
|
|
@@ -2884,246 +2768,12 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2884
2768
|
* 省略/空 = 无筛选。
|
|
2885
2769
|
*/
|
|
2886
2770
|
cmdLs(args, ctx, offset = 0, rootDir, q) {
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
// - [] : list current cwd
|
|
2894
|
-
// - [dirName] : list subdirectory if it exists
|
|
2895
|
-
let targetDir = cwd;
|
|
2896
|
-
if (args[0]) {
|
|
2897
|
-
// Expand tilde to home directory (path.resolve does not handle ~)
|
|
2898
|
-
if (args[0].startsWith('~')) {
|
|
2899
|
-
args[0] = path.join(os.homedir(), args[0].slice(1));
|
|
2900
|
-
}
|
|
2901
|
-
// path.resolve (not path.join) so absolute args[0] is honored as-is
|
|
2902
|
-
// instead of being concatenated onto cwd (which broke /ls card nav).
|
|
2903
|
-
const potentialDir = path.resolve(cwd, args[0]);
|
|
2904
|
-
if (!fs.existsSync(potentialDir)) {
|
|
2905
|
-
return { text: `ls: ${args[0]}: No such file or directory` };
|
|
2906
|
-
}
|
|
2907
|
-
let isDir;
|
|
2908
|
-
try {
|
|
2909
|
-
isDir = fs.statSync(potentialDir).isDirectory();
|
|
2910
|
-
}
|
|
2911
|
-
catch {
|
|
2912
|
-
// TOCTOU:existsSync 与 statSync 之间目标被删
|
|
2913
|
-
return { text: `ls: ${args[0]}: No such file or directory` };
|
|
2914
|
-
}
|
|
2915
|
-
if (!isDir) {
|
|
2916
|
-
// 命中文件:列出该文件本身(用户可能想下载它或确认是否存在),
|
|
2917
|
-
// 不再回 `ls: xxx: Not a directory` 把文件路径当错误。
|
|
2918
|
-
return this.buildFileCard(potentialDir);
|
|
2919
|
-
}
|
|
2920
|
-
targetDir = potentialDir;
|
|
2921
|
-
}
|
|
2922
|
-
// 浏览起点(「返回」按钮的目标):`/ls <dir>` 的起点就是该目录本身,之后
|
|
2923
|
-
// 进入子目录/翻页/刷新时由卡片 payload 的 root 一路带过来。省略时 = 本次
|
|
2924
|
-
// 列出的目录,因此「返回」永远不会把用户丢回工作目录。
|
|
2925
|
-
const browseRoot = rootDir ? path.resolve(cwd, rootDir) : targetDir;
|
|
2926
|
-
try {
|
|
2927
|
-
const entries = fs.readdirSync(targetDir, { withFileTypes: true });
|
|
2928
|
-
// 逐条目分类。`Dirent.isFile()/isDirectory()` 对符号链接恒 false(旧实现
|
|
2929
|
-
// 因此完全不列链接);必须 stat 解析目标。单条目 stat 失败(悬空链接、
|
|
2930
|
-
// readdir 与 stat 之间被删、无权限)只让该条目降级为「大小未知」,不能
|
|
2931
|
-
// 让整次列目录失败——一个坏邻居毁掉整张卡是旧实现的第二宗罪。
|
|
2932
|
-
const dirs = [];
|
|
2933
|
-
const files = [];
|
|
2934
|
-
for (const entry of entries) {
|
|
2935
|
-
let isDir = entry.isDirectory();
|
|
2936
|
-
let size;
|
|
2937
|
-
if (!isDir) {
|
|
2938
|
-
try {
|
|
2939
|
-
const st = fs.statSync(path.join(targetDir, entry.name));
|
|
2940
|
-
isDir = st.isDirectory();
|
|
2941
|
-
if (!isDir)
|
|
2942
|
-
size = st.size;
|
|
2943
|
-
}
|
|
2944
|
-
catch {
|
|
2945
|
-
// 目标拿不到:按文件列出,size 留空
|
|
2946
|
-
}
|
|
2947
|
-
}
|
|
2948
|
-
if (isDir)
|
|
2949
|
-
dirs.push(entry.name);
|
|
2950
|
-
else
|
|
2951
|
-
files.push({ name: entry.name, size });
|
|
2952
|
-
}
|
|
2953
|
-
dirs.sort((a, b) => a.localeCompare(b));
|
|
2954
|
-
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
2955
|
-
// Merge dirs and files for pagination, preserving type info
|
|
2956
|
-
const allItems = [
|
|
2957
|
-
...dirs.map((name) => ({ name, isDir: true })),
|
|
2958
|
-
...files.map((f) => ({ name: f.name, isDir: false, size: f.size })),
|
|
2959
|
-
];
|
|
2960
|
-
// 关键词筛选:只匹配当前层条目名(不递归子目录)。大小写不敏感的子串包含
|
|
2961
|
-
// ——禁止 new RegExp(q)(用户搜 `foo(` 会直接抛)。插在排序之后、分页之前。
|
|
2962
|
-
const ql = filter?.toLowerCase();
|
|
2963
|
-
const visibleItems = ql
|
|
2964
|
-
? allItems.filter((i) => i.name.toLowerCase().includes(ql))
|
|
2965
|
-
: allItems;
|
|
2966
|
-
const visibleDirCount = visibleItems.reduce((n, i) => n + (i.isDir ? 1 : 0), 0);
|
|
2967
|
-
const visibleFileCount = visibleItems.length - visibleDirCount;
|
|
2968
|
-
// Pagination calculations(clamp 到最后一页边界,原先不 clamp 会显示空页)
|
|
2969
|
-
const { totalCount, totalPages, safeOffset, currentPage, pageItems, hasPagination } = pageSlice(visibleItems, offset, CommandRouter.LS_PAGE_SIZE);
|
|
2970
|
-
// Check if we need to show parent directory button
|
|
2971
|
-
const parentDir = path.dirname(targetDir);
|
|
2972
|
-
const hasParent = parentDir !== targetDir;
|
|
2973
|
-
// 「切换」相对 cwd(把工作目录切到当前浏览目录)
|
|
2974
|
-
const isSubdir = targetDir !== cwd;
|
|
2975
|
-
// 「返回」相对浏览起点:在 `/ls <dir>` 的起点上不显示(无路可返)
|
|
2976
|
-
const canReturn = targetDir !== browseRoot;
|
|
2977
|
-
// Build header with parent button
|
|
2978
|
-
const headerElements = [];
|
|
2979
|
-
if (hasParent) {
|
|
2980
|
-
headerElements.push(cardButton('上级', { cmd: 'ls.browse', path: parentDir, root: browseRoot }, { type: 'default', size: 'small' }));
|
|
2981
|
-
}
|
|
2982
|
-
// Show "返回" button when browsing away from the /ls root (回到 /ls 指定的目录)
|
|
2983
|
-
if (canReturn) {
|
|
2984
|
-
headerElements.push(cardButton('返回', { cmd: 'ls.browse', path: browseRoot, root: browseRoot }, { type: 'default', size: 'small' }));
|
|
2985
|
-
}
|
|
2986
|
-
headerElements.push(cardButton('刷新', {
|
|
2987
|
-
cmd: 'ls.refresh',
|
|
2988
|
-
path: targetDir,
|
|
2989
|
-
offset: safeOffset,
|
|
2990
|
-
root: browseRoot,
|
|
2991
|
-
...(filter ? { q: filter } : {}),
|
|
2992
|
-
}, { type: 'default', size: 'small' }));
|
|
2993
|
-
// Show "切换" button when viewing a subdirectory (to switch cwd to this directory)
|
|
2994
|
-
if (isSubdir) {
|
|
2995
|
-
headerElements.push(
|
|
2996
|
-
// 切换只改 cwd,**不重置浏览起点**:root 一路带到切换后的卡片,
|
|
2997
|
-
// 否则「返回」会退化成「回到刚切换到的目录」(用户反馈的缺陷)。
|
|
2998
|
-
cardButton('切换', { cmd: 'ls.switch', path: targetDir, root: browseRoot }, { type: 'primary', size: 'small' }));
|
|
2999
|
-
}
|
|
3000
|
-
// Helper to create directory button - use targetDir
|
|
3001
|
-
const dirButton = (name) => cardButton(`📁 ${name}`, { cmd: 'ls.browse', path: path.join(targetDir, name), root: browseRoot }, { type: 'default' });
|
|
3002
|
-
// Helper to create file button with size - use targetDir
|
|
3003
|
-
const fileButton = (name, size) => {
|
|
3004
|
-
const sizeStr = size !== undefined ? ` (${formatBytes(size)})` : '';
|
|
3005
|
-
return cardButton(`📄 ${name}${sizeStr}`, { cmd: 'ls.file', path: path.join(targetDir, name) }, { type: 'default', size: 'small' });
|
|
3006
|
-
};
|
|
3007
|
-
// Build each section's elements (no tabs - use section headers)
|
|
3008
|
-
// Note: div+elements is not supported in CardKit 2.0, use column_set instead
|
|
3009
|
-
const elements = [];
|
|
3010
|
-
// Status line:只报条目构成;页码/总数交给分页栏文案行(同一信息不重复
|
|
3011
|
-
// 渲染两遍——2026-09-10 分页栏改成整行文案后重复会很明显)。筛选态一律用
|
|
3012
|
-
// 过滤后的计数,否则标题说 12 项、列表只有 3 项,卡片自相矛盾。
|
|
3013
|
-
const status = `\n共 ${visibleDirCount} 目录, ${visibleFileCount} 文件`;
|
|
3014
|
-
// Header info + navigation buttons - show targetDir in header
|
|
3015
|
-
elements.push({ tag: 'div', text: { tag: 'lark_md', content: `\`${targetDir}\`${status}` } });
|
|
3016
|
-
elements.push({
|
|
3017
|
-
tag: 'column_set',
|
|
3018
|
-
columns: headerElements.map((e) => ({ tag: 'column', width: 'auto', elements: [e] })),
|
|
3019
|
-
});
|
|
3020
|
-
elements.push({ tag: 'hr' });
|
|
3021
|
-
// 搜索行:头部导航按钮之下、内容区之上(主导航在上、过滤紧随其后、内容区
|
|
3022
|
-
// 在下)。path/root 必须进 callback value,否则提交后 handler 不知道刷哪。
|
|
3023
|
-
elements.push(searchBar({
|
|
3024
|
-
cmd: 'ls.filter',
|
|
3025
|
-
placeholder: '搜索本层目录/文件名,输完回车',
|
|
3026
|
-
currentQuery: filter,
|
|
3027
|
-
extra: { path: targetDir, root: browseRoot },
|
|
3028
|
-
}));
|
|
3029
|
-
if (filter) {
|
|
3030
|
-
elements.push({
|
|
3031
|
-
tag: 'div',
|
|
3032
|
-
text: {
|
|
3033
|
-
tag: 'lark_md',
|
|
3034
|
-
content: `🔍 筛选:"${filter}" · 命中 ${totalCount} 项(点「清除筛选」取消)`,
|
|
3035
|
-
},
|
|
3036
|
-
});
|
|
3037
|
-
}
|
|
3038
|
-
// Section 1 + 2: Directories / Files (show only items on current page)
|
|
3039
|
-
const pageDirs = pageItems.filter((i) => i.isDir);
|
|
3040
|
-
const pageFiles = pageItems.filter((i) => !i.isDir);
|
|
3041
|
-
if (filter && visibleItems.length === 0) {
|
|
3042
|
-
// 筛选零命中:两个区标题都不渲染(两行空区标题只是噪音),搜索行仍在,
|
|
3043
|
-
// 用户可直接改词再回车,或点「清除筛选」。
|
|
3044
|
-
elements.push({
|
|
3045
|
-
tag: 'div',
|
|
3046
|
-
text: { tag: 'lark_md', content: `🔍 无匹配条目(关键词:"${filter}")` },
|
|
3047
|
-
});
|
|
3048
|
-
}
|
|
3049
|
-
else {
|
|
3050
|
-
elements.push({
|
|
3051
|
-
tag: 'div',
|
|
3052
|
-
text: { tag: 'lark_md', content: `**📂 目录 (${visibleDirCount})**` },
|
|
3053
|
-
});
|
|
3054
|
-
if (pageDirs.length > 0) {
|
|
3055
|
-
for (const d of pageDirs) {
|
|
3056
|
-
elements.push({
|
|
3057
|
-
tag: 'column_set',
|
|
3058
|
-
flex_mode: 'none',
|
|
3059
|
-
columns: [{ tag: 'column', width: 'auto', elements: [dirButton(d.name)] }],
|
|
3060
|
-
});
|
|
3061
|
-
}
|
|
3062
|
-
}
|
|
3063
|
-
else if (visibleDirCount === 0) {
|
|
3064
|
-
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '无子目录' } });
|
|
3065
|
-
}
|
|
3066
|
-
else {
|
|
3067
|
-
elements.push({
|
|
3068
|
-
tag: 'div',
|
|
3069
|
-
text: { tag: 'lark_md', content: `(第 ${currentPage} 页无目录)` },
|
|
3070
|
-
});
|
|
3071
|
-
}
|
|
3072
|
-
elements.push({ tag: 'hr' });
|
|
3073
|
-
// Section 2: Files
|
|
3074
|
-
elements.push({
|
|
3075
|
-
tag: 'div',
|
|
3076
|
-
text: { tag: 'lark_md', content: `**📄 文件 (${visibleFileCount})**` },
|
|
3077
|
-
});
|
|
3078
|
-
if (pageFiles.length > 0) {
|
|
3079
|
-
for (const f of pageFiles) {
|
|
3080
|
-
elements.push({
|
|
3081
|
-
tag: 'column_set',
|
|
3082
|
-
flex_mode: 'none',
|
|
3083
|
-
columns: [{ tag: 'column', width: 'auto', elements: [fileButton(f.name, f.size)] }],
|
|
3084
|
-
});
|
|
3085
|
-
}
|
|
3086
|
-
}
|
|
3087
|
-
else if (visibleFileCount === 0) {
|
|
3088
|
-
elements.push({ tag: 'div', text: { tag: 'lark_md', content: '无文件' } });
|
|
3089
|
-
}
|
|
3090
|
-
else {
|
|
3091
|
-
elements.push({
|
|
3092
|
-
tag: 'div',
|
|
3093
|
-
text: { tag: 'lark_md', content: `(第 ${currentPage} 页无文件)` },
|
|
3094
|
-
});
|
|
3095
|
-
}
|
|
3096
|
-
}
|
|
3097
|
-
// Pagination bar (only shown when there are more items than PAGE_SIZE)
|
|
3098
|
-
if (hasPagination) {
|
|
3099
|
-
const bar = paginationBar({
|
|
3100
|
-
cmd: 'ls.page',
|
|
3101
|
-
offset: safeOffset,
|
|
3102
|
-
pageSize: CommandRouter.LS_PAGE_SIZE,
|
|
3103
|
-
total: totalCount,
|
|
3104
|
-
extra: { path: targetDir, root: browseRoot, ...(filter ? { q: filter } : {}) },
|
|
3105
|
-
label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 项)`,
|
|
3106
|
-
});
|
|
3107
|
-
elements.push({ tag: 'hr' });
|
|
3108
|
-
elements.push(...bar);
|
|
3109
|
-
}
|
|
3110
|
-
return {
|
|
3111
|
-
card: {
|
|
3112
|
-
schema: '2.0',
|
|
3113
|
-
config: { wide_screen_mode: true, update_multi: true },
|
|
3114
|
-
header: {
|
|
3115
|
-
title: { tag: 'plain_text', content: `📁 ${path.basename(targetDir)}` },
|
|
3116
|
-
template: 'blue',
|
|
3117
|
-
},
|
|
3118
|
-
body: {
|
|
3119
|
-
elements,
|
|
3120
|
-
},
|
|
3121
|
-
},
|
|
3122
|
-
};
|
|
3123
|
-
}
|
|
3124
|
-
catch (err) {
|
|
3125
|
-
return { text: `读取目录失败: ${err.message}` };
|
|
3126
|
-
}
|
|
2771
|
+
return buildLsCommand(args, ctx, {
|
|
2772
|
+
cwd: this.sessionStore.getCwd(ctx.userId),
|
|
2773
|
+
offset,
|
|
2774
|
+
rootDir,
|
|
2775
|
+
q,
|
|
2776
|
+
});
|
|
3127
2777
|
}
|
|
3128
2778
|
/**
|
|
3129
2779
|
* `/download <path>`(别名 `/d`):把本地文件直接发送到飞书。
|
|
@@ -3149,283 +2799,18 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3149
2799
|
}
|
|
3150
2800
|
return this.deliverFileToFeishu(path.resolve(input), ctx);
|
|
3151
2801
|
}
|
|
3152
|
-
/**
|
|
3153
|
-
* 单文件 ls 结果卡:路径 / 大小 / 修改时间 + 「下载」+「上级」。
|
|
3154
|
-
*
|
|
3155
|
-
* `/ls <file>` 命中普通文件时使用——用户可能想下载该文件或确认它是否存在,
|
|
3156
|
-
* 此前直接回 `ls: xxx: Not a directory` 把文件路径当成错误处理。
|
|
3157
|
-
*/
|
|
3158
|
-
buildFileCard(filePath) {
|
|
3159
|
-
let stat;
|
|
3160
|
-
try {
|
|
3161
|
-
stat = fs.statSync(filePath);
|
|
3162
|
-
}
|
|
3163
|
-
catch {
|
|
3164
|
-
stat = undefined;
|
|
3165
|
-
}
|
|
3166
|
-
const sizeText = stat ? formatBytes(stat.size) : '未知';
|
|
3167
|
-
const mtimeText = stat ? stat.mtime.toLocaleString('zh-CN', { hour12: false }) : '未知';
|
|
3168
|
-
const parentDir = path.dirname(filePath);
|
|
3169
|
-
const buttons = [
|
|
3170
|
-
cardButton('📎 下载', { cmd: 'ls.file', path: filePath }, { type: 'primary', size: 'small' }),
|
|
3171
|
-
];
|
|
3172
|
-
if (parentDir !== filePath) {
|
|
3173
|
-
buttons.push(
|
|
3174
|
-
// 浏览起点 = 文件所在目录:进入后「返回」回到这里,不回 工作目录
|
|
3175
|
-
cardButton('上级', { cmd: 'ls.browse', path: parentDir, root: parentDir }, { type: 'default', size: 'small' }));
|
|
3176
|
-
}
|
|
3177
|
-
return {
|
|
3178
|
-
card: {
|
|
3179
|
-
schema: '2.0',
|
|
3180
|
-
config: { wide_screen_mode: true, update_multi: true },
|
|
3181
|
-
header: {
|
|
3182
|
-
title: { tag: 'plain_text', content: `📄 ${path.basename(filePath)}` },
|
|
3183
|
-
template: 'blue',
|
|
3184
|
-
},
|
|
3185
|
-
body: {
|
|
3186
|
-
elements: [
|
|
3187
|
-
{
|
|
3188
|
-
tag: 'div',
|
|
3189
|
-
text: {
|
|
3190
|
-
tag: 'lark_md',
|
|
3191
|
-
content: `\`${filePath}\`\n文件 · 大小 ${sizeText} · 修改时间 ${mtimeText}`,
|
|
3192
|
-
},
|
|
3193
|
-
},
|
|
3194
|
-
{
|
|
3195
|
-
tag: 'column_set',
|
|
3196
|
-
columns: buttons.map((b) => ({ tag: 'column', width: 'auto', elements: [b] })),
|
|
3197
|
-
},
|
|
3198
|
-
{ tag: 'hr' },
|
|
3199
|
-
{
|
|
3200
|
-
tag: 'div',
|
|
3201
|
-
text: { tag: 'lark_md', content: '💡 也可用 `/download <path>` 直接下载该文件' },
|
|
3202
|
-
},
|
|
3203
|
-
],
|
|
3204
|
-
},
|
|
3205
|
-
},
|
|
3206
|
-
};
|
|
3207
|
-
}
|
|
3208
|
-
/**
|
|
3209
|
-
* `/ws` 实现(public:测试直接调用,替代 as unknown as,与 cmdLs 同款)。
|
|
3210
|
-
*
|
|
3211
|
-
* @param offset list 分支的分页起点(条目数)
|
|
3212
|
-
* @param q list 分支的关键词筛选:name 或完整 path 命中即保留,大小写
|
|
3213
|
-
* 不敏感。省略/空 = 无筛选。
|
|
3214
|
-
*/
|
|
3215
2802
|
cmdWs(args, ctx, offset = 0, q) {
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
}
|
|
3228
|
-
case 'use': {
|
|
3229
|
-
const name = args[1];
|
|
3230
|
-
if (!name)
|
|
3231
|
-
return { text: '用法: /ws use <name>' };
|
|
3232
|
-
const wsPath = this.workspaceStore.get(name);
|
|
3233
|
-
if (!wsPath)
|
|
3234
|
-
return { text: `workspace "${name}" 不存在` };
|
|
3235
|
-
if (!fs.existsSync(wsPath))
|
|
3236
|
-
return { text: `路径不存在: ${wsPath}` };
|
|
3237
|
-
// 目录校验与 /cd、ls.switch 对齐:目录被删后同路径出现同名文件(或
|
|
3238
|
-
// workspace.json 被手改)时 realpath 照样成功——放行会让下一次 run
|
|
3239
|
-
// 以文件为 cwd spawn(ENOTDIR)。
|
|
3240
|
-
if (!isDirectorySafe(wsPath))
|
|
3241
|
-
return { text: `不是目录: ${wsPath}` };
|
|
3242
|
-
// Canonicalize via realpath so it matches Claude JSONL cwd (2026-06-21).
|
|
3243
|
-
// TOCTOU guard: mirror cmdCd — if the path vanishes
|
|
3244
|
-
// between existsSync and realpath, reply gracefully instead of
|
|
3245
|
-
// throwing into the queue task's silent .catch.
|
|
3246
|
-
let canonical;
|
|
3247
|
-
try {
|
|
3248
|
-
canonical = fs.realpathSync(wsPath);
|
|
3249
|
-
}
|
|
3250
|
-
catch {
|
|
3251
|
-
return { text: `路径无效: ${wsPath}` };
|
|
3252
|
-
}
|
|
3253
|
-
// Record usage timestamp AFTER successful path validation
|
|
3254
|
-
this.workspaceStore.touch(name);
|
|
3255
|
-
// Switch cwd with auto-resume and user feedback (shared with /cd and ls.switch)
|
|
3256
|
-
return this.switchCwdAndNotify(ctx.userId, canonical, ctx);
|
|
3257
|
-
}
|
|
3258
|
-
case 'remove': {
|
|
3259
|
-
const name = args[1];
|
|
3260
|
-
if (!name)
|
|
3261
|
-
return { text: '用法: /ws remove <name>' };
|
|
3262
|
-
if (!this.workspaceStore.has(name))
|
|
3263
|
-
return { text: `workspace "${name}" 不存在` };
|
|
3264
|
-
this.workspaceStore.remove(name);
|
|
3265
|
-
return { text: `已删除 workspace "${name}"` };
|
|
3266
|
-
}
|
|
3267
|
-
case 'list':
|
|
3268
|
-
default: {
|
|
3269
|
-
const filter = q?.trim() || undefined;
|
|
3270
|
-
const entries = this.workspaceStore.list();
|
|
3271
|
-
const currentCwd = this.sessionStore.getCwd(ctx.userId);
|
|
3272
|
-
const sortMode = this.wsSortPreference.get(ctx.userId) ?? 'recent';
|
|
3273
|
-
// Sort entries by the current preference (view concern, not stored)
|
|
3274
|
-
if (sortMode === 'recent') {
|
|
3275
|
-
// Most recently used first; same lastUsedAt → alphabetical by name (deterministic)
|
|
3276
|
-
entries.sort((a, b) => {
|
|
3277
|
-
if (b.lastUsedAt !== a.lastUsedAt)
|
|
3278
|
-
return b.lastUsedAt - a.lastUsedAt;
|
|
3279
|
-
return a.name.localeCompare(b.name);
|
|
3280
|
-
});
|
|
3281
|
-
}
|
|
3282
|
-
else {
|
|
3283
|
-
// Alphabetical by alias name
|
|
3284
|
-
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
3285
|
-
}
|
|
3286
|
-
// 关键词筛选:alias name 或完整 path 命中即保留,大小写不敏感的子串包含
|
|
3287
|
-
// (禁止 new RegExp(q)——用户搜 `foo(` 会直接抛)。插在排序之后、分页
|
|
3288
|
-
// 之前:先分页再过滤会出现某页只剩 1 条的残页,且翻页时筛选像是丢了。
|
|
3289
|
-
const ql = filter?.toLowerCase();
|
|
3290
|
-
const visible = ql
|
|
3291
|
-
? entries.filter((e) => e.name.toLowerCase().includes(ql) || e.path.toLowerCase().includes(ql))
|
|
3292
|
-
: entries;
|
|
3293
|
-
// Pagination calculations (mirror cmdOrder): clamp stale/out-of-range
|
|
3294
|
-
// offsets so prev/next always step by WS_PAGE_SIZE.
|
|
3295
|
-
const { totalCount, totalPages, safeOffset, currentPage, pageItems: pageEntries, hasPagination, } = pageSlice(visible, offset, WS_PAGE_SIZE);
|
|
3296
|
-
// Build body elements: current cwd + cwd list with dividers
|
|
3297
|
-
const bodyElements = [];
|
|
3298
|
-
// Current working directory section
|
|
3299
|
-
bodyElements.push({
|
|
3300
|
-
tag: 'div',
|
|
3301
|
-
text: { tag: 'lark_md', content: `📂 当前工作目录:\`${currentCwd ?? '(未设置)'}\`` },
|
|
3302
|
-
});
|
|
3303
|
-
bodyElements.push({ tag: 'hr' });
|
|
3304
|
-
// 搜索行:「当前工作目录」之下、排序行之上(与 /ls 同一视觉动线:主信息
|
|
3305
|
-
// 在上、过滤紧随其后、列表在下)
|
|
3306
|
-
bodyElements.push(searchBar({
|
|
3307
|
-
cmd: 'ws.filter',
|
|
3308
|
-
placeholder: '搜索名称或路径,输完回车',
|
|
3309
|
-
currentQuery: filter,
|
|
3310
|
-
}));
|
|
3311
|
-
if (filter) {
|
|
3312
|
-
bodyElements.push({
|
|
3313
|
-
tag: 'div',
|
|
3314
|
-
text: {
|
|
3315
|
-
tag: 'lark_md',
|
|
3316
|
-
content: `🔍 筛选:"${filter}" · 命中 ${totalCount} 个 workspace(点「清除筛选」取消)`,
|
|
3317
|
-
},
|
|
3318
|
-
});
|
|
3319
|
-
}
|
|
3320
|
-
if (entries.length === 0 && !filter) {
|
|
3321
|
-
bodyElements.push({
|
|
3322
|
-
tag: 'div',
|
|
3323
|
-
text: { tag: 'lark_md', content: '没有保存的 workspace' },
|
|
3324
|
-
});
|
|
3325
|
-
}
|
|
3326
|
-
else if (filter && visible.length === 0) {
|
|
3327
|
-
bodyElements.push({
|
|
3328
|
-
tag: 'div',
|
|
3329
|
-
text: { tag: 'lark_md', content: `🔍 无匹配条目(关键词:"${filter}")` },
|
|
3330
|
-
});
|
|
3331
|
-
}
|
|
3332
|
-
else {
|
|
3333
|
-
// Sort mode indicator + toggle button: placed above the list so the user
|
|
3334
|
-
// sees "current mode" and "switch to X" before scanning entries.
|
|
3335
|
-
// On narrow (mobile) screens, the label and button must convey both
|
|
3336
|
-
// what-is-active and what-clicking-does to avoid ambiguity.
|
|
3337
|
-
const currentLabel = sortMode === 'recent' ? '🕐 最近使用' : '🔤 字母顺序';
|
|
3338
|
-
const switchLabel = sortMode === 'recent' ? '🔤 字母顺序' : '🕐 最近使用';
|
|
3339
|
-
bodyElements.push({ tag: 'hr' });
|
|
3340
|
-
bodyElements.push({
|
|
3341
|
-
tag: 'column_set',
|
|
3342
|
-
columns: [
|
|
3343
|
-
{
|
|
3344
|
-
tag: 'column',
|
|
3345
|
-
width: 'weighted',
|
|
3346
|
-
weight: 1,
|
|
3347
|
-
vertical_align: 'center',
|
|
3348
|
-
elements: [
|
|
3349
|
-
{
|
|
3350
|
-
tag: 'div',
|
|
3351
|
-
text: { tag: 'lark_md', content: `排序:${currentLabel}` },
|
|
3352
|
-
},
|
|
3353
|
-
],
|
|
3354
|
-
},
|
|
3355
|
-
{
|
|
3356
|
-
tag: 'column',
|
|
3357
|
-
width: 'auto',
|
|
3358
|
-
elements: [
|
|
3359
|
-
cardButton(`切换为 ${switchLabel}`, { cmd: 'ws.sort', ...(filter ? { q: filter } : {}) }, { type: 'default', size: 'small' }),
|
|
3360
|
-
],
|
|
3361
|
-
},
|
|
3362
|
-
],
|
|
3363
|
-
});
|
|
3364
|
-
bodyElements.push({ tag: 'hr' });
|
|
3365
|
-
for (const entry of pageEntries) {
|
|
3366
|
-
bodyElements.push({
|
|
3367
|
-
tag: 'div',
|
|
3368
|
-
text: { tag: 'lark_md', content: `**${entry.name}** → \`${entry.path}\`` },
|
|
3369
|
-
});
|
|
3370
|
-
// Use column_set+column for 2.0 (action tag not supported in 2.0)
|
|
3371
|
-
bodyElements.push({
|
|
3372
|
-
tag: 'column_set',
|
|
3373
|
-
columns: [
|
|
3374
|
-
{
|
|
3375
|
-
tag: 'column',
|
|
3376
|
-
width: 'auto',
|
|
3377
|
-
elements: [
|
|
3378
|
-
cardButton('切换', {
|
|
3379
|
-
cmd: 'ws.use',
|
|
3380
|
-
name: entry.name,
|
|
3381
|
-
offset: safeOffset,
|
|
3382
|
-
...(filter ? { q: filter } : {}),
|
|
3383
|
-
}, { type: 'primary', size: 'small' }),
|
|
3384
|
-
],
|
|
3385
|
-
},
|
|
3386
|
-
{
|
|
3387
|
-
tag: 'column',
|
|
3388
|
-
width: 'auto',
|
|
3389
|
-
elements: [
|
|
3390
|
-
cardButton('删除', {
|
|
3391
|
-
cmd: 'ws.remove',
|
|
3392
|
-
name: entry.name,
|
|
3393
|
-
offset: safeOffset,
|
|
3394
|
-
...(filter ? { q: filter } : {}),
|
|
3395
|
-
}, { type: 'danger', size: 'small' }),
|
|
3396
|
-
],
|
|
3397
|
-
},
|
|
3398
|
-
],
|
|
3399
|
-
});
|
|
3400
|
-
bodyElements.push({ tag: 'hr' });
|
|
3401
|
-
}
|
|
3402
|
-
bodyElements.pop();
|
|
3403
|
-
// Pagination bar — separate row below the list (not crowded with sort
|
|
3404
|
-
// toggle; mobile-friendly vertical layout).
|
|
3405
|
-
if (hasPagination) {
|
|
3406
|
-
const bar = paginationBar({
|
|
3407
|
-
cmd: 'ws.page',
|
|
3408
|
-
offset: safeOffset,
|
|
3409
|
-
pageSize: WS_PAGE_SIZE,
|
|
3410
|
-
total: totalCount,
|
|
3411
|
-
extra: filter ? { q: filter } : {},
|
|
3412
|
-
label: `**${currentPage}/${totalPages}**(${totalCount})`,
|
|
3413
|
-
prevText: '⬅',
|
|
3414
|
-
nextText: '➡',
|
|
3415
|
-
});
|
|
3416
|
-
bodyElements.push({ tag: 'hr' });
|
|
3417
|
-
bodyElements.push(...bar);
|
|
3418
|
-
}
|
|
3419
|
-
}
|
|
3420
|
-
const card = {
|
|
3421
|
-
schema: '2.0',
|
|
3422
|
-
config: { wide_screen_mode: true, update_multi: true },
|
|
3423
|
-
header: { title: { tag: 'plain_text', content: 'Workspaces' } },
|
|
3424
|
-
body: { elements: bodyElements },
|
|
3425
|
-
};
|
|
3426
|
-
return { card };
|
|
3427
|
-
}
|
|
3428
|
-
}
|
|
2803
|
+
return buildWsCommand(args, ctx, offset, q, {
|
|
2804
|
+
getCwd: (userId) => this.sessionStore.getCwd(userId),
|
|
2805
|
+
saveWorkspace: (name, cwd) => this.workspaceStore.save(name, cwd),
|
|
2806
|
+
getWorkspace: (name) => this.workspaceStore.get(name),
|
|
2807
|
+
hasWorkspace: (name) => this.workspaceStore.has(name),
|
|
2808
|
+
removeWorkspace: (name) => this.workspaceStore.remove(name),
|
|
2809
|
+
touchWorkspace: (name) => this.workspaceStore.touch(name),
|
|
2810
|
+
listWorkspaces: () => this.workspaceStore.list(),
|
|
2811
|
+
getSortMode: (userId) => this.wsSortPreference.get(userId) ?? 'recent',
|
|
2812
|
+
switchCwd: (userId, cwd, actionCtx) => this.switchCwdAndNotify(userId, cwd, actionCtx),
|
|
2813
|
+
});
|
|
3429
2814
|
}
|
|
3430
2815
|
/**
|
|
3431
2816
|
* DSH /resume preset 一致性校验:返回 mismatch 详情,或 undefined(一致/不可判定)。
|
|
@@ -3454,286 +2839,18 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3454
2839
|
return { sessionPreset, currentPreset };
|
|
3455
2840
|
}
|
|
3456
2841
|
cmdResume(args, ctx, offset = 0) {
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
if (/^\d+$/.test(args[1])) {
|
|
3470
|
-
limit = parseInt(args[1], 10);
|
|
3471
|
-
}
|
|
3472
|
-
else {
|
|
3473
|
-
sessionIdArg = args[1];
|
|
3474
|
-
}
|
|
3475
|
-
}
|
|
3476
|
-
}
|
|
3477
|
-
else if (/^\d+$/.test(args[0])) {
|
|
3478
|
-
limit = parseInt(args[0], 10);
|
|
3479
|
-
}
|
|
3480
|
-
else {
|
|
3481
|
-
sessionIdArg = args[0];
|
|
3482
|
-
}
|
|
3483
|
-
}
|
|
3484
|
-
// `/resume <sessionId>` — manually set session id (needs an existing cwd to bind to)
|
|
3485
|
-
if (sessionIdArg) {
|
|
3486
|
-
if (!entry || !cwd)
|
|
3487
|
-
return { text: '请先 /cd 设置工作目录' };
|
|
3488
|
-
const reader = agentKind !== this.config.defaultAgent
|
|
3489
|
-
? this.sessionReaderRegistry.get(agentKind)
|
|
3490
|
-
: this.sessionReader;
|
|
3491
|
-
const verifyContent = reader.readSessionContent(sessionIdArg, cwd);
|
|
3492
|
-
// If session doesn't exist, reject the write
|
|
3493
|
-
if (verifyContent.events.length === 0 &&
|
|
3494
|
-
!verifyContent.usage &&
|
|
3495
|
-
!verifyContent.aiTitle &&
|
|
3496
|
-
!verifyContent.recap &&
|
|
3497
|
-
!verifyContent.displayTitle) {
|
|
3498
|
-
return {
|
|
3499
|
-
text: `未找到 session ${sessionIdArg}(当前目录: ${cwd})。请确认该 session 属于此目录,或先 /cd 到正确目录。`,
|
|
3500
|
-
};
|
|
3501
|
-
}
|
|
3502
|
-
// DSH preset 一致性校验(§6 风险 1):session 的 preset 在创建时固定,
|
|
3503
|
-
// 若与当前配置不符,直接复用会让后续 run 报 agent-preset-conflict。
|
|
3504
|
-
// 不静默复用——明确提示用户,由用户决定是否继续(配置里手动改/清 preset)。
|
|
3505
|
-
if (agentKind === 'dsh' && this.config.defaultAgent === 'dsh') {
|
|
3506
|
-
const presetMismatch = this.dshPresetMismatch(reader, sessionIdArg, cwd);
|
|
3507
|
-
if (presetMismatch) {
|
|
3508
|
-
return {
|
|
3509
|
-
text: `⚠️ 该 DSH session 的 preset 是「${presetMismatch.sessionPreset}」,与当前配置「${presetMismatch.currentPreset}」不一致。\n` +
|
|
3510
|
-
`preset 在 session 创建时固定,无法中途切换(DSH 返回 agent-preset-conflict)。\n` +
|
|
3511
|
-
`继续恢复将在下次 run 失败。请在 /config 中把预设模式改回「${presetMismatch.sessionPreset}」后重试,或保持当前配置并发新消息新建会话。`,
|
|
3512
|
-
};
|
|
3513
|
-
}
|
|
3514
|
-
}
|
|
3515
|
-
// 校验通过后才写入
|
|
3516
|
-
this.sessionStore.setSessionId(ctx.userId, agentKind, sessionIdArg, cwd);
|
|
3517
|
-
const { events: content, usage, isActive, aiTitle, recap, displayTitle, activeRunRunId, } = this.readSessionDisplayState(sessionIdArg, cwd, {
|
|
3518
|
-
agentKind,
|
|
3519
|
-
maxEvents: AUTO_RESUME_MAX_EVENTS,
|
|
3520
|
-
});
|
|
3521
|
-
// Action buttons: stop (active) + compact (capable runner, not running) + new session
|
|
3522
|
-
const actions = this.buildResumeActionButtons({
|
|
3523
|
-
isActive,
|
|
3524
|
-
activeRunRunId,
|
|
3525
|
-
cwd,
|
|
3526
|
-
compact: this.canCompactSession(agentKind, cwd)
|
|
3527
|
-
? { sessionId: sessionIdArg, agentKind }
|
|
3528
|
-
: undefined,
|
|
3529
|
-
newSession: true,
|
|
3530
|
-
});
|
|
3531
|
-
const card = buildSessionHistoryCard({
|
|
3532
|
-
sessionId: sessionIdArg,
|
|
3533
|
-
cwd,
|
|
3534
|
-
displayTitle,
|
|
3535
|
-
aiTitle,
|
|
3536
|
-
recap,
|
|
3537
|
-
events: content,
|
|
3538
|
-
usage,
|
|
3539
|
-
}, {
|
|
3540
|
-
agentKind,
|
|
3541
|
-
headerText: `📂 \`${cwd}\`\n会话: **${sessionIdArg}**`,
|
|
3542
|
-
title: `${isActive ? '⏳ 恢复会话(完成中)' : '🔁 恢复会话'} · ${agentDisplayName(agentKind)}`,
|
|
3543
|
-
usageResult: 'success',
|
|
3544
|
-
emptyPlaceholder: '_该会话暂无新消息可显示(最后一条为用户输入)_',
|
|
3545
|
-
actions,
|
|
3546
|
-
});
|
|
3547
|
-
return { card };
|
|
3548
|
-
}
|
|
3549
|
-
// `/resume` or `/resume list` or `/resume <N>` — list sessions for the current cwd as a card
|
|
3550
|
-
// Use agentKind to select the appropriate sessionReader
|
|
3551
|
-
if (!cwd)
|
|
3552
|
-
return { text: '请先 /cd 设置工作目录' };
|
|
3553
|
-
const pageSize = clampInt(limit ?? RESUME_PAGE_SIZE, 1, RESUME_PAGE_SIZE);
|
|
3554
|
-
let reader;
|
|
3555
|
-
// Use this.sessionReader when agentKind matches default, otherwise get from registry
|
|
3556
|
-
if (agentKind === this.config.defaultAgent) {
|
|
3557
|
-
reader = this.sessionReader;
|
|
3558
|
-
}
|
|
3559
|
-
else {
|
|
3560
|
-
reader = this.sessionReaderRegistry.get(agentKind);
|
|
3561
|
-
}
|
|
3562
|
-
// Fetch the requested page. If the offset is stale (sessions added/removed
|
|
3563
|
-
// since the page was rendered), clamp it to the last valid page start and
|
|
3564
|
-
// re-fetch so the card never shows a misaligned/empty page.
|
|
3565
|
-
let pageOffset = offset;
|
|
3566
|
-
let pageResult;
|
|
3567
|
-
try {
|
|
3568
|
-
pageResult = reader.listSessions(cwd, { limit: pageSize, offset: pageOffset });
|
|
3569
|
-
// 末页起点对齐页边界((总页数-1)*pageSize,如 25 条/页 20 → offset 20),
|
|
3570
|
-
// 这样越界 offset clamp 后 prev/next 仍按 pageSize 步长移动,不会错位。
|
|
3571
|
-
const maxOffset = Math.max(0, Math.ceil(pageResult.total / pageSize) - 1) * pageSize;
|
|
3572
|
-
if (pageOffset < 0 || pageOffset > maxOffset) {
|
|
3573
|
-
pageOffset = clampInt(pageOffset, 0, maxOffset);
|
|
3574
|
-
pageResult = reader.listSessions(cwd, { limit: pageSize, offset: pageOffset });
|
|
3575
|
-
}
|
|
3576
|
-
}
|
|
3577
|
-
catch (err) {
|
|
3578
|
-
// 读取失败必须给用户可见反馈,而不是误导性的「没有 session 记录」
|
|
3579
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
3580
|
-
return { text: `读取 ${agentDisplayName(agentKind)} session 列表失败: ${msg}` };
|
|
3581
|
-
}
|
|
3582
|
-
const { sessions: allSessions, total } = pageResult;
|
|
3583
|
-
if (allSessions.length === 0) {
|
|
3584
|
-
const agentName = agentDisplayName(agentKind);
|
|
3585
|
-
const hint = agentKind === this.config.defaultAgent
|
|
3586
|
-
? `\n提示:可使用 /resume <agent> 切换查看其他 Agent 的 session`
|
|
3587
|
-
: '';
|
|
3588
|
-
return { text: `当前目录没有 ${agentName} session 记录\n${cwd}${hint}` };
|
|
3589
|
-
}
|
|
3590
|
-
// Pre-fetch aiTitle/recap/displayTitle only for the first few rows —
|
|
3591
|
-
// full reads are expensive (whole-file JSONL scan). Remaining rows fall
|
|
3592
|
-
// back to the lightweight summary already returned by listSessions.
|
|
3593
|
-
const sessionMeta = new Map();
|
|
3594
|
-
const prefetchCount = Math.min(RESUME_CONTENT_PREFETCH, allSessions.length);
|
|
3595
|
-
for (const s of allSessions.slice(0, prefetchCount)) {
|
|
3596
|
-
// 行上已经带了服务端投影预览(DSH:title + turnOutline 末条)→ 跳过预取。
|
|
3597
|
-
// DSH 的 readSessionContent 每行走 3 次 unary:`session/list`(cwd 守卫)、
|
|
3598
|
-
// `session/projections`(取 throughSeq)、再 `session/page`(maxMessages=10000,
|
|
3599
|
-
// 拉整段历史)。按前 5 行预取等于 15 次同步 HTTP,而这些信息一次
|
|
3600
|
-
// `session/list` 就已经给全了。
|
|
3601
|
-
if (s.title || s.lastUserMessage || s.lastAssistantMessage)
|
|
3602
|
-
continue;
|
|
3603
|
-
// 优先走轻量摘要(只取标题/用量,不构造 events)——codex 实现了
|
|
3604
|
-
// readSessionSummary,避免对前 N 条各做一次全量 JSONL 解析来构造
|
|
3605
|
-
// 立即被丢弃的 events 数组。其他 agent 无此方法时回退全量读取再取字段。
|
|
3606
|
-
const meta = reader.readSessionSummary
|
|
3607
|
-
? reader.readSessionSummary(s.sessionId, cwd)
|
|
3608
|
-
: (() => {
|
|
3609
|
-
const c = reader.readSessionContent(s.sessionId, cwd);
|
|
3610
|
-
return { aiTitle: c.aiTitle, recap: c.recap, displayTitle: c.displayTitle };
|
|
3611
|
-
})();
|
|
3612
|
-
sessionMeta.set(s.sessionId, {
|
|
3613
|
-
aiTitle: meta.aiTitle,
|
|
3614
|
-
recap: meta.recap,
|
|
3615
|
-
displayTitle: meta.displayTitle,
|
|
3616
|
-
});
|
|
3617
|
-
}
|
|
3618
|
-
const now = Date.now();
|
|
3619
|
-
function formatRelativeTime(mtimeMs) {
|
|
3620
|
-
const diffSec = Math.floor((now - mtimeMs) / 1000);
|
|
3621
|
-
if (diffSec < 60)
|
|
3622
|
-
return '刚刚';
|
|
3623
|
-
if (diffSec < 3600)
|
|
3624
|
-
return `${Math.floor(diffSec / 60)} 分钟前`;
|
|
3625
|
-
if (diffSec < 86400)
|
|
3626
|
-
return `${Math.floor(diffSec / 3600)} 小时前`;
|
|
3627
|
-
if (diffSec < 604800)
|
|
3628
|
-
return `${Math.floor(diffSec / 86400)} 天前`;
|
|
3629
|
-
return new Date(mtimeMs).toLocaleDateString('zh-CN');
|
|
3630
|
-
}
|
|
3631
|
-
const elements = [
|
|
3632
|
-
{
|
|
3633
|
-
tag: 'div',
|
|
3634
|
-
text: { tag: 'lark_md', content: `📂 当前工作目录:\`${cwd}\`` },
|
|
3635
|
-
},
|
|
3636
|
-
];
|
|
3637
|
-
// Get current sessionId for the agent being listed
|
|
3638
|
-
const currentId = entry?.sessions?.get(agentKind) ?? '';
|
|
3639
|
-
for (const s of allSessions) {
|
|
3640
|
-
// Only mark as current if we're listing the defaultAgent's sessions
|
|
3641
|
-
const isCurrent = agentKind === this.config.defaultAgent && s.sessionId === currentId;
|
|
3642
|
-
const timeStr = formatRelativeTime(s.mtime);
|
|
3643
|
-
const buttonText = isCurrent ? '✓ 当前会话' : '恢复此会话';
|
|
3644
|
-
const meta = sessionMeta.get(s.sessionId);
|
|
3645
|
-
// Title priority:
|
|
3646
|
-
// 1. 行级 title(服务端投影,DSH)→ 「标题」。
|
|
3647
|
-
// 2. Prefetched displayTitle (aiTitle or last user message) keeps its
|
|
3648
|
-
// original label (AI 标题 / 最近输入).
|
|
3649
|
-
// 3. Otherwise the lightweight summary renders under the neutral
|
|
3650
|
-
// "会话摘要" label — summary is the first user message, not the
|
|
3651
|
-
// last input, so "最近输入" would be semantically wrong.
|
|
3652
|
-
// 4. Placeholder summaries (no user message) render no title section
|
|
3653
|
-
// at all — never leak '(无摘要)'/'(no user message)'/'New Session'.
|
|
3654
|
-
const summaryIsPlaceholder = RESUME_SUMMARY_PLACEHOLDERS.has(s.summary.trim());
|
|
3655
|
-
const prefetchedTitle = meta?.displayTitle;
|
|
3656
|
-
// 行级 title 也过占位符过滤:DSH 对空会话可能给 "New Session",旧路径
|
|
3657
|
-
// 的 summary 会被挡掉,新路径如果不过滤就会把它当真实标题渲染。
|
|
3658
|
-
const rowTitleRaw = s.title?.trim() ?? '';
|
|
3659
|
-
const rowTitle = rowTitleRaw && !RESUME_SUMMARY_PLACEHOLDERS.has(rowTitleRaw) ? rowTitleRaw : undefined;
|
|
3660
|
-
// aiTitle 不能单独作为"预取标题"键:若 displayTitle 缺失(理论上 opencode
|
|
3661
|
-
// info.title 非空但 export 无 user text),标题兜底是 summary,label 必须
|
|
3662
|
-
// 是中性"会话摘要"而不是"最近输入"(Review 边缘分支)。
|
|
3663
|
-
const titleText = prefetchedTitle ?? rowTitle ?? (summaryIsPlaceholder ? '' : s.summary);
|
|
3664
|
-
const titleLabel = prefetchedTitle
|
|
3665
|
-
? meta?.aiTitle
|
|
3666
|
-
? 'AI 标题'
|
|
3667
|
-
: '最近输入'
|
|
3668
|
-
: rowTitle
|
|
3669
|
-
? '标题'
|
|
3670
|
-
: summaryIsPlaceholder
|
|
3671
|
-
? ''
|
|
3672
|
-
: '会话摘要';
|
|
3673
|
-
const sections = [];
|
|
3674
|
-
if (titleText) {
|
|
3675
|
-
sections.push(`🏷️ **${titleLabel}**\n${titleText.slice(0, 80)}${titleText.length > 80 ? '...' : ''}`);
|
|
3676
|
-
}
|
|
3677
|
-
if (meta?.recap) {
|
|
3678
|
-
sections.push(`📝 **Recap**\n${meta.recap.slice(0, 60)}${meta.recap.length > 60 ? '...' : ''}`);
|
|
3679
|
-
}
|
|
3680
|
-
// 行级预览(DSH 服务端投影):最后一句我说了什么 / 最后一句它答了什么。
|
|
3681
|
-
// 只对提供了字段的 reader 渲染,其他 agent 的卡片一个字都不变。
|
|
3682
|
-
if (s.lastUserMessage) {
|
|
3683
|
-
sections.push(`✏️ **最近输入**\n${sessionPreviewLine(s.lastUserMessage, RESUME_PREVIEW_MAX)}`);
|
|
3684
|
-
}
|
|
3685
|
-
if (s.lastAssistantMessage) {
|
|
3686
|
-
sections.push(`📤 **最后输出**\n${sessionPreviewLine(s.lastAssistantMessage, RESUME_PREVIEW_MAX)}`);
|
|
3687
|
-
}
|
|
3688
|
-
const infoStr = sections.length > 0 ? '\n' + sections.join('\n──\n') : '';
|
|
3689
|
-
elements.push({ tag: 'hr' });
|
|
3690
|
-
// Both current and non-current sessions get a button for visual consistency
|
|
3691
|
-
elements.push({
|
|
3692
|
-
tag: 'div',
|
|
3693
|
-
text: {
|
|
3694
|
-
tag: 'lark_md',
|
|
3695
|
-
content: `**${s.sessionId}**${infoStr}\n⏱ ${timeStr}${isCurrent ? ' •' : ''}`,
|
|
3696
|
-
},
|
|
3697
|
-
});
|
|
3698
|
-
elements.push({
|
|
3699
|
-
tag: 'column_set',
|
|
3700
|
-
columns: [
|
|
3701
|
-
{
|
|
3702
|
-
tag: 'column',
|
|
3703
|
-
width: 'auto',
|
|
3704
|
-
elements: [
|
|
3705
|
-
cardButton(buttonText, { cmd: 'resume.use', sessionId: s.sessionId, agent: agentKind }, { type: isCurrent ? 'default' : 'primary', disabled: isCurrent }),
|
|
3706
|
-
],
|
|
3707
|
-
},
|
|
3708
|
-
],
|
|
3709
|
-
});
|
|
3710
|
-
}
|
|
3711
|
-
// Pagination bar (only shown when there are more sessions than pageSize)
|
|
3712
|
-
if (total > pageSize) {
|
|
3713
|
-
const { totalPages, currentPage } = pageInfo(total, pageOffset, pageSize);
|
|
3714
|
-
const bar = paginationBar({
|
|
3715
|
-
cmd: 'resume.page',
|
|
3716
|
-
offset: pageOffset,
|
|
3717
|
-
pageSize,
|
|
3718
|
-
total,
|
|
3719
|
-
extra: { agent: agentKind, pageSize },
|
|
3720
|
-
label: `第 ${currentPage}/${totalPages} 页 · 共 ${total} 个会话`,
|
|
3721
|
-
});
|
|
3722
|
-
elements.push({ tag: 'hr' });
|
|
3723
|
-
elements.push(...bar);
|
|
3724
|
-
}
|
|
3725
|
-
const card = {
|
|
3726
|
-
schema: '2.0',
|
|
3727
|
-
config: { wide_screen_mode: true },
|
|
3728
|
-
header: {
|
|
3729
|
-
title: {
|
|
3730
|
-
tag: 'plain_text',
|
|
3731
|
-
content: `🔁 恢复历史会话 · ${agentDisplayName(agentKind)}`,
|
|
3732
|
-
},
|
|
3733
|
-
},
|
|
3734
|
-
body: { elements },
|
|
3735
|
-
};
|
|
3736
|
-
return { card };
|
|
2842
|
+
return buildResumeCommand(args, ctx, offset, {
|
|
2843
|
+
defaultAgent: this.config.defaultAgent,
|
|
2844
|
+
validAgents: CommandRouter.VALID_AGENTS,
|
|
2845
|
+
getSessionEntry: (userId) => this.sessionStore.get(userId),
|
|
2846
|
+
setSessionId: (userId, agent, sessionId, cwd) => this.sessionStore.setSessionId(userId, agent, sessionId, cwd),
|
|
2847
|
+
defaultReader: () => this.sessionReader,
|
|
2848
|
+
readerFor: (agent) => this.sessionReaderRegistry.get(agent),
|
|
2849
|
+
dshPresetMismatch: (reader, sessionId, cwd) => this.dshPresetMismatch(reader, sessionId, cwd),
|
|
2850
|
+
readDisplay: (sessionId, cwd, opts) => this.readSessionDisplayState(sessionId, cwd, opts),
|
|
2851
|
+
buildActions: (opts) => this.buildResumeActionButtons(opts),
|
|
2852
|
+
canCompact: (agent, cwd) => this.canCompactSession(agent, cwd),
|
|
2853
|
+
});
|
|
3737
2854
|
}
|
|
3738
2855
|
cmdActive(_args, _ctx, offset = 0) {
|
|
3739
2856
|
// List all active runs from lark-remote memory (not from file system scan)
|
|
@@ -3957,216 +3074,17 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3957
3074
|
// 使用共享渲染模块构建卡片(Agent 选择器作为普通 select 字段参与渲染)
|
|
3958
3075
|
return { card: buildConfigCardFromTabs(tabs, displayConfig) };
|
|
3959
3076
|
}
|
|
3960
|
-
/** /order 实现(public
|
|
3961
|
-
cmdOrder(args,
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
this.orderStore.
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
}
|
|
3971
|
-
else {
|
|
3972
|
-
// Pagination calculations
|
|
3973
|
-
const { totalCount, totalPages, safeOffset, currentPage, pageItems: pageOrders, hasPagination, } = pageSlice(allOrders, offset, ORDER_PAGE_SIZE);
|
|
3974
|
-
for (let i = 0; i < pageOrders.length; i++) {
|
|
3975
|
-
const order = pageOrders[i];
|
|
3976
|
-
const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
|
|
3977
|
-
// 文字在上方(有别名时用 `$name` 前缀标识触发词)
|
|
3978
|
-
elements.push({
|
|
3979
|
-
tag: 'div',
|
|
3980
|
-
text: {
|
|
3981
|
-
tag: 'lark_md',
|
|
3982
|
-
content: order.alias ? `\`$${order.alias}\` ${displayText}` : displayText,
|
|
3983
|
-
},
|
|
3984
|
-
});
|
|
3985
|
-
// 操作行:左侧别名按钮(无别名显示 +别名,有别名点击可改),
|
|
3986
|
-
// 右侧「删除指令」按钮。两列均 weighted 1:1,跨行对齐。
|
|
3987
|
-
const leftColumn = order.alias
|
|
3988
|
-
? [
|
|
3989
|
-
cardButton(`$${order.alias}`, { cmd: 'order.aliasEdit', orderId: order.id, offset: safeOffset }, { type: 'default', size: 'small' }),
|
|
3990
|
-
cardButton('✕', {
|
|
3991
|
-
cmd: 'order.aliasRemove',
|
|
3992
|
-
orderId: order.id,
|
|
3993
|
-
aliasName: order.alias,
|
|
3994
|
-
offset: safeOffset,
|
|
3995
|
-
}, { type: 'default', size: 'small' }),
|
|
3996
|
-
]
|
|
3997
|
-
: [
|
|
3998
|
-
cardButton('+别名', { cmd: 'order.aliasEdit', orderId: order.id, offset: safeOffset }, { type: 'default', size: 'small' }),
|
|
3999
|
-
];
|
|
4000
|
-
elements.push({
|
|
4001
|
-
tag: 'column_set',
|
|
4002
|
-
columns: [
|
|
4003
|
-
{
|
|
4004
|
-
tag: 'column',
|
|
4005
|
-
width: 'weighted',
|
|
4006
|
-
weight: 1,
|
|
4007
|
-
elements: leftColumn,
|
|
4008
|
-
},
|
|
4009
|
-
{
|
|
4010
|
-
tag: 'column',
|
|
4011
|
-
width: 'weighted',
|
|
4012
|
-
weight: 1,
|
|
4013
|
-
vertical_align: 'center',
|
|
4014
|
-
elements: [
|
|
4015
|
-
{
|
|
4016
|
-
tag: 'column_set',
|
|
4017
|
-
columns: [
|
|
4018
|
-
{
|
|
4019
|
-
tag: 'column',
|
|
4020
|
-
width: 'auto',
|
|
4021
|
-
elements: [
|
|
4022
|
-
cardButton('▶ 执行', { cmd: 'order.exec', orderId: order.id }, { type: 'primary', size: 'small' }),
|
|
4023
|
-
],
|
|
4024
|
-
},
|
|
4025
|
-
{
|
|
4026
|
-
// 编辑:弹蓝色 header 输入卡(CardKit 2.0 input,回车提交),
|
|
4027
|
-
// 镜像 order.aliasEdit 流程。order.textEdit 是 control-only action
|
|
4028
|
-
// (isImmediateAction 注册),不 spawn agent。
|
|
4029
|
-
tag: 'column',
|
|
4030
|
-
width: 'auto',
|
|
4031
|
-
elements: [
|
|
4032
|
-
cardButton('编辑', { cmd: 'order.textEdit', orderId: order.id, offset: safeOffset }, { type: 'default', size: 'small' }),
|
|
4033
|
-
],
|
|
4034
|
-
},
|
|
4035
|
-
{
|
|
4036
|
-
tag: 'column',
|
|
4037
|
-
width: 'auto',
|
|
4038
|
-
elements: [
|
|
4039
|
-
cardButton('删除', { cmd: 'order.delete', orderId: order.id, offset: safeOffset }, { type: 'danger', size: 'small' }),
|
|
4040
|
-
],
|
|
4041
|
-
},
|
|
4042
|
-
],
|
|
4043
|
-
},
|
|
4044
|
-
],
|
|
4045
|
-
},
|
|
4046
|
-
],
|
|
4047
|
-
});
|
|
4048
|
-
// 非最后一项,添加分隔线
|
|
4049
|
-
if (i < pageOrders.length - 1) {
|
|
4050
|
-
elements.push({ tag: 'hr' });
|
|
4051
|
-
}
|
|
4052
|
-
}
|
|
4053
|
-
// Pagination bar (only shown when there are more items than ORDER_PAGE_SIZE)
|
|
4054
|
-
if (hasPagination) {
|
|
4055
|
-
const bar = paginationBar({
|
|
4056
|
-
cmd: 'order.page',
|
|
4057
|
-
offset: safeOffset,
|
|
4058
|
-
pageSize: ORDER_PAGE_SIZE,
|
|
4059
|
-
total: totalCount,
|
|
4060
|
-
label: `**第 ${currentPage}/${totalPages} 页**(共 ${totalCount} 条)`,
|
|
4061
|
-
});
|
|
4062
|
-
elements.push({ tag: 'hr' });
|
|
4063
|
-
elements.push(...bar);
|
|
4064
|
-
}
|
|
4065
|
-
}
|
|
4066
|
-
return {
|
|
4067
|
-
card: {
|
|
4068
|
-
schema: '2.0',
|
|
4069
|
-
config: { wide_screen_mode: true, update_multi: true },
|
|
4070
|
-
header: { title: { tag: 'plain_text', content: '📋 指令' }, template: 'turquoise' },
|
|
4071
|
-
body: { elements },
|
|
4072
|
-
},
|
|
4073
|
-
};
|
|
4074
|
-
}
|
|
4075
|
-
// /order alias — CLI 别名管理(卡片是主入口,命令是备选):
|
|
4076
|
-
// /order alias add <orderId|N> <name> — 给某条指令绑定别名
|
|
4077
|
-
// /order alias rm <orderId|N> — 移除某条指令的别名
|
|
4078
|
-
// /order alias list — 列出已绑定的别名(并入列表卡片)
|
|
4079
|
-
if (sub === 'alias') {
|
|
4080
|
-
const op = args[1];
|
|
4081
|
-
if (op === 'add') {
|
|
4082
|
-
const target = args[2];
|
|
4083
|
-
const name = args[3];
|
|
4084
|
-
if (!target || !name) {
|
|
4085
|
-
return { text: '用法: /order alias add <orderId|序号> <别名名>' };
|
|
4086
|
-
}
|
|
4087
|
-
return this.cmdOrderAliasAdd(target, name);
|
|
4088
|
-
}
|
|
4089
|
-
if (op === 'rm') {
|
|
4090
|
-
const target = args[2];
|
|
4091
|
-
if (!target) {
|
|
4092
|
-
return { text: '用法: /order alias rm <orderId|序号>' };
|
|
4093
|
-
}
|
|
4094
|
-
return this.cmdOrderAliasRemove(target);
|
|
4095
|
-
}
|
|
4096
|
-
if (!op || op === 'list') {
|
|
4097
|
-
// 别名管理列表并入列表卡片
|
|
4098
|
-
return this.cmdOrder([], _ctx, offset);
|
|
4099
|
-
}
|
|
4100
|
-
return {
|
|
4101
|
-
text: '用法: /order alias [add <orderId|序号> <别名名> | rm <orderId|序号> | list]',
|
|
4102
|
-
};
|
|
4103
|
-
}
|
|
4104
|
-
// /order save <text>
|
|
4105
|
-
if (sub === 'save') {
|
|
4106
|
-
const text = args.slice(1).join(' ');
|
|
4107
|
-
if (!text) {
|
|
4108
|
-
return { text: '用法: /order save <指令文本>' };
|
|
4109
|
-
}
|
|
4110
|
-
try {
|
|
4111
|
-
this.orderStore.save(text);
|
|
4112
|
-
return { text: `✅ 已保存指令: ${text.slice(0, 50)}${text.length > 50 ? '...' : ''}` };
|
|
4113
|
-
}
|
|
4114
|
-
catch (err) {
|
|
4115
|
-
return { text: `保存失败: ${err.message}` };
|
|
4116
|
-
}
|
|
4117
|
-
}
|
|
4118
|
-
// /order edit <orderId|序号> <新文本> — CLI 备选路径(卡片是主入口)。
|
|
4119
|
-
// 与 /order save / alias add|rm 对称;复用 resolveOrderTarget。
|
|
4120
|
-
// 与卡片路径(handleOrderTextInput)对齐 trim 语义,避免「卡片 trim、CLI 不 trim」
|
|
4121
|
-
// 两条入口对同一文本存出不同结果。
|
|
4122
|
-
if (sub === 'edit') {
|
|
4123
|
-
const target = args[1];
|
|
4124
|
-
const newText = args.slice(2).join(' ').trim();
|
|
4125
|
-
if (!target || !newText) {
|
|
4126
|
-
return { text: '用法: /order edit <orderId|序号> <新文本>' };
|
|
4127
|
-
}
|
|
4128
|
-
const order = this.resolveOrderTarget(target);
|
|
4129
|
-
if (!order)
|
|
4130
|
-
return { text: `指令不存在: ${target}` };
|
|
4131
|
-
try {
|
|
4132
|
-
this.orderStore.updateText(order.id, newText);
|
|
4133
|
-
const preview = newText.length > 50 ? newText.slice(0, 50) + '...' : newText;
|
|
4134
|
-
return { text: `✅ 已更新指令: ${preview}` };
|
|
4135
|
-
}
|
|
4136
|
-
catch (err) {
|
|
4137
|
-
return { text: `保存失败: ${err.message}` };
|
|
4138
|
-
}
|
|
4139
|
-
}
|
|
4140
|
-
return {
|
|
4141
|
-
text: '用法: /order [list|save <text>|edit <orderId|序号> <新文本>|alias add <orderId|序号> <别名名>|alias rm <orderId|序号>]',
|
|
4142
|
-
};
|
|
4143
|
-
}
|
|
4144
|
-
/** /order alias add:按 orderId 或列表序号(1 基)解析目标指令并绑定别名。 */
|
|
4145
|
-
cmdOrderAliasAdd(target, name) {
|
|
4146
|
-
const order = this.resolveOrderTarget(target);
|
|
4147
|
-
if (!order)
|
|
4148
|
-
return { text: `指令不存在: ${target}` };
|
|
4149
|
-
try {
|
|
4150
|
-
this.orderStore.setAlias(order.id, name);
|
|
4151
|
-
return { text: `✅ 已给指令绑定别名 $${name}` };
|
|
4152
|
-
}
|
|
4153
|
-
catch (err) {
|
|
4154
|
-
return { text: `保存失败: ${err.message}` };
|
|
4155
|
-
}
|
|
4156
|
-
}
|
|
4157
|
-
/** /order alias rm:按 orderId 或列表序号(1 基)移除指令的别名。 */
|
|
4158
|
-
cmdOrderAliasRemove(target) {
|
|
4159
|
-
const order = this.resolveOrderTarget(target);
|
|
4160
|
-
if (!order)
|
|
4161
|
-
return { text: `指令不存在: ${target}` };
|
|
4162
|
-
if (!order.alias)
|
|
4163
|
-
return { text: '该指令没有别名' };
|
|
4164
|
-
// 先读别名名再解绑:setAlias(undefined) 会 delete 同一对象引用上的 alias 字段。
|
|
4165
|
-
const aliasName = order.alias;
|
|
4166
|
-
this.orderStore.setAlias(order.id, undefined);
|
|
4167
|
-
return { text: `✅ 已移除别名 $${aliasName}` };
|
|
3077
|
+
/** /order 实现(public:测试直接调用)。 */
|
|
3078
|
+
cmdOrder(args, ctx, offset = 0) {
|
|
3079
|
+
return buildOrderCommand(args, ctx, offset, {
|
|
3080
|
+
reload: () => this.orderStore.reload(),
|
|
3081
|
+
list: () => this.orderStore.get(),
|
|
3082
|
+
save: (text) => this.orderStore.save(text),
|
|
3083
|
+
updateText: (id, text) => this.orderStore.updateText(id, text),
|
|
3084
|
+
setAlias: (id, alias) => this.orderStore.setAlias(id, alias),
|
|
3085
|
+
resolveTarget: (target) => this.resolveOrderTarget(target),
|
|
3086
|
+
});
|
|
4168
3087
|
}
|
|
4169
|
-
/** 将 `/order alias add|rm` 的目标解析为 order:优先 orderId 精确匹配,回退列表序号(1 基)。 */
|
|
4170
3088
|
resolveOrderTarget(target) {
|
|
4171
3089
|
const all = this.orderStore.get();
|
|
4172
3090
|
const byId = all.find((o) => o.id === target);
|