lark-remote 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/bridge/approval-coordinator.d.ts +18 -1
  5. package/dist/bridge/approval-coordinator.js +57 -10
  6. package/dist/bridge/index.js +90 -17
  7. package/dist/card/approval-render.js +18 -0
  8. package/dist/card/run-state.js +10 -5
  9. package/dist/config/index.d.ts +16 -0
  10. package/dist/config/index.js +46 -6
  11. package/dist/config/opencode-config.d.ts +3 -3
  12. package/dist/config/opencode-config.js +38 -22
  13. package/dist/inbound/authorize.d.ts +34 -0
  14. package/dist/inbound/authorize.js +31 -0
  15. package/dist/index.js +116 -63
  16. package/dist/instance-lock.d.ts +9 -0
  17. package/dist/instance-lock.js +113 -42
  18. package/dist/router/config/dsh.d.ts +51 -21
  19. package/dist/router/config/dsh.js +255 -66
  20. package/dist/router/config/opencode.js +16 -6
  21. package/dist/router/config/types.d.ts +6 -3
  22. package/dist/router/index.d.ts +1 -0
  23. package/dist/router/index.js +13 -5
  24. package/dist/runner/codex/app-server/translator.js +21 -7
  25. package/dist/runner/common/acp/base-acp-runner.d.ts +12 -0
  26. package/dist/runner/common/acp/base-acp-runner.js +83 -8
  27. package/dist/runner/common/acp/protocol-types.d.ts +10 -2
  28. package/dist/runner/common/acp/protocol-types.js +3 -1
  29. package/dist/runner/common/connection-based-runner.d.ts +15 -0
  30. package/dist/runner/common/connection-based-runner.js +40 -5
  31. package/dist/runner/common/jsonrpc/connection-manager.d.ts +15 -0
  32. package/dist/runner/common/jsonrpc/connection-manager.js +83 -50
  33. package/dist/runner/dsh/approval.d.ts +53 -0
  34. package/dist/runner/dsh/approval.js +161 -0
  35. package/dist/runner/dsh/auth.d.ts +36 -0
  36. package/dist/runner/dsh/auth.js +118 -0
  37. package/dist/runner/dsh/client.d.ts +161 -42
  38. package/dist/runner/dsh/client.js +551 -244
  39. package/dist/runner/dsh/daemon.d.ts +85 -0
  40. package/dist/runner/dsh/daemon.js +272 -0
  41. package/dist/runner/dsh/merge.d.ts +9 -0
  42. package/dist/runner/dsh/merge.js +51 -0
  43. package/dist/runner/dsh/runner.d.ts +94 -11
  44. package/dist/runner/dsh/runner.js +553 -67
  45. package/dist/runner/dsh/translator.d.ts +43 -3
  46. package/dist/runner/dsh/translator.js +115 -38
  47. package/dist/runner/dsh/types.d.ts +189 -27
  48. package/dist/runner/dsh/types.js +11 -5
  49. package/dist/runner/kimi/acp/runner.js +15 -3
  50. package/dist/runner/opencode/acp/error-monitor.d.ts +2 -2
  51. package/dist/runner/opencode/acp/error-monitor.js +2 -2
  52. package/dist/runner/opencode/acp/runner.d.ts +9 -0
  53. package/dist/runner/opencode/acp/runner.js +40 -1
  54. package/dist/runner/opencode/acp/translator.d.ts +4 -2
  55. package/dist/runner/opencode/acp/translator.js +12 -3
  56. package/dist/runner/pi/rpc/translator.d.ts +2 -0
  57. package/dist/runner/pi/rpc/translator.js +27 -1
  58. package/dist/runner/types.d.ts +34 -0
  59. package/dist/runner/types.js +24 -0
  60. package/dist/session/codex/rollout-reader.js +44 -7
  61. package/dist/session/dsh/sessions.d.ts +45 -6
  62. package/dist/session/dsh/sessions.js +171 -27
  63. package/dist/session/opencode/sessions.d.ts +18 -7
  64. package/dist/session/opencode/sessions.js +294 -15
  65. package/dist/session/pi/sessions.js +6 -2
  66. package/package.json +3 -1
@@ -130,10 +130,11 @@ export const CodexConfigSchema = z.object({
130
130
  });
131
131
  /** OpenCode-specific configuration (pure ACP mode: `opencode acp` over stdio). */
