lark-remote 0.5.8 → 0.5.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.
@@ -568,7 +568,7 @@ export class QueueManager {
568
568
  ...this.buildQueueActionButtons(cwd, messageId, true),
569
569
  ],
570
570
  });
571
- await this.updateCard(cardMessageId, card);
571
+ await this.updateCardWithRetry(cardMessageId, card);
572
572
  getLogger().debug(`[queue-manager] queue card updated to executing messageId=${messageId}`);
573
573
  }
574
574
  catch (err) {
@@ -597,9 +597,9 @@ export class QueueManager {
597
597
  taskList.splice(index, 1);
598
598
  }
599
599
  this.indexRemove(cwd, messageId);
600
- // Note: queueCardMessages mapping is NOT deleted here.
601
- // The caller (handleQueueCancel) will call updateQueueCardToCancelled
602
- // which will clean up the mapping after updating the card.
600
+ // queueCardMessages mapping 仍不在这里删:卡片更新由 Bridge.removeFromQueue
601
+ // 统一触发(单一收口),updateQueueCardToCancelled 成功/失败后都会清掉映射。
602
+ // 直接调 QueueManager.removeFromQueue 的测试/内部用途不会自动更新卡片。
603
603
  getLogger().info(`[queue-manager] removed from queue cwd=${cwd} messageId=${messageId}`);
604
604
  return true;
605
605
  }
@@ -626,7 +626,7 @@ export class QueueManager {
626
626
  ],
627
627
  });
628
628
  try {
629
- await this.updateCard(cardMessageId, card);
629
+ await this.updateCardWithRetry(cardMessageId, card);
630
630
  getLogger().debug(`[queue-manager] queue card updated to cancelled, messageId=${messageId}`);
631
631
  }
632
632
  catch (err) {
@@ -636,6 +636,21 @@ export class QueueManager {
636
636
  this.queueCardMessages.delete(messageId);
637
637
  }
638
638
  }
639
+ /**
640
+ * 卡片 PATCH 带一次重试:一次网络抖动不该让排队卡永久停在旧状态——映射
641
+ * 会在本次调用结束时被清理,之后没有任何自动修正机会。
642
+ * 第二次仍失败则向上抛,由调用方 catch 记日志(行为与改前一致)。
643
+ */
644
+ async updateCardWithRetry(messageId, card) {
645
+ try {
646
+ await this.updateCard(messageId, card);
647
+ return;
648
+ }
649
+ catch (err) {
650
+ getLogger().warn(`[queue-manager] queue card update failed, retrying once messageId=${messageId}: ${err.message}`);
651
+ }
652
+ await this.updateCard(messageId, card);
653
+ }
639
654
  /** Get task metadata from queue. */
