lark-remote 0.5.8 → 0.5.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.
@@ -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 {
@@ -823,134 +822,143 @@ export class CommandRouter {
823
822
  await this.bridge.sendResult({ text: '⚠️ 正在压缩会话,消息将在压缩完成后按顺序执行。' }, ctx);
824
823
  return;
825
824
  }
826
- // 1.5. If the user edited the message, the original closure (captured at
827
- // enqueue time with stale content) must NOT run. Register a one-shot
828
- // replacement closure on the task's EXISTING queue slot BEFORE any await:
829
- // interruptCurrentRun / markQueueCardExecuting can let the chain advance to
830
- // this task's begin, and begin consumes the replacement at that point — a
831
- // registration after those awaits would arrive too late (the stale closure
832
- // runs and the replacement leaks as a permanent dead closure). Registering
833
- // on the existing slot (instead of removeFromQueue + re-enqueue) preserves
834
- // the queue position: enqueue can only append to the promise chain tail,
835
- // which would put the edited task behind tasks queued after it.
836
- if (targetTask.editedMessage) {
837
- const editedContent = targetTask.editedMessage;
838
- ///Step3: 替换闭包复用原任务的 binding(不重新快照)+ 恢复丢失的
839
- // cwdOverride(lane cwd 同源)。否则编辑后重新执行的 run 会丢 cwd 与绑定。
840
- this.bridge.setTaskReplacement(cwd, messageId, async () => {
841
- await this.handle(editedContent, ctx, {
842
- cwdOverride: cwd,
843
- binding: targetTask.binding,
825
+ // 1.4. Hold the target's run-card start until this handler has sent its
826
+ // final feedback. The queue chain can advance during the awaits below
827
+ // (interruptCurrentRun / the queue-card PATCH): the target may begin and
828
+ // call forwardToClaude before the toast is sent. Without this hold the
829
+ // run card would land first, leaving the toast as the newest message —
830
+ // the chat list then previews the toast instead of the run card's title
831
+ // (which is how the user follows a run without opening the conversation).
832
+ // Registered before any await, released in finally on every exit path;
833
+ // forwardToClaude waits for it before creating the run card.
834
+ const releaseRunStart = this.bridge.holdQueueTaskRun(cwd, messageId);
835
+ try {
836
+ // 1.5. If the user edited the message, the original closure (captured at
837
+ // enqueue time with stale content) must NOT run. Register a one-shot
838
+ // replacement closure on the task's EXISTING queue slot BEFORE any await:
839
+ // interruptCurrentRun / markQueueCardExecuting can let the chain advance to
840
+ // this task's begin, and begin consumes the replacement at that point — a
841
+ // registration after those awaits would arrive too late (the stale closure
842
+ // runs and the replacement leaks as a permanent dead closure). Registering
843
+ // on the existing slot (instead of removeFromQueue + re-enqueue) preserves
844
+ // the queue position: enqueue can only append to the promise chain tail,
845
+ // which would put the edited task behind tasks queued after it.
846
+ if (targetTask.editedMessage) {
847
+ const editedContent = targetTask.editedMessage;
848
+ ///Step3: 替换闭包复用原任务的 binding(不重新快照)+ 恢复丢失的
849
+ // cwdOverride(lane cwd 同源)。否则编辑后重新执行的 run 会丢 cwd 与绑定。
850
+ this.bridge.setTaskReplacement(cwd, messageId, async () => {
851
+ await this.handle(editedContent, ctx, {
852
+ cwdOverride: cwd,
853
+ binding: targetTask.binding,
854
+ });
844
855
  });
856
+ }
857
+ // 2. Stop current running process
858
+ // (Note: queue executing count is reset inside Bridge.interruptCurrentRun
859
+ // so all stop paths — /stop, /t, card stop, queue.immediate — are covered.)
860
+ const interrupt = await this.bridge.interruptCurrentRunDetailed({
861
+ userId: ctx.userId,
862
+ chatId: ctx.chatId,
863
+ cwd,
845
864
  });
846
- }
847
- // 2. Stop current running process
848
- // (Note: queue executing count is reset inside Bridge.interruptCurrentRun
849
- // so all stop paths — /stop, /t, card stop, queue.immediate — are covered.)
850
- const interrupt = await this.bridge.interruptCurrentRunDetailed({
851
- userId: ctx.userId,
852
- chatId: ctx.chatId,
853
- cwd,
854
- });
855
- const stopped = interrupt.stopped;
856
- // A remote (DSH) stop is only a request; when it cannot be confirmed the
857
- // target must not be promised "immediate" execution — its prompt would sit
858
- // behind the still-running turn with no visible progress.
859
- const stopUnconfirmed = interrupt.stop?.status === 'unconfirmed';
860
- getLogger().info(`[router] queue.immediate: stopped=${stopped} unconfirmed=${stopUnconfirmed}`);
861
- // 3. Remove all tasks BEFORE this one (by position in the current queue).
862
- // The target may have begun while interruptCurrentRun was awaiting (the chain
863
- // advances concurrently): then it is already out of the snapshot and every
864
- // remaining queued task is BEHIND it — clearing must stop, not run off the
865
- // end and delete tasks queued after the target.
866
- const tasks = this.bridge.getQueuedTasks(cwd);
867
- const targetIdx = tasks.findIndex((t) => t.messageId === messageId);
868
- let removedCount = 0;
869
- const removedIds = [];
870
- // Phase 1: remove synchronously. The queue chain advances on any await
871
- // (card PATCH round trips included), so every task before the target must
872
- // be removed before yielding to the event loop — otherwise a task the user
873
- // asked to clear can begin executing while its cancellation card is still
874
- // in flight.
875
- for (let i = 0; i < targetIdx; i++) {
876
- const task = tasks[i];
877
- if (this.bridge.removeFromQueue(cwd, task.messageId)) {
878
- removedCount++;
879
- removedIds.push(task.messageId);
865
+ const stopped = interrupt.stopped;
866
+ // A remote (DSH) stop is only a request; when it cannot be confirmed the
867
+ // target must not be promised "immediate" execution — its prompt would sit
868
+ // behind the still-running turn with no visible progress.
869
+ const stopUnconfirmed = interrupt.stop?.status === 'unconfirmed';
870
+ getLogger().info(`[router] queue.immediate: stopped=${stopped} unconfirmed=${stopUnconfirmed}`);
871
+ // 3. Remove all tasks BEFORE this one (by position in the current queue).
872
+ // The target may have begun while interruptCurrentRun was awaiting (the chain
873
+ // advances concurrently): then it is already out of the snapshot and every
874
+ // remaining queued task is BEHIND it — clearing must stop, not run off the
875
+ // end and delete tasks queued after the target.
876
+ const tasks = this.bridge.getQueuedTasks(cwd);
877
+ const targetIdx = tasks.findIndex((t) => t.messageId === messageId);
878
+ let removedCount = 0;
879
+ // Phase 1: remove synchronously. The queue chain advances on any await
880
+ // (card PATCH round trips included), so every task before the target must
881
+ // be removed before yielding to the event loop — otherwise a task the user
882
+ // asked to clear can begin executing while its cancellation card is still
883
+ // in flight.
884
+ for (let i = 0; i < targetIdx; i++) {
885
+ const task = tasks[i];
886
+ // 撤销卡片由 removeFromQueue 统一触发(fire-and-forget),保持本循环
887
+ // 同步推进的既有不变式。
888
+ if (this.bridge.removeFromQueue(cwd, task.messageId)) {
889
+ removedCount++;
890
+ }
880
891
  }
881
- }
882
- if (targetIdx < 0) {
883
- getLogger().debug(`[router] queue.immediate: target no longer queued (began/cancelled), not clearing tasks behind it`);
884
- }
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
- // 4. DO NOT remove the target task - keep it in queue to execute immediately
892
- // The queue will naturally execute it after the current task (which we just stopped)
893
- // 4.4. Refresh the replacement with the latest edited content (idempotent:
894
- // re-registering over the same slot). Covers the user editing the message
895
- // again while interruptCurrentRun was awaiting; skipped automatically once
896
- // the task has begun, because getQueuedTask then returns undefined.
897
- const latestTask = this.bridge.getQueuedTask(cwd, messageId);
898
- if (latestTask?.editedMessage) {
899
- const editedContent = latestTask.editedMessage;
900
- ///Step3: 复用原任务 binding + 恢复 cwdOverride(同上)。
901
- this.bridge.setTaskReplacement(cwd, messageId, async () => {
902
- await this.handle(editedContent, ctx, {
903
- cwdOverride: cwd,
904
- binding: latestTask.binding,
892
+ if (targetIdx < 0) {
893
+ getLogger().debug(`[router] queue.immediate: target no longer queued (began/cancelled), not clearing tasks behind it`);
894
+ }
895
+ // 4. DO NOT remove the target task - keep it in queue to execute immediately
896
+ // The queue will naturally execute it after the current task (which we just stopped)
897
+ // 4.4. Refresh the replacement with the latest edited content (idempotent:
898
+ // re-registering over the same slot). Covers the user editing the message
899
+ // again while interruptCurrentRun was awaiting; skipped automatically once
900
+ // the task has begun, because getQueuedTask then returns undefined.
901
+ const latestTask = this.bridge.getQueuedTask(cwd, messageId);
902
+ if (latestTask?.editedMessage) {
903
+ const editedContent = latestTask.editedMessage;
904
+ ///Step3: 复用原任务 binding + 恢复 cwdOverride(同上)。
905
+ this.bridge.setTaskReplacement(cwd, messageId, async () => {
906
+ await this.handle(editedContent, ctx, {
907
+ cwdOverride: cwd,
908
+ binding: latestTask.binding,
909
+ });
905
910
  });
906
- });
907
- }
908
- // 4.5/6. The target must only be marked executing (and the toast may only
909
- // promise immediate execution) when it is really the next task after the
910
- // stop. While interruptCurrentRun was awaiting, a task ahead of the target
911
- // may have begun and now occupy the cwd — the target is still queued
912
- // but is NOT next, so its card must stay queued and the toast must say so.
913
- const targetStillQueued = this.bridge.getQueuedTask(cwd, messageId) !== undefined;
914
- if (!targetStillQueued) {
915
- // The target began or was cancelled while earlier awaits were in flight —
916
- // a success toast must never promise execution of a task that will not
917
- // run. The two missing-reason states get distinct feedback: a target
918
- // that began while the interrupt was in flight is already executing, so
919
- // the toast must acknowledge that instead of telling the user nothing
920
- // was scheduled; only a target that was actually cancelled (never began)
921
- // gets the "未安排执行" wording.
922
- if (this.bridge.hasTaskBegan(messageId)) {
923
- await this.bridge.sendResult({ text: `ℹ️ 目标消息已开始执行,无需重复操作。已清除 ${removedCount} 条排队消息。` }, ctx);
911
+ }
912
+ // 4.5/6. The target must only be marked executing (and the toast may only
913
+ // promise immediate execution) when it is really the next task after the
914
+ // stop. While interruptCurrentRun was awaiting, a task ahead of the target
915
+ // may have begun and now occupy the cwd — the target is still queued
916
+ // but is NOT next, so its card must stay queued and the toast must say so.
917
+ const targetStillQueued = this.bridge.getQueuedTask(cwd, messageId) !== undefined;
918
+ if (!targetStillQueued) {
919
+ // The target began or was cancelled while earlier awaits were in flight —
920
+ // a success toast must never promise execution of a task that will not
921
+ // run. The two missing-reason states get distinct feedback: a target
922
+ // that began while the interrupt was in flight is already executing, so
923
+ // the toast must acknowledge that instead of telling the user nothing
924
+ // was scheduled; only a target that was actually cancelled (never began)
925
+ // gets the "未安排执行" wording.
926
+ if (this.bridge.hasTaskBegan(messageId)) {
927
+ await this.bridge.sendResult({ text: `ℹ️ 目标消息已开始执行,无需重复操作。已清除 ${removedCount} 条排队消息。` }, ctx);
928
+ }
929
+ else {
930
+ await this.bridge.sendResult({
931
+ text: `⚠️ 目标消息已不在队列中(可能已被撤销),未安排执行。已清除 ${removedCount} 条排队消息。`,
932
+ }, ctx);
933
+ }
934
+ return;
935
+ }
936
+ const workspaceBusy = this.bridge.isBusyFor(cwd);
937
+ if (stopUnconfirmed) {
938
+ const name = interrupt.agentKind ? agentDisplayName(interrupt.agentKind) : 'agent';
939
+ await this.bridge.sendResult({
940
+ text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
941
+ `已清除 ${removedCount} 条排队消息;您的消息已提交,但可能要等该 turn 结束后才开始。`,
942
+ }, ctx);
943
+ }
944
+ else if (!workspaceBusy) {
945
+ // Normal path: nothing new began during the stop, the target is next.
946
+ // Mark its own card as executing now so buttons grey out immediately.
947
+ // Done BEFORE any removal so getQueuedTask still returns the task with
948
+ // its (possibly edited) messagePreview for the card. The queue callback's
949
+ // later updateQueueCardToExecuting call becomes a no-op (idempotent).
950
+ await this.bridge.markQueueCardExecuting(cwd, messageId);
951
+ const stopPrefix = stopped ? '⚡ 已停止当前任务,' : '';
952
+ await this.bridge.sendResult({ text: `${stopPrefix}清除了 ${removedCount} 条排队消息。您的消息将立即执行。` }, ctx);
924
953
  }
925
954
  else {
926
955
  await this.bridge.sendResult({
927
- text: `⚠️ 目标消息已不在队列中(可能已被撤销),未安排执行。已清除 ${removedCount} 条排队消息。`,
956
+ text: `⚠️ 排在目标之前的任务已开始执行,目标消息保持排队。已清除 ${removedCount} 条排队消息。`,
928
957
  }, ctx);
929
958
  }
930
- return;
931
959
  }
932
- const workspaceBusy = this.bridge.isBusyFor(cwd);
933
- if (stopUnconfirmed) {
934
- const name = interrupt.agentKind ? agentDisplayName(interrupt.agentKind) : 'agent';
935
- await this.bridge.sendResult({
936
- text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
937
- `已清除 ${removedCount} 条排队消息;您的消息已提交,但可能要等该 turn 结束后才开始。`,
938
- }, ctx);
939
- }
940
- else if (!workspaceBusy) {
941
- // Normal path: nothing new began during the stop, the target is next.
942
- // Mark its own card as executing now so buttons grey out immediately.
943
- // Done BEFORE any removal so getQueuedTask still returns the task with
944
- // its (possibly edited) messagePreview for the card. The queue callback's
945
- // later updateQueueCardToExecuting call becomes a no-op (idempotent).
946
- await this.bridge.markQueueCardExecuting(cwd, messageId);
947
- const stopPrefix = stopped ? '⚡ 已停止当前任务,' : '';
948
- await this.bridge.sendResult({ text: `${stopPrefix}清除了 ${removedCount} 条排队消息。您的消息将立即执行。` }, ctx);
949
- }
950
- else {
951
- await this.bridge.sendResult({
952
- text: `⚠️ 排在目标之前的任务已开始执行,目标消息保持排队。已清除 ${removedCount} 条排队消息。`,
953
- }, ctx);
960
+ finally {
961
+ releaseRunStart();
954
962
  }
955
963
  }
956
964
  /**
@@ -2491,6 +2499,11 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2491
2499
  '请在 DSH Web UI 检查;需要终止时再发一次 /stop。',
2492
2500
  };
2493
2501
  }
2502
+ // 多个 workspace 并行时 /stop 会停掉全部;把这件事说出来,避免用户以为
2503
+ // 只停了当前那个(旧实现只停一条且无提示)。
2504
+ const count = interrupt.stoppedCount ?? 1;
2505
+ if (count > 1)
2506
+ return { text: `✅ 已停止 ${count} 个运行中的任务` };
2494
2507
  return null;
2495
2508
  }
2496
2509
  async cmdExit() {
@@ -2628,6 +2641,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2628
2641
  activeRun.terminal !== 'error' &&
2629
2642
  activeRun.terminal !== 'interrupted' &&
2630
2643
  activeRun.terminal !== 'idle_timeout' &&
2644
+ activeRun.terminal !== 'goal_continuing' &&
2631
2645
  activeRun.sessionId === sessionId;
2632
2646
  const activeUsage = activeRunUsage(activeRun);
2633
2647
  return {
@@ -3538,9 +3552,10 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3538
3552
  const prefetchCount = Math.min(RESUME_CONTENT_PREFETCH, allSessions.length);
3539
3553
  for (const s of allSessions.slice(0, prefetchCount)) {
3540
3554
  // 行上已经带了服务端投影预览(DSH:title + turnOutline 末条)→ 跳过预取。
3541
- // DSH 的 readSessionContent 是 3 个 curl 加一次 maxMessages=10000 的
3542
- // session/page;一页 5 个会话按行预取等于 15 次同步 HTTP,而这些信息
3543
- // session/list 已经给全了。
3555
+ // DSH 的 readSessionContent 每行走 3 次 unary:`session/list`(cwd 守卫)、
3556
+ // `session/projections`(取 throughSeq)、再 `session/page`(maxMessages=10000,
3557
+ // 拉整段历史)。按前 5 行预取等于 15 次同步 HTTP,而这些信息一次
3558
+ // `session/list` 就已经给全了。
3544
3559
  if (s.title || s.lastUserMessage || s.lastAssistantMessage)
3545
3560
  continue;
3546
3561
  // 优先走轻量摘要(只取标题/用量,不构造 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}`);
@@ -147,6 +147,12 @@ export declare class ConnectionManager<TClient extends ConnectionClient = JsonRp
147
147
  *
148
148
  * `idleTtlMs: 0` = 不回收(与 claude 会话层的 `armIdleTimer` 同口径;按 0
149
149
  * setTimeout 会秒删刚建好的连接,等于每条消息重起一次进程)。
150
+ *
151
+ * 定时器回调带 **slot 身份校验**:武装之后 slot 可能已被替换——进程自退走
152
+ * baseOnClose(删 slot 但不经过 disposeSlot)、或同 cwd 已 acquire 了新连接。
153
+ * 这两种情况下本定时器都已过期,不得回收「当时恰好挂在该 cwd 上」的连接:
154
+ * 2026-10-24 线上事故即一条泄漏的过期 TTL 杀掉继任连接的在途 turn,卡片
155
+ * 误报 "Codex app-server connection closed"。
150
156
  */
151
157
  notifyIdle(cwd: string): void;
152
158
  private createClient;
@@ -183,18 +183,27 @@ export class ConnectionManager {
183
183
  *
184
184
  * `idleTtlMs: 0` = 不回收(与 claude 会话层的 `armIdleTimer` 同口径;按 0
185
185
  * setTimeout 会秒删刚建好的连接,等于每条消息重起一次进程)。
186
+ *
187
+ * 定时器回调带 **slot 身份校验**:武装之后 slot 可能已被替换——进程自退走
188
+ * baseOnClose(删 slot 但不经过 disposeSlot)、或同 cwd 已 acquire 了新连接。
189
+ * 这两种情况下本定时器都已过期,不得回收「当时恰好挂在该 cwd 上」的连接:
190
+ * 2026-10-24 线上事故即一条泄漏的过期 TTL 杀掉继任连接的在途 turn,卡片
191
+ * 误报 "Codex app-server connection closed"。
186
192
  */
187
193
  notifyIdle(cwd) {
188
194
  if (this.idleTtlMs <= 0)
189
195
  return;
190
196
  const slot = this.slots.get(cwd);
191
- if (slot) {
192
- this.clearIdleTimer(slot);
193
- slot.idleTimer = setTimeout(() => {
194
- getLogger().info(`[${this.logTag}] idle timeout for cwd=${cwd}, releasing connection`);
195
- this.disposeSlot(cwd);
196
- }, this.idleTtlMs);
197
- }
197
+ if (!slot)
198
+ return;
199
+ this.clearIdleTimer(slot);
200
+ slot.idleTimer = setTimeout(() => {
201
+ slot.idleTimer = null;
202
+ if (this.slots.get(cwd) !== slot)
203
+ return;
204
+ getLogger().info(`[${this.logTag}] idle timeout for cwd=${cwd}, releasing connection`);
205
+ this.disposeSlot(cwd);
206
+ }, this.idleTtlMs);
198
207
  }
199
208
  // =========================================================================
200
209
  // Internal
@@ -222,7 +231,13 @@ export class ConnectionManager {
222
231
  getLogger().info(`[${this.logTag}] connection closed cwd=${cwd}`);
223
232
  // 只清理自己创建的 slot:并发 release + 重新 acquire 后 slot 可能已被
224
233
  // 新连接占据,误删会破坏新连接。
225
- if (this.slots.get(cwd)?.client === client) {
234
+ const current = this.slots.get(cwd);
235
+ if (current?.client === client) {
236
+ // 进程自退(codex app-server 偶发 exit 0,非本程序发起,不经
237
+ // disposeSlot)也要撤掉挂在旧 slot 上的 idle TTL:定时器不随 slot
238
+ // 删除而取消,到期时会按 cwd 误杀继任连接的在途 turn(2026-10-24
239
+ // 线上事故,见 notifyIdle 的身份校验与对应回归测试)。
240
+ this.clearIdleTimer(current);
226
241
  this.slots.delete(cwd);
227
242
  this.onConnectionLost?.(cwd);
228
243
  }
@@ -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;
@@ -0,0 +1,106 @@
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
+ const PHASES = new Set(['active', 'paused', 'blocked', 'complete']);
15
+ function isRecord(value) {
16
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
17
+ }
18
+ function nonNegativeInt(value) {
19
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined;
20
+ }
21
+ function parseSnapshot(value, roundsStarted) {
22
+ if (!isRecord(value))
23
+ return undefined;
24
+ const phase = value.phase;
25
+ if (typeof phase !== 'string' || !PHASES.has(phase))
26
+ return undefined;
27
+ if (typeof value.objective !== 'string')
28
+ return undefined;
29
+ if (typeof value.id !== 'string' || value.id.length === 0)
30
+ return undefined;
31
+ const max = nonNegativeInt(value.maxGoalRounds);
32
+ if (max === undefined)
33
+ return undefined;
34
+ const reason = value.blockedReason;
35
+ const blockedReason = isRecord(reason) && typeof reason.code === 'string' && typeof reason.message === 'string'
36
+ ? { code: reason.code, message: reason.message }
37
+ : undefined;
38
+ return {
39
+ id: value.id,
40
+ objective: value.objective,
41
+ // 上面已按白名单校验,这里收窄回联合类型。
42
+ phase: phase,
43
+ roundsStarted: nonNegativeInt(roundsStarted) ?? 0,
44
+ maxGoalRounds: max,
45
+ ...(blockedReason ? { blockedReason } : {}),
46
+ };
47
+ }
48
+ /**
49
+ * 解析投影里的 goal 状态。
50
+ *
51
+ * @returns `GoalView` = 有 goal;`null` = 宿主明确没有 goal;`undefined` = 读不懂。
52
+ */
53
+ export function goalFromProjection(value) {
54
+ if (value === null)
55
+ return null;
56
+ if (!isRecord(value))
57
+ return undefined;
58
+ // 宿主状态信封:{current:{goal,roundsStarted,...}, seenGoalIds, failure}
59
+ if ('current' in value) {
60
+ const current = value.current;
61
+ if (current === null)
62
+ return null;
63
+ if (!isRecord(current))
64
+ return undefined;
65
+ if (current.goal === undefined || current.goal === null)
66
+ return null;
67
+ return parseSnapshot(current.goal, current.roundsStarted);
68
+ }
69
+ // 兼容直接投递 GoalProjection 的形态:{goal, roundsStarted, ...}
70
+ if (value.goal === undefined || value.goal === null)
71
+ return null;
72
+ return parseSnapshot(value.goal, value.roundsStarted);
73
+ }
74
+ /**
75
+ * 解析 `goal/change` 事件 payload。
76
+ *
77
+ * @returns `GoalView` = 变更后的 goal;`null` = clear(现在没有 goal);
78
+ * `undefined` = 读不懂。
79
+ */
80
+ export function goalFromChange(data) {
81
+ if (!isRecord(data))
82
+ return undefined;
83
+ if (data.operation === 'clear')
84
+ return null;
85
+ if (typeof data.operation !== 'string')
86
+ return undefined;
87
+ return parseSnapshot(data.goal, data.roundsStarted);
88
+ }
89
+ /** 便利构造:把解析结果包成 runner 事件。 */
90
+ export function goalStateEvent(goal, known) {
91
+ return { type: 'goal_state', goal, known, timestamp: new Date().toISOString() };
92
+ }
93
+ /** 两次观测是否等价(用于抑制重复帧,不触发重渲染)。 */
94
+ export function sameGoal(a, b) {
95
+ if (a === b)
96
+ return true;
97
+ if (a === null || a === undefined || b === null || b === undefined)
98
+ return false;
99
+ return (a.id === b.id &&
100
+ a.phase === b.phase &&
101
+ a.roundsStarted === b.roundsStarted &&
102
+ a.maxGoalRounds === b.maxGoalRounds &&
103
+ a.objective === b.objective &&
104
+ a.blockedReason?.code === b.blockedReason?.code);
105
+ }
106
+ //# sourceMappingURL=goal-view.js.map