132
132
  const OpencodeConfigSchema = z.object({
133
- /** Provider ID for the LLM backend (e.g. 'anthropic'). */
134
- providerID: z.string().default('anthropic'),
135
- /** Model ID for the LLM backend (e.g. 'claude-sonnet-4-20250514'). */
136
- modelID: z.string().default('claude-sonnet-4-20250514'),
133
+ /** Provider ID for the LLM backend. `opencode` is present in both v1 and v2
134
+ * catalogs; a user-selected provider/model overrides it. */
135
+ providerID: z.string().default('opencode'),
136
+ /** Model ID for the LLM backend. `big-pickle` exists in both v1 and v2. */
137
+ modelID: z.string().default('big-pickle'),
137
138
  /** Session mode (opencode agent name): 'build' (default) or 'plan'. */
138
139
  mode: z.enum(['build', 'plan']).default('build'),
139
140
  /** ACP sub-configuration. Structurally mirrors kimi: the card exposes
@@ -189,8 +190,33 @@ const KimiConfigSchema = z.object({
189
190
  export const DEFAULT_DSH_HOST = 'http://127.0.0.1:3080';
190
191
  /** DSH (DeepSeek Harness) Web Host connection config. */
191
192
  const DshConfigSchema = z.object({
192
- /** DSH Web Host base URL (no auth, local). */
193
+ /** DSH Web Host base URL (local). */
193
194
  host: z.string().url().default(DEFAULT_DSH_HOST),
195
+ /**
196
+ * 全局 DSH 未运行时是否由 lark-remote 后台拉起(detached,lark-remote
197
+ * 退出后继续运行)。默认 true;设为 false 时只连接已运行的 DSH。
198
+ */
199
+ manageProcess: z.boolean().default(true),
200
+ /** DSH 的 $DSH_HOME;默认 $DSH_HOME 或 ~/.dsh。用于启动锁和 DSH 日志目录。 */
201
+ dshHome: z.string().optional(),
202
+ /** DSH 可执行文件;默认 dsh。 */
203
+ dshCommand: z.string().optional(),
204
+ /** 后台拉起 DSH 后等待端口可用的上限(毫秒)。 */
205
+ startTimeoutMs: z.number().int().min(1000).max(600000).default(30_000),
206
+ /**
207
+ * DSH Web launch token (0.2.x requires auth). Paste the token from the URL
208
+ * printed by `dsh web`, or the whole URL — the client extracts `token`.
209
+ * Optional fallback: normally lark-remote mints cookies from the durable
210
+ * DSH browser-session secret instead.
211
+ */
212
+ token: z.string().optional(),
213
+ /**
214
+ * DSH credentials file containing the durable browser-session signing
215
+ * secret. Defaults to `$DSH_HOME/.credentials.yaml` or
216
+ * `~/.dsh/.credentials.yaml`. Reading that secret lets lark-remote mint a
217
+ * fresh browser cookie after an external `dsh web` restart.
218
+ */
219
+ credentialsFile: z.string().optional(),
194
220
  /**
195
221
  * Session preset(agentPreset)。preset 在 session 创建时固定,中途切换会被
196
222
  * 服务端拒绝(agent-preset-conflict)。留空 = 跟随服务端默认(不传 agentPreset)。
@@ -198,10 +224,24 @@ const DshConfigSchema = z.object({
198
224
  * 控制 + 运行时 DSH API 报错兜底。
199
225
  */
200
226
  agentPreset: z.string().optional(),
227
+ /**
228
+ * 权限预设(沙箱模式 + 审批策略)。与 agentPreset 不同,它可以在会话中
229
+ * 切换;lark-remote 会在会话首次 run 前执行一次 `/permission <preset>`
230
+ * 对齐。留空 = 不干预,跟随 DSH 当前会话/服务端默认。
231
+ */
232
+ permissionPreset: z.string().optional(),
233
+ /**
234
+ * 模型服务商(DSH provider route)。取值来自 `session/modelCatalog` 的
235
+ * `groups[].id`,例如 deepseek-official / deepseek-account / anthropic。
236
+ * 留空 = 兼容旧配置:按 deepseek-official 处理(与 provider 硬编码时代的
237
+ * 行为一致)。枚举不在此硬编码:provider 清单随 DSH profile bundle 与
238
+ * 用户配置变化。
239
+ */
240
+ provider: z.string().optional(),
201
241
  /** 模型 ID。留空 = 跟随服务端默认。 */
202
242
  model: z.string().optional(),
203
243
  /**
204
- * 推理强度(off / low / high / max,跟随 llm.models 实际档位)。留空 = 跟随
244
+ * 推理强度(off / low / high / max,跟随 session/modelCatalog 实际档位)。留空 = 跟随
205
245
  * 服务端默认。枚举不硬编码(理由同上 agentPreset)。
206
246
  */
207
247
  reasoningEffort: z.string().optional(),
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * OpenCode 配置读取工具
3
3
  *
4
- * 从 opencode models --verbose 命令获取 provider 和 model 信息
4
+ * 从 opencode models 命令获取 provider 和 model 信息
5
5
  */
6
6
  interface OpencodeConfigResult {
7
7
  /** provider 名称列表 */
@@ -12,9 +12,9 @@ interface OpencodeConfigResult {
12
12
  /** 清空缓存(仅供测试使用,生产代码无调用点) */
13
13
  export declare function invalidateOpencodeConfigCache(): void;
14
14
  /**
15
- * 加载 opencode 配置,执行 opencode models --verbose 并解析
15
+ * 加载 opencode 配置,执行 opencode models 并解析
16
16
  *
17
17
  * @returns provider 列表和 model 查询接口
18
18
  */
19
- export declare function loadOpencodeConfig(): OpencodeConfigResult;
19
+ export declare function loadOpencodeConfig(binary?: string): OpencodeConfigResult;
20
20
  export {};
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * OpenCode 配置读取工具
3
3
  *
4
- * 从 opencode models --verbose 命令获取 provider 和 model 信息
4
+ * 从 opencode models 命令获取 provider 和 model 信息
5
5
  */
6
6
  import { resolveExecutable } from '../platform/command.js';
7
7
  import { spawnProcessSync } from '../platform/spawn.js';
@@ -13,7 +13,8 @@ import { getLogger } from '../logger/index.js';
13
13
  */
14
14
  const FALLBACK_MODELS = {
15
15
  opencode: ['big-pickle'],
16
- deepseek: ['deepseek-chat'],
16
+ 'opencode-go': ['deepseek-v4.1-flash'],
17
+ deepseek: ['deepseek-chat', 'deepseek-flash', 'deepseek-v4-pro'],
17
18
  'minimax-cn-coding-plan': ['MiniMax-M2.5'],
18
19
  };
19
20
  /** fallback provider 列表从模型表派生,保证不会出现空模型列表的 provider(历史曾混入无模型的 myprovider/volcengine-plan) */
@@ -51,33 +52,41 @@ function buildFallbackResult() {
51
52
  };
52
53
  }
53
54
  /**
54
- * 加载 opencode 配置,执行 opencode models --verbose 并解析
55
+ * 加载 opencode 配置,执行 opencode models 并解析
55
56
  *
56
57
  * @returns provider 列表和 model 查询接口
57
58
  */
58
- export function loadOpencodeConfig() {
59
+ export function loadOpencodeConfig(binary = 'opencode') {
59
60
  const now = Date.now();
60
61
  // 正缓存命中:TTL 内不重复 execSync
61
- if (opencodeConfigCache && now - opencodeConfigCache.timestamp < OPENCODE_CONFIG_CACHE_TTL_MS) {
62
+ if (opencodeConfigCache?.binary === binary &&
63
+ now - opencodeConfigCache.timestamp < OPENCODE_CONFIG_CACHE_TTL_MS) {
62
64
  return opencodeConfigCache.result;
63
65
  }
64
66
  // 失败负缓存:命令最近失败过,TTL 内直接用 fallback,不再同步阻塞
65
- if (opencodeConfigFailedAt !== null &&
66
- now - opencodeConfigFailedAt < OPENCODE_NEGATIVE_CACHE_TTL_MS) {
67
+ if (opencodeConfigFailedAt?.binary === binary &&
68
+ now - opencodeConfigFailedAt.timestamp < OPENCODE_NEGATIVE_CACHE_TTL_MS) {
67
69
  return buildFallbackResult();
68
70
  }
69
71
  try {
70
72
  // 经 seam 解析真实可执行文件(win32 PATHEXT),cross-spawn 同步执行
71
- const resolved = resolveExecutable('opencode');
73
+ const resolved = resolveExecutable(binary);
72
74
  if (!resolved)
73
75
  throw new Error('opencode not found');
74
- // 执行 opencode models --verbose 获取所有模型信息
75
- const res = spawnProcessSync(resolved.file, ['models', '--verbose'], {
76
+ // v2 removed `--verbose`; plain `models` prints one `provider/model` per
77
+ // line in both v1 and v2. Try the warm shared daemon first (fast); if it is
78
+ // unavailable, retry with v2's private `--standalone` server.
79
+ const modelOpts = {
76
80
  encoding: 'utf-8',
77
81
  // 30s → 10s,与 codex/kimi 对齐;maxBuffer 防模型清单 ENOBUFS 隐性截断
78
82
  timeout: 10000,
79
83
  maxBuffer: 64 * 1024 * 1024,
80
- });
84
+ windowsHide: true,
85
+ };
86
+ let res = spawnProcessSync(resolved.file, ['models'], modelOpts);
87
+ if (res.error || res.status !== 0) {
88
+ res = spawnProcessSync(resolved.file, ['models', '--standalone'], modelOpts);
89
+ }
81
90
  if (res.error)
82
91
  throw res.error;
83
92
  if (res.status !== 0) {
@@ -89,16 +98,23 @@ export function loadOpencodeConfig() {
89
98
  // 解析输出:每个模型以 provider/model 格式开头,后面跟着 JSON 块
90
99
  const lines = output.split('\n');
91
100
  for (const line of lines) {
92
- // 检测模型头如 "opencode/big-pickle" 或 "deepseek/deepseek-chat"
93
- const match = line.match(/^([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_.-]+)$/);
94
- if (match) {
95
- const providerID = match[1];
96
- const modelID = match[2];
97
- if (!providerModels.has(providerID)) {
98
- providerModels.set(providerID, []);
99
- }
100
- providerModels.get(providerID).push(modelID);
101
+ // 检测模型头如 "opencode/big-pickle" 或 "deepseek/deepseek-chat".
102
+ // Split on the FIRST slash: some providers expose model IDs that
103
+ // themselves contain "/" (e.g. openrouter/anthropic/...).
104
+ const trimmed = line.trim();
105
+ const slash = trimmed.indexOf('/');
106
+ if (slash <= 0 || slash === trimmed.length - 1)
107
+ continue;
108
+ const providerID = trimmed.slice(0, slash);
109
+ const modelID = trimmed.slice(slash + 1);
110
+ if (!/^[a-zA-Z0-9_.-]+$/.test(providerID))
111
+ continue;
112
+ if (/\s/.test(modelID))
113
+ continue;
114
+ if (!providerModels.has(providerID)) {
115
+ providerModels.set(providerID, []);
101
116
  }
117
+ providerModels.get(providerID).push(modelID);
102
118
  }
103
119
  const providerNames = Array.from(providerModels.keys()).sort();
104
120
  const modelOptions = (provider) => {
@@ -115,7 +131,7 @@ export function loadOpencodeConfig() {
115
131
  return providerModels.get(provider) ?? [];
116
132
  };
117
133
  const result = { providerNames, modelOptions };
118
- opencodeConfigCache = { result, timestamp: now };
134
+ opencodeConfigCache = { binary, result, timestamp: now };
119
135
  opencodeConfigFailedAt = null;
120
136
  return result;
121
137
  }
@@ -123,7 +139,7 @@ export function loadOpencodeConfig() {
123
139
  // 如果命令失败,使用 fallback
124
140
  const logger = getLogger();
125
141
  logger.warn(`[opencode-config] failed to load opencode models: ${error}`);
126
- opencodeConfigFailedAt = now;
142
+ opencodeConfigFailedAt = { binary, timestamp: now };
127
143
  return buildFallbackResult();
128
144
  }
129
145
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * InboundAuthorizer:入站消息/媒体的统一 owner 授权入口。
3
+ *
4
+ * owner 绑定只发生在文本消息的 `binder.authorize()`
5
+ * 里,媒体入口只做只读 `isOwner()` 检查。新安装用户的第一条消息是纯图片/文件
6
+ * 时,媒体处理被静默丢弃、绑定也永远不完成。文本与媒体现在都走这里:
7
+ *
8
+ * - `rejected`:已绑定但不是 owner → 调用方静默丢弃;
9
+ * - `bind_success`:首条入站消息完成绑定 → 触发一次 onboarding(默认 cwd、
10
+ * 欢迎语、帮助卡),媒体调用方继续下载(纯媒体最终走 no-text 回执);
11
+ * - `owner`:正常放行。
12
+ *
13
+ * onboarding 用一次性 promise 去重:一条 post 同时带图和文字时,connector 会
14
+ * 先后触发媒体与文本两个入口,两个入口都看到 bind_success 也只跑一次。
15
+ */
16
+ import type { OwnerBinder } from '../binder.js';
17
+ export type InboundAuthorization = 'owner' | 'bind_success' | 'rejected';
18
+ export interface InboundAuthorizerOptions {
19
+ binder: OwnerBinder;
20
+ /**
21
+ * 首次绑定时的副作用(同步设置默认 cwd + 异步发欢迎语/帮助卡)。
22
+ * 同步部分必须在返回前完成:用户的下一条消息要能找到已写好的 cwd。
23
+ */
24
+ onBind: (userId: string, chatId: string) => void | Promise<void>;
25
+ }
26
+ export declare class InboundAuthorizer {
27
+ private readonly opts;
28
+ private onboarding;
29
+ constructor(opts: InboundAuthorizerOptions);
30
+ /** 绑定/授权判定 + 首次绑定时触发 onboarding。 */
31
+ authorize(userId: string, chatId: string): InboundAuthorization;
32
+ /** 等待首次绑定的 onboarding 完成(测试 / 收尾用;生产路径不阻塞)。 */
33
+ whenBound(): Promise<void>;
34
+ }
@@ -0,0 +1,31 @@
1
+ import { getLogger } from '../logger/index.js';
2
+ export class InboundAuthorizer {
3
+ opts;
4
+ onboarding;
5
+ constructor(opts) {
6
+ this.opts = opts;
7
+ }
8
+ /** 绑定/授权判定 + 首次绑定时触发 onboarding。 */
9
+ authorize(userId, chatId) {
10
+ const decision = this.opts.binder.authorize(userId, chatId);
11
+ if (decision.kind === 'bind_success' && this.onboarding === undefined) {
12
+ // onBind 同步调用(默认 cwd 的写入必须在返回前生效,下一条消息才会命中
13
+ // 已设置的 cwd);只把它的异步后续(发欢迎语/帮助卡)挂到 promise 上。
14
+ try {
15
+ this.onboarding = Promise.resolve(this.opts.onBind(userId, chatId)).catch((err) => {
16
+ getLogger().error('[binder] onboarding failed:', err);
17
+ });
18
+ }
19
+ catch (err) {
20
+ this.onboarding = Promise.resolve();
21
+ getLogger().error('[binder] onboarding failed:', err);
22
+ }
23
+ }
24
+ return decision.kind;
25
+ }
26
+ /** 等待首次绑定的 onboarding 完成(测试 / 收尾用;生产路径不阻塞)。 */
27
+ whenBound() {
28
+ return this.onboarding ?? Promise.resolve();
29
+ }
30
+ }
31
+ //# sourceMappingURL=authorize.js.map
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { warmCodexCatalogCache } from './config/codex-config.js';
10
10
  import { SessionReaderRegistry, SessionStore } from './session/index.js';
11
11
  import { ClaudeSessionReader, CodexSessionReader, OpencodeSessionReader, PiSessionReader, KimiSessionReader, DshSessionReader, } from './session/index.js';
12
12
  import { CommandRouter, isImmediateAction, DIRECT_RETURN_CMDS, SESSION_MUTATING_ACTION_CMDS, COMPACT_ACTION_CMDS, } from './router/index.js';
13
+ import { DshDaemon } from './runner/dsh/daemon.js';
13
14
  import { dispatchOrderExecForQueue } from './router/order-exec-dispatch.js';
14
15
  import { buildCardActionFullValue } from './router/card-action-payload.js';
15
16
  import { actionFeedbackText } from './router/card-action-feedback.js';
@@ -26,6 +27,7 @@ import { checkLatestVersion, compareVersions, runInstallLatest, formatUpdateHint
26
27
  import { classifyRejection } from './error-classification.js';
27
28
  import { WorkspaceStore } from './workspace/index.js';
28
29
  import { InboundTurnAssembler } from './inbound/turn-assembler.js';
30
+ import { InboundAuthorizer } from './inbound/authorize.js';
29
31
  import { stripPlaceholders } from './inbound/placeholder.js';
30
32
  import { buildSessionHistoryCard } from './router/card-helpers.js';
31
33
  import { newSessionButton, resumeCompactButton, agentDisplayName } from './card/card-shared.js';
@@ -136,6 +138,15 @@ function initializeRunner(config, configDir, cliArgs) {
136
138
  // 单点化:runner factory 取最新 config 的唯一入口(语义保持,
137
139
  // container 未接线时回退启动快照)。
138
140
  const latest = () => agentRegistry.getConfigContainer()?.current ?? config;
141
+ // One global DSH per machine/DSH_HOME. This resolver reuses a listening
142
+ // process or starts `dsh web` detached; it never owns the child's lifetime.
143
+ const dshDaemon = new DshDaemon({
144
+ host: () => getAgentConfig(latest(), 'dsh')?.host,
145
+ dshHome: () => getAgentConfig(latest(), 'dsh')?.dshHome,
146
+ dshCommand: () => getAgentConfig(latest(), 'dsh')?.dshCommand,
147
+ manageProcess: () => getAgentConfig(latest(), 'dsh')?.manageProcess ?? true,
148
+ startTimeoutMs: () => getAgentConfig(latest(), 'dsh')?.startTimeoutMs,
149
+ });
139
150
  // Claude factory reads from configContainer (not closure) so runtime
140
151
  // config changes (model, effort, stopGraceMs) take effect after
141
152
  // bridge.setConfig() + clearRunners(). Same pattern as codex/pi/kimi.
@@ -181,8 +192,14 @@ function initializeRunner(config, configDir, cliArgs) {
181
192
  sessionReaderRegistry.register('claude', new ClaudeSessionReader());
182
193
  sessionReaderRegistry.register('codex', codexSessionReader);
183
194
  // Register OpencodeSessionReader (CLI version)
184
- // Using `opencode session list` and `opencode export` instead of HTTP API
185
- const opencodeSessionReader = new OpencodeSessionReader();
195
+ // Using `opencode session list` and `session export` instead of HTTP API.
196
+ // Share the configured binary with the ACP runner so `opencode2` (v2) can
197
+ // be used without the reader silently falling back to a v1 `opencode`.
198
+ // The reader is registered once; changing acp.binary at runtime requires a
199
+ // lark-remote restart.
200
+ const opencodeSessionReader = new OpencodeSessionReader({
201
+ binary: getAgentConfig(config, 'opencode')?.acp?.binary,
202
+ });
186
203
  agentRegistry.register('opencode', (_ws) => {
187
204
  const ocConfig = getAgentConfig(latest(), 'opencode');
188
205
  // Build model string with validation: provider/model format
@@ -258,19 +275,39 @@ function initializeRunner(config, configDir, cliArgs) {
258
275
  // hostProvider 每次调用读最新 config:/config 修改 host 后 runner 重建为
259
276
  // 新 host,reader(/resume、用量、完成卡)也跟随新 host。
260
277
  hostProvider: () => getAgentConfig(latest(), 'dsh')?.host,
278
+ dshHomeProvider: () => getAgentConfig(latest(), 'dsh')?.dshHome,
279
+ tokenProvider: () => getAgentConfig(latest(), 'dsh')?.token,
280
+ credentialsFileProvider: () => getAgentConfig(latest(), 'dsh')?.credentialsFile,
261
281
  });
262
282
  agentRegistry.register('dsh', (_ws) => {
263
283
  const dshConf = getAgentConfig(latest(), 'dsh');
284
+ // Warm the shared process in the background so the first message and
285
+ // `/resume` do not pay the full startup delay. Failures surface from
286
+ // DshRunner.run(), which awaits the same resolver.
287
+ void dshDaemon.ensure().catch((error) => {
288
+ getLogger().warn(`[dsh] background start failed: ${error.message}`);
289
+ });
264
290
  return new DshRunner({
265
291
  kind: 'dsh',
266
292
  sessionReader: dshSessionReader,
293
+ daemon: dshDaemon,
267
294
  host: dshConf?.host,
295
+ dshHome: dshConf?.dshHome,
296
+ token: dshConf?.token,
297
+ credentialsFile: dshConf?.credentialsFile,
268
298
  agentPreset: dshConf?.agentPreset,
299
+ permissionPreset: dshConf?.permissionPreset,
300
+ provider: dshConf?.provider,
269
301
  model: dshConf?.model,
270
302
  reasoningEffort: dshConf?.reasoningEffort,
271
303
  });
272
304
  });
273
305
  sessionReaderRegistry.register('dsh', dshSessionReader);
306
+ if (config.defaultAgent === 'dsh') {
307
+ void dshDaemon.ensure().catch((error) => {
308
+ getLogger().warn(`[dsh] startup probe failed: ${error.message}`);
309
+ });
310
+ }
274
311
  // 2026-07-17: 注册所有 agent 的显示名,实现单点真相
275
312
  agentRegistry.registerDisplayName('claude', 'Claude');
276
313
  agentRegistry.registerDisplayName('codex', 'Codex');
@@ -347,13 +384,69 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
347
384
  },
348
385
  });
349
386
  router.setInboundFlusher(() => assembler.flushAll('flush'));
387
+ // 统一的入站授权入口(文本 + 媒体共用):未绑定时,首条入站消息——无论
388
+ // 纯文本、纯媒体还是图文混合——都完成 owner 绑定并触发一次 onboarding。
389
+ // 媒体入口原先只做只读 isOwner 检查,新安装用户第一条消息是截图时既不绑定、
390
+ // 也不保存、也没有任何反馈。
391
+ const authorizer = new InboundAuthorizer({
392
+ binder,
393
+ onBind: (userId, chatId) => {
394
+ // First-run onboarding: set default cwd + send welcome + Help card.
395
+ // setCwd is synchronous (before returning the async send promise) so the
396
+ // user's next message — which will hit the `owner` branch now that
397
+ // startup-contact.json has been written — finds cwd already set.
398
+ // process.cwd() is the only sensible default: the user started
399
+ // lark-remote in the directory they want to work in.
400
+ // setCwd only when no cwd is set yet (e.g. fresh start after bind).
401
+ // Don't overwrite an existing cwd — the user may have already set one
402
+ // via /cd or a previous bind. Use realpath for canonical form (matching
403
+ // Claude JSONL cwd, e.g. /tmp → /private/tmp on macOS).
404
+ let resolvedCwd;
405
+ try {
406
+ resolvedCwd = fs.realpathSync(process.cwd());
407
+ }
408
+ catch {
409
+ resolvedCwd = process.cwd();
410
+ }
411
+ if (!sessionStore.getCwd(userId)) {
412
+ sessionStore.setCwd(userId, resolvedCwd);
413
+ }
414
+ return (async () => {
415
+ try {
416
+ // Message 1: bind confirmation + status (combined to avoid
417
+ // Feishu out-of-order delivery across three separate messages).
418
+ await connector.sendWithRetry(chatId, {
419
+ text: `✅ 已绑定到本账号,此后仅你可使用本应用\n` +
420
+ `📂 当前工作目录: \`${resolvedCwd}\`\n` +
421
+ `🤖 默认 Agent: ${agentDisplayName(config.defaultAgent)}\n` +
422
+ `💡 直接输入消息即可开始对话,或 /cd 切换目录`,
423
+ });
424
+ // Message 2: Help card.
425
+ // Use router.cmdHelp() + manual send (without replyTo) so the Help
426
+ // card appears as an independent message, not a reply to the PIN
427
+ // message. router.handle('/help') would set replyTo=msg.messageId
428
+ // (the PIN), causing the card to nest under the PIN in Feishu UI.
429
+ const helpResult = router.cmdHelp();
430
+ if (helpResult.card) {
431
+ await connector.sendWithRetry(chatId, { card: helpResult.card });
432
+ }
433
+ }
434
+ catch (err) {
435
+ logger.error('[binder] onboarding send failed:', err);
436
+ }
437
+ })();
438
+ },
439
+ });
350
440
  // 入站媒体(图片/文件/视频/语音/表情):先认证后下载(修复)。
351
441
  // connector 只上报"媒体到达",这里先过 owner + enabled 闸门,通过后才
352
442
  // 下载——未认证/关闭配置时不会发生任何网络下载或内存/磁盘占用。
353
443
  // 下载是异步的:立刻把在途 promise 交给装配器(`kind: 'media'`),
354
444
  // 保证「先图后文」不会先提交一个只含文本的 turn。
355
445
  connector.setInboundMediaDetectedHandler((msg) => {
356
- if (!binder.isOwner(msg.userId)) {
446
+ // 与文本入口共用授权:首条纯媒体也在这里完成 owner 绑定(bind_success
447
+ // 继续走下载 + 装配,纯媒体最终由装配器发 no-text 回执)。
448
+ const authorization = authorizer.authorize(msg.userId, msg.chatId);
449
+ if (authorization === 'rejected') {
357
450
  logger.warn(`[media] rejected inbound media from non-owner ${msg.userId}`);
358
451
  return;
359
452
  }
@@ -402,58 +495,16 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
402
495
  });
403
496
  });
404
497
  connector.setMessageHandler((msg) => {
405
- // 绑定/授权闸门:仅 owner 放行;非 owner 静默丢弃;未绑定时首条消息认领
406
- const decision = binder.authorize(msg.userId, msg.chatId);
407
- if (decision.kind === 'rejected')
498
+ // 绑定/授权闸门:仅 owner 放行;非 owner 静默丢弃;未绑定时首条消息认领。
499
+ // 与媒体入口共用 InboundAuthorizer:bind_success 的 onboarding 只跑一次
500
+ // (同一条 post 同时带图和文字时两个入口都会看到 bind_success)。
501
+ const decision = authorizer.authorize(msg.userId, msg.chatId);
502
+ if (decision === 'rejected')
408
503
  return;
409
- if (decision.kind === 'bind_success') {
410
- // First-run onboarding: set default cwd + send welcome + Help card.
411
- // setCwd is synchronous (outside the async closure) so that the user's
412
- // next message — which will hit the `owner` branch now that
413
- // startup-contact.json has been written — finds cwd already set.
414
- // process.cwd() is the only sensible default: the user started
415
- // lark-remote in the directory they want to work in.
416
- // setCwd only when no cwd is set yet (e.g. fresh start after bind).
417
- // Don't overwrite an existing cwd — the user may have already set one
418
- // via /cd or a previous bind. Use realpath for canonical form (matching
419
- // Claude JSONL cwd, e.g. /tmp → /private/tmp on macOS).
420
- let resolvedCwd;
421
- try {
422
- resolvedCwd = fs.realpathSync(process.cwd());
423
- }
424
- catch {
425
- resolvedCwd = process.cwd();
426
- }
427
- if (!sessionStore.getCwd(msg.userId)) {
428
- sessionStore.setCwd(msg.userId, resolvedCwd);
429
- }
430
- void (async () => {
431
- try {
432
- // Message 1: bind confirmation + status (combined to avoid
433
- // Feishu out-of-order delivery across three separate messages).
434
- await connector.sendWithRetry(msg.chatId, {
435
- text: `✅ 已绑定到本账号,此后仅你可使用本应用\n` +
436
- `📂 当前工作目录: \`${resolvedCwd}\`\n` +
437
- `🤖 默认 Agent: ${agentDisplayName(config.defaultAgent)}\n` +
438
- `💡 直接输入消息即可开始对话,或 /cd 切换目录`,
439
- });
440
- // Message 2: Help card.
441
- // Use router.cmdHelp() + manual send (without replyTo) so the Help
442
- // card appears as an independent message, not a reply to the PIN
443
- // message. router.handle('/help') would set replyTo=msg.messageId
444
- // (the PIN), causing the card to nest under the PIN in Feishu UI.
445
- const helpResult = router.cmdHelp();
446
- if (helpResult.card) {
447
- await connector.sendWithRetry(msg.chatId, { card: helpResult.card });
448
- }
449
- }
450
- catch (err) {
451
- logger.error('[binder] onboarding send failed:', err);
452
- }
453
- })();
504
+ // 首条消息只用于完成绑定,不转发给 agent(与旧行为一致)。
505
+ if (decision === 'bind_success')
454
506
  return;
455
- }
456
- // decision.kind === 'owner':正常处理(不再每条覆盖 startup-contact)
507
+ // decision === 'owner':正常处理(不再每条覆盖 startup-contact)
457
508
  logger.info(`message from ${msg.userId} (${msg.rawContentType}): ${msg.content.slice(0, 100)}`);
458
509
  // Add Typing reaction to indicate "still alive"
459
510
  void connector.addReaction(msg.messageId, 'Typing');
@@ -497,17 +548,19 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
497
548
  `(msg_type=${msg.rawContentType})`);
498
549
  }
499
550
  const commandEligible = msg.rawContentType === 'text' && stripped.kinds.length === 0 && stripped.clean !== '';
500
- // 命令消息旁路装配窗口:其它 /命令 立即执行,窗口内已装配的内容
501
- // 作为独立提交冲刷(/stop 已在上面处理,立即生效不等待)。
551
+ // 命令消息旁路装配窗口:/命令 立即执行,但必须先等窗口内已装配的内容
552
+ // (含在途媒体下载)提交完成。此前 `void flush()` 不等待,
553
+ // 有下载在途时命令会先于 commit 执行——发送在 /cd、/new 前的消息被绑定到
554
+ // 新 cwd/session,媒体也落到新目录。/stop 已在上面走快速路径,不受影响。
502
555
  if (commandEligible && (stripped.clean.startsWith('/') || stripped.clean.startsWith('!'))) {
503
- void assembler.flush(msg.userId, msg.chatId, 'flush');
504
- void router
505
- .handle(stripped.clean, {
506
- userId: msg.userId,
507
- chatId: msg.chatId,
508
- messageId: msg.messageId,
509
- }, { allowCommandPrefix: true })
510
- .catch((err) => logger.error('[control] command failed:', err));
556
+ void (async () => {
557
+ await assembler.flush(msg.userId, msg.chatId, 'flush');
558
+ await router.handle(stripped.clean, {
559
+ userId: msg.userId,
560
+ chatId: msg.chatId,
561
+ messageId: msg.messageId,
562
+ }, { allowCommandPrefix: true });
563
+ })().catch((err) => logger.error('[control] command failed:', err));
511
564
  return;
512
565
  }
513
566
  // 普通消息:进装配器,等静默期窗口到期(下载落定)后合并成一个 turn。
@@ -33,6 +33,15 @@ export declare class InstanceLock {
33
33
  * 与 CIM CommandLine 首 token 归一后必然一致(bun/node 垫片场景亦然)。
34
34
  */
35
35
  private lockContents;
36
+ /** 原子创建锁文件;已存在返回 false,其它 IO 错误向上抛。 */
37
+ private tryCreate;
38
+ /**
39
+ * 把夺来的活锁放回原位。优先 `link`(目标已存在会失败,不会覆盖更新的锁),
40
+ * 再删 quarantine。目标已被更新锁占据时放弃恢复——此时当前锁文件属于一个
41
+ * 活实例,拒绝取锁仍然是对的。
42
+ */
43
+ private restoreQuarantine;
44
+ /** 读取锁文件内容(无副作用:不再在读取失败时 unlink 当前锁)。 */
36
45
  private readPidAndName;
37
46
  /**
38
47
  * 锁是否仍由「我们的」实例持有(fail-open 三态):