640
655
  getQueuedTask(cwd, messageId) {
641
656
  // O(1) index lookup instead of array `find`.
@@ -22,6 +22,8 @@ const TERMINAL_LABELS = {
22
22
  idle_timeout: '已超时',
23
23
  running: '运行中',
24
24
  finalizing: '完成中',
25
+ // 本轮结束、goal 仍 active:任务没结束,标签不能说"完成"。
26
+ goal_continuing: '目标继续',
25
27
  };
26
28
  export function terminalToLabel(terminal) {
27
29
  return TERMINAL_LABELS[terminal] ?? '运行中';
@@ -37,6 +39,8 @@ export function terminalToColor(terminal) {
37
39
  return 'orange';
38
40
  if (terminal === 'finalizing')
39
41
  return 'orange';
42
+ if (terminal === 'goal_continuing')
43
+ return 'orange';
40
44
  if (terminal === 'running')
41
45
  return 'blue';
42
46
  return 'red';
@@ -1,4 +1,4 @@
1
- import type { RunState, ToolEntry } from './run-state.js';
1
+ import { type RunState, type ToolEntry } from './run-state.js';
2
2
  export interface RunCardRenderOptions {
3
3
  /**
4
4
  * Agent kind for the run card header title (e.g. "Claude · 思考中").
@@ -12,6 +12,13 @@ export interface RunCardRenderOptions {
12
12
  * 无 turn_started → operationKind undefined)同样能拿到按钮。
13
13
  */
14
14
  compactSupported?: boolean;
15
+ /**
16
+ * 卡片没有 `state.sessionId` 时,是否仍有可压缩的会话(bridge 在开跑前从
17
+ * sessionStore 探到的会话)。`false` 表示「本轮没有 init,之前也没有任何
18
+ * 会话」——此时 Compact 按钮点了必然反查不到记录,不渲染。缺省(undefined)
19
+ * 保持原有「按钮可见」语义,供渲染层独立调用方使用。
20
+ */
21
+ sessionFallbackAvailable?: boolean;
15
22
  }
16
23
  type BlockGroup = {
17
24
  kind: 'thinking';
@@ -1,10 +1,11 @@
1
+ import { isRunTerminal, } from './run-state.js';
1
2
  import { terminalToColor, terminalToLabel, newSessionButton, stopButton, compactButton, cardButton, agentDisplayName, } from './card-shared.js';
2
3
  import { collapsibleMarkdownPanel, markdownDiv } from './collapsible.js';
3
4
  import { toolBodyMd, toolHeaderText } from './tool-render.js';
4
5
  import { truncateMarkdownTables, CARD_BUDGET_BYTES } from './text-truncate.js';
5
6
  import { truncateUtf8 } from '../common/truncate.js';
6
7
  import { countCardElements, MAX_CARD_ELEMENTS } from './card-budget.js';
7
- import { displayWidth } from '../common/text-width.js';
8
+ import { displayWidth, truncateToDisplayWidth } from '../common/text-width.js';
8
9
  import { isContentFilterFailure } from '../common/turn-failure.js';
9
10
  import { formatClock, formatTimestamp } from './time.js';
10
11
  import { formatUsageStats } from '../router/usage-format.js';
@@ -45,10 +46,75 @@ function shouldShowCompactButton(state, options) {
45
46
  return false;
46
47
  if (options.compactSupported === false)
47
48
  return false;
48
- return (state.terminal === 'done' ||
49
- state.terminal === 'error' ||
50
- state.terminal === 'interrupted' ||
51
- state.terminal === 'idle_timeout');
49
+ // 没有可反查的会话 = 点了必然失败:「/stop 落在 init 之前」的那类卡
50
+ // 上会留一个注定回「该任务已结束」的死按钮。本轮 sessionId 优先;没有时看
51
+ // bridge 在开跑前探到的 store 会话(`sessionFallbackAvailable`,缺省视为可用
52
+ // 以保持渲染层独立调用方的既有语义)。
53
+ if (!state.sessionId && options.sessionFallbackAvailable === false)
54
+ return false;
55
+ // goal 仍 active = 宿主会自动起下一轮,会话永远不 idle,压缩必被拒(DSH 的
56
+ // 手动压缩要求 agent 空闲)。给按钮就是给一个注定失败的点击:用户点
57
+ // compact 只会拿到"还有一轮在跑"。
58
+ if (state.goal?.phase === 'active')
59
+ return false;
60
+ return isRunTerminal(state.terminal);
61
+ }
62
+ /**
63
+ * goal 区块:目标是什么、跑到第几轮、当前什么状态。
64
+ *
65
+ * 为什么放在状态行下面(而不是摘要区):手机上用户只会扫前两屏。goal 是解释
66
+ * "这轮为什么结束却没收尾/为什么不能压缩"的唯一线索,必须靠前。
67
+ *
68
+ * 窄屏约束:每行一个 div,段间用 ` · `,不做 auto+grow 并排。
69
+ */
70
+ const GOAL_INLINE_WIDTH = 64;
71
+ const GOAL_FULL_MAX_BYTES = 2_400;
72
+ /** 目标阶段的短标签。 */
73
+ function goalPhaseLabel(goal) {
74
+ if (goal.phase === 'active')
75
+ return '目标进行中';
76
+ if (goal.phase === 'paused')
77
+ return '目标已暂停';
78
+ if (goal.phase === 'blocked')
79
+ return '目标被阻断';
80
+ return '目标已完成';
81
+ }
82
+ /**
83
+ * Build the goal block (empty when the session has no goal).
84
+ *
85
+ * 状态来源是宿主的 `goal` 投影/`goal/change`,不是桥的推断:把"我这一轮结束
86
+ * 了"当任务结束,就是"卡片写完成、任务还在跑"那类缺陷的来源。
87
+ */
88
+ function buildGoalSection(state) {
89
+ const goal = state.goal;
90
+ if (!goal)
91
+ return [];
92
+ const elements = [];
93
+ const round = `第 ${goal.roundsStarted}/${goal.maxGoalRounds} 轮`;
94
+ elements.push(markdownDiv(`🎯 **${goalPhaseLabel(goal)}** · ${round}`));
95
+ // 目标全文可能有换行/超长:行内压成单行并截断,全文进折叠面板。
96
+ const singleLine = goal.objective.replace(/\s+/gu, ' ').trim();
97
+ const inline = displayWidth(singleLine) <= GOAL_INLINE_WIDTH
98
+ ? singleLine
99
+ : truncateToDisplayWidth(singleLine, GOAL_INLINE_WIDTH);
100
+ elements.push(markdownDiv(`目标:${inline}`, 'notation'));
101
+ if (inline !== singleLine) {
102
+ elements.push(collapsibleMarkdownPanel({
103
+ title: '🎯 目标全文',
104
+ expanded: false,
105
+ border: 'grey',
106
+ content: truncateUtf8(goal.objective, GOAL_FULL_MAX_BYTES),
107
+ textSize: 'notation',
108
+ }));
109
+ }
110
+ if (goal.phase === 'blocked' && goal.blockedReason) {
111
+ elements.push(markdownDiv(`⛔ 阻断:${goal.blockedReason.code} — ${goal.blockedReason.message}`, 'notation'));
112
+ }
113
+ if (goal.phase === 'active' && state.terminal === 'goal_continuing') {
114
+ // 这句话就是整张卡的核心信息:本轮结束 ≠ 任务结束。
115
+ elements.push(markdownDiv(`_本轮已结束;DSH 会自动开始第 ${goal.roundsStarted + 1} 轮_`, 'notation'));
116
+ }
117
+ return elements;
52
118
  }
53
119
  /**
54
120
  * Wrap text block in a collapsible panel with timestamp in the header.
@@ -397,9 +463,22 @@ function assembleRunCard(state, options, elements) {
397
463
  template: headerTemplate(state),
398
464
  title: { content: headerTitle(state, options), tag: 'plain_text' },
399
465
  },
400
- body: { elements },
466
+ // goal 区块紧随状态行(所有预算档位都以此为第一元素),让"目标是什么/
467
+ // 第几轮"出现在折叠面板之前;它只有 2–3 个元素,兜底档位也扛得住。
468
+ body: { elements: insertAfterStatusRow(elements, buildGoalSection(state)) },
401
469
  };
402
470
  }
471
+ /**
472
+ * 把目标区块插到状态行之后。所有预算档位的 elements 都以 statusRow 开头;
473
+ * 空数组时原样返回,避免把 `undefined` 塞进飞书卡片数组。
474
+ */
475
+ function insertAfterStatusRow(elements, extra) {
476
+ if (extra.length === 0)
477
+ return elements;
478
+ if (elements.length === 0)
479
+ return extra;
480
+ return [elements[0], ...extra, ...elements.slice(1)];
481
+ }
403
482
  /**
404
483
  * Build a budget-fallback tier (degraded or extreme): keep the last N thinking
405
484
  * blocks and last N tools, truncate text/thinking per tier, then render in
@@ -964,7 +1043,14 @@ function buildSummaryContent(state, options = {}) {
964
1043
  model: state.model,
965
1044
  costUsd: state.costUsd,
966
1045
  reasoningTokens: state.reasoningTokens,
967
- }, { showResult: true, result });
1046
+ }, {
1047
+ showResult: true,
1048
+ result,
1049
+ // 本轮结束但目标继续:不能报"已完成",也不能说"出错"。
1050
+ ...(state.terminal === 'goal_continuing'
1051
+ ? { completedLabel: '⏳ 本轮结束 · 目标仍在继续' }
1052
+ : {}),
1053
+ });
968
1054
  elements.push(markdownDiv(usageStatsStr + empty));
969
1055
  // 后台任务:跨 turn 生命周期,必须显式告知并可逐个停止。
970
1056
  elements.push(...buildBackgroundJobsSection(state));
@@ -1086,8 +1172,13 @@ function statusTagLabel(terminal) {
1086
1172
  /** CardKit 2.0 header title — agent-aware via options.agentKind. */
1087
1173
  function headerTitle(state, options = {}) {
1088
1174
  const agent = agentDisplayName(options.agentKind ?? 'claude');
1089
- if (state.terminal === 'done')
1090
- return `✅ ${agent} · 已完成`;
1175
+ // 本轮 turn 结束但 goal 仍 active:这是"一轮结束",不是"任务完成"。标题
1176
+ // 必须说清楚,否则用户会以为活儿干完了。
1177
+ if (state.terminal === 'goal_continuing')
1178
+ return `⏳ ${agent} · 本轮结束(目标继续)`;
1179
+ if (state.terminal === 'done') {
1180
+ return state.goal?.phase === 'complete' ? `✅ ${agent} · 目标已完成` : `✅ ${agent} · 已完成`;
1181
+ }
1091
1182
  if (state.terminal === 'error')
1092
1183
  return `⚠️ ${agent} · 出错`;
1093
1184
  if (state.terminal === 'interrupted')
@@ -1,5 +1,22 @@
1
- import type { AgentEvent, ApprovalView, ToolHint } from '../runner/index.js';
2
- export type RunTerminal = 'running' | 'finalizing' | 'done' | 'error' | 'interrupted' | 'idle_timeout';
1
+ import type { AgentEvent, ApprovalView, ToolHint, GoalView } from '../runner/index.js';
2
+ export type RunTerminal = 'running' | 'finalizing' | 'done' | 'error' | 'interrupted' | 'idle_timeout'
3
+ /**
4
+ * 本轮 turn 已结束,但会话的 goal 仍 active:宿主会自动开始下一轮,任务没有
5
+ * 完成,卡片不得写"完成"。与 done 的差别只在语义与渲染,
6
+ * 卡片同样停止更新。
7
+ */
8
+ | 'goal_continuing';
9
+ /**
10
+ * 已收尾的 run 终态集合(卡片不再更新)。
11
+ *
12
+ * 单一来源:事件消费守卫、可压缩记账白名单、Compact 按钮可见性三处共用,
13
+ * 禁止各自再抄一份字面量——三处漂移过一次(goal_continuing 只进白名单不进
14
+ * 按钮判据),新增终态时必漏一处的风险是结构性的。
15
+ */
16
+ export declare const RUN_TERMINAL_STATES: readonly ["done", "error", "interrupted", "idle_timeout", "goal_continuing"];
17
+ export type RunFinalTerminal = (typeof RUN_TERMINAL_STATES)[number];
18
+ /** 该状态是否已收尾(非 running/finalizing)。 */
19
+ export declare function isRunTerminal(terminal: RunTerminal): terminal is RunFinalTerminal;
3
20
  export type RunFooter = 'thinking' | 'tool_running' | 'streaming' | null;
4
21
  /** 中断来源(interrupted 终态的原因),供卡片如实归因。 */
5
22
  export type InterruptedReason = 'approval_timeout' | 'user_stop' | 'approval_cancelled';
@@ -161,6 +178,16 @@ export interface RunState {
161
178
  status?: string;
162
179
  progress?: string;
163
180
  }>;
181
+ /**
182
+ * 会话级长期目标(DSH goal)。跨 turn 生命周期:goal 仍 active 时,本轮结束
183
+ * 只是"一轮结束",宿主会自动续跑,卡片必须显示目标是什么、跑到第几轮。
184
+ */
185
+ goal?: GoalView;
186
+ /**
187
+ * goal 是否被成功观测过。`false` = 探测失败,渲染层不得据此断言"没有目标/
188
+ * 已完成"(把读不到当成没有,就退回了"任务在跑、卡片写完成"的旧缺陷)。
189
+ */
190
+ goalKnown?: boolean;
164
191
  }
165
192
  export interface FinishMeta {
166
193
  resultSubtype?: 'success' | 'error' | 'interrupted';
@@ -3,6 +3,24 @@ const MAX_REASONING_CHARS = 4_000;
3
3
  const MAX_TEXT_CHARS = 12_000;
4
4
  const MAX_TOOL_DETAIL_CHARS = 3_000;
5
5
  const MAX_BLOCKS = 24;
6
+ /**
7
+ * 已收尾的 run 终态集合(卡片不再更新)。
8
+ *
9
+ * 单一来源:事件消费守卫、可压缩记账白名单、Compact 按钮可见性三处共用,
10
+ * 禁止各自再抄一份字面量——三处漂移过一次(goal_continuing 只进白名单不进
11
+ * 按钮判据),新增终态时必漏一处的风险是结构性的。
12
+ */
13
+ export const RUN_TERMINAL_STATES = [
14
+ 'done',
15
+ 'error',
16
+ 'interrupted',
17
+ 'idle_timeout',
18
+ 'goal_continuing',
19
+ ];
20
+ /** 该状态是否已收尾(非 running/finalizing)。 */
21
+ export function isRunTerminal(terminal) {
22
+ return RUN_TERMINAL_STATES.includes(terminal);
23
+ }
6
24
  export function createInitialRunState(runId) {
7
25
  return {
8
26
  runId,
@@ -17,16 +35,17 @@ export function reduceRunState(state, event) {
17
35
  // 只有终态才阻止事件处理。finalizing 是非终态,
18
36
  // 仍需累积 assistant 内容(thinking、text、tool_use、后台任务输出)。
19
37
  const terminal = state.terminal;
20
- if (terminal === 'done' ||
21
- terminal === 'error' ||
22
- terminal === 'interrupted' ||
23
- terminal === 'idle_timeout') {
38
+ if (isRunTerminal(terminal)) {
24
39
  return state;
25
40
  }
26
41
  if (event.type === 'system')
27
42
  return reduceSystemEvent(state, event);
28
43
  if (event.type === 'run_status')
29
44
  return reduceRunStatusEvent(state, event);
45
+ if (event.type === 'goal_state') {
46
+ // 覆盖写:每次观测都是宿主的整份快照(phase/轮次/目标全文),不做增量。
47
+ return { ...state, goal: event.goal ?? undefined, goalKnown: event.known };
48
+ }
30
49
  if (event.type === 'background_jobs')
31
50
  return reduceBackgroundJobsEvent(state, event);
32
51
  if (event.type === 'result')
package/dist/index.js CHANGED
@@ -532,16 +532,17 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
532
532
  const content = router.expandAliasMessage(msg.content);
533
533
  if (isStopCommand(content)) {
534
534
  void (async () => {
535
- const stopped = await bridge.interruptCurrentRun({
535
+ const interrupt = await bridge.interruptCurrentRunDetailed({
536
536
  userId: msg.userId,
537
537
  chatId: msg.chatId,
538
538
  });
539
- if (!stopped) {
540
- await bridge.sendResult({ text: '当前没有运行中的进程' }, {
541
- userId: msg.userId,
542
- chatId: msg.chatId,
543
- messageId: msg.messageId,
544
- });
539
+ const ctx = { userId: msg.userId, chatId: msg.chatId, messageId: msg.messageId };
540
+ if (!interrupt.stopped) {
541
+ await bridge.sendResult({ text: '当前没有运行中的进程' }, ctx);
542
+ }
543
+ else if ((interrupt.stoppedCount ?? 1) > 1) {
544
+ // 并行 workspace 时 /stop 停全部,必须让用户看到数量。
545
+ await bridge.sendResult({ text: `✅ 已停止 ${interrupt.stoppedCount} 个运行中的任务` }, ctx);
545
546
  }
546
547
  })().catch((err) => logger.error('[control] /stop failed:', err));
547
548
  return;
@@ -783,9 +783,8 @@ export class CommandRouter {
783
783
  const { cwd, messageId } = payload;
784
784
  const removed = this.bridge.removeFromQueue(cwd, messageId);
785
785
  if (removed) {
786
- // Update the queue card in-place to "cancelled" state
787
- await this.bridge.updateQueueCardToCancelled(cwd, messageId);
788
- // Send a brief confirmation (user can see the card has been updated)
786
+ // 队列卡的「已撤销」更新由 removeFromQueue 统一触发(单一收口);
787
+ // 这里只负责给点击者一个即时反馈。
789
788
  await this.bridge.sendResult({ text: '✅ 已从队列中撤销' }, ctx);
790
789
  }
791
790
  else {
@@ -866,7 +865,6 @@ export class CommandRouter {
866
865
  const tasks = this.bridge.getQueuedTasks(cwd);
867
866
  const targetIdx = tasks.findIndex((t) => t.messageId === messageId);
868
867
  let removedCount = 0;
869
- const removedIds = [];
870
868
  // Phase 1: remove synchronously. The queue chain advances on any await
871
869
  // (card PATCH round trips included), so every task before the target must
872
870
  // be removed before yielding to the event loop — otherwise a task the user
@@ -874,20 +872,15 @@ export class CommandRouter {
874
872
  // in flight.
875
873
  for (let i = 0; i < targetIdx; i++) {
876
874
  const task = tasks[i];
875
+ // 撤销卡片由 removeFromQueue 统一触发(fire-and-forget),保持本循环
876
+ // 同步推进的既有不变式。
877
877
  if (this.bridge.removeFromQueue(cwd, task.messageId)) {
878
878
  removedCount++;
879
- removedIds.push(task.messageId);
880
879
  }
881
880
  }
882
881
  if (targetIdx < 0) {
883
882
  getLogger().debug(`[router] queue.immediate: target no longer queued (began/cancelled), not clearing tasks behind it`);
884
883
  }
885
- // Phase 2: update cancelled cards after all removals. All tasks before the
886
- // target are already gone, so any chain advance can only reach the target
887
- // itself — the card updates may safely run in the background.
888
- for (const removedId of removedIds) {
889
- void this.bridge.updateQueueCardToCancelled(cwd, removedId);
890
- }
891
884
  // 4. DO NOT remove the target task - keep it in queue to execute immediately
892
885
  // The queue will naturally execute it after the current task (which we just stopped)
893
886
  // 4.4. Refresh the replacement with the latest edited content (idempotent:
@@ -2491,6 +2484,11 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2491
2484
  '请在 DSH Web UI 检查;需要终止时再发一次 /stop。',
2492
2485
  };
2493
2486
  }
2487
+ // 多个 workspace 并行时 /stop 会停掉全部;把这件事说出来,避免用户以为
2488
+ // 只停了当前那个(旧实现只停一条且无提示)。
2489
+ const count = interrupt.stoppedCount ?? 1;
2490
+ if (count > 1)
2491
+ return { text: `✅ 已停止 ${count} 个运行中的任务` };
2494
2492
  return null;
2495
2493
  }
2496
2494
  async cmdExit() {
@@ -2628,6 +2626,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2628
2626
  activeRun.terminal !== 'error' &&
2629
2627
  activeRun.terminal !== 'interrupted' &&
2630
2628
  activeRun.terminal !== 'idle_timeout' &&
2629
+ activeRun.terminal !== 'goal_continuing' &&
2631
2630
  activeRun.sessionId === sessionId;
2632
2631
  const activeUsage = activeRunUsage(activeRun);
2633
2632
  return {
@@ -3538,9 +3537,10 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3538
3537
  const prefetchCount = Math.min(RESUME_CONTENT_PREFETCH, allSessions.length);
3539
3538
  for (const s of allSessions.slice(0, prefetchCount)) {
3540
3539
  // 行上已经带了服务端投影预览(DSH:title + turnOutline 末条)→ 跳过预取。
3541
- // DSH 的 readSessionContent 是 3 个 curl 加一次 maxMessages=10000 的
3542
- // session/page;一页 5 个会话按行预取等于 15 次同步 HTTP,而这些信息
3543
- // session/list 已经给全了。
3540
+ // DSH 的 readSessionContent 每行走 3 次 unary:`session/list`(cwd 守卫)、
3541
+ // `session/projections`(取 throughSeq)、再 `session/page`(maxMessages=10000,
3542
+ // 拉整段历史)。按前 5 行预取等于 15 次同步 HTTP,而这些信息一次
3543
+ // `session/list` 就已经给全了。
3544
3544
  if (s.title || s.lastUserMessage || s.lastAssistantMessage)
3545
3545
  continue;
3546
3546
  // 优先走轻量摘要(只取标题/用量,不构造 events)——codex 实现了
@@ -52,4 +52,9 @@ export declare function formatUsageStats(usage?: {
52
52
  }, options?: {
53
53
  showResult?: boolean;
54
54
  result?: string;
55
+ /**
56
+ * 首行状态文案。默认 `✅ 已完成`;goal 仍 active 的收尾卡必须换掉它——
57
+ * 本轮 turn 结束不等于任务结束。
58
+ */
59
+ completedLabel?: string;
55
60
  }): string;
@@ -40,7 +40,7 @@ function formatTokenK(n) {
40
40
  export function formatUsageStats(usage, options) {
41
41
  const lines = [];
42
42
  // 1. Success 状态(通用格式,无具体结果值)
43
- lines.push('✅ 已完成');
43
+ lines.push(options?.completedLabel ?? '✅ 已完成');
44
44
  // 1.4. 实际生效模型(非空字符串才显示)
45
45
  if (usage?.model) {
46
46
  lines.push(`Model - ${usage.model}`);
@@ -20,6 +20,7 @@
20
20
  * replay, and seq-based dedup.
21
21
  */
22
22
  import type { DshApprovalStreamItem, DshCommandResult, DshModelCatalogValue, DshModelSelection, DshOpenTurnState, DshPermissionCatalogValue, DshJobView, DshPresetListValue, DshProjectionItem, DshProjectionSnapshot, DshQuestion, DshRemoteEventOutcome, DshSessionPageValue, DshStreamItem, DshTokenUsage } from './types.js';
23
+ import type { GoalView } from '../types.js';
23
24
  interface WebSocketMessageEvent {
24
25
  data: unknown;
25
26
  }
@@ -193,6 +194,20 @@ export declare class DshClient {
193
194
  signal?: AbortSignal;
194
195
  timeoutMs?: number;
195
196
  }): Promise<DshProjectionSnapshot | null>;
197
+ /**
198
+ * Read the session's current goal (one unary, authoritative at call time).
199
+ *
200
+ * 收尾判定必须用宿主状态:本轮 turn 结束不代表任务结束——goal 仍 active 时
201
+ * driver 会在 quiescence 立刻起下一轮。`known:false` 表示读不到,调用方不得
202
+ * 据此断言"已完成"(探测失败降级为收尾,正是"任务还在跑却写完成"的来源)。
203
+ */
204
+ goalState(sessionId: string, opts?: {
205
+ signal?: AbortSignal;
206
+ timeoutMs?: number;
207
+ }): Promise<{
208
+ known: boolean;
209
+ goal: GoalView | null;
210
+ }>;
196
211
  /**
197
212
  * Find this run's prompt if it is still sitting in the host's inbox.
198
213
  *
@@ -24,6 +24,7 @@ import WebSocket from 'ws';
24
24
  import { getLogger } from '../../logger/index.js';
25
25
  import { DSH_COOKIE_LIFETIME_MS, isDshCredentialsNotFound, mintDshBrowserCookie, readDshBrowserSecret, resolveDshCredentialsFile, } from './auth.js';
26
26
  import { DEFAULT_REQUEST_TIMEOUT_MS } from '../common/defaults.js';
27
+ import { goalFromProjection } from './goal-view.js';
27
28
  function defaultWebSocketFactory(url, options) {
28
29
  const socket = new WebSocket(url, {
29
30
  ...(options?.headers ? { headers: options.headers } : {}),
@@ -449,6 +450,32 @@ export class DshClient {
449
450
  : {},
450
451
  };
451
452
  }
453
+ /**
454
+ * Read the session's current goal (one unary, authoritative at call time).
455
+ *
456
+ * 收尾判定必须用宿主状态:本轮 turn 结束不代表任务结束——goal 仍 active 时
457
+ * driver 会在 quiescence 立刻起下一轮。`known:false` 表示读不到,调用方不得
458
+ * 据此断言"已完成"(探测失败降级为收尾,正是"任务还在跑却写完成"的来源)。
459
+ */
460
+ async goalState(sessionId, opts) {
461
+ let snapshot;
462
+ try {
463
+ snapshot = await this.projectionSnapshot(sessionId, opts);
464
+ }
465
+ catch (err) {
466
+ if (opts?.signal?.aborted)
467
+ throw err;
468
+ getLogger().warn(`[dsh] goal probe failed session=${sessionId}: ${err.message}`);
469
+ return { known: false, goal: null };
470
+ }
471
+ // 拿不到投影 = 宿主没这个会话的投影,不等于"没有 goal"。
472
+ if (!snapshot)
473
+ return { known: false, goal: null };
474
+ const parsed = goalFromProjection(snapshot.values.goal);
475
+ if (parsed === undefined)
476
+ return { known: false, goal: null };
477
+ return { known: true, goal: parsed };
478
+ }
452
479
  /**
453
480
  * Find this run's prompt if it is still sitting in the host's inbox.
454
481
  *
@@ -0,0 +1,36 @@
1
+ /**
2
+ * DSH `goal` 域 → 卡片用的 {@link GoalView} 解析。
3
+ *
4
+ * 两个来源、同一个目标类型:
5
+ * 1. 投影值:`session/projections` 的 `values.goal` 与 `session/control` 的
6
+ * `{key:'goal'}` 帧,宿主状态包一层 `current`(`{current:{goal,roundsStarted
7
+ * ,createdAt,updatedAt},seenGoalIds,failure}`);
8
+ * 2. 会话事件 `goal/change`:create/edit/pause/resume/complete/block 携带完整
9
+ * snapshot + roundsStarted;clear 是墓碑,表示"现在没有 goal"。
10
+ *
11
+ * 解析失败返回 `undefined`,表示**读不懂**——它与"没有 goal"(`null`)必须区分:
12
+ * 把读不懂当成没有 goal,就会在 goal 还在自动续跑时收尾卡片。
13
+ */
14
+ import type { GoalView } from '../types.js';
15
+ /**
16
+ * 解析投影里的 goal 状态。
17
+ *
18
+ * @returns `GoalView` = 有 goal;`null` = 宿主明确没有 goal;`undefined` = 读不懂。
19
+ */
20
+ export declare function goalFromProjection(value: unknown): GoalView | null | undefined;
21
+ /**
22
+ * 解析 `goal/change` 事件 payload。
23
+ *
24
+ * @returns `GoalView` = 变更后的 goal;`null` = clear(现在没有 goal);
25
+ * `undefined` = 读不懂。
26
+ */
27
+ export declare function goalFromChange(data: unknown): GoalView | null | undefined;
28
+ /** 便利构造:把解析结果包成 runner 事件。 */
29
+ export declare function goalStateEvent(goal: GoalView | null, known: boolean): {
30
+ type: 'goal_state';
31
+ goal: GoalView | null;
32
+ known: boolean;
33
+ timestamp: string;
34
+ };
35
+ /** 两次观测是否等价(用于抑制重复帧,不触发重渲染)。 */
36
+ export declare function sameGoal(a: GoalView | null | undefined, b: GoalView | null | undefined): boolean;