lark-remote 0.5.6 → 0.5.8
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.
- package/CHANGELOG.md +46 -6
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/dist/bridge/index.d.ts +63 -1
- package/dist/bridge/index.js +147 -14
- package/dist/bridge/queue-manager.js +41 -58
- package/dist/card/approval-render.js +2 -1
- package/dist/card/bash-card-session.d.ts +0 -1
- package/dist/card/bash-card-session.js +0 -8
- package/dist/card/bash-renderer.js +2 -1
- package/dist/card/card-budget.d.ts +37 -0
- package/dist/card/card-budget.js +143 -24
- package/dist/card/card-session.d.ts +3 -13
- package/dist/card/card-session.js +4 -18
- package/dist/card/card-shared.d.ts +11 -0
- package/dist/card/card-shared.js +19 -13
- package/dist/card/run-card-session.d.ts +0 -1
- package/dist/card/run-card-session.js +0 -6
- package/dist/card/run-renderer.d.ts +18 -0
- package/dist/card/run-renderer.js +330 -62
- package/dist/card/run-state.d.ts +26 -0
- package/dist/card/run-state.js +45 -0
- package/dist/card/text-truncate.d.ts +3 -5
- package/dist/card/text-truncate.js +3 -5
- package/dist/card/time.d.ts +14 -0
- package/dist/card/time.js +30 -0
- package/dist/card/tool-render.d.ts +15 -2
- package/dist/card/tool-render.js +42 -82
- package/dist/common/text-width.d.ts +24 -0
- package/dist/common/text-width.js +93 -0
- package/dist/common/tool-summary.d.ts +19 -0
- package/dist/common/tool-summary.js +171 -0
- package/dist/common/turn-failure.d.ts +13 -0
- package/dist/common/turn-failure.js +18 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +1 -1
- package/dist/connector/index.js +1 -1
- package/dist/index.js +66 -13
- package/dist/order/index.d.ts +2 -0
- package/dist/order/index.js +2 -0
- package/dist/platform/command.js +1 -1
- package/dist/platform/shell.d.ts +2 -2
- package/dist/platform/spawn.d.ts +1 -1
- package/dist/platform/spawn.js +2 -2
- package/dist/platform/types.d.ts +1 -1
- package/dist/router/card-helpers.d.ts +18 -16
- package/dist/router/card-helpers.js +88 -46
- package/dist/router/config/codex.js +2 -3
- package/dist/router/config/common/model-patch.d.ts +8 -0
- package/dist/router/config/common/model-patch.js +13 -0
- package/dist/router/config/common/render.js +30 -77
- package/dist/router/config/dsh.js +2 -1
- package/dist/router/config/kimi.js +3 -3
- package/dist/router/index.d.ts +9 -11
- package/dist/router/index.js +165 -356
- package/dist/router/order-exec-dispatch.js +2 -1
- package/dist/runner/claude/session.d.ts +1 -1
- package/dist/runner/claude/session.js +3 -3
- package/dist/runner/common/defaults.d.ts +2 -0
- package/dist/runner/common/defaults.js +3 -0
- package/dist/runner/common/jsonrpc/connection-manager.js +1 -1
- package/dist/runner/common/jsonrpc/transport.d.ts +1 -1
- package/dist/runner/common/jsonrpc/transport.js +3 -3
- package/dist/runner/common/spawning-runner.d.ts +1 -1
- package/dist/runner/common/spawning-runner.js +2 -2
- package/dist/runner/dsh/auth.js +2 -1
- package/dist/runner/dsh/client.d.ts +100 -3
- package/dist/runner/dsh/client.js +290 -6
- package/dist/runner/dsh/exit-cancel.d.ts +32 -0
- package/dist/runner/dsh/exit-cancel.js +72 -0
- package/dist/runner/dsh/inflight.d.ts +62 -0
- package/dist/runner/dsh/inflight.js +140 -0
- package/dist/runner/dsh/runner.d.ts +90 -4
- package/dist/runner/dsh/runner.js +436 -18
- package/dist/runner/dsh/translator.d.ts +1 -1
- package/dist/runner/dsh/translator.js +103 -1
- package/dist/runner/dsh/types.d.ts +60 -0
- package/dist/runner/types.d.ts +95 -2
- package/dist/runner/types.js +9 -0
- package/dist/session/claude/session-index.d.ts +0 -4
- package/dist/session/claude/session-index.js +0 -8
- package/dist/session/claude/sessions.js +2 -2
- package/dist/session/dsh/sessions.js +157 -17
- package/dist/update/install.js +1 -1
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ORDER_NOT_FOUND_TEXT } from '../order/index.js';
|
|
1
2
|
/**
|
|
2
3
|
* Dispatch an `order.exec` card action as an equivalent queued user message
|
|
3
4
|
* (Plan A): resolve the order text at the enqueue boundary, then enqueue it
|
|
@@ -20,7 +21,7 @@ export async function dispatchOrderExecForQueue(args) {
|
|
|
20
21
|
}
|
|
21
22
|
const resolved = router.resolveOrderExecForQueue(orderId);
|
|
22
23
|
if (!resolved) {
|
|
23
|
-
await bridge.sendResult({ text:
|
|
24
|
+
await bridge.sendResult({ text: ORDER_NOT_FOUND_TEXT }, ctx);
|
|
24
25
|
return 'not-found';
|
|
25
26
|
}
|
|
26
27
|
// Step4/入队时刻快照 agent+session,随任务闭包带到执行时刻,
|
|
@@ -147,7 +147,7 @@ export declare class ClaudeSession extends SpawningRunner {
|
|
|
147
147
|
private consumeTurn;
|
|
148
148
|
/**
|
|
149
149
|
* 构造进程退出且无终态 result 时的统一错误结果(对齐原 SpawningRunner
|
|
150
|
-
* buildResultEvent 语义,含
|
|
150
|
+
* buildResultEvent 语义,含 win32 command-not-found 的双条件定性)。
|
|
151
151
|
*/
|
|
152
152
|
private buildStreamEndedError;
|
|
153
153
|
private wakeWaiters;
|
|
@@ -306,7 +306,7 @@ export class ClaudeSession extends SpawningRunner {
|
|
|
306
306
|
this.cwd = opts.cwd;
|
|
307
307
|
this.autoApprove = false;
|
|
308
308
|
this.pendingToolInputs.clear();
|
|
309
|
-
//
|
|
309
|
+
// win32 的 command-not-found 嫌疑标记每次 spawn 重置(spawnChild 置位,收尾双条件消费)。
|
|
310
310
|
this.commandNotFoundSeen = false;
|
|
311
311
|
// 每次 run 独立:上次 run 会话内跟踪到的计划文件不得泄漏到本次
|
|
312
312
|
// (否则本次 ExitPlanMode 无 plan/planFilePath 时 resolvePlanContent
|
|
@@ -422,7 +422,7 @@ export class ClaudeSession extends SpawningRunner {
|
|
|
422
422
|
}
|
|
423
423
|
/**
|
|
424
424
|
* 构造进程退出且无终态 result 时的统一错误结果(对齐原 SpawningRunner
|
|
425
|
-
* buildResultEvent 语义,含
|
|
425
|
+
* buildResultEvent 语义,含 win32 command-not-found 的双条件定性)。
|
|
426
426
|
*/
|
|
427
427
|
async buildStreamEndedError() {
|
|
428
428
|
// 进程已死但 proc 'close' 事件可能略晚于 stdout 关闭:等退出登记拿可靠
|
|
@@ -438,7 +438,7 @@ export class ClaudeSession extends SpawningRunner {
|
|
|
438
438
|
errorMessage = `${this.binary} killed by signal ${exited.signal}${stderrTail ? `: ${stderrTail}` : ''}`;
|
|
439
439
|
}
|
|
440
440
|
else if (exited.code !== null && exited.code !== 0) {
|
|
441
|
-
//
|
|
441
|
+
// 双条件定性:本分支已保证「用户未停 + signal 为 null + 非零退出」,
|
|
442
442
|
// 这里只补嗅探嫌疑标记这第二个条件。
|
|
443
443
|
const commandNotFound = this.commandNotFoundSeen;
|
|
444
444
|
if (commandNotFound) {
|
|
@@ -17,8 +17,8 @@ import { JsonlRpcTransport } from './transport.js';
|
|
|
17
17
|
import { JsonRpcClient } from './client.js';
|
|
18
18
|
import { getLogger } from '../../../logger/index.js';
|
|
19
19
|
import { agentStopperRegistry, } from '../../../platform/agent-stopper.js';
|
|
20
|
+
import { DEFAULT_REQUEST_TIMEOUT_MS } from '../defaults.js';
|
|
20
21
|
const DEFAULT_IDLE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
21
|
-
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; // 60 seconds
|
|
22
22
|
/** acquire() 的轮次上限:防止 slot 被持续释放时无限重建连接。 */
|
|
23
23
|
const MAX_ACQUIRE_ATTEMPTS = 5;
|
|
24
24
|
export class ConnectionManager {
|
|
@@ -30,7 +30,7 @@ export declare class JsonlRpcTransport {
|
|
|
30
30
|
/** JSON lines buffered while stdin is under backpressure (bounded by callers). */
|
|
31
31
|
private writeQueue;
|
|
32
32
|
private flushing;
|
|
33
|
-
/**
|
|
33
|
+
/** win32 嗅探嫌疑标记(双条件定性见下方 onExit)。 */
|
|
34
34
|
private commandNotFoundSeen;
|
|
35
35
|
constructor(opts: {
|
|
36
36
|
binary: string;
|
|
@@ -34,7 +34,7 @@ export class JsonlRpcTransport {
|
|
|
34
34
|
/** JSON lines buffered while stdin is under backpressure (bounded by callers). */
|
|
35
35
|
writeQueue = [];
|
|
36
36
|
flushing = false;
|
|
37
|
-
/**
|
|
37
|
+
/** win32 嗅探嫌疑标记(双条件定性见下方 onExit)。 */
|
|
38
38
|
commandNotFoundSeen = false;
|
|
39
39
|
constructor(opts) {
|
|
40
40
|
this.binary = opts.binary;
|
|
@@ -106,7 +106,7 @@ export class JsonlRpcTransport {
|
|
|
106
106
|
const text = chunk.toString('utf-8');
|
|
107
107
|
stderrBuf = (stderrBuf + text).slice(-MAX_STDERR_BYTES);
|
|
108
108
|
getLogger().debug(`[jsonrpc-transport] stderr: ${text.trimEnd()}`);
|
|
109
|
-
//
|
|
109
|
+
// win32 command-not-found 嗅探:命中只记嫌疑标记,不立即杀——
|
|
110
110
|
// agent 正常输出可能引用该错误文本(调试 Windows 报错场景),见行即杀
|
|
111
111
|
// 会误杀 run;定性由 onExit 的「标记 + 非零退出」双条件完成,真挂起由
|
|
112
112
|
// 上层超时兜底
|
|
@@ -169,7 +169,7 @@ export class JsonlRpcTransport {
|
|
|
169
169
|
// ignore trailing incomplete line
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
|
-
//
|
|
172
|
+
// 双条件定性:嫌疑标记 + 非零正常退出 → command-not-found。
|
|
173
173
|
// reason 仅供日志/诊断,消费方不解析该字符串(baseOnClose 无参)。
|
|
174
174
|
let reason = signal ? `signal:${signal}` : `exit:${code}`;
|
|
175
175
|
if (this.commandNotFoundSeen && signal === null && code !== null && code !== 0) {
|
|
@@ -84,7 +84,7 @@ export declare abstract class SpawningRunner {
|
|
|
84
84
|
*/
|
|
85
85
|
protected stoppedByUser: boolean;
|
|
86
86
|
/**
|
|
87
|
-
*
|
|
87
|
+
* win32 command-not-found 嗅探嫌疑标记:stderr 命中特征行时置位,
|
|
88
88
|
* 但**不**立即杀进程——agent 正常输出可能引用该错误文本(调试 Windows
|
|
89
89
|
* 报错场景),见行即杀会误杀 run。定性走双条件:子类收尾时「标记在 +
|
|
90
90
|
* 非零退出」才判 command-not-found;真挂起由既有 grace/超时兜底。
|
|
@@ -127,7 +127,7 @@ export class SpawningRunner {
|
|
|
127
127
|
*/
|
|
128
128
|
stoppedByUser = false;
|
|
129
129
|
/**
|
|
130
|
-
*
|
|
130
|
+
* win32 command-not-found 嗅探嫌疑标记:stderr 命中特征行时置位,
|
|
131
131
|
* 但**不**立即杀进程——agent 正常输出可能引用该错误文本(调试 Windows
|
|
132
132
|
* 报错场景),见行即杀会误杀 run。定性走双条件:子类收尾时「标记在 +
|
|
133
133
|
* 非零退出」才判 command-not-found;真挂起由既有 grace/超时兜底。
|
|
@@ -253,7 +253,7 @@ export class SpawningRunner {
|
|
|
253
253
|
const text = chunk.toString('utf-8').trim();
|
|
254
254
|
if (text) {
|
|
255
255
|
this.spawnStderr = (this.spawnStderr + '\n' + text).trim().slice(-STDERR_TAIL_BYTES);
|
|
256
|
-
//
|
|
256
|
+
// win32 command-not-found 嗅探:经 cmd 垫片启动失败不是 spawn 的
|
|
257
257
|
// ENOENT,而是 stderr 的 "is not recognized..." 行。命中只记嫌疑标记
|
|
258
258
|
// (双条件之一),不立即杀——定性由子类收尾的「标记 + 非零退出」
|
|
259
259
|
// 双条件完成;子进程真挂起由既有 grace/超时看门狗兜底。
|
package/dist/runner/dsh/auth.js
CHANGED
|
@@ -13,6 +13,7 @@ import fs from 'node:fs';
|
|
|
13
13
|
import os from 'node:os';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import YAML from 'yaml';
|
|
16
|
+
import { isWin32 } from '../../platform/select.js';
|
|
16
17
|
/** Credential record that owns the browser-cookie signing secret. */
|
|
17
18
|
export const DSH_BROWSER_CREDENTIAL_KEY = 'client-connection/browser-session';
|
|
18
19
|
/** Short lifetime keeps every request safely inside DSH's configurable max age. */
|
|
@@ -40,7 +41,7 @@ function assertOwnerOnly(file, stats) {
|
|
|
40
41
|
throw new Error(`DSH credentials file ${file} is not a regular file`);
|
|
41
42
|
}
|
|
42
43
|
// Windows has no POSIX mode bits; DSH itself skips the same check there.
|
|
43
|
-
if (
|
|
44
|
+
if (isWin32())
|
|
44
45
|
return;
|
|
45
46
|
if ((stats.mode & 0o077) !== 0) {
|
|
46
47
|
throw new Error(`DSH credentials file ${file} is readable beyond its owner ` +
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* `sessionEvents()` adds per-session filtering, reconnect + follow-snapshot
|
|
20
20
|
* replay, and seq-based dedup.
|
|
21
21
|
*/
|
|
22
|
-
import type { DshApprovalStreamItem, DshCommandResult, DshModelCatalogValue, DshModelSelection, DshPermissionCatalogValue, DshPresetListValue, DshQuestion, DshRemoteEventOutcome, DshSessionPageValue, DshStreamItem, DshTokenUsage } from './types.js';
|
|
22
|
+
import type { DshApprovalStreamItem, DshCommandResult, DshModelCatalogValue, DshModelSelection, DshOpenTurnState, DshPermissionCatalogValue, DshJobView, DshPresetListValue, DshProjectionItem, DshProjectionSnapshot, DshQuestion, DshRemoteEventOutcome, DshSessionPageValue, DshStreamItem, DshTokenUsage } from './types.js';
|
|
23
23
|
interface WebSocketMessageEvent {
|
|
24
24
|
data: unknown;
|
|
25
25
|
}
|
|
@@ -182,6 +182,68 @@ export declare class DshClient {
|
|
|
182
182
|
signal?: AbortSignal;
|
|
183
183
|
timeoutMs?: number;
|
|
184
184
|
}): Promise<DshSessionPageValue>;
|
|
185
|
+
/**
|
|
186
|
+
* Read the session's exact projection values.
|
|
187
|
+
*
|
|
188
|
+
* Returns `null` when the host has no projections for the session (e.g. it was
|
|
189
|
+
* deleted). A failed unary still throws — callers that only need a
|
|
190
|
+
* best-effort probe catch it themselves.
|
|
191
|
+
*/
|
|
192
|
+
projectionSnapshot(sessionId: string, opts?: {
|
|
193
|
+
signal?: AbortSignal;
|
|
194
|
+
timeoutMs?: number;
|
|
195
|
+
}): Promise<DshProjectionSnapshot | null>;
|
|
196
|
+
/**
|
|
197
|
+
* Find this run's prompt if it is still sitting in the host's inbox.
|
|
198
|
+
*
|
|
199
|
+
* DSH 的 prompt 受理后先进入 inbox,被 turn 取走(claim)就不再出现;停止
|
|
200
|
+
* 时用它区分“还没开始 → 可以从队列里撤掉”和“已经在跑 → 只能 cancel”。
|
|
201
|
+
* `source.rpcId` 是 `session/prompt` 的 requestId,归属不会认错。
|
|
202
|
+
*/
|
|
203
|
+
findPendingPrompt(sessionId: string, requestId: string, opts?: {
|
|
204
|
+
signal?: AbortSignal;
|
|
205
|
+
timeoutMs?: number;
|
|
206
|
+
}): Promise<string | undefined>;
|
|
207
|
+
/**
|
|
208
|
+
* Remove one not-yet-claimed inbox occurrence (`session/updateQueue`).
|
|
209
|
+
*
|
|
210
|
+
* @returns true when the host removed it; false when it was claimed between
|
|
211
|
+
* the projection read and this call (`session/queue-item-not-found`).
|
|
212
|
+
*/
|
|
213
|
+
removeQueuedPrompt(sessionId: string, itemId: string, opts?: {
|
|
214
|
+
signal?: AbortSignal;
|
|
215
|
+
timeoutMs?: number;
|
|
216
|
+
}): Promise<boolean>;
|
|
217
|
+
/**
|
|
218
|
+
* Whether the session still has a running turn.
|
|
219
|
+
*
|
|
220
|
+
* 主信号是 `session/list` 的 `running` 字段:服务端权威(会话列表页"运行中"
|
|
221
|
+
* 用的就是它),一次 unary,投票不受桥接进程内存影响。名单里没有该会话或
|
|
222
|
+
* 服务端不返回 `running` 时,回退到事件探测({@link probeOpenTurnByEvents}),
|
|
223
|
+
* 两者都判不出来就返回 false(fail open,仍由 DSH 自己拒绝)。
|
|
224
|
+
*/
|
|
225
|
+
hasOpenTurn(sessionId: string, opts?: {
|
|
226
|
+
signal?: AbortSignal;
|
|
227
|
+
timeoutMs?: number;
|
|
228
|
+
}): Promise<boolean>;
|
|
229
|
+
/**
|
|
230
|
+
* Tri-state version of {@link hasOpenTurn}.
|
|
231
|
+
*
|
|
232
|
+
* Stop verification must distinguish "the host says idle" from "the probe
|
|
233
|
+
* could not tell" — reporting `closed` on a failed probe would let a still
|
|
234
|
+
* running turn be reported as stopped. `unknown` is the honest answer there.
|
|
235
|
+
*/
|
|
236
|
+
openTurnState(sessionId: string, opts?: {
|
|
237
|
+
signal?: AbortSignal;
|
|
238
|
+
timeoutMs?: number;
|
|
239
|
+
}): Promise<DshOpenTurnState>;
|
|
240
|
+
/**
|
|
241
|
+
* Fallback probe: the newest durable event's type tells whether a turn is open.
|
|
242
|
+
*
|
|
243
|
+
* 只在拿不到 `running` 时用。名单刻意保守——`job/*`(后台任务可在 turn 外
|
|
244
|
+
* 结算)、`user/message`、`turn/end` 都不算,判不出来就报告 `unknown`。
|
|
245
|
+
*/
|
|
246
|
+
private probeOpenTurnByEvents;
|
|
185
247
|
/**
|
|
186
248
|
* One logical stream over `/api/remote.mux`. The WebSocket must carry the
|
|
187
249
|
* same auth cookie as unary calls. Ends on the server's `end` frame, socket
|
|
@@ -203,9 +265,44 @@ export declare class DshClient {
|
|
|
203
265
|
* Per-session subscription: yields session events, assistant stream chunks
|
|
204
266
|
* and approval notices for `sessionId`, auto-reconnecting on stream
|
|
205
267
|
* drop/error and replaying the follow snapshot (dedup by seq). Terminates on
|
|
206
|
-
* turn/end or `signal` abort.
|
|
268
|
+
* the run's own turn/end or `signal` abort.
|
|
269
|
+
*
|
|
270
|
+
* `promptRequestId` binds the stream to this run's prompt. `mode:'queue'`
|
|
271
|
+
* prompts are accepted while the agent is mid-turn and start a turn only
|
|
272
|
+
* later, so the first `turn/end` after `initialSeq` can belong to that OTHER
|
|
273
|
+
* turn. Everything recorded before the prompt's own durable `user/message`
|
|
274
|
+
* (whose source echoes the request id as `rpcId`) is foreign: dropping it
|
|
275
|
+
* keeps the card "running" until this run's turn actually finishes.
|
|
276
|
+
*/
|
|
277
|
+
sessionEvents(sessionId: string, signal: AbortSignal, initialSeq?: number, promptRequestId?: string): AsyncGenerator<DshStreamItem>;
|
|
278
|
+
/**
|
|
279
|
+
* Host-wide live projection stream (`session/control`).
|
|
280
|
+
*
|
|
281
|
+
* 一份 baseline(当前所有活跃 session 的完整投影值)+ 之后每个投影的变化帧。
|
|
282
|
+
* 调用方按 sessionId 过滤:这是 DSH 上唯一"服务端变一次推一次"的通道,
|
|
283
|
+
* 不需要轮询 `session/projections`。
|
|
284
|
+
*
|
|
285
|
+
* 失败必须降级为"没有状态条",不能影响 run:控制流是增强通道,断了运行
|
|
286
|
+
* 照常(异常在此吞掉并记日志,不向 merge 抛)。
|
|
287
|
+
*/
|
|
288
|
+
controlFrames(signal: AbortSignal): AsyncGenerator<DshProjectionItem>;
|
|
289
|
+
/**
|
|
290
|
+
* One session's current background-job roster.
|
|
291
|
+
*
|
|
292
|
+
* `job/list` 是流(生命周期变化时推整份名单),但"此刻有哪些任务在跑"只需
|
|
293
|
+
* 要第一帧:文档明确首帧就是当前真相(whole-set replacement)。取到即断开,
|
|
294
|
+
* 不长期持有连接——任务列表只用于收尾时的一句话与停止按钮。
|
|
295
|
+
*
|
|
296
|
+
* 失败返回 undefined(与"没有任务"区分):拿不到就别在卡片上写"0 个后台任务"。
|
|
207
297
|
*/
|
|
208
|
-
|
|
298
|
+
jobRows(sessionId: string, opts?: {
|
|
299
|
+
signal?: AbortSignal;
|
|
300
|
+
timeoutMs?: number;
|
|
301
|
+
}): Promise<DshJobView[] | undefined>;
|
|
302
|
+
/** Kill one background job on the user's behalf (`job/kill`). */
|
|
303
|
+
jobKill(sessionId: string, jobId: string, opts?: {
|
|
304
|
+
signal?: AbortSignal;
|
|
305
|
+
}): Promise<boolean>;
|
|
209
306
|
/** Extract TokenUsage from an assistant/message data payload (absent → undefined). */
|
|
210
307
|
static usageOf(data: Record<string, unknown>): DshTokenUsage | undefined;
|
|
211
308
|
}
|
|
@@ -23,6 +23,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
23
23
|
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
|
+
import { DEFAULT_REQUEST_TIMEOUT_MS } from '../common/defaults.js';
|
|
26
27
|
function defaultWebSocketFactory(url, options) {
|
|
27
28
|
const socket = new WebSocket(url, {
|
|
28
29
|
...(options?.headers ? { headers: options.headers } : {}),
|
|
@@ -52,8 +53,59 @@ function isDshAuthError(error) {
|
|
|
52
53
|
const RECONNECT_BACKOFF_MS = 200;
|
|
53
54
|
/** Follow snapshot budget. `session/follow` also stays live after the snapshot. */
|
|
54
55
|
const FOLLOW_MAX_MESSAGES = 200;
|
|
55
|
-
/**
|
|
56
|
-
|
|
56
|
+
/**
|
|
57
|
+
* 只在 turn 内部落盘的会话事件类型:看到它们说明 turn 还没结束。
|
|
58
|
+
* 名单刻意保守——`user/message`(压缩 checkpoint 会以 turn:null 落盘)、
|
|
59
|
+
* `job/*`(后台任务可在 turn 外结算)、`turn/end`、`command/*` 等都不算,
|
|
60
|
+
* 判不出来时宁可放行,让 DSH 自己拒绝。
|
|
61
|
+
*/
|
|
62
|
+
const IN_TURN_EVENT_TYPES = new Set([
|
|
63
|
+
'step/start',
|
|
64
|
+
'step/end',
|
|
65
|
+
'assistant/chunk',
|
|
66
|
+
'assistant/message',
|
|
67
|
+
'assistant/attempt',
|
|
68
|
+
'tool/call',
|
|
69
|
+
'tool/result',
|
|
70
|
+
'request/header',
|
|
71
|
+
]);
|
|
72
|
+
/**
|
|
73
|
+
* Parse the `inbox` projection into the pending items this repo consumes.
|
|
74
|
+
*
|
|
75
|
+
* The projection's items are wire data (typed `{}` by the server schema); keep
|
|
76
|
+
* this defensive: one malformed row must not hide the others.
|
|
77
|
+
*/
|
|
78
|
+
function inboxItemsOf(values) {
|
|
79
|
+
const inbox = values.inbox;
|
|
80
|
+
if (!inbox || typeof inbox !== 'object' || Array.isArray(inbox))
|
|
81
|
+
return [];
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const target of ['next-turn', 'next-step']) {
|
|
84
|
+
const list = inbox[target];
|
|
85
|
+
if (!Array.isArray(list))
|
|
86
|
+
continue;
|
|
87
|
+
for (const raw of list) {
|
|
88
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
89
|
+
continue;
|
|
90
|
+
const item = raw;
|
|
91
|
+
if (typeof item.id !== 'string' || item.id.length === 0)
|
|
92
|
+
continue;
|
|
93
|
+
const source = item.source;
|
|
94
|
+
const parsedSource = source && typeof source === 'object' && !Array.isArray(source)
|
|
95
|
+
? {
|
|
96
|
+
...(typeof source.kind === 'string'
|
|
97
|
+
? { kind: source.kind }
|
|
98
|
+
: {}),
|
|
99
|
+
...(typeof source.rpcId === 'string'
|
|
100
|
+
? { rpcId: source.rpcId }
|
|
101
|
+
: {}),
|
|
102
|
+
}
|
|
103
|
+
: undefined;
|
|
104
|
+
out.push({ id: item.id, ...(parsedSource ? { source: parsedSource } : {}) });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
57
109
|
/** Extract the `token` query parameter from a pasted `dsh web` URL. */
|
|
58
110
|
export function extractDshToken(raw) {
|
|
59
111
|
const value = (raw ?? '').trim();
|
|
@@ -85,6 +137,9 @@ const DSH_ARGS_KEY = {
|
|
|
85
137
|
'session/projections': 'request',
|
|
86
138
|
'session/page': 'request',
|
|
87
139
|
'session/follow': 'request',
|
|
140
|
+
'session/updateQueue': 'request',
|
|
141
|
+
'job/list': 'request',
|
|
142
|
+
'job/kill': 'request',
|
|
88
143
|
'session/modelCatalog': null,
|
|
89
144
|
'agentPresets/list': null,
|
|
90
145
|
'permissionPresets/catalog': null,
|
|
@@ -364,7 +419,7 @@ export class DshClient {
|
|
|
364
419
|
* `session/prompt` so a follow snapshot can skip already-completed turns.
|
|
365
420
|
*/
|
|
366
421
|
async latestEventSeq(sessionId, opts) {
|
|
367
|
-
const value =
|
|
422
|
+
const value = await this.projectionSnapshot(sessionId, opts);
|
|
368
423
|
return typeof value?.asOfSeq === 'number' ? value.asOfSeq : -1;
|
|
369
424
|
}
|
|
370
425
|
/** Read one cold history page. `throughSeq` normally comes from
|
|
@@ -376,6 +431,110 @@ export class DshClient {
|
|
|
376
431
|
maxMessages,
|
|
377
432
|
}, opts));
|
|
378
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* Read the session's exact projection values.
|
|
436
|
+
*
|
|
437
|
+
* Returns `null` when the host has no projections for the session (e.g. it was
|
|
438
|
+
* deleted). A failed unary still throws — callers that only need a
|
|
439
|
+
* best-effort probe catch it themselves.
|
|
440
|
+
*/
|
|
441
|
+
async projectionSnapshot(sessionId, opts) {
|
|
442
|
+
const value = (await this.unary('session/projections', { sessionId }, opts));
|
|
443
|
+
if (!value || typeof value !== 'object')
|
|
444
|
+
return null;
|
|
445
|
+
return {
|
|
446
|
+
...(typeof value.asOfSeq === 'number' ? { asOfSeq: value.asOfSeq } : {}),
|
|
447
|
+
values: value.values && typeof value.values === 'object' && !Array.isArray(value.values)
|
|
448
|
+
? value.values
|
|
449
|
+
: {},
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Find this run's prompt if it is still sitting in the host's inbox.
|
|
454
|
+
*
|
|
455
|
+
* DSH 的 prompt 受理后先进入 inbox,被 turn 取走(claim)就不再出现;停止
|
|
456
|
+
* 时用它区分“还没开始 → 可以从队列里撤掉”和“已经在跑 → 只能 cancel”。
|
|
457
|
+
* `source.rpcId` 是 `session/prompt` 的 requestId,归属不会认错。
|
|
458
|
+
*/
|
|
459
|
+
async findPendingPrompt(sessionId, requestId, opts) {
|
|
460
|
+
const snapshot = await this.projectionSnapshot(sessionId, opts);
|
|
461
|
+
if (!snapshot)
|
|
462
|
+
return undefined;
|
|
463
|
+
return inboxItemsOf(snapshot.values).find((item) => item.source?.kind === 'user' && item.source.rpcId === requestId)?.id;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Remove one not-yet-claimed inbox occurrence (`session/updateQueue`).
|
|
467
|
+
*
|
|
468
|
+
* @returns true when the host removed it; false when it was claimed between
|
|
469
|
+
* the projection read and this call (`session/queue-item-not-found`).
|
|
470
|
+
*/
|
|
471
|
+
async removeQueuedPrompt(sessionId, itemId, opts) {
|
|
472
|
+
try {
|
|
473
|
+
await this.unary('session/updateQueue', { sessionId, itemId, action: { kind: 'remove' } }, opts);
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
if (err instanceof DshError && err.code === 'session/queue-item-not-found')
|
|
478
|
+
return false;
|
|
479
|
+
throw err;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Whether the session still has a running turn.
|
|
484
|
+
*
|
|
485
|
+
* 主信号是 `session/list` 的 `running` 字段:服务端权威(会话列表页"运行中"
|
|
486
|
+
* 用的就是它),一次 unary,投票不受桥接进程内存影响。名单里没有该会话或
|
|
487
|
+
* 服务端不返回 `running` 时,回退到事件探测({@link probeOpenTurnByEvents}),
|
|
488
|
+
* 两者都判不出来就返回 false(fail open,仍由 DSH 自己拒绝)。
|
|
489
|
+
*/
|
|
490
|
+
async hasOpenTurn(sessionId, opts) {
|
|
491
|
+
return (await this.openTurnState(sessionId, opts)) === 'open';
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Tri-state version of {@link hasOpenTurn}.
|
|
495
|
+
*
|
|
496
|
+
* Stop verification must distinguish "the host says idle" from "the probe
|
|
497
|
+
* could not tell" — reporting `closed` on a failed probe would let a still
|
|
498
|
+
* running turn be reported as stopped. `unknown` is the honest answer there.
|
|
499
|
+
*/
|
|
500
|
+
async openTurnState(sessionId, opts) {
|
|
501
|
+
try {
|
|
502
|
+
const list = (await this.unary('session/list', {}, opts));
|
|
503
|
+
const row = list?.items?.find((item) => String(item.sessionId) === sessionId);
|
|
504
|
+
if (row && typeof row.running === 'boolean')
|
|
505
|
+
return row.running ? 'open' : 'closed';
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
getLogger().warn(`[dsh] open-turn probe: session/list failed for ${sessionId}: ${err.message}`);
|
|
509
|
+
}
|
|
510
|
+
return this.probeOpenTurnByEvents(sessionId, opts);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Fallback probe: the newest durable event's type tells whether a turn is open.
|
|
514
|
+
*
|
|
515
|
+
* 只在拿不到 `running` 时用。名单刻意保守——`job/*`(后台任务可在 turn 外
|
|
516
|
+
* 结算)、`user/message`、`turn/end` 都不算,判不出来就报告 `unknown`。
|
|
517
|
+
*/
|
|
518
|
+
async probeOpenTurnByEvents(sessionId, opts) {
|
|
519
|
+
try {
|
|
520
|
+
// A missing projection snapshot is not "idle": the host simply cannot
|
|
521
|
+
// tell us. Only a known watermark with no events (or a closed tail) is
|
|
522
|
+
// evidence that no turn is open.
|
|
523
|
+
const snapshot = await this.projectionSnapshot(sessionId, opts);
|
|
524
|
+
if (!snapshot || typeof snapshot.asOfSeq !== 'number' || snapshot.asOfSeq < 0) {
|
|
525
|
+
return 'unknown';
|
|
526
|
+
}
|
|
527
|
+
const value = await this.page(sessionId, snapshot.asOfSeq, 1, opts);
|
|
528
|
+
const last = value.records.at(-1)?.event;
|
|
529
|
+
if (last === undefined)
|
|
530
|
+
return 'closed';
|
|
531
|
+
return last.type === 'turn/start' || IN_TURN_EVENT_TYPES.has(last.type) ? 'open' : 'closed';
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
getLogger().warn(`[dsh] open-turn probe failed for session ${sessionId}: ${err.message}`);
|
|
535
|
+
return 'unknown';
|
|
536
|
+
}
|
|
537
|
+
}
|
|
379
538
|
/**
|
|
380
539
|
* One logical stream over `/api/remote.mux`. The WebSocket must carry the
|
|
381
540
|
* same auth cookie as unary calls. Ends on the server's `end` frame, socket
|
|
@@ -675,10 +834,41 @@ export class DshClient {
|
|
|
675
834
|
* Per-session subscription: yields session events, assistant stream chunks
|
|
676
835
|
* and approval notices for `sessionId`, auto-reconnecting on stream
|
|
677
836
|
* drop/error and replaying the follow snapshot (dedup by seq). Terminates on
|
|
678
|
-
* turn/end or `signal` abort.
|
|
837
|
+
* the run's own turn/end or `signal` abort.
|
|
838
|
+
*
|
|
839
|
+
* `promptRequestId` binds the stream to this run's prompt. `mode:'queue'`
|
|
840
|
+
* prompts are accepted while the agent is mid-turn and start a turn only
|
|
841
|
+
* later, so the first `turn/end` after `initialSeq` can belong to that OTHER
|
|
842
|
+
* turn. Everything recorded before the prompt's own durable `user/message`
|
|
843
|
+
* (whose source echoes the request id as `rpcId`) is foreign: dropping it
|
|
844
|
+
* keeps the card "running" until this run's turn actually finishes.
|
|
679
845
|
*/
|
|
680
|
-
async *sessionEvents(sessionId, signal, initialSeq = -1) {
|
|
681
|
-
const state = { lastSeq: initialSeq };
|
|
846
|
+
async *sessionEvents(sessionId, signal, initialSeq = -1, promptRequestId) {
|
|
847
|
+
const state = { lastSeq: initialSeq, owns: promptRequestId === undefined };
|
|
848
|
+
let foreignTailReported = false;
|
|
849
|
+
/** Mark this run as the owner once its own prompt message is recorded. */
|
|
850
|
+
const ownsEvent = (ev) => {
|
|
851
|
+
if (state.owns)
|
|
852
|
+
return true;
|
|
853
|
+
if (ev.type !== 'user/message')
|
|
854
|
+
return false;
|
|
855
|
+
const source = ev.data
|
|
856
|
+
?.source;
|
|
857
|
+
if (source?.kind === 'user' && source.rpcId === promptRequestId) {
|
|
858
|
+
state.owns = true;
|
|
859
|
+
return true;
|
|
860
|
+
}
|
|
861
|
+
return false;
|
|
862
|
+
};
|
|
863
|
+
const admit = (ev) => {
|
|
864
|
+
if (ownsEvent(ev))
|
|
865
|
+
return true;
|
|
866
|
+
if (!foreignTailReported) {
|
|
867
|
+
foreignTailReported = true;
|
|
868
|
+
getLogger().info(`[dsh] prompt ${promptRequestId ?? '(none)'} is queued behind a running turn for session ${sessionId}; holding the card open until its own turn starts`);
|
|
869
|
+
}
|
|
870
|
+
return false;
|
|
871
|
+
};
|
|
682
872
|
let authRefreshAttempted = false;
|
|
683
873
|
while (!signal.aborted) {
|
|
684
874
|
try {
|
|
@@ -692,6 +882,8 @@ export class DshClient {
|
|
|
692
882
|
if (!ev || ev.seq <= state.lastSeq)
|
|
693
883
|
continue;
|
|
694
884
|
state.lastSeq = ev.seq;
|
|
885
|
+
if (!admit(ev))
|
|
886
|
+
continue;
|
|
695
887
|
const item = this.eventItem(sessionId, ev);
|
|
696
888
|
if (item)
|
|
697
889
|
yield item;
|
|
@@ -705,6 +897,8 @@ export class DshClient {
|
|
|
705
897
|
if (ev.seq <= state.lastSeq)
|
|
706
898
|
continue;
|
|
707
899
|
state.lastSeq = ev.seq;
|
|
900
|
+
if (!admit(ev))
|
|
901
|
+
continue;
|
|
708
902
|
const item = this.eventItem(sessionId, ev);
|
|
709
903
|
if (item)
|
|
710
904
|
yield item;
|
|
@@ -713,6 +907,9 @@ export class DshClient {
|
|
|
713
907
|
continue;
|
|
714
908
|
}
|
|
715
909
|
if (frame.type === 'assistant-stream') {
|
|
910
|
+
// Mid-turn deltas of the turn this prompt is queued behind.
|
|
911
|
+
if (!state.owns)
|
|
912
|
+
continue;
|
|
716
913
|
yield { kind: 'assistant-stream', sessionId, frame: frame.frame };
|
|
717
914
|
}
|
|
718
915
|
}
|
|
@@ -739,6 +936,93 @@ export class DshClient {
|
|
|
739
936
|
await new Promise((resolve) => setTimeout(resolve, RECONNECT_BACKOFF_MS));
|
|
740
937
|
}
|
|
741
938
|
}
|
|
939
|
+
/**
|
|
940
|
+
* Host-wide live projection stream (`session/control`).
|
|
941
|
+
*
|
|
942
|
+
* 一份 baseline(当前所有活跃 session 的完整投影值)+ 之后每个投影的变化帧。
|
|
943
|
+
* 调用方按 sessionId 过滤:这是 DSH 上唯一"服务端变一次推一次"的通道,
|
|
944
|
+
* 不需要轮询 `session/projections`。
|
|
945
|
+
*
|
|
946
|
+
* 失败必须降级为"没有状态条",不能影响 run:控制流是增强通道,断了运行
|
|
947
|
+
* 照常(异常在此吞掉并记日志,不向 merge 抛)。
|
|
948
|
+
*/
|
|
949
|
+
async *controlFrames(signal) {
|
|
950
|
+
try {
|
|
951
|
+
// mux 的 payload 与 unary 同信封:`{args:{...}}`。control 无参数,故空 args;
|
|
952
|
+
// 少了这层包装网关直接拒绝(live 实测 0 帧,测试用假服务端不会发现)。
|
|
953
|
+
for await (const raw of this.muxStream('session/control', { args: {} }, signal)) {
|
|
954
|
+
const frame = raw;
|
|
955
|
+
if (!frame || typeof frame !== 'object')
|
|
956
|
+
continue;
|
|
957
|
+
if (frame.type === 'baseline') {
|
|
958
|
+
const projections = frame.value?.projections ??
|
|
959
|
+
{};
|
|
960
|
+
for (const [sessionId, snapshot] of Object.entries(projections)) {
|
|
961
|
+
const values = snapshot?.values ?? {};
|
|
962
|
+
for (const [key, value] of Object.entries(values)) {
|
|
963
|
+
yield { kind: 'projection', sessionId, key, value };
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
if (frame.type === 'projection' && frame.sessionId && frame.key) {
|
|
969
|
+
yield {
|
|
970
|
+
kind: 'projection',
|
|
971
|
+
sessionId: frame.sessionId,
|
|
972
|
+
key: frame.key,
|
|
973
|
+
value: frame.value,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
catch (err) {
|
|
979
|
+
if (signal.aborted)
|
|
980
|
+
return;
|
|
981
|
+
getLogger().warn(`[dsh] session/control stream unavailable (${err.message}); run continues without live status`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* One session's current background-job roster.
|
|
986
|
+
*
|
|
987
|
+
* `job/list` 是流(生命周期变化时推整份名单),但"此刻有哪些任务在跑"只需
|
|
988
|
+
* 要第一帧:文档明确首帧就是当前真相(whole-set replacement)。取到即断开,
|
|
989
|
+
* 不长期持有连接——任务列表只用于收尾时的一句话与停止按钮。
|
|
990
|
+
*
|
|
991
|
+
* 失败返回 undefined(与"没有任务"区分):拿不到就别在卡片上写"0 个后台任务"。
|
|
992
|
+
*/
|
|
993
|
+
async jobRows(sessionId, opts = {}) {
|
|
994
|
+
const timer = new AbortController();
|
|
995
|
+
const timeout = setTimeout(() => timer.abort(), opts.timeoutMs ?? 2_000);
|
|
996
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, timer.signal]) : timer.signal;
|
|
997
|
+
try {
|
|
998
|
+
for await (const frame of this.muxStream('job/list', { args: dshArgs('job/list', { sessionId }) }, signal)) {
|
|
999
|
+
const rows = frame;
|
|
1000
|
+
if (rows?.type === 'rows') {
|
|
1001
|
+
return Array.isArray(rows.jobs) ? rows.jobs : [];
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return undefined;
|
|
1005
|
+
}
|
|
1006
|
+
catch (err) {
|
|
1007
|
+
getLogger().debug(`[dsh] job/list unavailable: ${err.message}`);
|
|
1008
|
+
return undefined;
|
|
1009
|
+
}
|
|
1010
|
+
finally {
|
|
1011
|
+
clearTimeout(timeout);
|
|
1012
|
+
timer.abort();
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
/** Kill one background job on the user's behalf (`job/kill`). */
|
|
1016
|
+
async jobKill(sessionId, jobId, opts = {}) {
|
|
1017
|
+
try {
|
|
1018
|
+
await this.unary('job/kill', { sessionId, jobId }, opts);
|
|
1019
|
+
return true;
|
|
1020
|
+
}
|
|
1021
|
+
catch (err) {
|
|
1022
|
+
getLogger().warn(`[dsh] job/kill failed job=${jobId}: ${err.message}`);
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
742
1026
|
/** Extract TokenUsage from an assistant/message data payload (absent → undefined). */
|
|
743
1027
|
static usageOf(data) {
|
|
744
1028
|
return data.usage ?? undefined;
|