@botlearn-course/daemon 0.0.1 → 0.0.3

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 (42) hide show
  1. package/README.md +12 -1
  2. package/dist/agent-service-session.d.ts +67 -0
  3. package/dist/agent-service-session.js +796 -0
  4. package/dist/agent-service-ws-protocol.d.ts +28 -0
  5. package/dist/agent-service-ws-protocol.js +128 -0
  6. package/dist/cli.d.ts +1 -2
  7. package/dist/cli.js +114 -13
  8. package/dist/course-client.js +4 -2
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.js +4 -0
  11. package/dist/mcp/report-progress-server.d.ts +21 -0
  12. package/dist/mcp/report-progress-server.js +136 -0
  13. package/dist/mcp/report-progress.d.ts +29 -0
  14. package/dist/mcp/report-progress.js +60 -0
  15. package/dist/run-dispatcher.d.ts +15 -0
  16. package/dist/run-dispatcher.js +126 -6
  17. package/dist/runtime-env.d.ts +9 -0
  18. package/dist/runtime-env.js +40 -0
  19. package/dist/runtime-profile.js +25 -13
  20. package/dist/runtimes/acp-stream.js +2 -0
  21. package/dist/runtimes/codex.d.ts +1 -1
  22. package/dist/runtimes/codex.js +2 -2
  23. package/dist/runtimes/deepseek-tui.d.ts +6 -2
  24. package/dist/runtimes/deepseek-tui.js +238 -41
  25. package/dist/runtimes/engine.d.ts +14 -3
  26. package/dist/runtimes/engine.js +32 -5
  27. package/dist/runtimes/hermes-agent.d.ts +1 -1
  28. package/dist/runtimes/hermes-agent.js +3 -2
  29. package/dist/runtimes/ndjson-stream.d.ts +1 -1
  30. package/dist/runtimes/ndjson-stream.js +4 -2
  31. package/dist/runtimes/openclaw-acp.js +3 -1
  32. package/dist/runtimes/progress.d.ts +50 -0
  33. package/dist/runtimes/progress.js +339 -0
  34. package/dist/sandbox-supervisor.d.ts +3 -0
  35. package/dist/sandbox-supervisor.js +176 -0
  36. package/dist/transcript.js +6 -0
  37. package/dist/types.d.ts +29 -3
  38. package/dist/websocket-client.d.ts +43 -0
  39. package/dist/websocket-client.js +320 -0
  40. package/dist/workspace.d.ts +9 -0
  41. package/dist/workspace.js +43 -2
  42. package/package.json +3 -2
@@ -2,7 +2,10 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync, realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import net from "node:net";
5
+ import { MAX_PROGRESS_EVENTS_PER_ATTEMPT } from "../mcp/report-progress.js";
6
+ import { runtimeChildEnv, runtimeChildIdentity } from "../runtime-env.js";
5
7
  import { readCommandVersion, resolveCommandOnPath } from "./probe.js";
8
+ import { adaptDeepseekProgressStarted, cleanupProgressMcpConfig, createDeepseekProgressState, createProgressMcpConfig, deepseekProgressDispositions, isDeepseekProgressCompletion, progressMcpAutoInjectionSupported, progressSystemContext, } from "./progress.js";
6
9
  import { consoleLogger, wrapEngineAdapter, } from "./engine.js";
7
10
  const log = consoleLogger;
8
11
  const DEEPSEEK_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
@@ -14,6 +17,7 @@ const PROCESS_POOL = new Map();
14
17
  /** 单 daemon、单套 env 配置:池里最多一个 server。 */
15
18
  const POOL_KEY = "default";
16
19
  let exitCleanupHookInstalled = false;
