lark-remote 0.5.5 → 0.5.7

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 (58) hide show
  1. package/CHANGELOG.md +48 -7
  2. package/README.en.md +2 -2
  3. package/README.md +2 -2
  4. package/dist/bridge/approval-coordinator.d.ts +13 -0
  5. package/dist/bridge/approval-coordinator.js +34 -1
  6. package/dist/bridge/index.d.ts +48 -1
  7. package/dist/bridge/index.js +86 -14
  8. package/dist/card/approval-render.js +23 -1
  9. package/dist/card/card-budget.d.ts +37 -0
  10. package/dist/card/card-budget.js +128 -5
  11. package/dist/card/run-renderer.d.ts +24 -0
  12. package/dist/card/run-renderer.js +256 -60
  13. package/dist/card/run-state.d.ts +26 -0
  14. package/dist/card/run-state.js +45 -0
  15. package/dist/card/time.d.ts +14 -0
  16. package/dist/card/time.js +30 -0
  17. package/dist/card/tool-render.d.ts +15 -2
  18. package/dist/card/tool-render.js +40 -66
  19. package/dist/common/text-width.d.ts +24 -0
  20. package/dist/common/text-width.js +93 -0
  21. package/dist/common/tool-summary.d.ts +11 -0
  22. package/dist/common/tool-summary.js +153 -0
  23. package/dist/config/index.d.ts +1 -1
  24. package/dist/config/index.js +1 -1
  25. package/dist/connector/index.js +1 -1
  26. package/dist/index.js +63 -8
  27. package/dist/platform/command.js +1 -1
  28. package/dist/platform/shell.d.ts +2 -2
  29. package/dist/platform/spawn.d.ts +1 -1
  30. package/dist/platform/spawn.js +2 -2
  31. package/dist/platform/types.d.ts +1 -1
  32. package/dist/router/card-helpers.d.ts +18 -16
  33. package/dist/router/card-helpers.js +65 -4
  34. package/dist/router/index.d.ts +9 -0
  35. package/dist/router/index.js +57 -7
  36. package/dist/runner/claude/session.d.ts +1 -1
  37. package/dist/runner/claude/session.js +3 -3
  38. package/dist/runner/common/jsonrpc/transport.d.ts +1 -1
  39. package/dist/runner/common/jsonrpc/transport.js +3 -3
  40. package/dist/runner/common/spawning-runner.d.ts +1 -1
  41. package/dist/runner/common/spawning-runner.js +2 -2
  42. package/dist/runner/dsh/auth.js +2 -1
  43. package/dist/runner/dsh/client.d.ts +124 -6
  44. package/dist/runner/dsh/client.js +398 -12
  45. package/dist/runner/dsh/exit-cancel.d.ts +32 -0
  46. package/dist/runner/dsh/exit-cancel.js +72 -0
  47. package/dist/runner/dsh/inflight.d.ts +62 -0
  48. package/dist/runner/dsh/inflight.js +140 -0
  49. package/dist/runner/dsh/runner.d.ts +117 -7
  50. package/dist/runner/dsh/runner.js +607 -21
  51. package/dist/runner/dsh/translator.d.ts +1 -1
  52. package/dist/runner/dsh/translator.js +103 -1
  53. package/dist/runner/dsh/types.d.ts +91 -1
  54. package/dist/runner/types.d.ts +86 -2
  55. package/dist/session/claude/sessions.js +2 -2
  56. package/dist/session/dsh/sessions.js +100 -15
  57. package/dist/update/install.js +1 -1
  58. package/package.json +1 -1
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Synchronous `session/cancel` for the process-exit path.
3
+ *
4
+ * A DSH turn does not live in a local child process: it runs inside the shared
5
+ * `dsh web` host (`DshDaemon`). So the usual "kill my spawned child" exit
6
+ * cleanup does nothing for it, and a bridge that exits mid-turn leaves the
7
+ * turn running with no subscriber — an orphan turn that keeps consuming tokens
8
+ * and mutating the workspace invisibly (2026-10-19: one ran 2h21m / 345 steps
9
+ * after its bridge died).
10
+ *
11
+ * `process.on('exit')` cannot await, so this path uses the same synchronous
12
+ * curl transport the session reader already uses: bounded by `timeoutMs` and
13
+ * never throws.
14
+ */
15
+ /** Upper bound on the exit-path cancel:退出不能被一个卡住的 host 拖住。 */
16
+ export declare const EXIT_CANCEL_TIMEOUT_MS = 2000;
17
+ export interface SyncCancelOptions {
18
+ baseUrl: string;
19
+ sessionId: string;
20
+ /** Configured `agents.dsh.dshHome` (selects `.credentials.yaml`). */
21
+ dshHome?: string;
22
+ /** Explicit credentials file; wins over `dshHome`/`$DSH_HOME`. */
23
+ credentialsFile?: string;
24
+ timeoutMs?: number;
25
+ }
26
+ /**
27
+ * Fire one `session/cancel` synchronously. Never throws: a failed cancel must
28
+ * not block the exit path or mask the real exit reason.
29
+ *
30
+ * @returns true when the host accepted the request.
31
+ */
32
+ export declare function cancelSessionSync(opts: SyncCancelOptions): boolean;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Synchronous `session/cancel` for the process-exit path.
3
+ *
4
+ * A DSH turn does not live in a local child process: it runs inside the shared
5
+ * `dsh web` host (`DshDaemon`). So the usual "kill my spawned child" exit
6
+ * cleanup does nothing for it, and a bridge that exits mid-turn leaves the
7
+ * turn running with no subscriber — an orphan turn that keeps consuming tokens
8
+ * and mutating the workspace invisibly (2026-10-19: one ran 2h21m / 345 steps
9
+ * after its bridge died).
10
+ *
11
+ * `process.on('exit')` cannot await, so this path uses the same synchronous
12
+ * curl transport the session reader already uses: bounded by `timeoutMs` and
13
+ * never throws.
14
+ */
15
+ import { getLogger } from '../../logger/index.js';
16
+ import { isWin32 } from '../../platform/select.js';
17
+ import { spawnProcessSync } from '../../platform/spawn.js';
18
+ import { DSH_COOKIE_LIFETIME_MS, mintDshBrowserCookie, readDshBrowserSecretSync, resolveDshCredentialsFile, } from './auth.js';
19
+ /** Upper bound on the exit-path cancel:退出不能被一个卡住的 host 拖住。 */
20
+ export const EXIT_CANCEL_TIMEOUT_MS = 2_000;
21
+ /**
22
+ * Fire one `session/cancel` synchronously. Never throws: a failed cancel must
23
+ * not block the exit path or mask the real exit reason.
24
+ *
25
+ * @returns true when the host accepted the request.
26
+ */
27
+ export function cancelSessionSync(opts) {
28
+ const logger = getLogger();
29
+ const timeoutMs = opts.timeoutMs ?? EXIT_CANCEL_TIMEOUT_MS;
30
+ let cookie;
31
+ try {
32
+ const file = opts.credentialsFile?.trim() || resolveDshCredentialsFile(process.env, opts.dshHome);
33
+ cookie = mintDshBrowserCookie(opts.baseUrl, readDshBrowserSecretSync(file), Date.now(), DSH_COOKIE_LIFETIME_MS);
34
+ }
35
+ catch (err) {
36
+ // 没有持久密钥就签不出 cookie;此时只能记一笔,放弃取消(启动 token 的
37
+ // /connect 换 cookie 是异步的,退出路径上等不起)。
38
+ logger.warn(`[dsh] exit-cancel skipped: cannot mint cookie (${err.message})`);
39
+ return false;
40
+ }
41
+ const body = JSON.stringify({
42
+ type: 'client-request',
43
+ rpcId: `rpc-exit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
44
+ method: 'session/cancel',
45
+ payload: { args: { request: { sessionId: opts.sessionId } } },
46
+ });
47
+ const res = spawnProcessSync('curl', [
48
+ '-s',
49
+ '-o',
50
+ isWin32() ? 'NUL' : '/dev/null',
51
+ '-w',
52
+ '%{http_code}',
53
+ '-X',
54
+ 'POST',
55
+ `${opts.baseUrl.replace(/\/$/, '')}/api/session/cancel`,
56
+ '-H',
57
+ 'content-type: application/json',
58
+ '-H',
59
+ `Cookie: ${cookie}`,
60
+ '--max-time',
61
+ String(Math.max(1, Math.ceil(timeoutMs / 1000))),
62
+ '--data',
63
+ body,
64
+ ], { encoding: 'utf-8', timeout: timeoutMs, maxBuffer: 1024 * 1024 });
65
+ const status = String(res.stdout ?? '').trim();
66
+ const ok = status === '200';
67
+ if (!ok) {
68
+ logger.warn(`[dsh] exit-cancel failed for ${opts.sessionId}: ${res.error?.message ?? `http ${status || 'n/a'}`}`);
69
+ }
70
+ return ok;
71
+ }
72
+ //# sourceMappingURL=exit-cancel.js.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Durable record of DSH prompts that may outlive the bridge process.
3
+ *
4
+ * A DSH turn runs inside the shared `dsh web` host, not in a child process. If
5
+ * the bridge dies (SIGKILL, crash) the turn keeps running with no subscriber —
6
+ * the 2026-10-19 incident ran 2h21m / 345 steps after its bridge exited. The
7
+ * runner writes one entry when it submits a prompt and removes it once the run
8
+ * (or a verified stop) accounts for that prompt; the next startup reconciles
9
+ * anything left behind:
10
+ *
11
+ * 1. a still-pending prompt (identified by `source.rpcId`) is removed from the
12
+ * host inbox, so it can never start a turn nobody is watching;
13
+ * 2. a session that still reports an open turn is reported to the owner once —
14
+ * we deliberately do not cancel it, because the open turn may belong to the
15
+ * DSH Web UI rather than to the dead bridge.
16
+ *
17
+ * This is a best-effort safety net, never a correctness dependency: every
18
+ * method swallows storage errors so a broken state file cannot block a run.
19
+ */
20
+ import type { DshClient } from './client.js';
21
+ /** Single source for the marker file shared by the runner and startup reconcile. */
22
+ export declare function dshInflightPath(configDir: string): string;
23
+ export interface DshInflightEntry {
24
+ sessionId: string;
25
+ promptRequestId: string;
26
+ baseUrl: string;
27
+ cwd?: string;
28
+ updatedAt: number;
29
+ }
30
+ export declare class DshInflightStore {
31
+ private readonly filePath;
32
+ constructor(filePath: string);
33
+ list(): DshInflightEntry[];
34
+ record(entry: Omit<DshInflightEntry, 'updatedAt'>): void;
35
+ clear(sessionId: string, promptRequestId: string): void;
36
+ private write;
37
+ }
38
+ export interface DshInflightReconcileOptions {
39
+ store: DshInflightStore;
40
+ client: Pick<DshClient, 'baseUrl' | 'findPendingPrompt' | 'removeQueuedPrompt' | 'openTurnState'>;
41
+ /** Owner notification; omitted when no p2p recipient is known yet. */
42
+ notify?: (text: string) => Promise<void>;
43
+ timeoutMs?: number;
44
+ }
45
+ export interface DshInflightReconcileResult {
46
+ /** Pending prompts removed from the host inbox. */
47
+ removed: number;
48
+ /** Sessions that still reported an open turn (or an unreadable state). */
49
+ open: number;
50
+ /** True when the host was unreachable; entries are kept for the next start. */
51
+ skipped: boolean;
52
+ }
53
+ /**
54
+ * Reconcile entries left by a previous process.
55
+ *
56
+ * Only entries for the currently configured host are considered; entries for
57
+ * another host are kept untouched. On a transport failure the remaining
58
+ * entries are left in the file so a later startup can retry.
59
+ */
60
+ export declare function reconcileInflightDshRuns(opts: DshInflightReconcileOptions): Promise<DshInflightReconcileResult>;
61
+ /** One short owner-facing notice; session ids are truncated, not paths. */
62
+ export declare function formatInflightWarning(entries: DshInflightEntry[]): string;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Durable record of DSH prompts that may outlive the bridge process.
3
+ *
4
+ * A DSH turn runs inside the shared `dsh web` host, not in a child process. If
5
+ * the bridge dies (SIGKILL, crash) the turn keeps running with no subscriber —
6
+ * the 2026-10-19 incident ran 2h21m / 345 steps after its bridge exited. The
7
+ * runner writes one entry when it submits a prompt and removes it once the run
8
+ * (or a verified stop) accounts for that prompt; the next startup reconciles
9
+ * anything left behind:
10
+ *
11
+ * 1. a still-pending prompt (identified by `source.rpcId`) is removed from the
12
+ * host inbox, so it can never start a turn nobody is watching;
13
+ * 2. a session that still reports an open turn is reported to the owner once —
14
+ * we deliberately do not cancel it, because the open turn may belong to the
15
+ * DSH Web UI rather than to the dead bridge.
16
+ *
17
+ * This is a best-effort safety net, never a correctness dependency: every
18
+ * method swallows storage errors so a broken state file cannot block a run.
19
+ */
20
+ import path from 'node:path';
21
+ import { getLogger } from '../../logger/index.js';
22
+ import { atomicWriteJson } from '../../persistence/atomic-write.js';
23
+ import { loadJsonFile } from '../../persistence/load-json-file.js';
24
+ /** Single source for the marker file shared by the runner and startup reconcile. */
25
+ export function dshInflightPath(configDir) {
26
+ return path.join(configDir, 'dsh-inflight.json');
27
+ }
28
+ /** Keep the file bounded; stale entries are pruned by age and count. */
29
+ const MAX_ENTRIES = 50;
30
+ /** A turn cannot plausibly live this long; drop the record instead of nagging. */
31
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
32
+ function isEntry(value) {
33
+ if (!value || typeof value !== 'object' || Array.isArray(value))
34
+ return false;
35
+ const entry = value;
36
+ return (typeof entry.sessionId === 'string' &&
37
+ entry.sessionId.length > 0 &&
38
+ typeof entry.promptRequestId === 'string' &&
39
+ entry.promptRequestId.length > 0 &&
40
+ typeof entry.baseUrl === 'string' &&
41
+ entry.baseUrl.length > 0 &&
42
+ typeof entry.updatedAt === 'number');
43
+ }
44
+ function sameEntry(left, right) {
45
+ return left.sessionId === right.sessionId && left.promptRequestId === right.promptRequestId;
46
+ }
47
+ export class DshInflightStore {
48
+ filePath;
49
+ constructor(filePath) {
50
+ this.filePath = filePath;
51
+ }
52
+ list() {
53
+ const parsed = loadJsonFile(this.filePath, undefined);
54
+ const raw = Array.isArray(parsed?.entries) ? parsed.entries : [];
55
+ const now = Date.now();
56
+ return raw
57
+ .filter(isEntry)
58
+ .filter((entry) => now - entry.updatedAt <= MAX_AGE_MS)
59
+ .slice(-MAX_ENTRIES);
60
+ }
61
+ record(entry) {
62
+ const next = this.list().filter((existing) => !sameEntry(existing, entry));
63
+ next.push({ ...entry, updatedAt: Date.now() });
64
+ this.write(next);
65
+ }
66
+ clear(sessionId, promptRequestId) {
67
+ const next = this.list().filter((entry) => !sameEntry(entry, { sessionId, promptRequestId }));
68
+ this.write(next);
69
+ }
70
+ write(entries) {
71
+ try {
72
+ atomicWriteJson(this.filePath, { entries: entries.slice(-MAX_ENTRIES) });
73
+ }
74
+ catch (err) {
75
+ // The marker is a safety net; losing it must never fail the run.
76
+ getLogger().warn(`[dsh] inflight marker write failed: ${err.message}`);
77
+ }
78
+ }
79
+ }
80
+ /**
81
+ * Reconcile entries left by a previous process.
82
+ *
83
+ * Only entries for the currently configured host are considered; entries for
84
+ * another host are kept untouched. On a transport failure the remaining
85
+ * entries are left in the file so a later startup can retry.
86
+ */
87
+ export async function reconcileInflightDshRuns(opts) {
88
+ const timeoutMs = opts.timeoutMs ?? 3_000;
89
+ const entries = opts.store.list().filter((entry) => entry.baseUrl === opts.client.baseUrl);
90
+ if (entries.length === 0)
91
+ return { removed: 0, open: 0, skipped: false };
92
+ let removed = 0;
93
+ const open = [];
94
+ for (const entry of entries.slice(0, 10)) {
95
+ try {
96
+ const itemId = await opts.client.findPendingPrompt(entry.sessionId, entry.promptRequestId, {
97
+ timeoutMs,
98
+ });
99
+ if (itemId) {
100
+ const ok = await opts.client.removeQueuedPrompt(entry.sessionId, itemId, { timeoutMs });
101
+ if (ok)
102
+ removed++;
103
+ }
104
+ const state = await opts.client.openTurnState(entry.sessionId, { timeoutMs });
105
+ if (state !== 'closed')
106
+ open.push(entry);
107
+ // Handled once: do not nag on every later startup.
108
+ opts.store.clear(entry.sessionId, entry.promptRequestId);
109
+ }
110
+ catch (err) {
111
+ getLogger().warn(`[dsh] inflight reconcile skipped (host unreachable?): ${err.message}`);
112
+ return { removed, open: open.length, skipped: true };
113
+ }
114
+ }
115
+ if (open.length > 0) {
116
+ getLogger().warn(`[dsh] inflight reconcile: ${open.length} session(s) may still have an open turn`);
117
+ if (opts.notify) {
118
+ try {
119
+ await opts.notify(formatInflightWarning(open));
120
+ }
121
+ catch (err) {
122
+ getLogger().warn(`[dsh] inflight notice failed: ${err.message}`);
123
+ }
124
+ }
125
+ }
126
+ return { removed, open: open.length, skipped: false };
127
+ }
128
+ /** One short owner-facing notice; session ids are truncated, not paths. */
129
+ export function formatInflightWarning(entries) {
130
+ const lines = entries.slice(0, 3).map((entry) => {
131
+ const id = entry.sessionId.length > 8 ? entry.sessionId.slice(0, 8) : entry.sessionId;
132
+ return `- session ${id}(${entry.cwd ?? '未知目录'})`;
133
+ });
134
+ if (entries.length > 3)
135
+ lines.push(`- …另有 ${entries.length - 3} 个`);
136
+ return (`⚠️ 上次退出时可能留下了未结束的 DSH turn(${entries.length} 个):\n` +
137
+ `${lines.join('\n')}\n` +
138
+ '请到 DSH Web UI 检查并终止;本机的 /stop 只能停止当前由 lark-remote 发起的 run。');
139
+ }
140
+ //# sourceMappingURL=inflight.js.map
@@ -6,13 +6,18 @@
6
6
  * alignment → synthetic system/init (DSH has no init event) → session/prompt
7
7
  * (mode:'queue') → consume `session/follow` + `$events` until turn/end.
8
8
  *
9
- * stop() → session/cancel + abort the mux subscription. killOrphan /
10
- * registerExitHandlers are no-ops (no local process). DSH approvals are
9
+ * stop() → session/cancel + abort the mux subscription. killOrphan is a no-op
10
+ * (no local process); registerExitHandlers cancels the in-flight turn
11
+ * synchronously on process exit, because the turn lives in the shared host and
12
+ * would otherwise keep running with no subscriber. DSH approvals are
11
13
  * two-channel: durable `approval/asked` / `approval/decided` drive the Feishu
12
14
  * card, while the answerable `$events` waterfall supplies eventId/clientId.
13
- * If `$events` is unavailable, the runner falls back to the Web UI prompt.
15
+ * `ask_user_question` prompts arrive only as a `user-questions/request`
16
+ * waterfall (no durable event); they are rendered as a `kind:'question'`
17
+ * approval on the same card. If `$events` is unavailable, the runner falls
18
+ * back to the Web UI prompt.
14
19
  */
15
- import type { AgentEvent, AgentSessionReader, AgentStatusInfo, SpawnOptions } from '../types.js';
20
+ import type { AgentEvent, AgentSessionReader, AgentStatusInfo, SpawnOptions, StopOutcome } from '../types.js';
16
21
  import type { AgentRunner } from '../types.js';
17
22
  import { DshClient, type WebSocketFactory } from './client.js';
18
23
  import type { DshDaemon } from './daemon.js';
@@ -45,6 +50,18 @@ export interface DshRunnerOptions {
45
50
  webSocketImpl?: WebSocketFactory;
46
51
  /** Unary request timeout in ms (default 60s); 0 disables. */
47
52
  requestTimeoutMs?: number;
53
+ /**
54
+ * 退出路径上取消在途 turn 的传输实现(测试注入)。
55
+ * 默认走同步 curl(`cancelSessionSync`):`process.on('exit')` 里不能 await。
56
+ */
57
+ cancelOnExit?: (info: {
58
+ baseUrl: string;
59
+ sessionId: string;
60
+ }) => void;
61
+ /**
62
+ * 在途 prompt 的持久化记录文件(启动对账用)。省略 = 不记录(测试、临时实例)。
63
+ */
64
+ inflightPath?: string;
48
65
  }
49
66
  export declare class DshRunner implements AgentRunner {
50
67
  readonly kind: "dsh";
@@ -68,6 +85,19 @@ export declare class DshRunner implements AgentRunner {
68
85
  private activeAbort;
69
86
  private activeSessionId;
70
87
  private running;
88
+ /** This run's prompt identity (`source.rpcId`); null outside a submitted prompt. */
89
+ private activePromptRequestId;
90
+ /** Whether the current prompt request was accepted by the host. */
91
+ private promptAdmitted;
92
+ /**
93
+ * Releases the next run's prompt only after this stop's withdraw/cancel has
94
+ * been applied. Aborting the local mux lets the queue advance immediately;
95
+ * without the gate the successor's prompt can reach the host before the
96
+ * cancel, and a session-scoped cancel would then hit the *new* turn.
97
+ */
98
+ private pendingStopGate;
99
+ private pendingStopSessionId;
100
+ private readonly inflight;
71
101
  private readonly daemon;
72
102
  /**
73
103
  * 已对齐模型的 session 集合:同一 session 二次 run 不再重发 selectModel
@@ -80,10 +110,19 @@ export declare class DshRunner implements AgentRunner {
80
110
  private readonly permissionAlignedSessions;
81
111
  /** `$events` approval waterfall state for the current run. */
82
112
  private readonly approvalTracker;
113
+ /**
114
+ * eventId → 待回答的 DSH `ask_user_question` waterfall。
115
+ *
116
+ * 提问没有 durable 审计事件,eventId 本身就是卡片 requestId;这里保存应答
117
+ * 所需的 clientId 与题面(DSH 回编按题目 id)。每轮 run 清空。
118
+ */
119
+ private readonly pendingQuestions;
83
120
  /** callId → tool call, used to render the command in the approval card. */
84
121
  private readonly toolCalls;
85
122
  /** False after `$events` is unavailable; approval/asked then falls back to text. */
86
123
  private approvalChannelAvailable;
124
+ /** 退出路径的取消实现(默认同步 curl,见 `cancelSessionSync`)。 */
125
+ private readonly cancelOnExit;
87
126
  constructor(opts: DshRunnerOptions);
88
127
  get isRunning(): boolean;
89
128
  getStatusInfo(): AgentStatusInfo;
@@ -102,6 +141,16 @@ export declare class DshRunner implements AgentRunner {
102
141
  * stream keeps either transport from starving the other.
103
142
  */
104
143
  private runTurn;
144
+ /**
145
+ * 收尾时仍在跑的后台任务。
146
+ *
147
+ * 只保留非终态(running/stopping):已完成的任务由 agent 自己的 output 呈
148
+ * 现,卡片列出来只会误导"还没跑完"。`job/list` 拿不到时返回空数组——没有
149
+ * 任务列表不等于有任务在跑。
150
+ */
151
+ private collectBackgroundJobs;
152
+ /** Kill one background job of this runner's session (card button path). */
153
+ killJob(sessionId: string, jobId: string): Promise<boolean>;
105
154
  /** Apply the configured permission preset once per session, before prompt. */
106
155
  private alignPermissionPreset;
107
156
  /** Hot-update the configured preset; the next run aligns it. */
@@ -109,11 +158,25 @@ export declare class DshRunner implements AgentRunner {
109
158
  permissionPreset?: string;
110
159
  }): Promise<void>;
111
160
  /**
112
- * Answer one DSH approval. `accept` / `decline` are the only user decisions
113
- * exposed by the DSH Web UI; `cancel` stops the turn through session/cancel.
161
+ * Answer one DSH approval or `ask_user_question` prompt.
162
+ *
163
+ * Approvals: `accept` / `decline` are the only user decisions exposed by the
164
+ * DSH Web UI; `cancel` stops the turn through session/cancel.
165
+ *
166
+ * Questions: `answer` / `decline`(跳过回答)resolve the `user-questions/request`
167
+ * waterfall with an answer batch; `cancel`(飞书卡超时)只把这一路交回
168
+ * `next`,让 DSH Web UI 仍可回答,不取消 turn。
114
169
  */
115
170
  respondApproval(requestId: number | string, response: unknown): Promise<void>;
171
+ /** Resolve one pending `ask_user_question` waterfall from a card action. */
172
+ private respondQuestion;
116
173
  private buildApprovalRequested;
174
+ /**
175
+ * Render one `user-questions/request` waterfall as a `kind:'question'`
176
+ * approval area. The card renderer is agent-agnostic: it draws options,
177
+ * the Other input, multi-select submit and skip from this view alone.
178
+ */
179
+ private buildQuestionRequested;
117
180
  private approvalResolved;
118
181
  private approvalFallbackText;
119
182
  /**
@@ -136,10 +199,57 @@ export declare class DshRunner implements AgentRunner {
136
199
  private awaitOrAbort;
137
200
  /** Send session/cancel with a short bound; never throws (cleanup path). */
138
201
  private cancelSessionQuietly;
202
+ /**
203
+ * Stop this run's work in the shared host.
204
+ *
205
+ * Two distinct things can be running, and DSH needs a different call for each:
206
+ *
207
+ * 1. A prompt that was accepted but not claimed yet still sits in the host
208
+ * inbox. `session/cancel` would NOT drop it (`keepInbox`), so the turn would
209
+ * start later with no subscriber — an orphan. Withdraw it by `rpcId` via
210
+ * `session/updateQueue` instead, and leave the (foreign) active turn alone.
211
+ * 2. An already-claimed turn (or a compact) can only be stopped with
212
+ * `session/cancel`. Since that request is session-scoped and only an
213
+ * acknowledgement, verify the session went idle before reporting success.
214
+ *
215
+ * The gate is held until the host-side action has landed. For a cancel that
216
+ * includes the verification (and possible retry), because a retry cancel is
217
+ * session-scoped and would otherwise hit the successor's new turn.
218
+ */
139
219
  stop(_opts?: {
140
220
  immediate?: boolean;
141
- }): Promise<void>;
221
+ }): Promise<StopOutcome>;
222
+ /** Wait for a concurrent stop to reach the host before submitting a new prompt. */
223
+ private awaitStopGate;
224
+ private performStop;
225
+ /**
226
+ * Locate this run's unclaimed prompt.
227
+ *
228
+ * An already-admitted prompt is visible immediately. A request still in
229
+ * flight can be admitted after stop aborted it, so poll the inbox briefly —
230
+ * that is the only client-side protection against a late orphan turn.
231
+ */
232
+ private findPendingPromptWithin;
233
+ private removePendingPrompt;
234
+ /** One cancel attempt, then one retry (the request is idempotent when idle). */
235
+ private cancelWithRetry;
236
+ private cancelOnce;
237
+ /** Poll the host until the session reports idle, or the budget runs out. */
238
+ private waitForClosed;
239
+ /** Sleep, but never past the stop deadline. Returns false when out of budget. */
240
+ private sleepWithin;
241
+ private stopProbeTimeout;
142
242
  killOrphan(): void;
243
+ /**
244
+ * 退出前取消在途 turn。
245
+ *
246
+ * turn 跑在常驻共享的 `dsh web` host 里,不在本进程的子进程树上;所以
247
+ * "杀自己的子进程"这套退出清理对它无效,桥接一退出,turn 继续无人接收地跑
248
+ * (2026-10-19 实测:一个孤儿 turn 在桥接死后又跑了 2h21m / 345 个 step)。
249
+ * 这里复用进程级退出分发器,用同步 curl 发一次 `session/cancel`。
250
+ */
143
251
  registerExitHandlers(): void;
144
252
  unregisterExitHandlers(): void;
253
+ /** Exit dispatcher hook: cancel the in-flight turn, bounded and never throwing. */
254
+ cleanupOnExit(): void;
145
255
  }