lark-remote 0.1.7 → 0.1.9

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/bridge/approval-coordinator.d.ts +30 -1
  3. package/dist/bridge/approval-coordinator.js +93 -5
  4. package/dist/bridge/index.d.ts +36 -6
  5. package/dist/bridge/index.js +56 -10
  6. package/dist/card/approval-render.js +287 -48
  7. package/dist/card/card-shared.d.ts +6 -4
  8. package/dist/card/card-shared.js +6 -4
  9. package/dist/card/run-renderer.d.ts +7 -0
  10. package/dist/card/run-renderer.js +22 -11
  11. package/dist/config/index.d.ts +24 -1
  12. package/dist/config/index.js +34 -3
  13. package/dist/index.js +40 -16
  14. package/dist/order/alias-resolve.d.ts +6 -2
  15. package/dist/order/index.d.ts +9 -2
  16. package/dist/order/index.js +33 -0
  17. package/dist/router/card-action-payload.d.ts +30 -0
  18. package/dist/router/card-action-payload.js +39 -0
  19. package/dist/router/config/claude.js +8 -3
  20. package/dist/router/config/dsh.d.ts +51 -0
  21. package/dist/router/config/dsh.js +179 -0
  22. package/dist/router/config/index.d.ts +1 -1
  23. package/dist/router/config/index.js +4 -1
  24. package/dist/router/config/types.d.ts +6 -0
  25. package/dist/router/index.d.ts +39 -4
  26. package/dist/router/index.js +471 -90
  27. package/dist/router/utils.js +12 -2
  28. package/dist/runner/agent-choices-common.js +3 -0
  29. package/dist/runner/claude/runner.js +22 -0
  30. package/dist/runner/claude/session.d.ts +26 -0
  31. package/dist/runner/claude/session.js +105 -9
  32. package/dist/runner/codex/app-server/protocol-types.d.ts +35 -0
  33. package/dist/runner/codex/app-server/protocol-types.js +1 -1
  34. package/dist/runner/codex/app-server/runner.d.ts +10 -0
  35. package/dist/runner/codex/app-server/runner.js +56 -1
  36. package/dist/runner/codex/app-server/translator.d.ts +7 -0
  37. package/dist/runner/codex/app-server/translator.js +45 -0
  38. package/dist/runner/common/acp/protocol-types.d.ts +33 -0
  39. package/dist/runner/common/acp/protocol-types.js +2 -0
  40. package/dist/runner/common/connection-based-runner.js +10 -5
  41. package/dist/runner/dsh/client.d.ts +95 -0
  42. package/dist/runner/dsh/client.js +418 -0
  43. package/dist/runner/dsh/fake-dsh-server.d.ts +60 -0
  44. package/dist/runner/dsh/fake-dsh-server.js +156 -0
  45. package/dist/runner/dsh/index.d.ts +3 -0
  46. package/dist/runner/dsh/index.js +4 -0
  47. package/dist/runner/dsh/runner.d.ts +57 -0
  48. package/dist/runner/dsh/runner.js +211 -0
  49. package/dist/runner/dsh/translator.d.ts +28 -0
  50. package/dist/runner/dsh/translator.js +98 -0
  51. package/dist/runner/dsh/types.d.ts +155 -0
  52. package/dist/runner/dsh/types.js +14 -0
  53. package/dist/runner/index.d.ts +1 -0
  54. package/dist/runner/index.js +1 -0
  55. package/dist/runner/kimi/acp/runner.js +84 -1
  56. package/dist/runner/kimi/acp/translator.d.ts +14 -0
  57. package/dist/runner/kimi/acp/translator.js +108 -3
  58. package/dist/runner/pi/rpc/runner.d.ts +1 -7
  59. package/dist/runner/pi/rpc/runner.js +8 -12
  60. package/dist/runner/probe.js +6 -1
  61. package/dist/runner/question-common.d.ts +26 -0
  62. package/dist/runner/question-common.js +42 -0
  63. package/dist/runner/types.d.ts +94 -10
  64. package/dist/session/claude/sessions.js +37 -15
  65. package/dist/session/dsh/index.d.ts +1 -0
  66. package/dist/session/dsh/index.js +2 -0
  67. package/dist/session/dsh/sessions.d.ts +44 -0
  68. package/dist/session/dsh/sessions.js +252 -0
  69. package/dist/session/index.d.ts +1 -0
  70. package/dist/session/index.js +1 -0
  71. package/dist/workspace/index.d.ts +12 -0
  72. package/dist/workspace/index.js +28 -2
  73. package/package.json +2 -1
  74. package/dist/order/alias-store.d.ts +0 -38
  75. package/dist/order/alias-store.js +0 -106
