lark-remote 0.1.3 → 0.1.4
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 +65 -0
- package/dist/bridge/index.d.ts +5 -1
- package/dist/bridge/index.js +3 -1
- package/dist/index.js +3 -1
- package/dist/router/config/index.d.ts +10 -0
- package/dist/router/config/index.js +17 -0
- package/dist/router/index.d.ts +26 -0
- package/dist/router/index.js +241 -15
- package/dist/workspace/index.d.ts +19 -1
- package/dist/workspace/index.js +51 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,67 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.1.4] - 2026-08-13
|
|
9
|
+
|
|
10
|
+
### 新增
|
|
11
|
+
|
|
12
|
+
- 工作区列表支持 `lastUsedAt` 排序,最近使用的 workspace 排在前面;排序切换 UI 移至卡片上方,明确显示当前模式与切换目标
|
|
13
|
+
- `/config` 卡片:未安装的 Coding Agent 沉底排序,已安装 agent 保持注册顺序;探测结果过期(`undefined`)时保持原位不误沉底
|
|
14
|
+
|
|
15
|
+
### 修复
|
|
16
|
+
|
|
17
|
+
- `/ws`、`/order` 卡片分页降为 15 条/页,修复飞书 ErrCode 11310 元素超限
|
|
18
|
+
- workspace 排序相关 P2/P3 review findings 修复
|
|
19
|
+
|
|
20
|
+
## [0.1.3] - 2026-08-12
|
|
21
|
+
|
|
22
|
+
### 新增
|
|
23
|
+
|
|
24
|
+
- Agent 可用性探测:启动时自动检测本机已安装/可用的 Agent CLI(`which` 探测),不可用的 Agent 会在 `/config` 卡片中标注;探测失败不阻塞启动,打开 `/config` 时会重试
|
|
25
|
+
- 首次运行引导:PIN 绑定成功后自动设置默认工作目录,并发送欢迎卡片 + 帮助卡片
|
|
26
|
+
- `message.patch` 业务码可观测性探针
|
|
27
|
+
- Context 统计支持显示占比:codex 上报 `context window` 上限时,卡片按百分比展示上下文用量
|
|
28
|
+
|
|
29
|
+
### 修复
|
|
30
|
+
|
|
31
|
+
- `displayTitle`/`summary` 跳过 task-notification 注入
|
|
32
|
+
- 修复 `scanClaudeSessions` 对迁移到其他目录的会话的遗漏(S2)
|
|
33
|
+
- 修复 probe 生命周期、工厂回退、卡片回调等评审发现的问题(F1-F8)
|
|
34
|
+
- 清理引用已删除文档(lessons-redlines、resume-card-budget-failure-analysis)导致的失效链接
|
|
35
|
+
|
|
36
|
+
### 变更
|
|
37
|
+
|
|
38
|
+
- 提取 `createAgentRegistries` 工厂,统一各 Agent 注册逻辑
|
|
39
|
+
- 移除 Agent 二进制路径配置,CLI 名称改为硬编码
|
|
40
|
+
- 移除 `pnpm-lock.yaml`,统一使用 `bun.lock` 管理依赖
|
|
41
|
+
- 新增 husky lint-staged pre-commit / pre-push 钩子(提交前 lint + typecheck)
|
|
42
|
+
- 启用 npm provenance(构建产物可溯源)
|
|
43
|
+
- 移除 watchdog 相关用户文档及 `open-source-playbook` 等临时文档
|
|
44
|
+
|
|
45
|
+
### 测试
|
|
46
|
+
|
|
47
|
+
- 合并 stub 工厂、净化 fixtures、移除冗余测试
|
|
48
|
+
- 新增 P1-15 configContainer 锚点测试(含 codex 负向断言)
|
|
49
|
+
|
|
50
|
+
## [0.1.2] - 2026-08-10
|
|
51
|
+
|
|
52
|
+
### 修复
|
|
53
|
+
|
|
54
|
+
- claude / opencode / kimi 的 Agent 工厂改为从 `configContainer` 读取配置,避免使用过期闭包导致配置不生效(P1-15)
|
|
55
|
+
- codex / kimi 的 `readSessionContent` 增加 cwd 校验,防止越界读取会话
|
|
56
|
+
- `listClaudeSessions` / `isClaudeSessionActive` 支持跨目录回退,处理会话迁移后的定位
|
|
57
|
+
- 修复 `handleQueueInput` 竞态:替换任务在 await 之前注册
|
|
58
|
+
- 完成卡片 `resume.use` 携带 agent 字段,确保会话 reader 路由正确
|
|
59
|
+
|
|
60
|
+
### 维护
|
|
61
|
+
|
|
62
|
+
- 升级 `@vitest/coverage-v8` 至 4.1.10
|
|
63
|
+
- 移除 CLI 子进程 smoke test,清理测试套件
|
|
64
|
+
|
|
65
|
+
## [0.1.1] - 2026-08-09
|
|
66
|
+
|
|
67
|
+
版本号 0.1.0 → 0.1.1,无用户可见功能变更。
|
|
68
|
+
|
|
8
69
|
## [0.1.0] - 2026-08-06
|
|
9
70
|
|
|
10
71
|
Initial release.
|
|
@@ -50,3 +111,7 @@ Initial release.
|
|
|
50
111
|
- Live Feishu API tests gated behind `FEISHU_LIVE_TEST=1` (external contributors can run full test suite without credentials)
|
|
51
112
|
|
|
52
113
|
[0.1.0]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.0
|
|
114
|
+
[0.1.1]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.1
|
|
115
|
+
[0.1.2]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.2
|
|
116
|
+
[0.1.3]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.3
|
|
117
|
+
[0.1.4]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.4
|
package/dist/bridge/index.d.ts
CHANGED
|
@@ -57,7 +57,11 @@ interface BridgeDeps {
|
|
|
57
57
|
/** Workspace store for fallback when sessionStore has no cwd */
|
|
58
58
|
workspaceStore?: {
|
|
59
59
|
get(name: string): string | undefined;
|
|
60
|
-
list():
|
|
60
|
+
list(): Array<{
|
|
61
|
+
name: string;
|
|
62
|
+
path: string;
|
|
63
|
+
lastUsedAt: number;
|
|
64
|
+
}>;
|
|
61
65
|
};
|
|
62
66
|
/** Override the idle watchdog timeout (default 15 min). Tests use a small value. */
|
|
63
67
|
idleTimeoutMs?: number;
|
package/dist/bridge/index.js
CHANGED
|
@@ -542,7 +542,9 @@ export class Bridge {
|
|
|
542
542
|
if (!cwd && this.workspaceStore) {
|
|
543
543
|
const workspaces = this.workspaceStore.list();
|
|
544
544
|
if (workspaces.length > 0) {
|
|
545
|
-
|
|
545
|
+
// NOTE: fallback uses insertion order, not sort preference — by design
|
|
546
|
+
// (workspace-sorting.md §9: cwd fallback stays insertion-order for now)
|
|
547
|
+
cwd = workspaces[0].path;
|
|
546
548
|
getLogger().info(`[bridge] resolveCwd fallback cwd=${cwd} (first saved workspace)`);
|
|
547
549
|
}
|
|
548
550
|
}
|
package/dist/index.js
CHANGED
|
@@ -394,7 +394,9 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
|
|
|
394
394
|
if (!workspace && workspaceStore) {
|
|
395
395
|
const workspaces = workspaceStore.list();
|
|
396
396
|
if (workspaces.length > 0) {
|
|
397
|
-
|
|
397
|
+
// NOTE: fallback uses insertion order, not sort preference — by design
|
|
398
|
+
// (workspace-sorting.md §9: cwd fallback stays insertion-order for now)
|
|
399
|
+
workspace = workspaces[0].path;
|
|
398
400
|
}
|
|
399
401
|
}
|
|
400
402
|
// Step4/D4: 入队时刻(T0)快照 agent+session,随任务闭包带到 T1 执行时刻,
|
|
@@ -16,3 +16,13 @@ import type { AgentConfigCardBuilder } from './types.js';
|
|
|
16
16
|
export declare function getConfigBuilder(agent: AgentKind): AgentConfigCardBuilder;
|
|
17
17
|
/** List all registered agent kinds. */
|
|
18
18
|
export declare function listRegisteredAgents(): AgentKind[];
|
|
19
|
+
/**
|
|
20
|
+
* Sort agent kinds for UI lists: agents that are definitively unavailable
|
|
21
|
+
* (availability === false) sink to the bottom; everything else keeps its
|
|
22
|
+
* input order. The sort is stable, so relative order within both groups is
|
|
23
|
+
* preserved (e.g. registration order in the config card selector).
|
|
24
|
+
*
|
|
25
|
+
* `undefined` (probe cache missing/expired) is treated as "keep in place",
|
|
26
|
+
* so a stale cache cannot misplace installed agents.
|
|
27
|
+
*/
|
|
28
|
+
export declare function sortAgentsByAvailability(kinds: AgentKind[], isAvailable: (kind: AgentKind) => boolean | undefined): AgentKind[];
|
|
@@ -35,4 +35,21 @@ export function getConfigBuilder(agent) {
|
|
|
35
35
|
export function listRegisteredAgents() {
|
|
36
36
|
return Object.keys(builders);
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Sort agent kinds for UI lists: agents that are definitively unavailable
|
|
40
|
+
* (availability === false) sink to the bottom; everything else keeps its
|
|
41
|
+
* input order. The sort is stable, so relative order within both groups is
|
|
42
|
+
* preserved (e.g. registration order in the config card selector).
|
|
43
|
+
*
|
|
44
|
+
* `undefined` (probe cache missing/expired) is treated as "keep in place",
|
|
45
|
+
* so a stale cache cannot misplace installed agents.
|
|
46
|
+
*/
|
|
47
|
+
export function sortAgentsByAvailability(kinds, isAvailable) {
|
|
48
|
+
const unavailable = new Set(kinds.filter((kind) => isAvailable(kind) === false));
|
|
49
|
+
return [...kinds].sort((a, b) => {
|
|
50
|
+
const rankA = unavailable.has(a) ? 1 : 0;
|
|
51
|
+
const rankB = unavailable.has(b) ? 1 : 0;
|
|
52
|
+
return rankA - rankB;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
38
55
|
//# sourceMappingURL=index.js.map
|
package/dist/router/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ interface CommandResult {
|
|
|
17
17
|
* These bypass the serial queue and execute immediately.
|
|
18
18
|
*
|
|
19
19
|
* ws.use — /ws list card "使用 <name>"
|
|
20
|
+
* ws.page — /ws list card "上一页/下一页"
|
|
20
21
|
* queue.immediate — queue card "立即执行" button (§9.6)
|
|
21
22
|
* queue.cancel — queue card "撤销" button
|
|
22
23
|
* queue.diagnose — queue card "诊断" button
|
|
@@ -25,6 +26,7 @@ interface CommandResult {
|
|
|
25
26
|
* new-session — clear sessionId only
|
|
26
27
|
* stop — interrupt current run
|
|
27
28
|
* ls.file — send file only
|
|
29
|
+
* ws.page — paginate /ws list only
|
|
28
30
|
* ws.remove — delete workspace alias only
|
|
29
31
|
* resume.use — set sessionId + agent for correct reader routing
|
|
30
32
|
* help.* — read-only help commands
|
|
@@ -98,6 +100,11 @@ export declare class CommandRouter {
|
|
|
98
100
|
* millisecond); the counter guarantees uniqueness regardless of timing.
|
|
99
101
|
*/
|
|
100
102
|
private orderExecKeyCounter;
|
|
103
|
+
/**
|
|
104
|
+
* Per-user sort preference for /ws list (memory-only, not persisted).
|
|
105
|
+
* Default 'recent' (most recently used first), can toggle to 'alpha'.
|
|
106
|
+
*/
|
|
107
|
+
private wsSortPreference;
|
|
101
108
|
/**
|
|
102
109
|
* Session reader registry for multi-agent path. Resolves the right
|
|
103
110
|
* reader by `config.defaultAgent` (and per-agent override params).
|
|
@@ -235,12 +242,31 @@ export declare class CommandRouter {
|
|
|
235
242
|
* Mirrors handleLsPage — updates the same card, never sends a new one.
|
|
236
243
|
*/
|
|
237
244
|
private handleOrderPage;
|
|
245
|
+
/**
|
|
246
|
+
* Handle ws.use card action: switch to the workspace, then refresh the /ws
|
|
247
|
+
* list card in place so "recent" sort order is immediately visible.
|
|
248
|
+
*
|
|
249
|
+
* Unlike the command-line `/ws use` (which returns text), the card action
|
|
250
|
+
* uses toast + inplace refresh for a cleaner UX (matching ws.remove/ws.sort).
|
|
251
|
+
* If auto-resume produces a card, it is sent as a separate reply.
|
|
252
|
+
*/
|
|
253
|
+
private handleWsUse;
|
|
238
254
|
/**
|
|
239
255
|
* Handle ws.remove: remove the workspace alias and refresh the /ws list card
|
|
240
256
|
* in place (mirrors handleOrderDelete). Without this the stale alias would
|
|
241
257
|
* remain visible on the card the user just clicked.
|
|
242
258
|
*/
|
|
243
259
|
private handleWsRemove;
|
|
260
|
+
/**
|
|
261
|
+
* Handle ws.page: paginate the /ws list in place.
|
|
262
|
+
* Mirrors handleOrderPage — updates the same card, never sends a new one.
|
|
263
|
+
*/
|
|
264
|
+
private handleWsPage;
|
|
265
|
+
/**
|
|
266
|
+
* Handle ws.sort: toggle sort preference and refresh the /ws list card.
|
|
267
|
+
* Sort preference is memory-only (not persisted); restart resets to 'recent'.
|
|
268
|
+
*/
|
|
269
|
+
private handleWsSort;
|
|
244
270
|
/**
|
|
245
271
|
* `ls.file` card button: send file to Feishu, rejecting if > 30MB.
|
|
246
272
|
*/
|
package/dist/router/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { markdownDiv, sessionEventPanel } from './card-helpers.js';
|
|
|
12
12
|
import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
|
|
13
13
|
import { atomicWrite } from '../persistence/atomic-write.js';
|
|
14
14
|
/** Config card builder - delegates to per-agent builders */
|
|
15
|
-
import { getConfigBuilder, listRegisteredAgents } from './config/index.js';
|
|
15
|
+
import { getConfigBuilder, listRegisteredAgents, sortAgentsByAvailability, } from './config/index.js';
|
|
16
16
|
import { probeAllAgents, getCachedAvailability } from '../runner/probe.js';
|
|
17
17
|
import { buildConfigCardFromTabs } from './config/common/render.js';
|
|
18
18
|
/** Max events to read for auto-resume and /resume cards.
|
|
@@ -23,8 +23,19 @@ const AUTO_RESUME_MAX_EVENTS = 5;
|
|
|
23
23
|
const RESUME_PAGE_SIZE = 5;
|
|
24
24
|
/** /active 卡片每页显示的最大条目数(agent run + bash run 合计)。 */
|
|
25
25
|
const ACTIVE_PAGE_SIZE = 20;
|
|
26
|
-
/**
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* /order 列表页大小;指令超过此数量时显示分页导航栏。
|
|
28
|
+
* 2026-08-13 实测:每行 2 个元素(div + column_set,行间 hr),20 行 + 分页栏
|
|
29
|
+
* = 61 个 body 元素,触发飞书 ErrCode 11310 "element exceeds the limit"
|
|
30
|
+
* (21+ 条指令时第 1 页必炸)。改为 15 行:3*15 - 1 + 2 = 46 个元素,留足余量。
|
|
31
|
+
*/
|
|
32
|
+
const ORDER_PAGE_SIZE = 15;
|
|
33
|
+
/**
|
|
34
|
+
* /ws 列表页大小。从 15 降为 5:头部 2 + 5 行 × 3(div + column_set + hr)+
|
|
35
|
+
* 底栏排序 1 + 分页栏 2 ≈ 20 个 body 元素,远低于单卡 60 元素上限(ErrCode 11310),
|
|
36
|
+
* 留足余量。
|
|
37
|
+
*/
|
|
38
|
+
const WS_PAGE_SIZE = 5;
|
|
28
39
|
/** /resume 列表页内全量预取(readSessionContent)的行数上限;其余行用轻量 summary 兜底。 */
|
|
29
40
|
const RESUME_CONTENT_PREFETCH = 5;
|
|
30
41
|
/**
|
|
@@ -39,6 +50,7 @@ import { newSessionButton, agentDisplayName } from '../card/card-shared.js';
|
|
|
39
50
|
* These bypass the serial queue and execute immediately.
|
|
40
51
|
*
|
|
41
52
|
* ws.use — /ws list card "使用 <name>"
|
|
53
|
+
* ws.page — /ws list card "上一页/下一页"
|
|
42
54
|
* queue.immediate — queue card "立即执行" button (§9.6)
|
|
43
55
|
* queue.cancel — queue card "撤销" button
|
|
44
56
|
* queue.diagnose — queue card "诊断" button
|
|
@@ -47,6 +59,7 @@ import { newSessionButton, agentDisplayName } from '../card/card-shared.js';
|
|
|
47
59
|
* new-session — clear sessionId only
|
|
48
60
|
* stop — interrupt current run
|
|
49
61
|
* ls.file — send file only
|
|
62
|
+
* ws.page — paginate /ws list only
|
|
50
63
|
* ws.remove — delete workspace alias only
|
|
51
64
|
* resume.use — set sessionId + agent for correct reader routing
|
|
52
65
|
* help.* — read-only help commands
|
|
@@ -67,7 +80,9 @@ export function isImmediateAction(cmd) {
|
|
|
67
80
|
cmd === 'ls.page' ||
|
|
68
81
|
cmd === 'resume.page' || // control operation: paginate only, never spawns claude
|
|
69
82
|
cmd === 'active.page' || // control operation: paginate active card
|
|
83
|
+
cmd === 'ws.page' || // control operation: paginate /ws list only
|
|
70
84
|
cmd === 'ws.remove' ||
|
|
85
|
+
cmd === 'ws.sort' || // control operation: toggle sort mode only
|
|
71
86
|
cmd === 'resume.use' ||
|
|
72
87
|
cmd === 'ws.use' ||
|
|
73
88
|
cmd === 'queue.immediate' ||
|
|
@@ -108,6 +123,11 @@ export class CommandRouter {
|
|
|
108
123
|
* millisecond); the counter guarantees uniqueness regardless of timing.
|
|
109
124
|
*/
|
|
110
125
|
orderExecKeyCounter = 0;
|
|
126
|
+
/**
|
|
127
|
+
* Per-user sort preference for /ws list (memory-only, not persisted).
|
|
128
|
+
* Default 'recent' (most recently used first), can toggle to 'alpha'.
|
|
129
|
+
*/
|
|
130
|
+
wsSortPreference = new Map();
|
|
111
131
|
/**
|
|
112
132
|
* Session reader registry for multi-agent path. Resolves the right
|
|
113
133
|
* reader by `config.defaultAgent` (and per-agent override params).
|
|
@@ -204,8 +224,11 @@ export class CommandRouter {
|
|
|
204
224
|
case 'ls.page':
|
|
205
225
|
return this.handleLsPage(value, ctx);
|
|
206
226
|
case 'ws.use':
|
|
207
|
-
await this.
|
|
208
|
-
|
|
227
|
+
return await this.handleWsUse(value, ctx);
|
|
228
|
+
case 'ws.sort':
|
|
229
|
+
return await this.handleWsSort(value, ctx);
|
|
230
|
+
case 'ws.page':
|
|
231
|
+
return await this.handleWsPage(value, ctx);
|
|
209
232
|
case 'ws.remove':
|
|
210
233
|
return await this.handleWsRemove(value, ctx);
|
|
211
234
|
case 'resume.use': {
|
|
@@ -932,6 +955,39 @@ export class CommandRouter {
|
|
|
932
955
|
await this.bridge.updateCardInPlace(result.card, ctx);
|
|
933
956
|
return { toast: { type: 'success', content: '' } };
|
|
934
957
|
}
|
|
958
|
+
/**
|
|
959
|
+
* Handle ws.use card action: switch to the workspace, then refresh the /ws
|
|
960
|
+
* list card in place so "recent" sort order is immediately visible.
|
|
961
|
+
*
|
|
962
|
+
* Unlike the command-line `/ws use` (which returns text), the card action
|
|
963
|
+
* uses toast + inplace refresh for a cleaner UX (matching ws.remove/ws.sort).
|
|
964
|
+
* If auto-resume produces a card, it is sent as a separate reply.
|
|
965
|
+
*/
|
|
966
|
+
async handleWsUse(value, ctx) {
|
|
967
|
+
const name = value.name ?? '';
|
|
968
|
+
const useResult = this.cmdWs(['use', name], ctx);
|
|
969
|
+
// Error cases (workspace not found, path invalid, etc.): relay as error toast
|
|
970
|
+
if (useResult.text && !useResult.text.includes('已切换')) {
|
|
971
|
+
return { toast: { type: 'error', content: useResult.text } };
|
|
972
|
+
}
|
|
973
|
+
// Success: if auto-resume produced a card, send it as a reply
|
|
974
|
+
if (useResult.card) {
|
|
975
|
+
await this.bridge.sendResult(useResult, ctx);
|
|
976
|
+
}
|
|
977
|
+
// Refresh the /ws list card in place so "recent" sort is immediately visible
|
|
978
|
+
const currentOffset = Math.max(0, Math.trunc(Number(value.offset) || 0));
|
|
979
|
+
const refreshed = this.cmdWs([], ctx, currentOffset);
|
|
980
|
+
if (refreshed.card) {
|
|
981
|
+
await this.bridge.updateCardInPlace(refreshed.card, ctx);
|
|
982
|
+
}
|
|
983
|
+
// Toast feedback (suppress text when auto-resume card was already sent)
|
|
984
|
+
return {
|
|
985
|
+
toast: {
|
|
986
|
+
type: 'success',
|
|
987
|
+
content: useResult.card ? '' : (useResult.text ?? `已切换到 workspace "${name}"`),
|
|
988
|
+
},
|
|
989
|
+
};
|
|
990
|
+
}
|
|
935
991
|
/**
|
|
936
992
|
* Handle ws.remove: remove the workspace alias and refresh the /ws list card
|
|
937
993
|
* in place (mirrors handleOrderDelete). Without this the stale alias would
|
|
@@ -945,11 +1001,40 @@ export class CommandRouter {
|
|
|
945
1001
|
// Execute the removal (consumes the write-side effect; the returned text
|
|
946
1002
|
// is discarded — feedback flows through the toast + refreshed card).
|
|
947
1003
|
this.cmdWs(['remove', name], ctx);
|
|
948
|
-
// Rebuild the /ws list card and update it in place
|
|
949
|
-
|
|
1004
|
+
// Rebuild the /ws list card and update it in place, preserving the page
|
|
1005
|
+
// the user was on when they clicked 删除.
|
|
1006
|
+
const currentOffset = Math.max(0, Math.trunc(Number(value.offset) || 0));
|
|
1007
|
+
const refreshed = this.cmdWs([], ctx, currentOffset);
|
|
950
1008
|
await this.bridge.updateCardInPlace(refreshed.card, ctx);
|
|
951
1009
|
return { toast: { type: 'success', content: `已删除 workspace "${name}"` } };
|
|
952
1010
|
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Handle ws.page: paginate the /ws list in place.
|
|
1013
|
+
* Mirrors handleOrderPage — updates the same card, never sends a new one.
|
|
1014
|
+
*/
|
|
1015
|
+
async handleWsPage(value, ctx) {
|
|
1016
|
+
const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
|
|
1017
|
+
// cmdWs internally clamps stale/out-of-range offsets
|
|
1018
|
+
const result = this.cmdWs([], ctx, offset);
|
|
1019
|
+
await this.bridge.updateCardInPlace(result.card, ctx);
|
|
1020
|
+
return { toast: { type: 'success', content: '' } };
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Handle ws.sort: toggle sort preference and refresh the /ws list card.
|
|
1024
|
+
* Sort preference is memory-only (not persisted); restart resets to 'recent'.
|
|
1025
|
+
*/
|
|
1026
|
+
async handleWsSort(value, ctx) {
|
|
1027
|
+
const userId = ctx.userId;
|
|
1028
|
+
const current = this.wsSortPreference.get(userId) ?? 'recent';
|
|
1029
|
+
const next = current === 'recent' ? 'alpha' : 'recent';
|
|
1030
|
+
this.wsSortPreference.set(userId, next);
|
|
1031
|
+
const result = this.cmdWs([], ctx, 0);
|
|
1032
|
+
await this.bridge.updateCardInPlace(result.card, ctx);
|
|
1033
|
+
const nextLabel = next === 'recent' ? '🕐 最近使用' : '🔤 字母顺序';
|
|
1034
|
+
return {
|
|
1035
|
+
toast: { type: 'success', content: `已切换为 ${nextLabel}` },
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
953
1038
|
/**
|
|
954
1039
|
* `ls.file` card button: send file to Feishu, rejecting if > 30MB.
|
|
955
1040
|
*/
|
|
@@ -2030,7 +2115,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2030
2115
|
return { text: `读取目录失败: ${err.message}` };
|
|
2031
2116
|
}
|
|
2032
2117
|
}
|
|
2033
|
-
cmdWs(args, ctx) {
|
|
2118
|
+
cmdWs(args, ctx, offset = 0) {
|
|
2034
2119
|
const sub = args[0]?.toLowerCase();
|
|
2035
2120
|
switch (sub) {
|
|
2036
2121
|
case 'save': {
|
|
@@ -2063,6 +2148,8 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2063
2148
|
catch {
|
|
2064
2149
|
return { text: `路径无效: ${wsPath}` };
|
|
2065
2150
|
}
|
|
2151
|
+
// Record usage timestamp AFTER successful path validation
|
|
2152
|
+
this.workspaceStore.touch(name);
|
|
2066
2153
|
// Switch cwd with auto-resume and user feedback (shared with /cd and ls.switch)
|
|
2067
2154
|
return this.switchCwdAndNotify(ctx.userId, canonical, ctx);
|
|
2068
2155
|
}
|
|
@@ -2079,6 +2166,29 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2079
2166
|
default: {
|
|
2080
2167
|
const entries = this.workspaceStore.list();
|
|
2081
2168
|
const currentCwd = this.sessionStore.getCwd(ctx.userId);
|
|
2169
|
+
const sortMode = this.wsSortPreference.get(ctx.userId) ?? 'recent';
|
|
2170
|
+
// Sort entries by the current preference (view concern, not stored)
|
|
2171
|
+
if (sortMode === 'recent') {
|
|
2172
|
+
// Most recently used first; same lastUsedAt → alphabetical by name (deterministic)
|
|
2173
|
+
entries.sort((a, b) => {
|
|
2174
|
+
if (b.lastUsedAt !== a.lastUsedAt)
|
|
2175
|
+
return b.lastUsedAt - a.lastUsedAt;
|
|
2176
|
+
return a.name.localeCompare(b.name);
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
else {
|
|
2180
|
+
// Alphabetical by alias name
|
|
2181
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
2182
|
+
}
|
|
2183
|
+
// Pagination calculations (mirror cmdOrder): clamp stale/out-of-range
|
|
2184
|
+
// offsets so prev/next always step by WS_PAGE_SIZE.
|
|
2185
|
+
const totalCount = entries.length;
|
|
2186
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / WS_PAGE_SIZE));
|
|
2187
|
+
const maxOffset = Math.max(0, (totalPages - 1) * WS_PAGE_SIZE);
|
|
2188
|
+
const safeOffset = Math.min(Math.max(offset, 0), maxOffset);
|
|
2189
|
+
const currentPage = Math.floor(safeOffset / WS_PAGE_SIZE) + 1;
|
|
2190
|
+
const pageEntries = entries.slice(safeOffset, safeOffset + WS_PAGE_SIZE);
|
|
2191
|
+
const hasPagination = totalCount > WS_PAGE_SIZE;
|
|
2082
2192
|
// Build body elements: current cwd + workspace list with dividers
|
|
2083
2193
|
const bodyElements = [];
|
|
2084
2194
|
// Current working directory section
|
|
@@ -2086,18 +2196,56 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2086
2196
|
tag: 'div',
|
|
2087
2197
|
text: { tag: 'lark_md', content: `📂 当前工作目录:\`${currentCwd ?? '(未设置)'}\`` },
|
|
2088
2198
|
});
|
|
2089
|
-
bodyElements.push({ tag: 'hr' });
|
|
2090
2199
|
if (entries.length === 0) {
|
|
2200
|
+
bodyElements.push({ tag: 'hr' });
|
|
2091
2201
|
bodyElements.push({
|
|
2092
2202
|
tag: 'div',
|
|
2093
2203
|
text: { tag: 'lark_md', content: '没有保存的 workspace' },
|
|
2094
2204
|
});
|
|
2095
2205
|
}
|
|
2096
2206
|
else {
|
|
2097
|
-
|
|
2207
|
+
// Sort mode indicator + toggle button: placed above the list so the user
|
|
2208
|
+
// sees "current mode" and "switch to X" before scanning entries.
|
|
2209
|
+
// On narrow (mobile) screens, the label and button must convey both
|
|
2210
|
+
// what-is-active and what-clicking-does to avoid ambiguity.
|
|
2211
|
+
const currentLabel = sortMode === 'recent' ? '🕐 最近使用' : '🔤 字母顺序';
|
|
2212
|
+
const switchLabel = sortMode === 'recent' ? '🔤 字母顺序' : '🕐 最近使用';
|
|
2213
|
+
bodyElements.push({ tag: 'hr' });
|
|
2214
|
+
bodyElements.push({
|
|
2215
|
+
tag: 'column_set',
|
|
2216
|
+
columns: [
|
|
2217
|
+
{
|
|
2218
|
+
tag: 'column',
|
|
2219
|
+
width: 'weighted',
|
|
2220
|
+
weight: 1,
|
|
2221
|
+
vertical_align: 'center',
|
|
2222
|
+
elements: [
|
|
2223
|
+
{
|
|
2224
|
+
tag: 'div',
|
|
2225
|
+
text: { tag: 'lark_md', content: `排序:${currentLabel}` },
|
|
2226
|
+
},
|
|
2227
|
+
],
|
|
2228
|
+
},
|
|
2229
|
+
{
|
|
2230
|
+
tag: 'column',
|
|
2231
|
+
width: 'auto',
|
|
2232
|
+
elements: [
|
|
2233
|
+
{
|
|
2234
|
+
tag: 'button',
|
|
2235
|
+
text: { tag: 'plain_text', content: `切换为 ${switchLabel}` },
|
|
2236
|
+
type: 'default',
|
|
2237
|
+
size: 'small',
|
|
2238
|
+
behaviors: [{ type: 'callback', value: { cmd: 'ws.sort' } }],
|
|
2239
|
+
},
|
|
2240
|
+
],
|
|
2241
|
+
},
|
|
2242
|
+
],
|
|
2243
|
+
});
|
|
2244
|
+
bodyElements.push({ tag: 'hr' });
|
|
2245
|
+
for (const entry of pageEntries) {
|
|
2098
2246
|
bodyElements.push({
|
|
2099
2247
|
tag: 'div',
|
|
2100
|
-
text: { tag: 'lark_md', content: `**${name}** → \`${
|
|
2248
|
+
text: { tag: 'lark_md', content: `**${entry.name}** → \`${entry.path}\`` },
|
|
2101
2249
|
});
|
|
2102
2250
|
// Use column_set+column for 2.0 (action tag not supported in 2.0)
|
|
2103
2251
|
bodyElements.push({
|
|
@@ -2112,7 +2260,12 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2112
2260
|
text: { tag: 'plain_text', content: '切换' },
|
|
2113
2261
|
type: 'primary',
|
|
2114
2262
|
size: 'small',
|
|
2115
|
-
behaviors: [
|
|
2263
|
+
behaviors: [
|
|
2264
|
+
{
|
|
2265
|
+
type: 'callback',
|
|
2266
|
+
value: { cmd: 'ws.use', name: entry.name, offset: safeOffset },
|
|
2267
|
+
},
|
|
2268
|
+
],
|
|
2116
2269
|
},
|
|
2117
2270
|
],
|
|
2118
2271
|
},
|
|
@@ -2125,15 +2278,86 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2125
2278
|
text: { tag: 'plain_text', content: '删除' },
|
|
2126
2279
|
type: 'danger',
|
|
2127
2280
|
size: 'small',
|
|
2128
|
-
behaviors: [
|
|
2281
|
+
behaviors: [
|
|
2282
|
+
{
|
|
2283
|
+
type: 'callback',
|
|
2284
|
+
value: { cmd: 'ws.remove', name: entry.name, offset: safeOffset },
|
|
2285
|
+
},
|
|
2286
|
+
],
|
|
2129
2287
|
},
|
|
2130
2288
|
],
|
|
2131
2289
|
},
|
|
2132
2290
|
],
|
|
2133
2291
|
});
|
|
2134
2292
|
bodyElements.push({ tag: 'hr' });
|
|
2135
|
-
}
|
|
2293
|
+
}
|
|
2136
2294
|
bodyElements.pop();
|
|
2295
|
+
// Pagination bar — separate row below the list (not crowded with sort
|
|
2296
|
+
// toggle; mobile-friendly vertical layout).
|
|
2297
|
+
if (hasPagination) {
|
|
2298
|
+
const hasPrev = safeOffset > 0;
|
|
2299
|
+
const hasNext = safeOffset + WS_PAGE_SIZE < totalCount;
|
|
2300
|
+
const pageColumns = [];
|
|
2301
|
+
if (hasPrev) {
|
|
2302
|
+
pageColumns.push({
|
|
2303
|
+
tag: 'column',
|
|
2304
|
+
width: 'auto',
|
|
2305
|
+
vertical_align: 'center',
|
|
2306
|
+
elements: [
|
|
2307
|
+
{
|
|
2308
|
+
tag: 'button',
|
|
2309
|
+
text: { tag: 'plain_text', content: '⬅' },
|
|
2310
|
+
type: 'default',
|
|
2311
|
+
size: 'small',
|
|
2312
|
+
behaviors: [
|
|
2313
|
+
{
|
|
2314
|
+
type: 'callback',
|
|
2315
|
+
value: { cmd: 'ws.page', offset: safeOffset - WS_PAGE_SIZE },
|
|
2316
|
+
},
|
|
2317
|
+
],
|
|
2318
|
+
},
|
|
2319
|
+
],
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
pageColumns.push({
|
|
2323
|
+
tag: 'column',
|
|
2324
|
+
width: 'weighted',
|
|
2325
|
+
weight: 1,
|
|
2326
|
+
vertical_align: 'center',
|
|
2327
|
+
elements: [
|
|
2328
|
+
{
|
|
2329
|
+
tag: 'div',
|
|
2330
|
+
text: {
|
|
2331
|
+
tag: 'lark_md',
|
|
2332
|
+
content: `**${currentPage}/${totalPages}**(${totalCount})`,
|
|
2333
|
+
},
|
|
2334
|
+
},
|
|
2335
|
+
],
|
|
2336
|
+
});
|
|
2337
|
+
if (hasNext) {
|
|
2338
|
+
pageColumns.push({
|
|
2339
|
+
tag: 'column',
|
|
2340
|
+
width: 'auto',
|
|
2341
|
+
vertical_align: 'center',
|
|
2342
|
+
elements: [
|
|
2343
|
+
{
|
|
2344
|
+
tag: 'button',
|
|
2345
|
+
text: { tag: 'plain_text', content: '➡' },
|
|
2346
|
+
type: 'default',
|
|
2347
|
+
size: 'small',
|
|
2348
|
+
behaviors: [
|
|
2349
|
+
{
|
|
2350
|
+
type: 'callback',
|
|
2351
|
+
value: { cmd: 'ws.page', offset: safeOffset + WS_PAGE_SIZE },
|
|
2352
|
+
},
|
|
2353
|
+
],
|
|
2354
|
+
},
|
|
2355
|
+
],
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
bodyElements.push({ tag: 'hr' });
|
|
2359
|
+
bodyElements.push({ tag: 'column_set', columns: pageColumns });
|
|
2360
|
+
}
|
|
2137
2361
|
}
|
|
2138
2362
|
const card = {
|
|
2139
2363
|
schema: '2.0',
|
|
@@ -2718,7 +2942,9 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2718
2942
|
// Direct inline construction (not via ConfigField) so that text (display label)
|
|
2719
2943
|
// and value (agentKind for config storage) can differ — uninstalled agents get
|
|
2720
2944
|
// a "⚠️ (未安装)" suffix in the label while the value stays clean.
|
|
2721
|
-
|
|
2945
|
+
// Uninstalled agents sink to the bottom; installed ones keep registration
|
|
2946
|
+
// order (claude → codex → pi → opencode → kimi).
|
|
2947
|
+
const allAgents = sortAgentsByAvailability(listRegisteredAgents(), getCachedAvailability);
|
|
2722
2948
|
const agentOptions = allAgents.map((kind) => {
|
|
2723
2949
|
const available = getCachedAvailability(kind);
|
|
2724
2950
|
const label = available === false ? `${agentDisplayName(kind)} ⚠️ (未安装)` : agentDisplayName(kind);
|
|
@@ -1,9 +1,18 @@
|
|
|
1
|
+
/** Public view of a workspace entry returned by list(). */
|
|
2
|
+
export interface WorkspaceEntry {
|
|
3
|
+
name: string;
|
|
4
|
+
path: string;
|
|
5
|
+
lastUsedAt: number;
|
|
6
|
+
}
|
|
1
7
|
/**
|
|
2
8
|
* Persistent named-alias store for workspace directories.
|
|
3
9
|
*
|
|
4
10
|
* Atomic write (§9.10): writes a temp file then `fs.rename` so a crash
|
|
5
11
|
* mid-write cannot leave a truncated JSON file. On load, a corrupt file
|
|
6
12
|
* is treated as empty (with a warning) so the bridge can still start.
|
|
13
|
+
*
|
|
14
|
+
* Data migration: old format `{ alias: "path" }` is normalized to
|
|
15
|
+
* `{ alias: { path, lastUsedAt: 0 } }` on first load and persisted.
|
|
7
16
|
*/
|
|
8
17
|
export declare class WorkspaceStore {
|
|
9
18
|
private filePath;
|
|
@@ -15,5 +24,14 @@ export declare class WorkspaceStore {
|
|
|
15
24
|
get(name: string): string | undefined;
|
|
16
25
|
has(name: string): boolean;
|
|
17
26
|
remove(name: string): void;
|
|
18
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Update lastUsedAt to now for the given alias and persist.
|
|
29
|
+
* No-op if the alias does not exist.
|
|
30
|
+
*/
|
|
31
|
+
touch(name: string): void;
|
|
32
|
+
/**
|
|
33
|
+
* List all workspace entries in insertion order.
|
|
34
|
+
* Sorting is the caller's responsibility (view concern).
|
|
35
|
+
*/
|
|
36
|
+
list(): WorkspaceEntry[];
|
|
19
37
|
}
|
package/dist/workspace/index.js
CHANGED
|
@@ -2,12 +2,16 @@ import path from 'node:path';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import { atomicWriteJson } from '../persistence/atomic-write.js';
|
|
4
4
|
import { loadJsonFile } from '../persistence/load-json-file.js';
|
|
5
|
+
import { getLogger } from '../logger/index.js';
|
|
5
6
|
/**
|
|
6
7
|
* Persistent named-alias store for workspace directories.
|
|
7
8
|
*
|
|
8
9
|
* Atomic write (§9.10): writes a temp file then `fs.rename` so a crash
|
|
9
10
|
* mid-write cannot leave a truncated JSON file. On load, a corrupt file
|
|
10
11
|
* is treated as empty (with a warning) so the bridge can still start.
|
|
12
|
+
*
|
|
13
|
+
* Data migration: old format `{ alias: "path" }` is normalized to
|
|
14
|
+
* `{ alias: { path, lastUsedAt: 0 } }` on first load and persisted.
|
|
11
15
|
*/
|
|
12
16
|
export class WorkspaceStore {
|
|
13
17
|
filePath;
|
|
@@ -21,9 +25,32 @@ export class WorkspaceStore {
|
|
|
21
25
|
const parsed = loadJsonFile(this.filePath, undefined);
|
|
22
26
|
if (!parsed)
|
|
23
27
|
return;
|
|
28
|
+
let needsMigration = false;
|
|
24
29
|
for (const [k, v] of Object.entries(parsed)) {
|
|
25
|
-
if (typeof v === 'string')
|
|
26
|
-
|
|
30
|
+
if (typeof v === 'string') {
|
|
31
|
+
// Old format: string value → { path, lastUsedAt: 0 }
|
|
32
|
+
this.data.set(k, { path: v, lastUsedAt: 0 });
|
|
33
|
+
needsMigration = true;
|
|
34
|
+
}
|
|
35
|
+
else if (typeof v === 'object' &&
|
|
36
|
+
v !== null &&
|
|
37
|
+
typeof v.path === 'string') {
|
|
38
|
+
// New format: extract path and lastUsedAt (default 0 if missing)
|
|
39
|
+
const obj = v;
|
|
40
|
+
const lastUsedAt = typeof obj.lastUsedAt === 'number' ? obj.lastUsedAt : 0;
|
|
41
|
+
if (typeof obj.lastUsedAt !== 'number') {
|
|
42
|
+
if (obj.lastUsedAt !== undefined) {
|
|
43
|
+
getLogger().warn(`[workspace] "${k}" has invalid lastUsedAt (${JSON.stringify(obj.lastUsedAt)}), resetting to 0`);
|
|
44
|
+
}
|
|
45
|
+
needsMigration = true;
|
|
46
|
+
}
|
|
47
|
+
this.data.set(k, { path: obj.path, lastUsedAt });
|
|
48
|
+
}
|
|
49
|
+
// Non-string, non-object entries are silently skipped (existing behavior)
|
|
50
|
+
}
|
|
51
|
+
// Write back immediately if migration occurred
|
|
52
|
+
if (needsMigration) {
|
|
53
|
+
this.persist();
|
|
27
54
|
}
|
|
28
55
|
}
|
|
29
56
|
persist() {
|
|
@@ -33,11 +60,11 @@ export class WorkspaceStore {
|
|
|
33
60
|
atomicWriteJson(this.filePath, obj);
|
|
34
61
|
}
|
|
35
62
|
save(name, dirPath) {
|
|
36
|
-
this.data.set(name, dirPath);
|
|
63
|
+
this.data.set(name, { path: dirPath, lastUsedAt: 0 });
|
|
37
64
|
this.persist();
|
|
38
65
|
}
|
|
39
66
|
get(name) {
|
|
40
|
-
return this.data.get(name);
|
|
67
|
+
return this.data.get(name)?.path;
|
|
41
68
|
}
|
|
42
69
|
has(name) {
|
|
43
70
|
return this.data.has(name);
|
|
@@ -46,8 +73,27 @@ export class WorkspaceStore {
|
|
|
46
73
|
this.data.delete(name);
|
|
47
74
|
this.persist();
|
|
48
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Update lastUsedAt to now for the given alias and persist.
|
|
78
|
+
* No-op if the alias does not exist.
|
|
79
|
+
*/
|
|
80
|
+
touch(name) {
|
|
81
|
+
const entry = this.data.get(name);
|
|
82
|
+
if (!entry)
|
|
83
|
+
return;
|
|
84
|
+
entry.lastUsedAt = Date.now();
|
|
85
|
+
this.persist();
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* List all workspace entries in insertion order.
|
|
89
|
+
* Sorting is the caller's responsibility (view concern).
|
|
90
|
+
*/
|
|
49
91
|
list() {
|
|
50
|
-
return [...this.data.entries()]
|
|
92
|
+
return [...this.data.entries()].map(([name, data]) => ({
|
|
93
|
+
name,
|
|
94
|
+
path: data.path,
|
|
95
|
+
lastUsedAt: data.lastUsedAt,
|
|
96
|
+
}));
|
|
51
97
|
}
|
|
52
98
|
}
|
|
53
99
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lark-remote",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Feishu (Lark) private-chat bridge to local coding agents — Claude Code / Codex / opencode / pi / kimi, with live-streaming CardKit 2.0 cards",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|