@botlearn-course/daemon 0.0.5 → 0.0.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.
package/README.md CHANGED
@@ -40,7 +40,9 @@ npx @botlearn-course/daemon@latest course logout
40
40
  `botlearn-sandbox-supervisor agent-service session`。它不是 BYOA 用户入口:生产环境只允许由
41
41
  Agent Service 以固定 argv 启动,通过 stdin 接收一次性 bootstrap,并把 daemon/runtime 分别降权
42
42
  到 `botlearn-control`/`user` UID。E2B Template 通过 root-owned 固定 launcher 将 DeepSeek
43
- 单向降权为 `user` 并设置 `no_new_privs`;runtime 用户本身没有 sudo 权限。
43
+ 单向降权为 `user` 并设置 `no_new_privs`;runtime 用户本身没有 sudo 权限。托管启动链只执行
44
+ `/opt` 下的固定 Node、supervisor、daemon、launcher 与 DeepSeek 文件,不信任 E2B 会开放给 runtime
45
+ 写入的 `/usr/local/bin`。
44
46
  `agent-service session --bootstrap-stdin` 同样属于受 supervisor 保护的内部协议,不应直接暴露给
45
47
  课程任务或 workspace shell。
46
48
 
@@ -103,6 +105,9 @@ runtime 凭据。
103
105
  (SIGTERM → SIGKILL)。
104
106
  - `report_progress` MCP 子进程以 clean environment 启动,不继承 Course 控制字段或模型凭据;
105
107
  transcript 与 Course event 只保留严格归一化后的 `summary` / `status`,不保存原始 tool arguments。
108
+ - 浏览器可见执行流使用 `agent-stream/0.1`:assistant 正文按 100ms/512 字符合并上报;
109
+ reasoning 只报告阶段,工具只报告安全名称和生命周期。raw chain-of-thought、工具参数、工具结果
110
+ 和完整命令输出不会进入 Course event。
106
111
  - `course logout` 只删除本机凭据;要让服务端立即吊销该 daemon,请在前端 daemon 列表执行
107
112
  revoke。
108
113
  - 本包 production 依赖为零,发布 tarball 只包含 `dist/`、`README.md`、`package.json`、
