lark-remote 0.1.8 → 0.1.10

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.
@@ -102,6 +102,28 @@ export class ClaudeRunner {
102
102
  this.session.setAutoApprove(true);
103
103
  await this.session.respondPermission(requestId, { behavior: 'allow' });
104
104
  return;
105
+ case 'accept_with_feedback': {
106
+ // 计划审批「批准并采纳修改」:allow + updatedInput.plan(改写过的新计划),
107
+ // 触发 Claude 侧 planWasEdited → tool_result 回显 edited plan 并写回文件。
108
+ const plan = response.plan;
109
+ if (!plan || !plan.trim()) {
110
+ throw new Error('approval accept_with_feedback missing plan payload');
111
+ }
112
+ await this.session.respondPermission(requestId, {
113
+ behavior: 'allow',
114
+ updatedInput: { plan },
115
+ });
116
+ return;
117
+ }
118
+ case 'decline_with_feedback': {
119
+ // 计划审批「拒绝并附意见」:deny + message,Claude 留在 plan 模式修订。
120
+ const message = response.message?.trim();
121
+ if (!message) {
122
+ throw new Error('approval decline_with_feedback missing message payload');
123
+ }
124
+ await this.session.respondPermission(requestId, { behavior: 'deny', message });
125
+ return;
126
+ }
105
127
  case 'decline':
106
128
  await this.session.respondPermission(requestId, { behavior: 'deny' });
107
129
  return;
@@ -46,6 +46,11 @@ export interface ClaudeSessionOptions {
46
46
  export interface PermissionResult {
47
47
  behavior: 'allow' | 'deny';
48
48
  message?: string;
49
+ /**
50
+ * allow 时显式覆盖 updatedInput(默认回写 pendingToolInputs 原始 input)。
51
+ * 计划审批「批准并采纳修改意见」用它携带改写的 plan。
52
+ */
53
+ updatedInput?: unknown;
49
54
  }
50
55
  /**
51
56
  * ClaudeSession extends SpawningRunner 复用其 spawn/pid 文件/killOrphan/
@@ -74,6 +79,12 @@ export declare class ClaudeSession extends SpawningRunner {
74
79
  private waitResolve;
75
80
  /** 待审批的 control_request 原始 input(回写 updatedInput 需要)。 */
76
81
  private pendingToolInputs;
82
+ /**
83
+ * 本会话内跟踪到的计划文件路径:Write/Edit 工具把文件写到 `plans` 目录
84
+ * 时记录(ExitPlanMode 的 plan 内容不总是经 input 注入,文件是可靠兜底)。
85
+ * 会话进程生命周期内有效(长驻进程 = 一个会话)。
86
+ */
87
+ private planFilePath;
77
88
  /** 允许所有:后续 control_request 自动 allow(本会话进程生命周期内有效)。 */
78
89
  private autoApprove;
79
90
  /** 串行化 stdin 写入(用户消息与审批响应交错到达)。 */
@@ -160,6 +171,21 @@ export declare class ClaudeSession extends SpawningRunner {
160
171
  */
161
172
  private writeStdin;
162
173
  private translateEvent;
174
+ /**
175
+ * 跟踪会话内 Write/Edit 工具写到 `plans` 目录的文件路径:CLI 对
176
+ * ExitPlanMode 的 plan 注入不稳定(input.plan 时有时无),但模型被要求
177
+ * 先把计划写入计划文件,路径可经工具调用可靠捕获(含模型写错目录的
178
+ * 退化场景——渲染层可展示实际落盘位置)。
179
+ */
180
+ private trackPlanFilePath;
181
+ /**
182
+ * ExitPlanMode 计划解析,优先级:
183
+ * 1. control_request input.plan(CLI normalizeToolInput 从磁盘注入,非稳定);
184
+ * 2. input.planFilePath 指向的文件;
185
+ * 3. 会话内 Write/Edit 跟踪到的计划文件。
186
+ * 全部落空时返回空对象,渲染层展示「未获取到计划文件内容」兜底。
187
+ */
188
+ private resolvePlanContent;
163
189
  private translateControlRequest;
164
190
  /** 统一方案:所有 runner 自己生成 timestamp(缺失时补)。 */
165
191
  private withTimestamp;
@@ -27,6 +27,7 @@
27
27
  * 进程在 turn 之间保持存活(stdin 不关闭),/stop / /new / /cd / 看门狗超时
28
28
  * 时经 ProcessStopper 组杀,下条消息按 SessionStore 的 sessionId --resume。
29
29
  */
30
+ import { readFile } from 'node:fs/promises';
30
31
  import { silentlyUnlink } from '../../common/fs.js';
31
32
  import { getLogger } from '../../logger/index.js';
32
33
  import { SpawningRunner } from '../common/spawning-runner.js';
@@ -35,6 +36,8 @@ import { authErrorEvent, syntheticInitEvent } from '../common/runner-utils.js';
35
36
  import { makeQuestionApprovalEvent } from '../question-common.js';
36
37
  /** 拒绝权限时的默认提示(进入 claude 上下文,中文用户可读)。 */
37
38
  const DENY_MESSAGE = '用户拒绝了此工具调用,请停止并等待用户指令。';
39
+ /** ExitPlanMode 计划文件读取上限(超出截断,防超大计划拖垮事件翻译)。 */
40
+ const MAX_PLAN_CHARS = 60_000;
38
41
  /** 会话级空闲回收默认 TTL(对齐 codex appServer.idleTtlMs 默认 30 分钟)。 */
39
42
  const DEFAULT_IDLE_TTL_MS = 30 * 60 * 1000;
40
43
  /** result 事件里表示「turn 中途压缩」的 subtype(不是 turn 结束)。 */
@@ -66,6 +69,12 @@ export class ClaudeSession extends SpawningRunner {
66
69
  waitResolve = null;
67
70
  /** 待审批的 control_request 原始 input(回写 updatedInput 需要)。 */
68
71
  pendingToolInputs = new Map();
72
+ /**
73
+ * 本会话内跟踪到的计划文件路径:Write/Edit 工具把文件写到 `plans` 目录
74
+ * 时记录(ExitPlanMode 的 plan 内容不总是经 input 注入,文件是可靠兜底)。
75
+ * 会话进程生命周期内有效(长驻进程 = 一个会话)。
76
+ */
77
+ planFilePath = '';
69
78
  /** 允许所有:后续 control_request 自动 allow(本会话进程生命周期内有效)。 */
70
79
  autoApprove = false;
71
80
  /** 串行化 stdin 写入(用户消息与审批响应交错到达)。 */
@@ -182,7 +191,9 @@ export class ClaudeSession extends SpawningRunner {
182
191
  if (result.behavior === 'allow') {
183
192
  await this.writeControlResponse(requestId, {
184
193
  behavior: 'allow',
185
- updatedInput: input ?? {},
194
+ // 显式 updatedInput 优先(计划审批「批准并采纳修改」携带改写 plan);
195
+ // 缺省回写原始 input,保证其他工具审批行为不变。
196
+ updatedInput: result.updatedInput !== undefined ? result.updatedInput : (input ?? {}),
186
197
  });
187
198
  }
188
199
  else {
@@ -301,6 +312,10 @@ export class ClaudeSession extends SpawningRunner {
301
312
  this.cwd = opts.cwd;
302
313
  this.autoApprove = false;
303
314
  this.pendingToolInputs.clear();
315
+ // 每次 run 独立:上次 run 会话内跟踪到的计划文件不得泄漏到本次
316
+ // (否则本次 ExitPlanMode 无 plan/planFilePath 时 resolvePlanContent
317
+ // 会误读上一次的计划文件当当前计划)。
318
+ this.planFilePath = '';
304
319
  let proc;
305
320
  try {
306
321
  proc = await this.spawnChild(opts);
@@ -344,7 +359,7 @@ export class ClaudeSession extends SpawningRunner {
344
359
  for await (const raw of stream) {
345
360
  if (generation !== this.processGeneration)
346
361
  return; // 旧进程残留事件丢弃
347
- const events = this.translateEvent(raw);
362
+ const events = await this.translateEvent(raw);
348
363
  if (events.length > 0) {
349
364
  this.eventQueue.push(...events);
350
365
  this.wakeWaiters();
@@ -530,7 +545,7 @@ export class ClaudeSession extends SpawningRunner {
530
545
  // =========================================================================
531
546
  // Internal: event translation
532
547
  // =========================================================================
533
- translateEvent(raw) {
548
+ async translateEvent(raw) {
534
549
  const event = raw;
535
550
  const type = event.type;
536
551
  // review P3-3:--resume 会先重放上一轮历史事件(旧 result 之外还可能有
@@ -538,6 +553,10 @@ export class ClaudeSession extends SpawningRunner {
538
553
  // 内容混进当前卡片(result 分支的 sawInit 守卫升级为全类型守卫)。
539
554
  if (type !== 'system' && !this.sawInit)
540
555
  return [];
556
+ // 跟踪会话内计划文件写入(ExitPlanMode 审批卡的 plan 文件兜底)。
557
+ if (type === 'assistant') {
558
+ this.trackPlanFilePath(event);
559
+ }
541
560
  if (type === 'control_request') {
542
561
  return this.translateControlRequest(event);
543
562
  }
@@ -588,7 +607,63 @@ export class ClaudeSession extends SpawningRunner {
588
607
  // 下游 reducer 忽略,保留诊断价值。
589
608
  return [this.withTimestamp(event)];
590
609
  }
591
- translateControlRequest(raw) {
610
+ /**
611
+ * 跟踪会话内 Write/Edit 工具写到 `plans` 目录的文件路径:CLI 对
612
+ * ExitPlanMode 的 plan 注入不稳定(input.plan 时有时无),但模型被要求
613
+ * 先把计划写入计划文件,路径可经工具调用可靠捕获(含模型写错目录的
614
+ * 退化场景——渲染层可展示实际落盘位置)。
615
+ */
616
+ trackPlanFilePath(raw) {
617
+ const content = raw.message?.content;
618
+ if (!Array.isArray(content))
619
+ return;
620
+ for (const block of content) {
621
+ const b = block;
622
+ if (b?.type !== 'tool_use')
623
+ continue;
624
+ if (b.name !== 'Write' && b.name !== 'Edit')
625
+ continue;
626
+ const fp = b.input?.file_path;
627
+ if (typeof fp === 'string' && fp.includes('/plans/')) {
628
+ this.planFilePath = fp;
629
+ }
630
+ }
631
+ }
632
+ /**
633
+ * ExitPlanMode 计划解析,优先级:
634
+ * 1. control_request input.plan(CLI normalizeToolInput 从磁盘注入,非稳定);
635
+ * 2. input.planFilePath 指向的文件;
636
+ * 3. 会话内 Write/Edit 跟踪到的计划文件。
637
+ * 全部落空时返回空对象,渲染层展示「未获取到计划文件内容」兜底。
638
+ */
639
+ async resolvePlanContent(input, trackedPath) {
640
+ const direct = typeof input.plan === 'string' && input.plan.trim() ? input.plan : undefined;
641
+ if (direct !== undefined) {
642
+ // 计划已随 input 注入时仍携带文件路径(input.planFilePath 或会话内跟踪),
643
+ // 供卡片展示「计划落盘位置」。
644
+ const filePath = typeof input.planFilePath === 'string' && input.planFilePath
645
+ ? input.planFilePath
646
+ : trackedPath;
647
+ return filePath ? { plan: direct, planFilePath: filePath } : { plan: direct };
648
+ }
649
+ const candidate = typeof input.planFilePath === 'string' && input.planFilePath
650
+ ? input.planFilePath
651
+ : trackedPath;
652
+ if (!candidate)
653
+ return {};
654
+ try {
655
+ const content = await readFile(candidate, 'utf8');
656
+ const trimmed = content.trim();
657
+ if (trimmed) {
658
+ return { plan: trimmed.slice(0, MAX_PLAN_CHARS), planFilePath: candidate };
659
+ }
660
+ }
661
+ catch {
662
+ // 计划文件缺失/不可读:fall through,渲染层兜底。
663
+ }
664
+ return {};
665
+ }
666
+ async translateControlRequest(raw) {
592
667
  const requestId = raw.request_id;
593
668
  if (requestId === null || requestId === undefined) {
594
669
  getLogger().warn(`[${this.logTag}] control_request missing request_id, dropping`);
@@ -642,21 +717,27 @@ export class ClaudeSession extends SpawningRunner {
642
717
  }
643
718
  return [makeQuestionApprovalEvent(requestId, questions, this.sessionId)];
644
719
  }
645
- // ExitPlanMode:语义是「退出计划模式」的通知型工具,input 天然为空(plan
646
- // 内容在 plan 文件 + 前置 thinking/text 里,不走 tool input)。它没有
647
- // command 语义,塞进 command 槽位会显示无意义 `{}` → 用独立 tool kind,
648
- // 标题/工具名 + reason 表达审批内容。
720
+ // ExitPlanMode:计划已写入计划文件(或经 input.plan 注入)。没有 command
721
+ // 语义,塞进 command 槽位会显示无意义 `{}` → 用独立 tool kind,把计划
722
+ // 全文 + 文件路径带给渲染层,决策对齐 TUI 四种交互。
649
723
  if (toolName === 'ExitPlanMode') {
650
- const rawInput = JSON.stringify(input);
724
+ const resolved = await this.resolvePlanContent(input, this.planFilePath);
651
725
  const view = {
652
726
  requestId,
653
727
  kind: 'tool',
654
728
  toolName,
655
729
  reason: typeof request.description === 'string' ? request.description : undefined,
656
- // 非空 input 携带给渲染层(当前 ExitPlanMode 为 `{}`,渲染层不展示)。
657
- ...(rawInput && rawInput !== '{}' ? { toolInput: rawInput.slice(0, 500) } : {}),
658
- // 计划审批没有「后续自动放行」语义,acceptAll 无意义;只留 accept/decline。
659
- availableDecisions: ['accept', 'decline'],
730
+ ...(resolved.plan !== undefined ? { plan: resolved.plan } : {}),
731
+ ...(resolved.planFilePath !== undefined ? { planFilePath: resolved.planFilePath } : {}),
732
+ // 对齐 TUI:批准并执行 / 批准并自动放行(acceptAll)/ 拒绝并附意见 /
733
+ // 批准并采纳修改 / 拒绝。
734
+ availableDecisions: [
735
+ 'accept',
736
+ 'acceptAll',
737
+ 'declineWithFeedback',
738
+ 'acceptWithFeedback',
739
+ 'decline',
740
+ ],
660
741
  };
661
742
  getLogger().info(`[${this.logTag}] permission request requestId=${requestId} tool=${toolName}`);
662
743
  return [
@@ -104,18 +104,23 @@ export class ConnectionBasedRunner {
104
104
  * unblock; otherwise cancel now via cancelCurrentTurn().
105
105
  */
106
106
  async stop(_opts) {
107
- if (!this._isRunning || !this.currentClient || !this.currentSessionId())
107
+ if (!this._isRunning)
108
108
  return;
109
+ // CC-01: 启动阶段(client/session 未建立)也必须记录停止意图,否则 /stop 被吞、
110
+ // turn 照常执行。executeTurn 在 setup 完成后检查 stopRequested 补发 cancel。
111
+ this.stopRequested = true;
112
+ this.forceFinish = true;
113
+ this.wakeWaiters();
114
+ if (!this.currentClient || !this.currentSessionId()) {
115
+ getLogger().warn(`[${this.logTag}] stop during setup (client/session not ready); will cancel after setup`);
116
+ return;
117
+ }
109
118
  if (this.shouldDeferStop()) {
110
119
  // turn/start 或 prompt 尚未响应:先标记,等 run() 完成后再补发 cancel。
111
- this.stopRequested = true;
112
- this.forceFinish = true;
113
- this.wakeWaiters();
114
120
  return;
115
121
  }
116
122
  // turn/start 或 prompt 已 resolved:立即 cancel;同时置 stopRequested 抑制
117
123
  // 尚未结算的 prompt 结果翻译(opencode 语义),对已结算方无害。
118
- this.stopRequested = true;
119
124
  try {
120
125
  await this.cancelCurrentTurn();
121
126
  }
@@ -44,6 +44,13 @@ export declare class DshRunner implements AgentRunner {
44
44
  private activeAbort;
45
45
  private activeSessionId;
46
46
  private running;
47
+ /**
48
+ * 已对齐模型的 session 集合:同一 session 二次 run 不再重发 selectModel
49
+ * (session.selectModel 会写服务端全局默认,每次 run 都写是写放大)。
50
+ * configuredModel 在实例生命周期内不变(/config 改模型会 clearRunners
51
+ * 重建实例 + 换新 sessionId),故「每 session 对齐一次」足以覆盖配置变化。
52
+ */
53
+ private readonly alignedSessions;
47
54
  constructor(opts: DshRunnerOptions);
48
55
  get isRunning(): boolean;
49
56
  getStatusInfo(): AgentStatusInfo;
@@ -28,6 +28,13 @@ export class DshRunner {
28
28
  activeAbort = null;
29
29
  activeSessionId = null;
30
30
  running = false;
31
+ /**
32
+ * 已对齐模型的 session 集合:同一 session 二次 run 不再重发 selectModel
33
+ * (session.selectModel 会写服务端全局默认,每次 run 都写是写放大)。
34
+ * configuredModel 在实例生命周期内不变(/config 改模型会 clearRunners
35
+ * 重建实例 + 换新 sessionId),故「每 session 对齐一次」足以覆盖配置变化。
36
+ */
37
+ alignedSessions = new Set();
31
38
  constructor(opts) {
32
39
  this.sessionReader = opts.sessionReader;
33
40
  this.configuredModel = opts.model;
@@ -51,99 +58,143 @@ export class DshRunner {
51
58
  };
52
59
  }
53
60
  async *run(message, opts) {
61
+ if (this.running) {
62
+ throw new Error('DshRunner is already running');
63
+ }
54
64
  const abort = new AbortController();
55
65
  this.activeAbort = abort;
56
66
  this.running = true;
57
67
  let sessionId = opts.sessionId;
58
- if (!sessionId) {
59
- const created = await this.client.createSession({
60
- cwd: opts.cwd,
61
- ...(this.agentPreset ? { agentPreset: this.agentPreset } : {}),
62
- });
63
- sessionId = created.sessionId;
64
- }
65
- this.activeSessionId = sessionId;
66
- // 模型/推理强度变更 = 保留 session,每次 run 前对齐(§4.3):
67
- // 该调用同时会写服务端全局默认;失败仅告警不阻断 run(模型校验由 DSH 负责)。
68
- if (this.configuredModel) {
69
- try {
70
- await this.client.selectModel({
71
- sessionId,
72
- provider: DSH_MODEL_PROVIDER,
73
- model: this.configuredModel,
74
- ...(this.reasoningEffort ? { reasoningEffort: this.reasoningEffort } : {}),
68
+ let terminalEmitted = false;
69
+ try {
70
+ // CC-03: session.create 必须包进同一 try/finally,否则创建失败(服务端不可达/报错)
71
+ // 会在进入旧 try 之前抛异常,finally 不执行 → running 恒 true、activeAbort 残留。
72
+ if (!sessionId) {
73
+ const created = await this.client.createSession({
74
+ cwd: opts.cwd,
75
+ ...(this.agentPreset ? { agentPreset: this.agentPreset } : {}),
75
76
  });
77
+ sessionId = created.sessionId;
76
78
  }
77
- catch (err) {
78
- getLogger().warn(`[dsh] selectModel failed for session ${sessionId} (run continues): ${err.message}`);
79
+ this.activeSessionId = sessionId;
80
+ // Startup abort:createSession 挂起期间 stop() 已 abort(此时 sessionId 尚
81
+ // 未知,stop() 无法 cancel)。createSession 返回后立即检查——已 abort 则
82
+ // cancel 这个刚建的 session 并中断,不让启动流程继续(selectModel/prompt)。
83
+ // 必须 yield interrupted result(不能裸 return):调用方依赖 result 收尾
84
+ // run 卡,否则 for-await 自然结束后无终态、卡片停留「运行中」。
85
+ if (abort.signal.aborted) {
86
+ try {
87
+ await this.client.unary('session.cancel', { sessionId });
88
+ }
89
+ catch {
90
+ /* 取消失败仅告警,session 已 abort 无需继续 */
91
+ }
92
+ getLogger().info(`[dsh] run aborted during startup, cancelled session ${sessionId}`);
93
+ terminalEmitted = true;
94
+ yield {
95
+ type: 'result',
96
+ subtype: 'interrupted',
97
+ session_id: sessionId,
98
+ errorMessage: 'run stopped',
99
+ };
100
+ return;
79
101
  }
80
- }
81
- yield {
82
- type: 'system',
83
- subtype: 'init',
84
- session_id: sessionId,
85
- cwd: opts.cwd,
86
- model: this.configuredModel ?? 'DSH',
87
- };
88
- let terminalEmitted = false;
89
- try {
90
- // Snapshot the pre-prompt high-water mark before mutating the session.
91
- // DSH session.history returns the whole session, so starting from seq 0
92
- // would replay the first completed turn on every follow-up prompt.
93
- const prePromptSeq = await this.client.latestEventSeq(sessionId);
94
- await this.client.unary('session.prompt', {
95
- sessionId,
96
- mode: 'queue',
97
- content: [{ type: 'text', text: message }],
98
- });
99
- const translator = new DshTranslator();
102
+ // 模型/推理强度对齐(§4.3):会话首次 run 时 selectModel 一次
103
+ // (该调用同时写服务端全局默认,故不重复写);同一 session 后续 run
104
+ // 跳过。失败仅告警不阻断 run(模型校验由 DSH 负责)。
105
+ if (this.configuredModel && !this.alignedSessions.has(sessionId)) {
106
+ try {
107
+ await this.client.selectModel({
108
+ sessionId,
109
+ provider: DSH_MODEL_PROVIDER,
110
+ model: this.configuredModel,
111
+ ...(this.reasoningEffort ? { reasoningEffort: this.reasoningEffort } : {}),
112
+ });
113
+ this.alignedSessions.add(sessionId);
114
+ }
115
+ catch (err) {
116
+ getLogger().warn(`[dsh] selectModel failed for session ${sessionId} (run continues): ${err.message}`);
117
+ }
118
+ }
119
+ yield {
120
+ type: 'system',
121
+ subtype: 'init',
122
+ session_id: sessionId,
123
+ cwd: opts.cwd,
124
+ model: this.configuredModel ?? 'DSH',
125
+ };
100
126
  try {
101
- for await (const item of this.client.sessionEvents(sessionId, opts.cwd, abort.signal, prePromptSeq)) {
102
- if (abort.signal.aborted)
103
- break;
104
- if (item.kind === 'approval') {
105
- getLogger().warn(`[dsh] approval requested approvalId=${item.approvalId} tool=${item.toolName} — resolve in DSH Web UI`);
106
- yield {
107
- type: 'assistant',
108
- message: {
109
- content: [
110
- {
111
- type: 'text',
112
- text: `⚠️ 需要授权(${item.toolName}):请在 DSH Web UI 处理 ${this.client.baseUrl}`,
113
- },
114
- ],
115
- },
116
- };
117
- continue;
118
- }
119
- for (const ev of translator.eventToAgentEvents(item.event, sessionId)) {
120
- yield ev;
121
- if (ev.type === 'result') {
122
- terminalEmitted = true;
123
- return;
127
+ // Snapshot the pre-prompt high-water mark before mutating the session.
128
+ // DSH session.history returns the whole session, so starting from seq 0
129
+ // would replay the first completed turn on every follow-up prompt.
130
+ const prePromptSeq = await this.client.latestEventSeq(sessionId);
131
+ await this.client.unary('session.prompt', {
132
+ sessionId,
133
+ mode: 'queue',
134
+ content: [{ type: 'text', text: message }],
135
+ });
136
+ const translator = new DshTranslator();
137
+ try {
138
+ for await (const item of this.client.sessionEvents(sessionId, opts.cwd, abort.signal, prePromptSeq)) {
139
+ if (abort.signal.aborted)
140
+ break;
141
+ if (item.kind === 'approval') {
142
+ getLogger().warn(`[dsh] approval requested approvalId=${item.approvalId} tool=${item.toolName} — resolve in DSH Web UI`);
143
+ yield {
144
+ type: 'assistant',
145
+ message: {
146
+ content: [
147
+ {
148
+ type: 'text',
149
+ text: `⚠️ 需要授权(${item.toolName}):请在 DSH Web UI 处理 ${this.client.baseUrl}`,
150
+ },
151
+ ],
152
+ },
153
+ };
154
+ continue;
155
+ }
156
+ for (const ev of translator.eventToAgentEvents(item.event, sessionId)) {
157
+ yield ev;
158
+ if (ev.type === 'result') {
159
+ terminalEmitted = true;
160
+ return;
161
+ }
124
162
  }
125
163
  }
126
164
  }
165
+ catch (err) {
166
+ if (abort.signal.aborted)
167
+ throw err;
168
+ getLogger().error(`[dsh] run consume error: ${err.message}`);
169
+ yield {
170
+ type: 'result',
171
+ subtype: 'error',
172
+ session_id: sessionId,
173
+ errorMessage: `DSH stream error: ${err.message}`,
174
+ };
175
+ terminalEmitted = true;
176
+ }
127
177
  }
128
178
  catch (err) {
129
- if (abort.signal.aborted)
130
- throw err;
131
- getLogger().error(`[dsh] run consume error: ${err.message}`);
132
- yield {
133
- type: 'result',
134
- subtype: 'error',
135
- session_id: sessionId,
136
- errorMessage: `DSH stream error: ${err.message}`,
137
- };
138
- terminalEmitted = true;
179
+ if (!abort.signal.aborted) {
180
+ yield {
181
+ type: 'result',
182
+ subtype: 'error',
183
+ session_id: sessionId,
184
+ errorMessage: `DSH run failed: ${err.message}`,
185
+ };
186
+ terminalEmitted = true;
187
+ }
139
188
  }
140
189
  }
141
190
  catch (err) {
191
+ // CC-03: createSession 等启动期失败也产出 error result(而非抛异常),
192
+ // 由本 catch 统一转 error result;state 清理在 finally。
142
193
  if (!abort.signal.aborted) {
143
194
  yield {
144
195
  type: 'result',
145
196
  subtype: 'error',
146
- session_id: sessionId,
197
+ session_id: sessionId ?? '',
147
198
  errorMessage: `DSH run failed: ${err.message}`,
148
199
  };
149
200
  terminalEmitted = true;
@@ -158,7 +209,7 @@ export class DshRunner {
158
209
  yield {
159
210
  type: 'result',
160
211
  subtype: abort.signal.aborted ? 'interrupted' : 'error',
161
- session_id: sessionId,
212
+ session_id: sessionId ?? '',
162
213
  errorMessage: abort.signal.aborted ? 'run stopped' : 'turn ended without a terminal event',
163
214
  };
164
215
  }
@@ -405,6 +405,21 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
405
405
  sessionId = newResult.sessionId;
406
406
  }
407
407
  this.activeSessionId = sessionId;
408
+ // CC-07: 下发配置的模型(provider/model)。session/new|resume 只带 cwd/mcpServers,
409
+ // 不带 model;不主动下发则实际跑 kimi 服务端默认模型(旧 KimiRunner 通过 -m 传模型,
410
+ // 迁移到纯 ACP 后丢失)。仿 opencode 用 session/set_config_option。失败仅告警不阻断。
411
+ if (this.model) {
412
+ try {
413
+ await client.request('session/set_config_option', {
414
+ sessionId,
415
+ configId: 'model',
416
+ value: this.model,
417
+ });
418
+ }
419
+ catch (err) {
420
+ getLogger().warn(`[${this.logTag}] set_config_option model failed (non-fatal): ${err.message}`);
421
+ }
422
+ }
408
423
  // Set permission mode (§5.1): EVERY fresh ACP session starts in
409
424
  // 'default' (= manual approvals, acp-server modes.ts DEFAULT_MODE_ID),
410
425
  // so the mode must be sent unconditionally — skipping yolo leaves the
@@ -134,7 +134,12 @@ export class PiRpcRunner extends ConnectionBasedRunner {
134
134
  this.currentTurnId = turnId;
135
135
  this.pushEvents([translator.produceTurnStarted(sessionId, turnId)]);
136
136
  this.promptSettled = false;
137
- await client.request({ type: 'prompt', message });
137
+ const response = await client.request({ type: 'prompt', message });
138
+ if (!response.success) {
139
+ // CC-08: pi 返回 success:false(无效模型/provider 错误/忙)时立即失败,
140
+ // 不能当成成功 ACK 继续等 agent_settled(否则最长 turn idle timeout)。
141
+ throw new Error(`pi prompt failed: ${response.error ?? 'unknown error'}`);
142
+ }
138
143
  this.promptSettled = true;
139
144
  }
140
145
  /**
@@ -5,8 +5,9 @@ import type { AgentKind, Runner } from './index.js';
5
5
  * up `config.defaultAgent` here to pick the concrete runner.
6
6
  *
7
7
  * Singleton caching: the factory MAY cache its instance internally. Most
8
- * current agents (claude, opencode, pi, kimi) are spawn-per-message; codex is
9
- * workspace-lifetime (app-server). Each factory returns a runner per call.
8
+ * current spawn-type agents (claude, opencode, pi, kimi) are spawn-per-message; codex is
9
+ * workspace-lifetime (app-server); dsh is HTTP+WS direct connection (no local
10
+ * subprocess). Each factory returns a runner per call.
10
11
  *
11
12
  * Dynamic config reload: factory can read latest config via registry's
12
13
  * configContainer, enabling runtime config changes to take effect.
@@ -9,8 +9,9 @@ let globalRegistry;
9
9
  * up `config.defaultAgent` here to pick the concrete runner.
10
10
  *
11
11
  * Singleton caching: the factory MAY cache its instance internally. Most
12
- * current agents (claude, opencode, pi, kimi) are spawn-per-message; codex is
13
- * workspace-lifetime (app-server). Each factory returns a runner per call.
12
+ * current spawn-type agents (claude, opencode, pi, kimi) are spawn-per-message; codex is
13
+ * workspace-lifetime (app-server); dsh is HTTP+WS direct connection (no local
14
+ * subprocess). Each factory returns a runner per call.
14
15
  *
15
16
  * Dynamic config reload: factory can read latest config via registry's
16
17
  * configContainer, enabling runtime config changes to take effect.