20
+ const DEEPSEEK_TURN_QUEUES = new Map();
17
21
  /**
18
22
  * daemon 退出时杀掉池中 deepseek server。池是内存态的,没有这个钩子
19
23
  * daemon 重启会留下孤儿 server。首次 spawn 时惰性安装,保持模块导入无副作用。
@@ -64,6 +68,8 @@ export class DeepseekTuiAdapter {
64
68
  explicitAuthToken;
65
69
  fetchFn;
66
70
  spawnFn;
71
+ progressEventMappingEnabled;
72
+ progressPromptInjectionEnabled;
67
73
  resolvedBinary = null;
68
74
  constructor(deps = {}) {
69
75
  this.explicitBinary = deps.binary ?? process.env.BOTLEARN_DEEPSEEK_TUI_BIN;
@@ -71,6 +77,11 @@ export class DeepseekTuiAdapter {
71
77
  this.explicitAuthToken = deps.authToken ?? process.env.BOTLEARN_DEEPSEEK_TUI_TOKEN;
72
78
  this.fetchFn = deps.fetchFn ?? fetch;
73
79
  this.spawnFn = deps.spawnFn ?? spawn;
80
+ this.progressPromptInjectionEnabled =
81
+ !this.explicitServerUrl && progressMcpAutoInjectionSupported();
82
+ this.progressEventMappingEnabled = this.explicitServerUrl
83
+ ? deps.progressEventMapping === true
84
+ : this.progressPromptInjectionEnabled;
74
85
  }
75
86
  async run(opts) {
76
87
  if (opts.signal.aborted) {
@@ -80,14 +91,28 @@ export class DeepseekTuiAdapter {
80
91
  error: "deepseek-tui aborted before start",
81
92
  };
82
93
  }
83
- const handle = await this.acquireHandle(opts);
84
- handle.inFlight += 1;
85
- if (handle.idleTimer)
86
- clearTimeout(handle.idleTimer);
87
94
  const turnAbort = new AbortController();
88
95
  const onAbort = () => turnAbort.abort();
89
96
  opts.signal.addEventListener("abort", onAbort, { once: true });
97
+ if (opts.signal.aborted)
98
+ turnAbort.abort();
99
+ let handle;
100
+ let countedInFlight = false;
101
+ let releaseTurn;
90
102
  try {
103
+ // The local server has a process-level kill fallback when turn-scoped interrupt
104
+ // fails. Serialize turns so cancelling one run can never terminate another run.
105
+ const turnQueueKey = this.explicitServerUrl
106
+ ? `external:${trimTrailingSlash(this.explicitServerUrl)}`
107
+ : POOL_KEY;
108
+ releaseTurn = await acquireDeepseekTurn(turnQueueKey, turnAbort.signal);
109
+ handle = await this.acquireHandle(opts, turnAbort.signal);
110
+ if (turnAbort.signal.aborted)
111
+ throw abortReason(turnAbort.signal);
112
+ handle.inFlight += 1;
113
+ countedInFlight = true;
114
+ if (handle.idleTimer)
115
+ clearTimeout(handle.idleTimer);
91
116
  const headers = authHeaders(handle.token);
92
117
  let threadId = opts.sessionId?.trim() || "";
93
118
  if (threadId && !isValidThreadId(threadId)) {
@@ -109,12 +134,16 @@ export class DeepseekTuiAdapter {
109
134
  threadId,
110
135
  opts,
111
136
  signal: turnAbort.signal,
137
+ handle,
112
138
  });
113
139
  const text = runResult.text;
114
140
  const error = runResult.error ?? (text === "" ? emptyCompletionError(handle.stderrTail) : undefined);
115
141
  return {
116
142
  text,
117
143
  newSessionId: threadId,
144
+ ...(runResult.progressDispositions
145
+ ? { progressDispositions: runResult.progressDispositions }
146
+ : {}),
118
147
  ...(error ? { error } : {}),
119
148
  };
120
149
  }
@@ -130,9 +159,12 @@ export class DeepseekTuiAdapter {
130
159
  }
131
160
  finally {
132
161
  opts.signal.removeEventListener("abort", onAbort);
133
- handle.inFlight -= 1;
134
- if (!this.explicitServerUrl)
135
- resetIdle(handle, POOL_KEY);
162
+ if (handle && countedInFlight) {
163
+ handle.inFlight = Math.max(0, handle.inFlight - 1);
164
+ if (!this.explicitServerUrl)
165
+ resetIdle(handle, POOL_KEY);
166
+ }
167
+ releaseTurn?.();
136
168
  }
137
169
  }
138
170
  resolveBinary() {
@@ -143,7 +175,7 @@ export class DeepseekTuiAdapter {
143
175
  this.resolvedBinary = resolveDeepseekCommand() ?? "deepseek";
144
176
  return this.resolvedBinary;
145
177
  }
146
- async acquireHandle(opts) {
178
+ async acquireHandle(opts, signal) {
147
179
  if (this.explicitServerUrl) {
148
180
  return {
149
181
  child: nullChild(),
@@ -158,16 +190,38 @@ export class DeepseekTuiAdapter {
158
190
  if (existing && !existing.closed)
159
191
  return existing;
160
192
  const port = await findFreePort();
193
+ if (signal.aborted)
194
+ throw abortReason(signal);
161
195
  const token = randomToken();
162
196
  const baseUrl = `http://127.0.0.1:${port}`;
163
- const child = this.spawnFn(this.resolveBinary(), ["serve", "--http", "--host", "127.0.0.1", "--port", String(port), "--auth-token", token], {
164
- cwd: opts.cwd,
165
- env: this.spawnEnv(),
166
- stdio: ["ignore", "pipe", "pipe"],
167
- // 自成进程组:解析到的二进制可能是会再 spawn 真实 deepseek-tui
168
- // server dispatcher,shutdown 必须对整组发信号而非仅直接子进程。
169
- detached: true,
170
- });
197
+ const progressMcpConfig = this.progressPromptInjectionEnabled
198
+ ? createProgressMcpConfig()
199
+ : undefined;
200
+ let child;
201
+ try {
202
+ child = this.spawnFn(this.resolveBinary(), [
203
+ "serve",
204
+ "--http",
205
+ "--host",
206
+ "127.0.0.1",
207
+ "--port",
208
+ String(port),
209
+ "--auth-token",
210
+ token,
211
+ ], {
212
+ cwd: opts.cwd,
213
+ env: this.spawnEnv(opts, progressMcpConfig?.path),
214
+ ...runtimeChildIdentity(),
215
+ stdio: ["ignore", "pipe", "pipe"],
216
+ // 自成进程组:解析到的二进制可能是会再 spawn 真实 deepseek-tui
217
+ // server 的 dispatcher,shutdown 必须对整组发信号而非仅直接子进程。
218
+ detached: true,
219
+ });
220
+ }
221
+ catch (error) {
222
+ cleanupProgressMcpConfig(progressMcpConfig);
223
+ throw error;
224
+ }
171
225
  installExitCleanupHook();
172
226
  const handle = {
173
227
  child,
@@ -176,6 +230,7 @@ export class DeepseekTuiAdapter {
176
230
  closed: false,
177
231
  inFlight: 0,
178
232
  stderrTail: "",
233
+ progressMcpConfig,
179
234
  };
180
235
  child.stderr?.setEncoding("utf8");
181
236
  child.stderr?.on("data", (chunk) => {
@@ -183,13 +238,25 @@ export class DeepseekTuiAdapter {
183
238
  });
184
239
  child.on("close", () => {
185
240
  handle.closed = true;
186
- PROCESS_POOL.delete(POOL_KEY);
241
+ if (PROCESS_POOL.get(POOL_KEY) === handle)
242
+ PROCESS_POOL.delete(POOL_KEY);
243
+ cleanupProgressMcpConfig(handle.progressMcpConfig);
244
+ handle.progressMcpConfig = undefined;
187
245
  });
188
246
  child.on("error", () => {
189
247
  handle.closed = true;
190
- PROCESS_POOL.delete(POOL_KEY);
248
+ if (PROCESS_POOL.get(POOL_KEY) === handle)
249
+ PROCESS_POOL.delete(POOL_KEY);
250
+ cleanupProgressMcpConfig(handle.progressMcpConfig);
251
+ handle.progressMcpConfig = undefined;
191
252
  });
192
- await waitForHealth(baseUrl, this.fetchFn, child, STARTUP_TIMEOUT_MS);
253
+ try {
254
+ await waitForHealth(baseUrl, this.fetchFn, child, STARTUP_TIMEOUT_MS, signal);
255
+ }
256
+ catch (error) {
257
+ shutdownHandle(handle, "startup-failed");
258
+ throw error;
259
+ }
193
260
  PROCESS_POOL.set(POOL_KEY, handle);
194
261
  resetIdle(handle, POOL_KEY);
195
262
  return handle;
@@ -198,12 +265,15 @@ export class DeepseekTuiAdapter {
198
265
  * 不设置 DEEPSEEK_RUNTIME_DIR:server 跨 run 池化共享,per-run 目录不成立;
199
266
  * BYOA 直接用用户本机 deepseek 自身的默认状态目录(含已登录凭据)。
200
267
  */