@@ -25,11 +25,18 @@ export interface ApprovalRequestedEvent {
25
25
  type: 'approval_requested';
26
26
  /** JSON-RPC id of the server request. Wire type is string | integer — keep raw. */
27
27
  requestId: number | string;
28
- kind: 'command' | 'file' | 'permissions' | 'question';
28
+ kind: 'command' | 'file' | 'permissions' | 'question' | 'tool';
29
29
  threadId: string;
30
30
  turnId: string;
31
31
  itemId: string;
32
32
  view: ApprovalView;
33
+ /**
34
+ * Per-request 审批超时覆盖(毫秒)。由 runner 在翻译时填入
35
+ * (Codex autoResolutionMs),coordinator 优先使用;
36
+ * 缺省回落 run 级默认超时。放在事件上而非桥按 agentKind 分支,
37
+ * 保证公共层无 agent 分支。
38
+ */
39
+ timeoutMs?: number;
33
40
  timestamp?: string;
34
41
  }
35
42
  export interface ApprovalResolvedEvent {
@@ -62,27 +69,74 @@ export type ApprovalAction = {
62
69
  /** Claude:允许当前请求 + 本会话后续所有权限请求自动放行(允许所有)。 */
63
70
  | {
64
71
  action: 'accept_all';
72
+ }
73
+ /**
74
+ * 计划审批「拒绝并附修改意见」:deny + message,Claude 留在 plan 模式
75
+ * 按意见修订(等价 TUI「Tell Claude what to change」)。
76
+ */
77
+ | {
78
+ action: 'decline_with_feedback';
79
+ message: string;
80
+ }
81
+ /**
82
+ * 计划审批「批准并采纳修改意见」:allow + updatedInput.plan(原计划追加
83
+ * 意见),触发 Claude 侧 planWasEdited → tool_result 回显
84
+ * "## Approved Plan (edited by user)"(等价 TUI shift+tab approve with feedback)。
85
+ */
86
+ | {
87
+ action: 'accept_with_feedback';
88
+ plan: string;
65
89
  } | {
66
90
  action: 'decline';
67
91
  } | {
68
92
  action: 'cancel';
69
93
  }
70
- /** Claude AskUserQuestion 答案(question → label 或 label[])。 */
94
+ /**
95
+ * AskUserQuestion 答案(question → label 或 label[])。
96
+ * notes:Codex user_note 补充说明(question → 文本),runner 编码为
97
+ * "user_note: <text>" 条目;其他 agent 忽略该字段。
98
+ */
71
99
  | {
72
100
  action: 'answer';
73
101
  answers: Record<string, string | string[]>;
102
+ notes?: Record<string, string>;
74
103
  };
75
- /** Claude Code AskUserQuestion 的单个选项(control_request input.questions[].options[])。 */
76
- export interface ClaudeUserQuestionOption {
104
+ /** AskUserQuestion 公共契约(Agent 无关)的单个选项。 */
105
+ export interface UserQuestionOption {
77
106
  label: string;
78
107
  description?: string;
79
108
  }
80
- /** Claude Code AskUserQuestion 的单个问题(control_request input.questions[])。 */
81
- export interface ClaudeUserQuestion {
109
+ /**
110
+ * AskUserQuestion 公共契约(Agent 无关)的单个问题。
111
+ *
112
+ * 接入约定(新 agent 接入 AskUserQuestion 时在这里扩展,禁止在 coordinator /
113
+ * renderer / router 里写 agent 分支):
114
+ * 1. 各 agent runner 把协议提问翻译为 `ApprovalView{ kind:'question', questions }`;
115
+ * 2. 用户回答经 ApprovalCoordinator 收集,以 `ApprovalAction{action:'answer',
116
+ * answers:{问题文本: string|string[]}}` 回传 runner;
117
+ * 3. runner 侧把文本 key 答案翻译回自己协议的 shape(Claude 直接用文本 key;
118
+ * Codex 需按问题 id 映射)。
119
+ */
120
+ export interface UserQuestion {
121
+ /** Agent 侧问题唯一标识(Codex request_user_input 的 id 预留;Claude 无此字段)。 */
122
+ id?: string;
82
123
  question: string;
83
124
  header?: string;
84
125
  multiSelect?: boolean;
85
- options: ClaudeUserQuestionOption[];
126
+ options: UserQuestionOption[];
127
+ /** 是否允许自定义答案(Codex isOther 预留;当前单选渲染层恒提供 Other 输入)。 */
128
+ isOther?: boolean;
129
+ /** 敏感输入(Codex isSecret 预留)。 */
130
+ isSecret?: boolean;
131
+ /** 自由文本题的输入框占位提示(渲染层自由文本题 input)。 */
132
+ placeholder?: string;
133
+ /**
134
+ * 是否渲染「补充说明(可选)」输入(Codex user_note;数据驱动无 agent 分支)。
135
+ * note 是选项之外的附加文本,随答案提交,不单独构成答案。
136
+ */
137
+ allowNote?: boolean;
138
+ /** 当前已填写的补充说明(卡片回显;coordinator answerNote 回流)。 */
139
+ note?: string;
86
140
  /**
87
141
  * 当前已选选项 label(卡片渲染与多选切换用;单选在协调器内即时提交)。
88
142
  * 与 permissions.items.selected 同模式:协调器原地更新后以
@@ -92,10 +146,38 @@ export interface ClaudeUserQuestion {
92
146
  }
93
147
  export interface ApprovalView {
94
148
  requestId: number | string;
95
- kind: 'command' | 'file' | 'permissions' | 'question';
149
+ kind: 'command' | 'file' | 'permissions' | 'question' | 'tool';
96
150
  reason?: string;
151
+ /**
152
+ * 提问卡顶部概要行(数据驱动,无 agent 分支):Kimi elicitation form 的
153
+ * 题面只在 form 级 message 里合并出现,逐字段 title 可能只是短 header,
154
+ * 概要行用于展示完整题干。
155
+ */
156
+ intro?: string;
97
157
  command?: string;
98
158
  commandCwd?: string;
159
+ /**
160
+ * 被审批的工具名(kind === 'tool' 时展示,如 ExitPlanMode)。非命令工具的
161
+ * 权限请求(plan 退出/通知型工具)input 无 command 语义,用工具名 + reason
162
+ * 表达审批内容,避免落入 command 槽位显示无意义 `{}`。
163
+ */
164
+ toolName?: string;
165
+ /**
166
+ * 计划全文(kind === 'tool' 且 toolName === 'ExitPlanMode' 时存在)。
167
+ * 来源:control_request input.plan(CLI normalizeToolInput 从磁盘注入,
168
+ * 非稳定)→ 计划文件读取(input.planFilePath / 会话内 Write/Edit 跟踪)。
169
+ */
170
+ plan?: string;
171
+ /** 计划文件路径(ExitPlanMode;input.planFilePath 或会话内跟踪所得)。 */
172
+ planFilePath?: string;
173
+ /**
174
+ * 用户已填写的计划修改意见(ExitPlanMode 反馈输入框;coordinator
175
+ * planFeedback 回流,卡片回显)。供「拒绝并附意见」/「批准并采纳修改」
176
+ * 两个决策复用。
177
+ */
178
+ planFeedback?: string;
179
+ /** 原始 input 的 JSON 字符串(kind === 'tool' 且 input 非空时展示)。 */
180
+ toolInput?: string;
99
181
  fileChanges?: Array<{
100
182
  path: string;
101
183
  kind: 'add' | 'update' | 'delete';
@@ -115,7 +197,7 @@ export interface ApprovalView {
115
197
  }>;
116
198
  };
117
199
  /** Claude AskUserQuestion 问题列表(kind === 'question' 时存在)。 */
118
- questions?: ClaudeUserQuestion[];
200
+ questions?: UserQuestion[];
119
201
  availableDecisions: string[];
120
202
  /** Raw payloads for structured decisions (e.g. acceptWithExecpolicyAmendment). */
121
203
  decisionPayloads?: Record<string, unknown>;
@@ -283,12 +365,14 @@ export interface Runner {
283
365
  */
284
366
  unregisterExitHandlers?(): void;
285
367
  }
286
- export type AgentKind = 'claude' | 'codex' | 'opencode' | 'pi' | 'kimi';
368
+ export type AgentKind = 'claude' | 'codex' | 'opencode' | 'pi' | 'kimi' | 'dsh';
287
369
  /** Unified session descriptor returned by every AgentSessionReader. */
288
370
  export interface AgentSession {
289
371
  sessionId: string;
290
372
  summary: string;
291
373
  mtime: number;
374
+ /** DSH:session 创建时固定的 preset;其他 agent 无此概念(undefined)。 */
375
+ agentPreset?: string;
292
376
  }
293
377
  /** A single renderable event from session history. */
294
378
  export interface AgentSessionContentEvent {
@@ -154,7 +154,7 @@ function isSessionActive(filePath, mtimeMs, now = Date.now()) {
154
154
  // Explicit terminal markers — regardless of mtime. Claude CLI 2.x uses
155
155
  // `last-prompt` / `mode` (no `result` event) to mark turn end, so a
156
156
  // 24-minute-old session with a last-prompt tail is already done
157
- // (regression 2026-06-21: c5e929a7 falsely reported as active).
157
+ // (regression 2026-06-21: previously falsely reported as active).
158
158
  const TERMINAL_TYPES = new Set(['result', 'last-prompt', 'mode', 'permission-mode']);
159
159
  if (typeof obj.type === 'string' && TERMINAL_TYPES.has(obj.type))
160
160
  return false;
@@ -224,7 +224,16 @@ export function isClaudeSessionActive(sessionId, cwd, opts = {}) {
224
224
  */
225
225
  function scalarScan(filePath) {
226
226
  const acc = new UsageAccumulator();
227
- const seenMessageIds = new Set();
227
+ // Per-message-id best usage record (per-field max). A streaming assistant
228
+ // message spans multiple jsonl lines sharing the same message id; some
229
+ // backends (third-party gateways, e.g. DeepSeek) write all-zero usage on
230
+ // the early placeholder lines (empty thinking block) and the real usage
231
+ // only on the final line carrying stop_reason. First-occurrence-wins dedup
232
+ // would pin the zero record and discard the real one (regression
233
+ // 2026-08-19: auto-resume card per-run stats all 0). Per-field max
234
+ // reconstructs the full usage regardless of line order — on the official
235
+ // API every line repeats identical usage, so max is a no-op there.
236
+ const usageByMessageId = new Map();
228
237
  let lastPostTokens = 0;
229
238
  let tailOffset = -1;
230
239
  let lastAiTitle;
@@ -244,13 +253,13 @@ function scalarScan(filePath) {
244
253
  const usage = msg?.usage;
245
254
  if (usage) {
246
255
  const msgId = msg?.id;
247
- if (msgId && !seenMessageIds.has(msgId)) {
248
- seenMessageIds.add(msgId);
249
- acc.add({
250
- input: usage.input_tokens ?? 0,
251
- output: usage.output_tokens ?? 0,
252
- cacheRead: usage.cache_read_input_tokens ?? 0,
253
- cacheCreation: usage.cache_creation_input_tokens ?? 0,
256
+ if (msgId) {
257
+ const prev = usageByMessageId.get(msgId);
258
+ usageByMessageId.set(msgId, {
259
+ input: Math.max(prev?.input ?? 0, usage.input_tokens ?? 0),
260
+ output: Math.max(prev?.output ?? 0, usage.output_tokens ?? 0),
261
+ cacheRead: Math.max(prev?.cacheRead ?? 0, usage.cache_read_input_tokens ?? 0),
262
+ cacheCreation: Math.max(prev?.cacheCreation ?? 0, usage.cache_creation_input_tokens ?? 0),
254
263
  });
255
264
  }
256
265
  }
@@ -305,6 +314,12 @@ function scalarScan(filePath) {
305
314
  }
306
315
  }
307
316
  });
317
+ // Feed one best record per message id, in first-appearance order (Map
318
+ // preserves insertion order), so acc.last is the LAST message's full usage
319
+ // and totals sum each API response exactly once.
320
+ for (const record of usageByMessageId.values()) {
321
+ acc.add(record);
322
+ }
308
323
  // Build usage result (same logic as old aggregateSessionUsage)
309
324
  const t = acc.totals;
310
325
  const l = acc.last;
@@ -319,14 +334,19 @@ function scalarScan(filePath) {
319
334
  if (contextLength === 0 || lastWindow > contextLength) {
320
335
  contextLength = lastWindow;
321
336
  }
337
+ // 非累计字段 = 末轮(本 run)scope,累计字段 = session 总和(所有 run)。
338
+ // 对齐 codex reader 语义(§9.21:`last_token_usage`=单 turn 增量、
339
+ // `total_token_usage`=session 累计)——否则 jsonl 兜底路径会把 session 累计
340
+ // 当成"本 run"显示,与卡片"本 run ≤ 累计"不变量冲突(累计反而更小)。
341
+ const lastTotal = l ? l.input + l.output + l.cacheRead + l.cacheCreation : 0;
322
342
  const usage = {
323
- inputTokens: t.input,
324
- outputTokens: t.output,
343
+ inputTokens: l ? l.input : 0,
344
+ outputTokens: l ? l.output : 0,
325
345
  contextLength,
326
346
  compactCount: acc.compactCount,
327
- cacheReadTokens: t.cacheRead > 0 ? t.cacheRead : undefined,
328
- cacheCreationTokens: t.cacheCreation > 0 ? t.cacheCreation : undefined,
329
- totalTokens: t.input + t.output + t.cacheRead + t.cacheCreation,
347
+ cacheReadTokens: l && l.cacheRead > 0 ? l.cacheRead : undefined,
348
+ cacheCreationTokens: l && l.cacheCreation > 0 ? l.cacheCreation : undefined,
349
+ totalTokens: lastTotal,
330
350
  cumulativeTotalTokens: t.input + t.output + t.cacheRead + t.cacheCreation,
331
351
  cumulativeInputTokens: t.input,
332
352
  cumulativeOutputTokens: t.output,
@@ -435,7 +455,9 @@ export function readSessionContent(sessionId, cwd, opts = {}) {
435
455
  // ~3×). Total parse count adds the index-miss fallback guard's fixed
436
456
  // early-stop overhead (~2 parses, independent of line count).
437
457
  const scan = scalarScan(filePath);
438
- const usage = scan.usage.inputTokens + scan.usage.outputTokens > 0 ? scan.usage : undefined;
458
+ // 有 usage 即返回:非累计字段已是末轮 scope,命中判定用 session 累计兜底
459
+ // (末轮可能 input/output 为 0 而仍有 cacheRead,累计口径不会被末轮遮挡)。
460
+ const usage = (scan.usage.cumulativeTotalTokens ?? 0) > 0 ? scan.usage : undefined;
439
461
  // Extract events AFTER the last user message by re-reading + re-parsing
440
462
  // only the tail. If no user message was found (tailOffset === -1), fall
441
463
  // back to re-reading the whole file so the card still has something to
@@ -0,0 +1 @@
1
+ export { DshSessionReader } from './sessions.js';
@@ -0,0 +1,2 @@
1
+ export { DshSessionReader } from './sessions.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * DshSessionReader — AgentSessionReader backed by the DSH Web Host over HTTP.
3
+ *
4
+ * The AgentSessionReader contract is fully synchronous (file-style readers),
5
+ * but DSH exposes only async HTTP. The reader therefore bridges with
6
+ * `spawnSync('curl', ...)` for its unary calls (session.list / session.history)
7
+ * — a synchronous HTTP round-trip to the local DSH host. This keeps the
8
+ * router/bridge interface contract intact without touching any upstream logic.
9
+ *
10
+ * listSessions: session.list → filter by cwd → sort by updatedAt desc → local
11
+ * pagination (DSH v1 has no cursor pagination).
12
+ * readSessionContent: session.history → map SessionEvents to display events +
13
+ * session-wide usage accumulation (assistant/message.usage).
14
+ */
15
+ import type { AgentSession, AgentSessionReader, SessionContent } from '../../runner/index.js';
16
+ interface DshSessionReaderOptions {
17
+ host?: string;
18
+ /** 每次调用时解析当前 host(/config 修改 host 后 runner 重建、reader 也跟随新 host)。
19
+ * 优先于固定的 `host`。 */
20
+ hostProvider?: () => string | undefined;
21
+ /** Injectable synchronous unary transport. Defaults to spawnSync curl. */
22
+ syncRequest?: (baseUrl: string, method: string, payload: unknown) => unknown;
23
+ }
24
+ export declare class DshSessionReader implements AgentSessionReader {
25
+ private readonly hostProvider;
26
+ private readonly syncRequest;
27
+ constructor(opts?: DshSessionReaderOptions);
28
+ /** 解析当前 host(hostProvider 优先,允许 /config 热改 host)。 */
29
+ private resolveBaseUrl;
30
+ private unary;
31
+ listSessions(cwd: string, opts?: {
32
+ limit?: number;
33
+ offset?: number;
34
+ }): {
35
+ sessions: AgentSession[];
36
+ total: number;
37
+ };
38
+ getNewestSession(cwd: string): AgentSession | null;
39
+ readSessionContent(sessionId: string, cwd: string, opts?: {
40
+ maxEvents?: number;
41
+ }): SessionContent;
42
+ isSessionActive(sessionId: string, cwd: string): boolean;
43
+ }
44
+ export {};
@@ -0,0 +1,252 @@
1
+ /**
2
+ * DshSessionReader — AgentSessionReader backed by the DSH Web Host over HTTP.
3
+ *
4
+ * The AgentSessionReader contract is fully synchronous (file-style readers),
5
+ * but DSH exposes only async HTTP. The reader therefore bridges with
6
+ * `spawnSync('curl', ...)` for its unary calls (session.list / session.history)
7
+ * — a synchronous HTTP round-trip to the local DSH host. This keeps the
8
+ * router/bridge interface contract intact without touching any upstream logic.
9
+ *
10
+ * listSessions: session.list → filter by cwd → sort by updatedAt desc → local
11
+ * pagination (DSH v1 has no cursor pagination).
12
+ * readSessionContent: session.history → map SessionEvents to display events +
13
+ * session-wide usage accumulation (assistant/message.usage).
14
+ */
15
+ import { spawnSync } from 'node:child_process';
16
+ import { getLogger } from '../../logger/index.js';
17
+ import { DEFAULT_DSH_HOST } from '../../config/index.js';
18
+ import { capEvents, paginate } from '../common/pagination.js';
19
+ import { truncateUtf8, TOOL_RESULT_MAX_BYTES } from '../../common/truncate.js';
20
+ /**
21
+ * CC-05: history 拉取上限。DSH session.history 的 maxMessages 同时限制返回条数,
22
+ * 若用它同时充当展示截断,>50 事件的会话累计 usage 会少算。这里拉取一个足够大的
23
+ * 上限保证 usage 累加覆盖整个会话;展示仍由 capEvents(opts?.maxEvents ?? 50) 截断。
24
+ * (若 DSH 支持分页 cursor 可改成分页,当前用有界大拉取,见 review-fix-plan-cc.md)
25
+ */
26
+ const DSH_HISTORY_FETCH_LIMIT = 10_000;
27
+ /** Synchronous unary POST via curl (the reader interface is synchronous). */
28
+ function defaultSyncRequest(baseUrl, method, payload) {
29
+ const body = JSON.stringify({
30
+ type: 'client-request',
31
+ rpcId: `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
32
+ method,
33
+ payload,
34
+ });
35
+ const res = spawnSync('curl', [
36
+ '-s',
37
+ '-X',
38
+ 'POST',
39
+ `${baseUrl}/api/${method}`,
40
+ '-H',
41
+ 'content-type: application/json',
42
+ '--data',
43
+ body,
44
+ ], { encoding: 'utf-8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 });
45
+ if (res.error || res.status !== 0) {
46
+ throw new Error(`DSH ${method} failed: ${(res.error?.message ?? res.stderr) || 'unknown'}`);
47
+ }
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(res.stdout);
51
+ }
52
+ catch (err) {
53
+ throw new Error(`DSH ${method} returned non-JSON: ${err.message}`, {
54
+ cause: err,
55
+ });
56
+ }
57
+ if (parsed.result?.ok !== true) {
58
+ throw new Error(parsed.result?.error?.message ?? `DSH ${method} failed`);
59
+ }
60
+ return parsed.result.value;
61
+ }
62
+ export class DshSessionReader {
63
+ hostProvider;
64
+ syncRequest;
65
+ constructor(opts = {}) {
66
+ const fixed = (opts.host ?? DEFAULT_DSH_HOST).replace(/\/$/, '');
67
+ this.hostProvider = opts.hostProvider ?? (() => fixed);
68
+ this.syncRequest = opts.syncRequest ?? defaultSyncRequest;
69
+ }
70
+ /** 解析当前 host(hostProvider 优先,允许 /config 热改 host)。 */
71
+ resolveBaseUrl() {
72
+ const host = this.hostProvider();
73
+ return (host && host.length > 0 ? host : DEFAULT_DSH_HOST).replace(/\/$/, '');
74
+ }
75
+ unary(method, payload) {
76
+ return this.syncRequest(this.resolveBaseUrl(), method, payload);
77
+ }
78
+ listSessions(cwd, opts) {
79
+ try {
80
+ const value = this.unary('session.list', {});
81
+ const items = value.items ?? [];
82
+ const filtered = items
83
+ .filter((i) => i.cwd === cwd)
84
+ .sort((a, b) => b.updatedAt - a.updatedAt || String(a.sessionId).localeCompare(String(b.sessionId)))
85
+ .map((i) => ({
86
+ sessionId: String(i.sessionId),
87
+ summary: '',
88
+ mtime: i.updatedAt,
89
+ ...(i.agentPreset ? { agentPreset: i.agentPreset } : {}),
90
+ }));
91
+ const { items: page, total } = paginate(filtered, opts ?? {});
92
+ return { sessions: page, total };
93
+ }
94
+ catch (error) {
95
+ getLogger().error(`[dsh-session-reader] listSessions error: ${error.message}`);
96
+ return { sessions: [], total: 0 };
97
+ }
98
+ }
99
+ getNewestSession(cwd) {
100
+ return this.listSessions(cwd, { limit: 1 }).sessions[0] ?? null;
101
+ }
102
+ readSessionContent(sessionId, cwd, opts) {
103
+ try {
104
+ const value = this.unary('session.history', {
105
+ sessionId,
106
+ maxMessages: DSH_HISTORY_FETCH_LIMIT,
107
+ });
108
+ const events = [];
109
+ let displayTitle;
110
+ let cumInput = 0;
111
+ let cumOutput = 0;
112
+ let cumCacheRead = 0;
113
+ let cumCacheWrite = 0;
114
+ let lastUsage;
115
+ for (const entry of value.events ?? []) {
116
+ const ev = entry.event;
117
+ if (!ev)
118
+ continue;
119
+ const contentEvents = mapEvent(ev);
120
+ events.push(...contentEvents.events);
121
+ if (contentEvents.title)
122
+ displayTitle = contentEvents.title;
123
+ const u = contentEvents.usage;
124
+ if (u) {
125
+ // Cumulative (session-wide): sum across ALL assistant/message usage.
126
+ cumInput += u.inputTokens ?? 0;
127
+ cumOutput += u.outputTokens ?? 0;
128
+ cumCacheRead += u.cacheReadTokens ?? 0;
129
+ cumCacheWrite += u.cacheWriteTokens ?? 0;
130
+ // Per-turn "current window" fields reflect the LAST assistant/message
131
+ // with usage (ccusage/`last_token_usage` 口径, AGENTS.md §9.21) — a
132
+ // multi-turn session's current context water-mark is the last turn's,
133
+ // not the accumulated total.
134
+ lastUsage = u;
135
+ }
136
+ }
137
+ let sessionUsage;
138
+ if (lastUsage) {
139
+ const input = lastUsage.inputTokens ?? 0;
140
+ const output = lastUsage.outputTokens ?? 0;
141
+ const cacheRead = lastUsage.cacheReadTokens ?? 0;
142
+ const cacheWrite = lastUsage.cacheWriteTokens ?? 0;
143
+ sessionUsage = {
144
+ inputTokens: input,
145
+ outputTokens: output,
146
+ contextLength: input + cacheRead + cacheWrite,
147
+ cacheReadTokens: cacheRead,
148
+ cacheCreationTokens: cacheWrite,
149
+ totalTokens: input + output + cacheRead + cacheWrite,
150
+ cumulativeTotalTokens: cumInput + cumOutput + cumCacheRead + cumCacheWrite,
151
+ cumulativeInputTokens: cumInput,
152
+ cumulativeOutputTokens: cumOutput,
153
+ cumulativeCacheReadTokens: cumCacheRead,
154
+ cumulativeCacheCreationTokens: cumCacheWrite,
155
+ };
156
+ }
157
+ return {
158
+ // CC-05: 展示仍按 maxEvents 尾截断(默认 50,保持原行为),usage 已在上面基于
159
+ // 完整 history 累加。
160
+ events: capEvents(events, opts?.maxEvents ?? 50),
161
+ ...(sessionUsage ? { usage: sessionUsage } : {}),
162
+ ...(displayTitle ? { displayTitle } : {}),
163
+ };
164
+ }
165
+ catch (error) {
166
+ getLogger().error(`[dsh-session-reader] readSessionContent error: ${error.message}`);
167
+ return { events: [] };
168
+ }
169
+ }
170
+ isSessionActive(sessionId, cwd) {
171
+ try {
172
+ const value = this.unary('session.list', {});
173
+ const found = (value.items ?? []).find((i) => String(i.sessionId) === sessionId && i.cwd === cwd);
174
+ return found?.running === true;
175
+ }
176
+ catch (error) {
177
+ getLogger().error(`[dsh-session-reader] isSessionActive error: ${error.message}`);
178
+ return false;
179
+ }
180
+ }
181
+ }
182
+ /** Map one DSH SessionEvent to display content events (+ title/usage side effects). */
183
+ function mapEvent(ev) {
184
+ const out = { events: [] };
185
+ switch (ev.type) {
186
+ case 'user/message': {
187
+ const content = ev.data.content ?? [];
188
+ const text = content
189
+ .filter((c) => c.type === 'text')
190
+ .map((c) => c.text ?? '')
191
+ .join('');
192
+ out.events.push({ type: 'user', content: text, timestamp: new Date(ev.time).toISOString() });
193
+ break;
194
+ }
195
+ case 'assistant/message': {
196
+ const content = ev.data.message
197
+ ?.content ?? [];
198
+ const text = content
199
+ .filter((c) => c.type === 'text')
200
+ .map((c) => c.text ?? '')
201
+ .join('');
202
+ if (text)
203
+ out.events.push({
204
+ type: 'assistant',
205
+ content: text,
206
+ timestamp: new Date(ev.time).toISOString(),
207
+ });
208
+ const usage = ev.data.usage;
209
+ if (usage && typeof usage === 'object')
210
+ out.usage = usage;
211
+ break;
212
+ }
213
+ case 'tool/call': {
214
+ const name = String(ev.data.name ?? '');
215
+ const args = typeof ev.data.arguments === 'string' ? ev.data.arguments : '';
216
+ out.events.push({
217
+ type: 'tool_use',
218
+ content: `${name}(${truncateUtf8(args, 200)})`,
219
+ timestamp: new Date(ev.time).toISOString(),
220
+ });
221
+ break;
222
+ }
223
+ case 'tool/result': {
224
+ const msg = ev.data.message;
225
+ const raw = stringifyContent(msg?.content);
226
+ out.events.push({
227
+ type: 'tool_result',
228
+ content: truncateUtf8(raw, TOOL_RESULT_MAX_BYTES),
229
+ timestamp: new Date(ev.time).toISOString(),
230
+ });
231
+ break;
232
+ }
233
+ default:
234
+ break;
235
+ }
236
+ return out;
237
+ }
238
+ function stringifyContent(content) {
239
+ if (content == null)
240
+ return '[tool result]';
241
+ if (typeof content === 'string')
242
+ return content;
243
+ if (Array.isArray(content)) {
244
+ return content
245
+ .map((c) => c && typeof c === 'object' && 'text' in c
246
+ ? String(c.text)
247
+ : JSON.stringify(c))
248
+ .join('');
249
+ }
250
+ return JSON.stringify(content);
251
+ }
252
+ //# sourceMappingURL=sessions.js.map
@@ -5,3 +5,4 @@ export { CodexSessionReader } from './codex/index.js';
5
5
  export { OpencodeSessionReader } from './opencode/index.js';
6
6
  export { PiSessionReader } from './pi/index.js';
7
7
  export { KimiSessionReader } from './kimi/index.js';
8
+ export { DshSessionReader } from './dsh/index.js';
@@ -8,4 +8,5 @@ export { CodexSessionReader } from './codex/index.js';
8
8
  export { OpencodeSessionReader } from './opencode/index.js';
9
9
  export { PiSessionReader } from './pi/index.js';
10
10
  export { KimiSessionReader } from './kimi/index.js';
11
+ export { DshSessionReader } from './dsh/index.js';
11
12
  //# sourceMappingURL=index.js.map
@@ -11,6 +11,12 @@ export interface WorkspaceEntry {
11
11
  * mid-write cannot leave a truncated JSON file. On load, a corrupt file
12
12
  * is treated as empty (with a warning) so the bridge can still start.
13
13
  *
14
+ * lastUsedAt 语义(2026-08-19 语义对齐):保存(save)与使用(use/touch)
15
+ * 都会把时间戳更新为当前时间。save 内部调用 touch,保证「最近使用」排序
16
+ * 对新保存的 workspace 同样生效(新条目应排在列表最前,而非沉底)。
17
+ * 同一毫秒内连续操作时 touch 会用 max+1 保证严格递增,避免 Date.now 相同
18
+ * 导致最近排序退化为字母序。
19
+ *
14
20
  * Data migration: old format `{ alias: "path" }` is normalized to
15
21
  * `{ alias: { path, lastUsedAt: 0 } }` on first load and persisted.
16
22
  */
@@ -27,6 +33,12 @@ export declare class WorkspaceStore {
27
33
  /**
28
34
  * Update lastUsedAt to now for the given alias and persist.
29
35
  * No-op if the alias does not exist.
36
+ *
37
+ * To keep "recent" ordering stable even for rapid successive saves/uses in
38
+ * the same millisecond, never allow another entry to share the same
39
+ * timestamp while this entry is being touched. If a peer already has a
40
+ * timestamp at or after the current wall-clock time, use max+1 so the
41
+ * touched entry always becomes the most recently used.
30
42
  */
31
43
  touch(name: string): void;
32
44
  /**
@@ -10,6 +10,12 @@ import { getLogger } from '../logger/index.js';
10
10
  * mid-write cannot leave a truncated JSON file. On load, a corrupt file
11
11
  * is treated as empty (with a warning) so the bridge can still start.
12
12
  *
13
+ * lastUsedAt 语义(2026-08-19 语义对齐):保存(save)与使用(use/touch)
14
+ * 都会把时间戳更新为当前时间。save 内部调用 touch,保证「最近使用」排序
15
+ * 对新保存的 workspace 同样生效(新条目应排在列表最前,而非沉底)。
16
+ * 同一毫秒内连续操作时 touch 会用 max+1 保证严格递增,避免 Date.now 相同
17
+ * 导致最近排序退化为字母序。
18
+ *
13
19
  * Data migration: old format `{ alias: "path" }` is normalized to
14
20
  * `{ alias: { path, lastUsedAt: 0 } }` on first load and persisted.
15
21
  */
@@ -60,8 +66,11 @@ export class WorkspaceStore {
60
66
  atomicWriteJson(this.filePath, obj);
61
67
  }
62
68
  save(name, dirPath) {
69
+ // 保存即视为使用(语义对齐):新建/覆盖的 workspace 都应拿到当前
70
+ // lastUsedAt,而不是 0,否则「最近使用」排序下新条目会沉底。
71
+ // touch() 负责统一写入时间戳并 persist,消除 save 写 0 / use 写 now 的不对称。
63
72
  this.data.set(name, { path: dirPath, lastUsedAt: 0 });
64
- this.persist();
73
+ this.touch(name);
65
74
  }
66
75
  get(name) {
67
76
  return this.data.get(name)?.path;
@@ -76,12 +85,29 @@ export class WorkspaceStore {
76
85
  /**
77
86
  * Update lastUsedAt to now for the given alias and persist.
78
87
  * No-op if the alias does not exist.
88
+ *
89
+ * To keep "recent" ordering stable even for rapid successive saves/uses in
90
+ * the same millisecond, never allow another entry to share the same
91
+ * timestamp while this entry is being touched. If a peer already has a
92
+ * timestamp at or after the current wall-clock time, use max+1 so the
93
+ * touched entry always becomes the most recently used.
79
94
  */
80
95
  touch(name) {
81
96
  const entry = this.data.get(name);
82
97
  if (!entry)
83
98
  return;
84
- entry.lastUsedAt = Date.now();
99
+ const now = Date.now();
100
+ let max = Math.max(now, entry.lastUsedAt);
101
+ let hasPeerAtOrAfterNow = false;
102
+ for (const [otherName, other] of this.data) {
103
+ if (otherName === name)
104
+ continue;
105
+ if (other.lastUsedAt > max)
106
+ max = other.lastUsedAt;
107
+ if (other.lastUsedAt >= now)
108
+ hasPeerAtOrAfterNow = true;
109
+ }
110
+ entry.lastUsedAt = hasPeerAtOrAfterNow ? max + 1 : max;
85
111
  this.persist();
86
112
  }
87
113
  /**