@@ -94,7 +94,10 @@ function loadState(sessionId, token) {
94
94
  return {
95
95
  sessionGeneration: Number(value.sessionGeneration ?? 0),
96
96
  nextOutboundSeq: Number(value.nextOutboundSeq ?? 0),
97
- reconnectToken: typeof value.reconnectToken === "string" ? value.reconnectToken : token,
97
+ // A controller launch always supplies a freshly minted, session-scoped bootstrap
98
+ // token. Preserve runtime/command continuity from disk, but never let an expired
99
+ // persisted reconnect token shadow the controller's recovery credential.
100
+ reconnectToken: token,
98
101
  completedCommands: Array.isArray(value.completedCommands)
99
102
  ? value.completedCommands.filter((item) => typeof item === "string")
100
103
  : [],
@@ -18,10 +18,28 @@ const MAX_TIMEOUT_SECONDS = 7200;
18
18
  const DEFAULT_MAX_OUTPUT_CHARS = 100_000;
19
19
  // wire 上单块文本上限;完整文本在本地 transcript。
20
20
  const BLOCK_TEXT_MAX_CHARS = 4000;
21
+ const CONTENT_FLUSH_MAX_CHARS = 512;
22
+ const CONTENT_FLUSH_INTERVAL_MS = 100;
23
+ const AGENT_STREAM_SCHEMA_VERSION = "agent-stream/0.1";
24
+ const SAFE_TOOL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,79}$/;
21
25
  function clampTimeoutSeconds(value) {
22
26
  const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_TIMEOUT_SECONDS;
23
27
  return Math.min(MAX_TIMEOUT_SECONDS, Math.max(MIN_TIMEOUT_SECONDS, n));
24
28
  }
29
+ function normalizeToolName(value) {
30
+ if (typeof value !== "string")
31
+ return undefined;
32
+ const name = value.trim();
33
+ return SAFE_TOOL_NAME.test(name) ? name : undefined;
34
+ }
35
+ function chunkUnicodeText(value, maxCodePoints) {
36
+ const codePoints = Array.from(value);
37
+ const chunks = [];
38
+ for (let offset = 0; offset < codePoints.length; offset += maxCodePoints) {
39
+ chunks.push(codePoints.slice(offset, offset + maxCodePoints).join(""));
40
+ }
41
+ return chunks;
42
+ }
25
43
  /**
26
44
  * Run dispatcher:把 Course Service 下发的 run.start 交给 runtime,
27
45
  * 并把 runtime 输出归一化成 run.block / run.message / run.completed 回报 Course Service。
@@ -110,6 +128,7 @@ export class RunDispatcher {
110
128
  let terminalSent = false;
111
129
  let timedOut = false;
112
130
  let timer;
131
+ let contentFlushTimer;
113
132
  let profileApplied = false;
114
133
  let toolCalls = 0;
115
134
  let toolLimitExceeded = false;
@@ -265,8 +284,80 @@ export class RunDispatcher {
265
284
  controller.abort();
266
285
  }, timeoutSeconds * 1000);
267
286
  let finalText = "";
268
- // 上一次真正上了 wire 的块 kind:thinking/status 只在 kind 切换时上报一次,避免刷屏。
287
+ // 上一次真正上了 wire 的块 kind:status 只在 kind 切换时上报一次,避免刷屏。
269
288
  let lastReportedKind = null;
289
+ let lastReasoningPhase = null;
290
+ let pendingContent = "";
291
+ let streamEventFailure;
292
+ let streamEventChain = Promise.resolve();
293
+ const queueStreamEvent = (event) => {
294
+ const operation = streamEventChain.then(async () => {
295
+ if (streamEventFailure)
296
+ throw streamEventFailure;
297
+ await send(event);
298
+ });
299
+ streamEventChain = operation.catch((error) => {
300
+ streamEventFailure ??= error;
301
+ });
302
+ return operation;
303
+ };
304
+ const awaitStreamEvents = async () => {
305
+ await streamEventChain;
306
+ if (streamEventFailure)
307
+ throw streamEventFailure;
308
+ };
309
+ const flushContent = async () => {
310
+ if (contentFlushTimer !== undefined) {
311
+ clearTimeout(contentFlushTimer);
312
+ contentFlushTimer = undefined;
313
+ }
314
+ if (!pendingContent) {
315
+ await awaitStreamEvents();
316
+ return;
317
+ }
318
+ const content = pendingContent;
319
+ pendingContent = "";
320
+ for (const text of chunkUnicodeText(redactSecretString(content), BLOCK_TEXT_MAX_CHARS)) {
321
+ if (!text)
322
+ continue;
323
+ await queueStreamEvent({
324
+ type: "run.block",
325
+ text,
326
+ payload: {
327
+ schema_version: AGENT_STREAM_SCHEMA_VERSION,
328
+ kind: "text_delta",
329
+ runtime: runtime.id,
330
+ },
331
+ });
332
+ }
333
+ await awaitStreamEvents();
334
+ };
335
+ const scheduleContentFlush = () => {
336
+ if (contentFlushTimer !== undefined)
337
+ return;
338
+ contentFlushTimer = setTimeout(() => {
339
+ contentFlushTimer = undefined;
340
+ void flushContent().catch((error) => {
341
+ streamEventFailure ??= error;
342
+ });
343
+ }, CONTENT_FLUSH_INTERVAL_MS);
344
+ };
345
+ const sendReasoningPhase = async (phase) => {
346
+ if (phase === "completed" && lastReasoningPhase !== "in_progress")
347
+ return;
348
+ if (phase === lastReasoningPhase)
349
+ return;
350
+ lastReasoningPhase = phase;
351
+ await queueStreamEvent({
352
+ type: "run.block",
353
+ payload: {
354
+ schema_version: AGENT_STREAM_SCHEMA_VERSION,
355
+ kind: "reasoning",
356
+ runtime: runtime.id,
357
+ phase,
358
+ },
359
+ });
360
+ };
270
361
  const sink = {
271
362
  progressDispositions: async (dispositions) => {
272
363
  progressDisposition.invalid += dispositions.invalid;
@@ -287,6 +378,7 @@ export class RunDispatcher {
287
378
  },
288
379
  block: async (block) => {
289
380
  if (block.kind === "progress") {
381
+ await flushContent();
290
382
  const normalized = tryNormalizeProgressReport({
291
383
  summary: block.summary,
292
384
  status: block.status,
@@ -363,19 +455,87 @@ export class RunDispatcher {
363
455
  }
364
456
  if (serverTerminal)
365
457
  return;
366
- const perBlock = block.kind === "tool_call" || block.kind === "tool_result" || block.kind === "error";
367
- const onTransition = (block.kind === "thinking" || block.kind === "status") &&
368
- block.kind !== lastReportedKind;
369
- if (!perBlock && !onTransition)
458
+ if (block.kind === "text_delta") {
459
+ if (!block.text)
460
+ return;
461
+ pendingContent += block.text;
462
+ if (pendingContent.length >= CONTENT_FLUSH_MAX_CHARS) {
463
+ await flushContent();
464
+ }
465
+ else {
466
+ scheduleContentFlush();
467
+ }
370
468
  return;
371
- lastReportedKind = block.kind;
372
- await send({
373
- type: "run.block",
374
- text: redactSecretString(truncateText(block.text ?? "", BLOCK_TEXT_MAX_CHARS)),
375
- payload: { kind: block.kind, runtime: runtime.id },
376
- });
469
+ }
470
+ await flushContent();
471
+ if (block.kind === "thinking") {
472
+ await sendReasoningPhase(block.phase ?? "in_progress");
473
+ return;
474
+ }
475
+ if (block.kind === "status") {
476
+ if (lastReportedKind === "status")
477
+ return;
478
+ lastReportedKind = "status";
479
+ await queueStreamEvent({
480
+ type: "run.block",
481
+ payload: {
482
+ schema_version: AGENT_STREAM_SCHEMA_VERSION,
483
+ kind: "status",
484
+ runtime: runtime.id,
485
+ phase: "executing",
486
+ },
487
+ });
488
+ return;
489
+ }
490
+ if (block.kind === "tool_call") {
491
+ lastReportedKind = block.kind;
492
+ lastReasoningPhase = null;
493
+ await queueStreamEvent({
494
+ type: "run.block",
495
+ payload: {
496
+ schema_version: AGENT_STREAM_SCHEMA_VERSION,
497
+ kind: "tool_call",
498
+ runtime: runtime.id,
499
+ status: "started",
500
+ ...(normalizeToolName(block.name)
501
+ ? { name: normalizeToolName(block.name) }
502
+ : {}),
503
+ },
504
+ });
505
+ return;
506
+ }
507
+ if (block.kind === "tool_result") {
508
+ lastReportedKind = block.kind;
509
+ lastReasoningPhase = null;
510
+ await queueStreamEvent({
511
+ type: "run.block",
512
+ payload: {
513
+ schema_version: AGENT_STREAM_SCHEMA_VERSION,
514
+ kind: "tool_result",
515
+ runtime: runtime.id,
516
+ status: block.status === "error" ? "error" : "completed",
517
+ ...(normalizeToolName(block.name)
518
+ ? { name: normalizeToolName(block.name) }
519
+ : {}),
520
+ },
521
+ });
522
+ return;
523
+ }
524
+ if (block.kind === "error") {
525
+ lastReportedKind = block.kind;
526
+ await queueStreamEvent({
527
+ type: "run.block",
528
+ payload: {
529
+ schema_version: AGENT_STREAM_SCHEMA_VERSION,
530
+ kind: "error",
531
+ runtime: runtime.id,
532
+ status: "error",
533
+ },
534
+ });
535
+ }
377
536
  },
378
537
  message: async (text) => {
538
+ await flushContent();
379
539
  finalText = text;
380
540
  },
381
541
  file: async (file) => {
@@ -483,6 +643,8 @@ export class RunDispatcher {
483
643
  finally {
484
644
  if (timer !== undefined)
485
645
  clearTimeout(timer);
646
+ if (contentFlushTimer !== undefined)
647
+ clearTimeout(contentFlushTimer);
486
648
  if (profileApplied)
487
649
  cleanupRunRuntimeProfile(runId);
488
650
  if (Object.values(progressDisposition).some((count) => count > 0)) {
@@ -20,7 +20,8 @@ const AGENT_SERVICE_SUPERVISOR_ENV_KEYS = [
20
20
  ];
21
21
  const RUNTIME_USER_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
22
22
  const RUNTIME_SUDO_BINARY = "/usr/bin/sudo";
23
- const MANAGED_RUNTIME_LAUNCHER = "/usr/local/bin/botlearn-runtime-launcher";
23
+ const MANAGED_RUNTIME_LAUNCHER = "/opt/botlearn/bin/botlearn-runtime-launcher";
24
+ const MANAGED_RUNTIME_BINARY = "/opt/deepseek-tui/0.8.39/bin/deepseek";
24
25
  /** Remove Course control-plane coordinates before any model/runtime child is created. */
25
26
  export function clearAgentServiceControlEnv(env = process.env) {
26
27
  for (const key of AGENT_SERVICE_CONTROL_ENV_KEYS)
@@ -72,6 +73,9 @@ export function runtimeChildLaunch(binary, args, env = process.env) {
72
73
  if (env.BOTLEARN_RUNTIME_LAUNCHER !== MANAGED_RUNTIME_LAUNCHER) {
73
74
  throw new Error("invalid supervisor-provided runtime launcher");
74
75
  }
76
+ if (binary !== MANAGED_RUNTIME_BINARY) {
77
+ throw new Error("invalid supervisor-provided runtime binary");
78
+ }
75
79
  return {
76
80
  binary: RUNTIME_SUDO_BINARY,
77
81
  args: [
@@ -324,22 +324,33 @@ function claudeStatusEvent(obj) {
324
324
  }
325
325
  function normalizeBlock(obj, seq) {
326
326
  let kind = "other";
327
+ const contents = Array.isArray(obj?.message?.content) ? obj.message.content : [];
327
328
  if (obj?.type === "assistant") {
328
- const contents = Array.isArray(obj.message?.content) ? obj.message.content : [];
329
329
  if (contents.some((c) => c?.type === "tool_use"))
330
330
  kind = "tool_use";
331
331
  else if (contents.some((c) => c?.type === "text"))
332
332
  kind = "assistant_text";
333
333
  }
334
334
  else if (obj?.type === "user") {
335
- const contents = Array.isArray(obj.message?.content) ? obj.message.content : [];
336
335
  if (contents.some((c) => c?.type === "tool_result"))
337
336
  kind = "tool_result";
338
337
  }
339
338
  else if (obj?.type === "system") {
340
339
  kind = "system";
341
340
  }
342
- return { raw: obj, kind, seq };
341
+ const tool = contents.find((c) => c?.type === "tool_use");
342
+ const text = contents
343
+ .filter((c) => c?.type === "text" && typeof c.text === "string")
344
+ .map((c) => c.text)
345
+ .join("");
346
+ return {
347
+ raw: obj,
348
+ kind,
349
+ seq,
350
+ ...(kind === "assistant_text" && text ? { text } : {}),
351
+ ...(kind === "tool_use" && typeof tool?.name === "string" ? { name: tool.name } : {}),
352
+ ...(kind === "tool_result" ? { status: "completed" } : {}),
353
+ };
343
354
  }
344
355
  export const claudeCodeModule = {
345
356
  id: "claude-code",
@@ -318,7 +318,23 @@ function normalizeBlock(obj, seq) {
318
318
  kind = type === "item.completed" ? "tool_result" : "tool_use";
319
319
  }
320
320
  }
321
- return { raw: obj, kind, seq };
321
+ const toolName = itemType === "mcp_tool_call"
322
+ ? obj?.item?.tool ?? obj?.item?.name ?? itemType
323
+ : itemType;
324
+ return {
325
+ raw: obj,
326
+ kind,
327
+ seq,
328
+ ...(kind === "assistant_text" && typeof obj?.item?.text === "string"
329
+ ? { text: obj.item.text }
330
+ : {}),
331
+ ...((kind === "tool_use" || kind === "tool_result") && typeof toolName === "string"
332
+ ? { name: toolName }
333
+ : {}),
334
+ ...(kind === "tool_result"
335
+ ? { status: obj?.item?.status === "failed" ? "error" : "completed" }
336
+ : {}),
337
+ };
322
338
  }
323
339
  export const codexModule = {
324
340
  id: "codex",
@@ -7,6 +7,18 @@ import { runtimeChildEnv, runtimeChildLaunch } from "../runtime-env.js";
7
7
  import { readCommandVersion, resolveCommandOnPath } from "./probe.js";
8
8
  import { adaptDeepseekProgressStarted, cleanupProgressMcpConfig, createDeepseekProgressState, createProgressMcpConfig, deepseekProgressDispositions, isDeepseekProgressCompletion, progressMcpAutoInjectionSupported, progressSystemContext, } from "./progress.js";
9
9
  import { consoleLogger, wrapEngineAdapter, } from "./engine.js";
10
+ class DeepseekHttpError extends Error {
11
+ status;
12
+ constructor(status, detail = "", operation = "") {
13
+ super(`${operation ? `${operation} ` : ""}HTTP ${status}${detail ? `: ${detail.slice(0, 300)}` : ""}`);
14
+ this.status = status;
15
+ this.name = "DeepseekHttpError";
16
+ }
17
+ }
18
+ function isMissingThreadHttpError(error) {
19
+ return (error instanceof DeepseekHttpError
20
+ && (error.status === 404 || error.status === 410));
21
+ }
10
22
  const log = consoleLogger;
11
23
  const DEEPSEEK_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
12
24
  const STARTUP_TIMEOUT_MS = 30_000;
@@ -150,8 +162,8 @@ export class DeepseekTuiAdapter {
150
162
  }
151
163
  catch (err) {
152
164
  const message = err instanceof Error ? err.message : String(err);
153
- // 服务端已丢失该线程(重启、GC)→ 清空 sessionId 让下次重建。
154
- const staleSession = opts.sessionId && /404|not found|missing/i.test(message);
165
+ // 服务端明确确认线程不存在/已过期时才清空 sessionId,让下一轮从 durable context 重建。
166
+ const staleSession = Boolean(opts.sessionId) && isMissingThreadHttpError(err);
155
167
  return {
156
168
  text: "",
157
169
  newSessionId: staleSession ? "" : (opts.sessionId ?? ""),
@@ -404,7 +416,7 @@ export class DeepseekTuiAdapter {
404
416
  async readEvents(url, headers, opts, signal) {
405
417
  const res = await this.fetchFn(url, { method: "GET", headers, signal });
406
418
  if (!res.ok)
407
- throw new Error(`events stream failed HTTP ${res.status}`);
419
+ throw new DeepseekHttpError(res.status, "", "events stream failed");
408
420
  if (!res.body)
409
421
  throw new Error("events stream response missing body");
410
422
  const reader = res.body.getReader();
@@ -538,7 +550,7 @@ export class DeepseekTuiAdapter {
538
550
  catch {
539
551
  // ignore
540
552
  }
541
- throw new Error(`HTTP ${res.status}${detail ? `: ${detail.slice(0, 300)}` : ""}`);
553
+ throw new DeepseekHttpError(res.status, detail);
542
554
  }
543
555
  return (await res.json());
544
556
  }
@@ -553,16 +565,37 @@ export function __resetDeepseekTuiPoolForTests() {
553
565
  }
554
566
  function normalizeDeepseekEvent(eventName, payload, seq) {
555
567
  if (eventName === "message.delta") {
556
- return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
568
+ return {
569
+ raw: { event: eventName, payload },
570
+ kind: "assistant_text",
571
+ seq,
572
+ text: stringField(payload, "content") ?? "",
573
+ };
557
574
  }
558
575
  if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
559
- return { raw: { event: eventName, payload }, kind: "tool_use", seq };
576
+ return {
577
+ raw: { event: eventName, payload },
578
+ kind: "tool_use",
579
+ seq,
580
+ ...(deepseekToolName(payload) ? { name: deepseekToolName(payload) } : {}),
581
+ };
560
582
  }
561
583
  if (eventName === "tool.completed" || isToolCompleted(eventName, payload)) {
562
- return { raw: { event: eventName, payload }, kind: "tool_result", seq };
584
+ return {
585
+ raw: { event: eventName, payload },
586
+ kind: "tool_result",
587
+ seq,
588
+ ...(deepseekToolName(payload) ? { name: deepseekToolName(payload) } : {}),
589
+ status: deepseekToolFailed(payload) ? "error" : "completed",
590
+ };
563
591
  }
564
592
  if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
565
- return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
593
+ return {
594
+ raw: { event: eventName, payload },
595
+ kind: "assistant_text",
596
+ seq,
597
+ text: extractDeepseekDelta(payload),
598
+ };
566
599
  }
567
600
  if (eventName === "item.completed" && isAgentReasoningItem(payload)) {
568
601
  return { raw: { event: eventName, payload }, kind: "thinking", seq };
@@ -674,6 +707,19 @@ function inferDeepseekToolName(item) {
674
707
  }
675
708
  return undefined;
676
709
  }
710
+ function deepseekToolName(payload) {
711
+ return (stringField(payload, "name")
712
+ ?? stringField(payload?.tool, "name")
713
+ ?? stringField(payload?.payload?.tool, "name")
714
+ ?? inferDeepseekToolName(payload?.item ?? payload?.payload?.item));
715
+ }
716
+ function deepseekToolFailed(payload) {
717
+ const status = (stringField(payload, "status")
718
+ ?? stringField(payload?.tool, "status")
719
+ ?? stringField(payload?.payload?.tool, "status")
720
+ ?? "").toLowerCase();
721
+ return status.includes("fail") || status.includes("error");
722
+ }
677
723
  function emptyCompletionError(stderrTail) {
678
724
  const tail = stderrTail.trim();
679
725
  if (!tail) {
@@ -9,6 +9,11 @@ export interface ContentStreamBlock {
9
9
  raw: unknown;
10
10
  kind: "assistant_text" | "tool_use" | "tool_result" | "system" | "thinking" | "other";
11
11
  seq: number;
12
+ /** Assistant-visible text only. Provider raw payloads must never be used as browser text. */
13
+ text?: string;
14
+ /** Safe provider-normalized tool identifier; arguments and results remain private. */
15
+ name?: string;
16
+ status?: "completed" | "error";
12
17
  }
13
18
  /** provider 已严格校验的进度块;禁止携带原始 tool envelope。 */
14
19
  export interface ProgressStreamBlock {
@@ -88,7 +88,12 @@ export function wrapEngineAdapter(id, engine, opts) {
88
88
  id,
89
89
  async run(run, sink, signal) {
90
90
  const payload = run.payload;
91
- const text = renderConversationInput(payload);
91
+ // DeepSeek persists and replays the native thread history. The durable Course Service
92
+ // conversation is recovery/bootstrap data, not a second history to inject on every turn.
93
+ const resumesDeepseekThread = id === "deepseek-tui" && Boolean(run.nativeSessionId?.trim());
94
+ const text = resumesDeepseekThread
95
+ ? (payload.input.text ?? "")
96
+ : renderConversationInput(payload);
92
97
  if (!text.trim()) {
93
98
  throw new RuntimeExecutionError("empty task brief");
94
99
  }
@@ -131,10 +136,22 @@ export function wrapEngineAdapter(id, engine, opts) {
131
136
  return;
132
137
  }
133
138
  const kind = BLOCK_KIND_MAP[block.kind] ?? "status";
134
- queueBlock({ kind, raw: block.raw });
139
+ queueBlock({
140
+ kind,
141
+ raw: block.raw,
142
+ ...(block.text !== undefined ? { text: block.text } : {}),
143
+ ...(block.name !== undefined ? { name: block.name } : {}),
144
+ ...(block.status !== undefined ? { status: block.status } : {}),
145
+ });
135
146
  },
136
147
  onStatus: (event) => {
137
148
  consoleLogger.debug(`${id} status`, { kind: event.kind, phase: event.phase });
149
+ if (event.kind === "thinking") {
150
+ queueBlock({
151
+ kind: "thinking",
152
+ phase: event.phase === "stopped" ? "completed" : "in_progress",
153
+ });
154
+ }
138
155
  },
139
156
  });
140
157
  // Preserve provider event order through durable run.block before the final run.message.
@@ -238,7 +238,20 @@ function normalizeBlock(obj, seq) {
238
238
  else if (type === "init" || type === "result") {
239
239
  kind = "system";
240
240
  }
241
- return { raw: obj, kind, seq };
241
+ return {
242
+ raw: obj,
243
+ kind,
244
+ seq,
245
+ ...(kind === "assistant_text" && typeof obj.content === "string"
246
+ ? { text: obj.content }
247
+ : {}),
248
+ ...(kind === "tool_use" && typeof obj.tool_name === "string"
249
+ ? { name: obj.tool_name }
250
+ : {}),
251
+ ...(kind === "tool_result"
252
+ ? { status: obj.status === "error" ? "error" : "completed" }
253
+ : {}),
254
+ };
242
255
  }
243
256
  export const geminiModule = {
244
257
  id: "gemini",
@@ -123,7 +123,7 @@ export class HermesAgentAdapter extends AcpRuntimeAdapter {
123
123
  blockKind = "assistant_text";
124
124
  }
125
125
  else if (kind === "tool_call" || kind === "tool_call_update") {
126
- blockKind = "tool_use";
126
+ blockKind = kind === "tool_call_update" ? "tool_result" : "tool_use";
127
127
  }
128
128
  else if (kind === "user_message_chunk") {
129
129
  blockKind = "other";
@@ -132,7 +132,24 @@ export class HermesAgentAdapter extends AcpRuntimeAdapter {
132
132
  const status = hermesStatusEvent(kind, update, assistantTextSeen);
133
133
  if (status)
134
134
  ctx.emitStatus(status);
135
- ctx.emitBlock({ raw: params, kind: blockKind, seq: ctx.seq });
135
+ const tool = update.toolCall;
136
+ ctx.emitBlock({
137
+ raw: params,
138
+ kind: blockKind,
139
+ seq: ctx.seq,
140
+ ...(blockKind === "assistant_text" && kind === "agent_message_chunk"
141
+ ? {
142
+ text: update.content?.text ?? "",
143
+ }
144
+ : {}),
145
+ ...((blockKind === "tool_use" || blockKind === "tool_result")
146
+ && typeof tool?.name === "string"
147
+ ? { name: tool.name }
148
+ : {}),
149
+ ...(blockKind === "tool_result"
150
+ ? { status: tool?.status === "failed" ? "error" : "completed" }
151
+ : {}),
152
+ });
136
153
  }
137
154
  /**
138
155
  * owner 信任:选第一个 `kind` 以 `allow_` 开头的选项,没有再退回第一个
@@ -323,7 +323,15 @@ function normalizeBlock(obj, seq) {
323
323
  else if (obj.category || obj.severity) {
324
324
  kind = "system";
325
325
  }
326
- return { raw: obj, kind, seq };
326
+ const text = kind === "assistant_text" ? extractText(obj.content) : "";
327
+ return {
328
+ raw: obj,
329
+ kind,
330
+ seq,
331
+ ...(text ? { text } : {}),
332
+ ...(kind === "tool_use" ? { name: firstToolName(obj.tool_calls) } : {}),
333
+ ...(kind === "tool_result" ? { status: "completed" } : {}),
334
+ };
327
335
  }
328
336
  export const kimiCliModule = {
329
337
  id: "kimi-cli",
@@ -177,12 +177,30 @@ export class OpenclawAcpAdapter {
177
177
  if (!text)
178
178
  return;
179
179
  seq += 1;
180
- emitBlock({ raw: sanitizeAssistantChunk(note, text), kind: "assistant_text", seq });
180
+ emitBlock({
181
+ raw: sanitizeAssistantChunk(note, text),
182
+ kind: "assistant_text",
183
+ seq,
184
+ text,
185
+ });
181
186
  return;
182
187
  }
183
188
  seq += 1;
184
189
  const kind = classifyAcpUpdate(note);
185
- emitBlock({ raw: note, kind, seq });
190
+ const toolCall = update?.toolCall;
191
+ const toolName = toolCall && typeof toolCall.name === "string" ? toolCall.name : undefined;
192
+ const toolStatus = toolCall && typeof toolCall.status === "string" ? toolCall.status.toLowerCase() : "";
193
+ emitBlock({
194
+ raw: note,
195
+ kind,
196
+ seq,
197
+ ...((kind === "tool_use" || kind === "tool_result") && toolName
198
+ ? { name: toolName }
199
+ : {}),
200
+ ...(kind === "tool_result"
201
+ ? { status: /fail|error/.test(toolStatus) ? "error" : "completed" }
202
+ : {}),
203
+ });
186
204
  };
187
205
  let abortListener;
188
206
  try {
@@ -293,6 +311,7 @@ export class OpenclawAcpAdapter {
293
311
  },
294
312
  kind: "assistant_text",
295
313
  seq,
314
+ text: textForBlock,
296
315
  });
297
316
  }
298
317
  }
@@ -23,6 +23,11 @@ export interface ProgressMcpConfig {
23
23
  export interface ProgressMcpConfigOptions {
24
24
  /** undefined auto-discovers explicit/default config; null creates a progress-only config. */
25
25
  baseConfigPath?: string | null;
26
+ /**
27
+ * undefined auto-detects the managed runtime boundary, null forces a private BYOA
28
+ * config, and a path writes a sanitized runtime-readable config below that root.
29
+ */
30
+ managedRoot?: string | null;
26
31
  platform?: NodeJS.Platform;
27
32
  }
28
33
  export declare class ProgressMcpConfigError extends Error {
@@ -45,6 +50,13 @@ export declare function isDeepseekProgressCompletion(payload: unknown, state: De
45
50
  */
46
51
  export declare function progressMcpAutoInjectionSupported(platform?: NodeJS.Platform): boolean;
47
52
  export declare function resolveExistingDeepseekMcpConfig(env?: NodeJS.ProcessEnv, home?: string): string | null;
48
- /** Create an ephemeral config that preserves a user's existing MCP servers and settings. */
53
+ /**
54
+ * Create an ephemeral DeepSeek MCP config.
55
+ *
56
+ * BYOA keeps the user's existing MCP settings in a private 0700/0600 directory. Managed
57
+ * Agent Service sessions instead write a progress-only 0750/0640 handoff below the
58
+ * supervisor-owned profile root so the separate runtime UID can read it without exposing
59
+ * control-plane configuration.
60
+ */
49
61
  export declare function createProgressMcpConfig(options?: ProgressMcpConfigOptions): ProgressMcpConfig;
50
62
  export declare function cleanupProgressMcpConfig(config: ProgressMcpConfig | undefined): void;
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
1
+ import { chmodSync, existsSync, lstatSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
2
  import { homedir, tmpdir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -10,6 +10,7 @@ export const DEEPSEEK_PROGRESS_TOOL_ALIASES = new Set([
10
10
  ]);
11
11
  export const DEEPSEEK_PROGRESS_SYSTEM_INSTRUCTION = [
12
12
  "BotLearn execution progress reporting:",
13
+ "- DeepSeek may defer this MCP tool. Before the first meaningful progress phase, if report_progress is not exposed, call tool_search_tool_regex once with query report_progress, then call the discovered progress tool.",
13
14
  "- Use report_progress only when a meaningful user-visible execution phase starts or completes.",
14
15
  "- Use status in_progress at phase start and completed only when that execution phase actually ends.",
15
16
  "- Short tasks need no progress report; do not report every command, file read, or retry.",
@@ -120,22 +121,37 @@ export function resolveExistingDeepseekMcpConfig(env = process.env, home = homed
120
121
  const defaultPath = path.join(home, ".deepseek", "mcp.json");
121
122
  return existsSync(defaultPath) ? defaultPath : null;
122
123
  }
123
- /** Create an ephemeral config that preserves a user's existing MCP servers and settings. */
124
+ /**
125
+ * Create an ephemeral DeepSeek MCP config.
126
+ *
127
+ * BYOA keeps the user's existing MCP settings in a private 0700/0600 directory. Managed
128
+ * Agent Service sessions instead write a progress-only 0750/0640 handoff below the
129
+ * supervisor-owned profile root so the separate runtime UID can read it without exposing
130
+ * control-plane configuration.
131
+ */
124
132
  export function createProgressMcpConfig(options = {}) {
125
133
  const platform = options.platform ?? process.platform;
126
134
  if (!progressMcpAutoInjectionSupported(platform)) {
127
135
  throw new ProgressMcpConfigError("report_progress MCP auto-injection is unavailable on Windows because clean child env isolation cannot be guaranteed");
128
136
  }
137
+ const managedRoot = resolveManagedProgressRoot(options.managedRoot);
138
+ if (managedRoot
139
+ && options.baseConfigPath !== undefined
140
+ && options.baseConfigPath !== null) {
141
+ throw new ProgressMcpConfigError("managed progress MCP config cannot preserve an existing DeepSeek MCP config");
142
+ }
129
143
  const serverPath = fileURLToPath(new URL("../mcp/report-progress-server.js", import.meta.url));
130
- const baseConfigPath = options.baseConfigPath === undefined
131
- ? resolveExistingDeepseekMcpConfig()
132
- : options.baseConfigPath;
144
+ const baseConfigPath = managedRoot
145
+ ? null
146
+ : options.baseConfigPath === undefined
147
+ ? resolveExistingDeepseekMcpConfig()
148
+ : options.baseConfigPath;
133
149
  const baseConfig = loadBaseMcpConfig(baseConfigPath);
134
150
  const baseServers = mergeMcpServerFields(baseConfig);
135
151
  if (Object.hasOwn(baseServers, "botlearn")) {
136
152
  throw new ProgressMcpConfigError("DeepSeek MCP server key 'botlearn' is reserved for BotLearn progress reporting");
137
153
  }
138
- const dir = mkdtempSync(path.join(tmpdir(), "botlearn-progress-mcp-"));
154
+ const dir = mkdtempSync(path.join(managedRoot ?? tmpdir(), "botlearn-progress-mcp-"));
139
155
  const configPath = path.join(dir, "mcp.json");
140
156
  const stagingPath = path.join(dir, ".mcp.json.tmp");
141
157
  const minimalPath = "/usr/bin:/bin";
@@ -157,7 +173,11 @@ export function createProgressMcpConfig(options = {}) {
157
173
  },
158
174
  };
159
175
  try {
176
+ if (managedRoot)
177
+ chmodSync(dir, 0o750);
160
178
  writeFileSync(stagingPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
179
+ if (managedRoot)
180
+ chmodSync(stagingPath, 0o640);
161
181
  renameSync(stagingPath, configPath);
162
182
  return { dir, path: configPath };
163
183
  }
@@ -166,6 +186,41 @@ export function createProgressMcpConfig(options = {}) {
166
186
  throw error;
167
187
  }
168
188
  }
189
+ function resolveManagedProgressRoot(explicit) {
190
+ if (explicit === null)
191
+ return null;
192
+ let root = explicit?.trim();
193
+ if (root === undefined) {
194
+ const managedRuntime = process.env.BOTLEARN_RUNTIME_USER?.trim();
195
+ if (!managedRuntime)
196
+ return null;
197
+ root = process.env.BOTLEARN_AGENT_SERVICE_PROFILE_ROOT?.trim();
198
+ if (!root) {
199
+ throw new ProgressMcpConfigError("managed runtime is missing BOTLEARN_AGENT_SERVICE_PROFILE_ROOT");
200
+ }
201
+ }
202
+ if (!root || !path.isAbsolute(root)) {
203
+ throw new ProgressMcpConfigError("managed progress MCP root must be an absolute path");
204
+ }
205
+ try {
206
+ const stat = lstatSync(root);
207
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
208
+ throw new Error("root is not a regular directory");
209
+ }
210
+ const currentUid = process.getuid?.();
211
+ if (currentUid !== undefined && stat.uid !== currentUid) {
212
+ throw new Error("root is not owned by the control process uid");
213
+ }
214
+ if ((stat.mode & 0o022) !== 0) {
215
+ throw new Error("root must not be group or world writable");
216
+ }
217
+ }
218
+ catch (error) {
219
+ const message = error instanceof Error ? error.message : String(error);
220
+ throw new ProgressMcpConfigError(`managed progress MCP root is unavailable: ${message}`);
221
+ }
222
+ return root;
223
+ }
169
224
  function loadBaseMcpConfig(configPath) {
170
225
  if (!configPath)
171
226
  return {};
@@ -10,7 +10,19 @@ const CONTROL_HOME = "/home/botlearn-control/.botlearn-course/daemon";
10
10
  const WORKSPACE = "/workspace";
11
11
  const RUNTIME_PROFILE_ROOT = "/run/botlearn-runtime-profiles";
12
12
  const SUPERVISOR_LOCK_ROOT = "/run/botlearn-sandbox-supervisors";
13
- const DAEMON_BINARY = "/usr/local/bin/botlearn-course-daemon";
13
+ const NODE_BINARY = "/opt/node/22.23.1/bin/node";
14
+ const DAEMON_ENTRY = "/opt/botlearn/course-daemon/dist/cli.js";
15
+ const RUNTIME_LAUNCHER = "/opt/botlearn/bin/botlearn-runtime-launcher";
16
+ const DEEPSEEK_BINARY = "/opt/deepseek-tui/0.8.39/bin/deepseek";
17
+ const MANAGED_PATH = [
18
+ "/opt/deepseek-tui/0.8.39/bin",
19
+ "/opt/node/22.23.1/bin",
20
+ "/usr/bin",
21
+ "/bin",
22
+ // E2B materializes this directory as runtime-writable. Keep it last and never
23
+ // use it for a managed control-plane executable.
24
+ "/usr/local/bin",
25
+ ].join(":");
14
26
  function numericId(flag, user) {
15
27
  const output = execFileSync("/usr/bin/id", [flag, user], {
16
28
  encoding: "utf8",
@@ -117,19 +129,25 @@ export async function runSandboxSupervisor(argv) {
117
129
  if (releaseLock === null)
118
130
  return 0;
119
131
  prepareDirectories(controlUid, controlGid, runtimeUid, runtimeSessionId, sessionGeneration);
120
- child = spawn(DAEMON_BINARY, ["agent-service", "session", "--bootstrap-stdin"], {
132
+ child = spawn(NODE_BINARY, [
133
+ DAEMON_ENTRY,
134
+ "agent-service",
135
+ "session",
136
+ "--bootstrap-stdin",
137
+ ], {
121
138
  uid: controlUid,
122
139
  gid: controlGid,
123
140
  env: {
124
141
  HOME: "/home/botlearn-control",
125
- PATH: "/usr/local/bin:/usr/bin:/bin",
142
+ PATH: MANAGED_PATH,
126
143
  BOTLEARN_DAEMON_HOME: CONTROL_HOME,
127
144
  BOTLEARN_RUNTIME_UID: String(runtimeUid),
128
145
  BOTLEARN_RUNTIME_GID: String(controlGid),
129
146
  BOTLEARN_RUNTIME_USER: RUNTIME_USER,
130
147
  BOTLEARN_RUNTIME_GROUP: CONTROL_USER,
131
148
  BOTLEARN_RUNTIME_HOME: "/home/user",
132
- BOTLEARN_RUNTIME_LAUNCHER: "/usr/local/bin/botlearn-runtime-launcher",
149
+ BOTLEARN_RUNTIME_LAUNCHER: RUNTIME_LAUNCHER,
150
+ BOTLEARN_DEEPSEEK_TUI_BIN: DEEPSEEK_BINARY,
133
151
  BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT: WORKSPACE,
134
152
  BOTLEARN_AGENT_SERVICE_PROFILE_ROOT: RUNTIME_PROFILE_ROOT,
135
153
  },
package/dist/types.d.ts CHANGED
@@ -114,6 +114,11 @@ export interface AppliedRunRuntimeProfile {
114
114
  export interface RuntimeContentBlock {
115
115
  kind: "text_delta" | "text" | "thinking" | "tool_call" | "tool_result" | "status" | "error";
116
116
  text?: string;
117
+ /** Safe provider-normalized tool identifier. Raw arguments/results stay in the transcript only. */
118
+ name?: string;
119
+ /** Public lifecycle metadata; never carries provider reasoning or tool output. */
120
+ phase?: "in_progress" | "completed";
121
+ status?: "completed" | "error";
117
122
  raw?: unknown;
118
123
  }
119
124
  /** 已由 provider adapter 严格归一化、无 provider raw envelope 的进度遥测。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Lightweight BotLearn Course daemon: run course tasks on your own machine with your own agent runtime (BYOA).",
5
5
  "type": "module",
6
6
  "bin": {