201
- spawnEnv() {
202
- return {
203
- ...process.env,
268
+ spawnEnv(opts, progressMcpConfigPath) {
269
+ const env = {
270
+ ...runtimeChildEnv(opts.env ?? process.env),
204
271
  FORCE_COLOR: "0",
205
272
  NO_COLOR: "1",
206
273
  };
274
+ if (progressMcpConfigPath)
275
+ env.DEEPSEEK_MCP_CONFIG = progressMcpConfigPath;
276
+ return env;
207
277
  }
208
278
  async createThread(baseUrl, headers, opts, signal) {
209
279
  const body = {
@@ -219,8 +289,11 @@ export class DeepseekTuiAdapter {
219
289
  body.model = selection.model;
220
290
  if (selection.reasoningEffort)
221
291
  body.reasoning_effort = selection.reasoningEffort;
222
- if (opts.systemContext)
223
- body.system_prompt = opts.systemContext;
292
+ const systemContext = this.progressPromptInjectionEnabled
293
+ ? progressSystemContext(opts.systemContext)
294
+ : opts.systemContext;
295
+ if (systemContext)
296
+ body.system_prompt = systemContext;
224
297
  const res = await this.requestJson(`${baseUrl}/v1/threads`, {
225
298
  method: "POST",
226
299
  headers,
@@ -236,16 +309,37 @@ export class DeepseekTuiAdapter {
236
309
  await this.requestJson(`${baseUrl}/v1/threads/${encodeURIComponent(threadId)}`, {
237
310
  method: "PATCH",
238
311
  headers,
239
- body: JSON.stringify({ system_prompt: systemContext ?? "" }),
312
+ body: JSON.stringify({
313
+ system_prompt: this.progressPromptInjectionEnabled
314
+ ? progressSystemContext(systemContext)
315
+ : (systemContext ?? ""),
316
+ }),
240
317
  signal,
241
318
  });
242
319
  }
243
320
  async startTurnAndReadEvents(args) {
244
- const { baseUrl, headers, threadId, opts, signal } = args;
321
+ const { baseUrl, headers, threadId, opts, signal, handle } = args;
245
322
  // 事件流必须先于 turn 打开,否则 turn 早期事件会丢。
246
323
  const eventsUrl = `${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=0`;
247
324
  const eventsAbort = new AbortController();
248
- const onAbort = () => eventsAbort.abort();
325
+ let turnId = "";
326
+ let interruptPromise;
327
+ const interrupt = () => {
328
+ if (!turnId)
329
+ return Promise.resolve();
330
+ interruptPromise ??= this.interruptTurn(baseUrl, headers, threadId, turnId).catch((error) => {
331
+ log.warn("deepseek-tui turn interrupt failed", {
332
+ error: error instanceof Error ? error.message : String(error),
333
+ });
334
+ if (!this.explicitServerUrl)
335
+ shutdownHandle(handle, "turn-interrupt-failed");
336
+ });
337
+ return interruptPromise;
338
+ };
339
+ const onAbort = () => {
340
+ eventsAbort.abort();
341
+ void interrupt();
342
+ };
249
343
  signal.addEventListener("abort", onAbort, { once: true });
250
344
  let eventsError;
251
345
  const eventsReaderPromise = this.readEvents(eventsUrl, headers, opts, eventsAbort.signal).catch((err) => {
@@ -265,23 +359,44 @@ export class DeepseekTuiAdapter {
265
359
  body.model = selection.model;
266
360
  if (selection.reasoningEffort)
267
361
  body.reasoning_effort = selection.reasoningEffort;
362
+ if (signal.aborted)
363
+ throw abortReason(signal);
268
364
  const started = await this.requestJson(`${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns`, {
269
365
  method: "POST",
270
366
  headers,
271
367
  body: JSON.stringify(body),
272
- signal,
368
+ // Once the request is sent, keep a short independent timeout so we can obtain
369
+ // turnId and explicitly interrupt it even if the outer run is cancelled.
370
+ signal: AbortSignal.timeout(5_000),
273
371
  });
274
- const turnId = stringField(started?.turn, "id") ?? stringField(started, "turn_id") ?? "";
372
+ turnId = stringField(started?.turn, "id") ?? stringField(started, "turn_id") ?? "";
373
+ if (signal.aborted)
374
+ await interrupt();
275
375
  const eventsReader = await eventsReaderPromise;
276
376
  if (!eventsReader)
277
377
  throw eventsError ?? new Error("events stream failed");
278
378
  return await eventsReader(turnId);
279
379
  }
280
380
  finally {
381
+ if (signal.aborted) {
382
+ if (turnId)
383
+ await interrupt();
384
+ else if (!this.explicitServerUrl)
385
+ shutdownHandle(handle, "cancelled-before-turn-id");
386
+ }
281
387
  eventsAbort.abort();
282
388
  signal.removeEventListener("abort", onAbort);
283
389
  }
284
390
  }
391
+ async interruptTurn(baseUrl, headers, threadId, turnId) {
392
+ const res = await this.fetchFn(`${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns/${encodeURIComponent(turnId)}/interrupt`, {
393
+ method: "POST",
394
+ headers,
395
+ signal: AbortSignal.timeout(2_000),
396
+ });
397
+ if (!res.ok)
398
+ throw new Error(`interrupt failed HTTP ${res.status}`);
399
+ }
285
400
  async readEvents(url, headers, opts, signal) {
286
401
  const res = await this.fetchFn(url, { method: "GET", headers, signal });
287
402
  if (!res.ok)
@@ -296,6 +411,7 @@ export class DeepseekTuiAdapter {
296
411
  let text = "";
297
412
  let errorText = "";
298
413
  let capped = false;
414
+ const progressState = createDeepseekProgressState();
299
415
  const append = (chunk) => {
300
416
  if (!chunk || capped)
301
417
  return;
@@ -317,10 +433,30 @@ export class DeepseekTuiAdapter {
317
433
  if (turnId && eventTurnId && eventTurnId !== turnId)
318
434
  return false;
319
435
  seq += 1;
320
- const block = normalizeDeepseekEvent(eventName, payload, seq);
436
+ const toolStarted = eventName === "tool.started" || isToolStarted(eventName, payload);
437
+ const toolCompleted = eventName === "tool.completed" || isToolCompleted(eventName, payload);
438
+ const progressStarted = toolStarted && this.progressEventMappingEnabled
439
+ ? adaptDeepseekProgressStarted(payload, seq, progressState)
440
+ : { matched: false };
441
+ if (progressStarted.limitExceeded) {
442
+ log.warn("deepseek-tui progress event limit exceeded", {
443
+ limit: MAX_PROGRESS_EVENTS_PER_ATTEMPT,
444
+ });
445
+ }
446
+ const suppressProgressResult = this.progressEventMappingEnabled &&
447
+ toolCompleted &&
448
+ isDeepseekProgressCompletion(payload, progressState);
449
+ const block = progressStarted.matched
450
+ ? (progressStarted.block ?? null)
451
+ : suppressProgressResult
452
+ ? null
453
+ : normalizeDeepseekEvent(eventName, payload, seq);
321
454
  if (block)
322
455
  opts.onBlock?.(block);
323
- const extractedError = extractDeepseekError(eventName, payload);
456
+ // report_progress is non-authoritative telemetry: its tool failure never fails the task.
457
+ const extractedError = suppressProgressResult
458
+ ? undefined
459
+ : extractDeepseekError(eventName, payload);
324
460
  if (extractedError)
325
461
  errorText = extractedError;
326
462
  if (eventName === "message.delta") {
@@ -332,7 +468,7 @@ export class DeepseekTuiAdapter {
332
468
  if (eventName === "turn.started" || embeddedDeepseekEvent(payload) === "turn.started") {
333
469
  opts.onStatus?.({ kind: "thinking", phase: "started", label: "Thinking" });
334
470
  }
335
- else if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
471
+ else if (toolStarted && !progressStarted.matched) {
336
472
  const label = stringField(payload, "name") ??
337
473
  stringField(payload?.tool, "name") ??
338
474
  stringField(payload?.payload?.tool, "name") ??
@@ -359,7 +495,12 @@ export class DeepseekTuiAdapter {
359
495
  continue;
360
496
  if (emit(frame.event, frame.data)) {
361
497
  await reader.cancel().catch(() => undefined);
362
- return { text: text.trim(), ...(errorText ? { error: errorText } : {}) };
498
+ const progressDispositions = deepseekProgressDispositions(progressState);
499
+ return {
500
+ text: text.trim(),
501
+ ...(errorText ? { error: errorText } : {}),
502
+ ...(progressDispositions ? { progressDispositions } : {}),
503
+ };
363
504
  }
364
505
  }
365
506
  }
@@ -368,7 +509,12 @@ export class DeepseekTuiAdapter {
368
509
  if (frame)
369
510
  emit(frame.event, frame.data);
370
511
  }
371
- return { text: text.trim(), ...(errorText ? { error: errorText } : {}) };
512
+ const progressDispositions = deepseekProgressDispositions(progressState);
513
+ return {
514
+ text: text.trim(),
515
+ ...(errorText ? { error: errorText } : {}),
516
+ ...(progressDispositions ? { progressDispositions } : {}),
517
+ };
372
518
  };
373
519
  }
374
520
  async requestJson(url, init) {
@@ -395,6 +541,7 @@ export function __resetDeepseekTuiPoolForTests() {
395
541
  shutdownHandle(handle, "test-reset");
396
542
  PROCESS_POOL.delete(key);
397
543
  }
544
+ DEEPSEEK_TURN_QUEUES.clear();
398
545
  }
399
546
  function normalizeDeepseekEvent(eventName, payload, seq) {
400
547
  if (eventName === "message.delta") {
@@ -490,11 +637,11 @@ function extractDeepseekError(eventName, payload) {
490
637
  stringField(payload?.payload, "message") ??
491
638
  stringField(payload?.payload, "error"));
492
639
  }
493
- if (eventName === "item.failed") {
494
- return (stringField(payload?.payload?.item, "detail") ??
495
- stringField(payload?.payload?.item, "summary") ??
496
- stringField(payload?.payload, "error"));
497
- }
640
+ // An item failure is a tool result inside an otherwise live turn. DeepSeek may use it
641
+ // to correct the arguments, choose another tool, and still complete the turn. Treating
642
+ // its detail as a sticky runtime error would discard that recovery and fail the run
643
+ // even after a later assistant message or successful turn completion. Only provider
644
+ // errors and terminal turn failures are run-level errors here.
498
645
  if (isDeepseekTerminalEvent(eventName, payload)) {
499
646
  const turn = payload?.payload?.turn ?? payload?.turn;
500
647
  const status = stringField(turn, "status");
@@ -564,6 +711,47 @@ function nextArgValue(args, index) {
564
711
  return next;
565
712
  return /^-\d/.test(next) ? next : undefined;
566
713
  }
714
+ async function acquireDeepseekTurn(key, signal) {
715
+ let released = false;
716
+ let releaseGate;
717
+ const gate = new Promise((resolve) => {
718
+ releaseGate = resolve;
719
+ });
720
+ const previous = DEEPSEEK_TURN_QUEUES.get(key) ?? Promise.resolve();
721
+ const tail = previous.then(() => gate);
722
+ DEEPSEEK_TURN_QUEUES.set(key, tail);
723
+ void tail.then(() => {
724
+ if (DEEPSEEK_TURN_QUEUES.get(key) === tail)
725
+ DEEPSEEK_TURN_QUEUES.delete(key);
726
+ });
727
+ const release = () => {
728
+ if (released)
729
+ return;
730
+ released = true;
731
+ releaseGate();
732
+ };
733
+ let rejectAbort;
734
+ const aborted = new Promise((_resolve, reject) => {
735
+ rejectAbort = reject;
736
+ });
737
+ const onAbort = () => rejectAbort(abortReason(signal));
738
+ signal.addEventListener("abort", onAbort, { once: true });
739
+ if (signal.aborted)
740
+ onAbort();
741
+ try {
742
+ await Promise.race([previous, aborted]);
743
+ if (signal.aborted)
744
+ throw abortReason(signal);
745
+ return release;
746
+ }
747
+ catch (error) {
748
+ release();
749
+ throw error;
750
+ }
751
+ finally {
752
+ signal.removeEventListener("abort", onAbort);
753
+ }
754
+ }
567
755
  function resetIdle(handle, key) {
568
756
  if (handle.idleTimer)
569
757
  clearTimeout(handle.idleTimer);
@@ -584,6 +772,8 @@ function shutdownHandle(handle, reason) {
584
772
  handle.closed = true;
585
773
  if (handle.idleTimer)
586
774
  clearTimeout(handle.idleTimer);
775
+ cleanupProgressMcpConfig(handle.progressMcpConfig);
776
+ handle.progressMcpConfig = undefined;
587
777
  try {
588
778
  const pid = handle.child.pid;
589
779
  if (typeof pid === "number" && pid > 0) {
@@ -614,20 +804,24 @@ function shutdownHandle(handle, reason) {
614
804
  }
615
805
  log.debug("deepseek-tui.shutdown", { reason });
616
806
  }
617
- async function waitForHealth(baseUrl, fetchFn, child, timeoutMs) {
807
+ async function waitForHealth(baseUrl, fetchFn, child, timeoutMs, signal) {
618
808
  const deadline = Date.now() + timeoutMs;
619
809
  let lastError = "";
620
810
  while (Date.now() < deadline) {
811
+ if (signal.aborted)
812
+ throw abortReason(signal);
621
813
  if (child.exitCode !== null) {
622
814
  throw new Error(`deepseek serve exited with code ${child.exitCode}`);
623
815
  }
624
816
  try {
625
- const res = await fetchFn(`${baseUrl}/health`, { method: "GET" });
817
+ const res = await fetchFn(`${baseUrl}/health`, { method: "GET", signal });
626
818
  if (res.ok)
627
819
  return;
628
820
  lastError = `HTTP ${res.status}`;
629
821
  }
630
822
  catch (err) {
823
+ if (signal.aborted)
824
+ throw abortReason(signal);
631
825
  lastError = err instanceof Error ? err.message : String(err);
632
826
  }
633
827
  await sleep(STARTUP_POLL_MS);
@@ -655,6 +849,9 @@ function randomToken() {
655
849
  function sleep(ms) {
656
850
  return new Promise((resolve) => setTimeout(resolve, ms));
657
851
  }
852
+ function abortReason(signal) {
853
+ return signal.reason instanceof Error ? signal.reason : new Error("deepseek-tui aborted");
854
+ }
658
855
  function stringField(obj, key) {
659
856
  const v = obj?.[key];
660
857
  return typeof v === "string" ? v : undefined;
@@ -1,15 +1,23 @@
1
- import { type CourseRuntime, type RuntimeFailureSummary } from "../types.js";
1
+ import { type CourseRuntime, type RuntimeFailureSummary, type RuntimeProgressDispositions } from "../types.js";
2
2
  import type { Logger } from "../log.js";
3
+ import type { ProgressReport } from "../mcp/report-progress.js";
3
4
  /**
4
5
  * 内部引擎契约:CLI/ACP 基类实现的是 pull-style options + 回调,
5
6
  * 由 wrapEngineAdapter 折叠成对外的 CourseRuntime(sink 语义)。
6
7
  */
7
- /** 底层 CLI 逐事件归一化出的块。seq 为本轮 1-based 单调递增。 */
8
- export interface StreamBlock {
8
+ export interface ContentStreamBlock {
9
9
  raw: unknown;
10
10
  kind: "assistant_text" | "tool_use" | "tool_result" | "system" | "thinking" | "other";
11
11
  seq: number;
12
12
  }
13
+ /** provider 已严格校验的进度块;禁止携带原始 tool envelope。 */
14
+ export interface ProgressStreamBlock {
15
+ kind: "progress";
16
+ seq: number;
17
+ progress: ProgressReport;
18
+ }
19
+ /** 底层 CLI 逐事件归一化出的块。seq 为本轮 1-based 单调递增。 */
20
+ export type StreamBlock = ContentStreamBlock | ProgressStreamBlock;
13
21
  export type RuntimeStatusEvent = {
14
22
  kind: "typing";
15
23
  phase: "started" | "stopped";
@@ -29,11 +37,14 @@ export interface EngineRunOptions {
29
37
  systemContext?: string;
30
38
  onBlock?: (block: StreamBlock) => void;
31
39
  onStatus?: (event: RuntimeStatusEvent) => void;
40
+ env?: NodeJS.ProcessEnv;
32
41
  }
33
42
  export interface EngineRunResult {
34
43
  text: string;
35
44
  newSessionId: string;
36
45
  costUsd?: number;
46
+ /** adapter 自身在 emit 前丢弃的进度计数;不包含 accepted,避免 dispatcher 重复计数。 */
47
+ progressDispositions?: RuntimeProgressDispositions;
37
48
  /** 非空表示硬失败;由包装层折叠为 RuntimeExecutionError。 */
38
49
  error?: string;
39
50
  runtimeFailure?: Partial<RuntimeFailureSummary>;
@@ -100,24 +100,51 @@ export function wrapEngineAdapter(id, engine, opts) {
100
100
  ...(model ? modelArgs(model) : []),
101
101
  ...selectionArgs,
102
102
  ];
103
+ let blockChain = Promise.resolve();
104
+ let progressBlockFailed = false;
105
+ let progressBlockError;
106
+ const queueBlock = (block, propagateFailure = false) => {
107
+ blockChain = blockChain.then(() => sink.block(block)).catch((err) => {
108
+ if (propagateFailure && !progressBlockFailed) {
109
+ progressBlockFailed = true;
110
+ progressBlockError = err;
111
+ }
112
+ consoleLogger.debug(`${id} sink.block failed`, { err: String(err) });
113
+ });
114
+ };
103
115
  const result = await engine.run({
104
116
  text,
105
- sessionId: null,
117
+ sessionId: run.nativeSessionId ?? null,
106
118
  cwd: run.workspaceDir,
107
119
  signal,
120
+ ...(run.runtimeEnv ? { env: run.runtimeEnv } : {}),
108
121
  ...(extraArgs.length > 0 ? { extraArgs } : {}),
109
122
  ...(systemContext !== undefined ? { systemContext } : {}),
110
123
  onBlock: (block) => {
124
+ if (block.kind === "progress") {
125
+ queueBlock({
126
+ kind: "progress",
127
+ runtime: id,
128
+ summary: block.progress.summary,
129
+ status: block.progress.status,
130
+ }, true);
131
+ return;
132
+ }
111
133
  const kind = BLOCK_KIND_MAP[block.kind] ?? "status";
112
- // 上报失败不打断 runtime 执行。
113
- void sink.block({ kind, raw: block.raw }).catch((err) => {
114
- consoleLogger.debug(`${id} sink.block failed`, { err: String(err) });
115
- });
134
+ queueBlock({ kind, raw: block.raw });
116
135
  },
117
136
  onStatus: (event) => {
118
137
  consoleLogger.debug(`${id} status`, { kind: event.kind, phase: event.phase });
119
138
  },
120
139
  });
140
+ // Preserve provider event order through durable run.block before the final run.message.
141
+ await blockChain;
142
+ if (progressBlockFailed)
143
+ throw progressBlockError;
144
+ if (result.progressDispositions) {
145
+ await sink.progressDispositions?.(result.progressDispositions);
146
+ }
147
+ await sink.runtimeSession?.(result.newSessionId);
121
148
  if (result.error) {
122
149
  throw new RuntimeExecutionError(result.error, "runtime_error", result.runtimeFailure);
123
150
  }
@@ -41,7 +41,7 @@ export declare class HermesAgentAdapter extends AcpRuntimeAdapter {
41
41
  * 用户 `~/.hermes` 的 `.env` / config.yaml 里。
42
42
  */
43
43
  protected buildArgs(_opts: EngineRunOptions): string[];
44
- protected spawnEnv(_opts: EngineRunOptions): NodeJS.ProcessEnv;
44
+ protected spawnEnv(opts: EngineRunOptions): NodeJS.ProcessEnv;
45
45
  /** spawn 前把 systemContext 原子写入 `<cwd>/AGENTS.md`(tmp 0600 + rename)。 */
46
46
  protected prepareTurn(opts: EngineRunOptions): void;
47
47
  /**
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { AcpRuntimeAdapter, } from "./acp-stream.js";
4
4
  import { firstExistingPath, readCommandVersion, resolveCommandOnPath, resolveHomePath, } from "./probe.js";
5
5
  import { wrapEngineAdapter, } from "./engine.js";
6
+ import { runtimeChildEnv } from "../runtime-env.js";
6
7
  /**
7
8
  * `hermes-acp` 不在 PATH 上时的已知绝对位置。上游 `scripts/install.sh`
8
9
  * (curl|bash 安装器)把私有 virtualenv 装到 `~/.hermes/hermes-agent/venv/`,
@@ -80,9 +81,9 @@ export class HermesAgentAdapter extends AcpRuntimeAdapter {
80
81
  buildArgs(_opts) {
81
82
  return [];
82
83
  }
83
- spawnEnv(_opts) {
84
+ spawnEnv(opts) {
84
85
  return {
85
- ...process.env,
86
+ ...runtimeChildEnv(opts.env ?? process.env),
86
87
  // 保持 ACP stdout 无 ANSI 码。
87
88
  NO_COLOR: "1",
88
89
  // 危险工具调用走 ACP request_permission。
@@ -46,6 +46,6 @@ export declare abstract class NdjsonStreamAdapter implements EngineAdapter {
46
46
  protected abstract buildArgs(opts: EngineRunOptions): string[];
47
47
  protected abstract handleEvent(obj: unknown, ctx: NdjsonEventCtx): void;
48
48
  /** 覆盖以调整 env(FORCE_COLOR=0、NO_COLOR=1 等)。 */
49
- protected spawnEnv(_opts: EngineRunOptions): NodeJS.ProcessEnv;
49
+ protected spawnEnv(opts: EngineRunOptions): NodeJS.ProcessEnv;
50
50
  run(opts: EngineRunOptions): Promise<EngineRunResult>;
51
51
  }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { runtimeChildEnv, runtimeChildIdentity } from "../runtime-env.js";
2
3
  import { safeCommand, sanitizeRuntimeFailureText, tailText } from "../redaction.js";
3
4
  import { sliceUtf8Bytes, utf8ByteLength } from "./text-cap.js";
4
5
  import { consoleLogger, } from "./engine.js";
@@ -21,8 +22,8 @@ export class NdjsonStreamAdapter {
21
22
  this.log = logger ?? consoleLogger;
22
23
  }
23
24
  /** 覆盖以调整 env(FORCE_COLOR=0、NO_COLOR=1 等)。 */
24
- spawnEnv(_opts) {
25
- return { ...process.env };
25
+ spawnEnv(opts) {
26
+ return runtimeChildEnv(opts.env ?? process.env);
26
27
  }
27
28
  async run(opts) {
28
29
  if (opts.signal.aborted) {
@@ -43,6 +44,7 @@ export class NdjsonStreamAdapter {
43
44
  const child = spawn(binary, args, {
44
45
  cwd: opts.cwd,
45
46
  env: this.spawnEnv(opts),
47
+ ...runtimeChildIdentity(),
46
48
  stdio: ["ignore", "pipe", "pipe"],
47
49
  });
48
50
  // spawn 是同步的,但若在 spawn 与稍后挂监听之间发生 abort 会被漏掉,