@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,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { CourseClientError, isRunTerminal } from "./course-client.js";
3
3
  import { reportFileCandidates } from "./file-candidates.js";
4
4
  import { log as defaultLog } from "./log.js";
5
+ import { MAX_PROGRESS_EVENTS_PER_ATTEMPT, tryNormalizeProgressReport, } from "./mcp/report-progress.js";
5
6
  import { errorInfo, redactSecretString, truncateText } from "./redaction.js";
6
7
  import { missingRunCapabilities } from "./runtime-capabilities.js";
7
8
  import { RunQueue } from "./run-queue.js";
@@ -35,10 +36,13 @@ export class RunDispatcher {
35
36
  runtimes;
36
37
  queue = new RunQueue();
37
38
  inflight = new Map();
39
+ scheduledRunIds = new Set();
40
+ pendingCancellations = new Set();
38
41
  defaultRuntimeId;
39
42
  log;
40
43
  scanLimits;
41
44
  now;
45
+ persistentSession;
42
46
  constructor(client, runtimes, opts = {}) {
43
47
  this.client = client;
44
48
  this.runtimes = runtimes;
@@ -46,22 +50,37 @@ export class RunDispatcher {
46
50
  this.log = opts.log ?? defaultLog;
47
51
  this.scanLimits = opts.scanLimits;
48
52
  this.now = opts.now ?? Date.now;
53
+ this.persistentSession = opts.persistentSession;
49
54
  }
50
55
  /** 排队执行一个 run。返回的 Promise 不 reject(失败已归一化回报为 run.failed)。 */
51
56
  dispatch(payload) {
52
57
  const key = payload.agent_instance_id ?? payload.agent_run_id;
53
- return this.queue.enqueue(key, () => this.execute(payload));
58
+ this.scheduledRunIds.add(payload.agent_run_id);
59
+ return this.queue
60
+ .enqueue(key, () => this.execute(payload))
61
+ .finally(() => {
62
+ this.scheduledRunIds.delete(payload.agent_run_id);
63
+ this.pendingCancellations.delete(payload.agent_run_id);
64
+ });
54
65
  }
55
66
  cancel(agentRunId) {
56
67
  const controller = this.inflight.get(agentRunId);
57
- if (!controller)
68
+ if (controller) {
69
+ controller.abort();
70
+ return true;
71
+ }
72
+ if (!this.scheduledRunIds.has(agentRunId))
58
73
  return false;
59
- controller.abort();
74
+ // dispatch() queues execution on a microtask. A durable cancel may arrive after the
75
+ // command ACK but before execute() installs its AbortController.
76
+ this.pendingCancellations.add(agentRunId);
60
77
  return true;
61
78
  }
62
79
  cancelAll() {
63
80
  for (const controller of this.inflight.values())
64
81
  controller.abort();
82
+ for (const runId of this.scheduledRunIds)
83
+ this.pendingCancellations.add(runId);
65
84
  }
66
85
  get activeCount() {
67
86
  return this.inflight.size;
@@ -80,6 +99,8 @@ export class RunDispatcher {
80
99
  const runId = payload.agent_run_id;
81
100
  const controller = new AbortController();
82
101
  this.inflight.set(runId, controller);
102
+ if (this.pendingCancellations.delete(runId))
103
+ controller.abort();
83
104
  let seq = 0;
84
105
  // 服务端已把本 run 判为终态(409):停止一切后续上报。
85
106
  let serverTerminal = false;
@@ -90,6 +111,15 @@ export class RunDispatcher {
90
111
  let profileApplied = false;
91
112
  let toolCalls = 0;
92
113
  let toolLimitExceeded = false;
114
+ let progressEvents = 0;
115
+ let lastProgressKey = null;
116
+ const progressDisposition = {
117
+ accepted: 0,
118
+ deduplicated: 0,
119
+ invalid: 0,
120
+ over_limit: 0,
121
+ server_rejected: 0,
122
+ };
93
123
  const startedAt = this.now();
94
124
  const usage = () => ({
95
125
  wall_time_ms: Math.max(0, this.now() - startedAt),
@@ -163,12 +193,13 @@ export class RunDispatcher {
163
193
  ...runtimeProfileInstructions(payload, applied),
164
194
  ];
165
195
  }
166
- const { workspaceDir } = ensureRunWorkspace(runId);
196
+ const persistentTurn = this.persistentSession?.prepareTurn(payload);
197
+ const { workspaceDir } = persistentTurn ?? ensureRunWorkspace(runId);
167
198
  const missingCapabilities = missingRunCapabilities(payload, workspaceDir);
168
199
  if (missingCapabilities.length > 0) {
169
200
  throw new RuntimeExecutionError(`runtime is missing required capabilities: ${missingCapabilities.join(", ")}`, "runtime_unavailable");
170
201
  }
171
- const transcript = new TranscriptWriter(transcriptPath(runId));
202
+ const transcript = new TranscriptWriter(persistentTurn?.transcriptFile ?? transcriptPath(runId));
172
203
  const timeoutSeconds = clampTimeoutSeconds(payload.limits.timeout_seconds);
173
204
  timer = setTimeout(() => {
174
205
  timedOut = true;
@@ -178,7 +209,74 @@ export class RunDispatcher {
178
209
  // 上一次真正上了 wire 的块 kind:thinking/status 只在 kind 切换时上报一次,避免刷屏。
179
210
  let lastReportedKind = null;
180
211
  const sink = {
212
+ progressDispositions: async (dispositions) => {
213
+ progressDisposition.invalid += dispositions.invalid;
214
+ progressDisposition.deduplicated += dispositions.deduplicated;
215
+ progressDisposition.over_limit += dispositions.over_limit;
216
+ },
181
217
  block: async (block) => {
218
+ if (block.kind === "progress") {
219
+ const normalized = tryNormalizeProgressReport({
220
+ summary: block.summary,
221
+ status: block.status,
222
+ });
223
+ if (!normalized) {
224
+ progressDisposition.invalid += 1;
225
+ return;
226
+ }
227
+ // Redact before the local transcript and every reporting client boundary.
228
+ const progress = tryNormalizeProgressReport({
229
+ summary: redactSecretString(normalized.summary),
230
+ status: normalized.status,
231
+ });
232
+ // Redaction can expand a token marker past 240 code points; never truncate or send it.
233
+ if (!progress) {
234
+ progressDisposition.invalid += 1;
235
+ return;
236
+ }
237
+ const key = `${progress.status}\0${progress.summary}`;
238
+ if (key === lastProgressKey) {
239
+ progressDisposition.deduplicated += 1;
240
+ return;
241
+ }
242
+ if (progressEvents >= MAX_PROGRESS_EVENTS_PER_ATTEMPT) {
243
+ progressDisposition.over_limit += 1;
244
+ return;
245
+ }
246
+ lastProgressKey = key;
247
+ progressEvents += 1;
248
+ transcript.writeBlock({
249
+ kind: "progress",
250
+ runtime: runtime.id,
251
+ summary: progress.summary,
252
+ status: progress.status,
253
+ });
254
+ if (serverTerminal)
255
+ return;
256
+ try {
257
+ await send({
258
+ type: "run.block",
259
+ payload: {
260
+ schema_version: "agent-progress/0.1",
261
+ kind: "progress",
262
+ runtime: runtime.id,
263
+ summary: progress.summary,
264
+ status: progress.status,
265
+ },
266
+ });
267
+ if (!serverTerminal)
268
+ progressDisposition.accepted += 1;
269
+ }
270
+ catch (error) {
271
+ if (error instanceof CourseClientError &&
272
+ (error.status === 400 || error.status === 422)) {
273
+ progressDisposition.server_rejected += 1;
274
+ return;
275
+ }
276
+ throw error;
277
+ }
278
+ return;
279
+ }
182
280
  transcript.writeBlock(block);
183
281
  if (block.kind === "tool_call") {
184
282
  toolCalls += 1;
@@ -212,8 +310,23 @@ export class RunDispatcher {
212
310
  file: async (file) => {
213
311
  await this.client.postFile(runId, file);
214
312
  },
313
+ runtimeSession: async (sessionId) => {
314
+ this.persistentSession?.persistNativeSession(sessionId);
315
+ },
215
316
  };
216
- await runtime.run({ payload, workspaceDir }, sink, controller.signal);
317
+ await runtime.run({
318
+ payload,
319
+ workspaceDir,
320
+ ...(persistentTurn
321
+ ? {
322
+ nativeSessionId: persistentTurn.nativeSessionId,
323
+ contextRevision: persistentTurn.contextRevision,
324
+ ...(persistentTurn.runtimeEnv
325
+ ? { runtimeEnv: persistentTurn.runtimeEnv }
326
+ : {}),
327
+ }
328
+ : {}),
329
+ }, sink, controller.signal);
217
330
  clearTimeout(timer);
218
331
  if (serverTerminal)
219
332
  return;
@@ -288,6 +401,13 @@ export class RunDispatcher {
288
401
  clearTimeout(timer);
289
402
  if (profileApplied)
290
403
  cleanupRunRuntimeProfile(runId);
404
+ if (Object.values(progressDisposition).some((count) => count > 0)) {
405
+ this.log.info("progress event dispositions", {
406
+ agentRunId: runId,
407
+ runtime: runtimeId,
408
+ ...progressDisposition,
409
+ });
410
+ }
291
411
  this.inflight.delete(runId);
292
412
  }
293
413
  }
@@ -0,0 +1,9 @@
1
+ /** Remove Course control-plane coordinates before any model/runtime child is created. */
2
+ export declare function clearAgentServiceControlEnv(env?: NodeJS.ProcessEnv): void;
3
+ /** Copy an environment for a runtime child without leaking Agent Service control values. */
4
+ export declare function runtimeChildEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
5
+ /** Run model processes as the Template's untrusted runtime UID when configured. */
6
+ export declare function runtimeChildIdentity(env?: NodeJS.ProcessEnv): {
7
+ uid?: number;
8
+ gid?: number;
9
+ };
@@ -0,0 +1,40 @@
1
+ const AGENT_SERVICE_CONTROL_ENV_KEYS = [
2
+ "BOTLEARN_COURSE_API_URL",
3
+ "BOTLEARN_AGENT_SERVICE_RUN_ID",
4
+ "BOTLEARN_AGENT_SERVICE_RUN_TOKEN",
5
+ "BOTLEARN_AGENT_SERVICE_WORKER_ID",
6
+ "BOTLEARN_AGENT_SERVICE_WS_URL",
7
+ "BOTLEARN_AGENT_SERVICE_RUNTIME_SESSION_ID",
8
+ "BOTLEARN_AGENT_SERVICE_SESSION_TOKEN",
9
+ ];
10
+ const AGENT_SERVICE_SUPERVISOR_ENV_KEYS = [
11
+ "BOTLEARN_RUNTIME_UID",
12
+ "BOTLEARN_RUNTIME_GID",
13
+ "BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT",
14
+ "BOTLEARN_AGENT_SERVICE_PROFILE_ROOT",
15
+ ];
16
+ /** Remove Course control-plane coordinates before any model/runtime child is created. */
17
+ export function clearAgentServiceControlEnv(env = process.env) {
18
+ for (const key of AGENT_SERVICE_CONTROL_ENV_KEYS)
19
+ delete env[key];
20
+ }
21
+ /** Copy an environment for a runtime child without leaking Agent Service control values. */
22
+ export function runtimeChildEnv(env = process.env) {
23
+ const childEnv = { ...env };
24
+ clearAgentServiceControlEnv(childEnv);
25
+ for (const key of AGENT_SERVICE_SUPERVISOR_ENV_KEYS)
26
+ delete childEnv[key];
27
+ return childEnv;
28
+ }
29
+ /** Run model processes as the Template's untrusted runtime UID when configured. */
30
+ export function runtimeChildIdentity(env = process.env) {
31
+ const uid = Number(env.BOTLEARN_RUNTIME_UID);
32
+ const gid = Number(env.BOTLEARN_RUNTIME_GID);
33
+ if (!Number.isInteger(uid) ||
34
+ uid < 1 ||
35
+ !Number.isInteger(gid) ||
36
+ gid < 1) {
37
+ return {};
38
+ }
39
+ return { uid, gid };
40
+ }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
3
  import path from "node:path";
4
- import { runtimeProfileDir, runRootDir } from "./workspace.js";
4
+ import { runtimeProfileDir, runtimeProfileRunRootDir } from "./workspace.js";
5
5
  const SAFE_ASSET_ID = /^[a-z0-9][a-z0-9._-]{0,159}$/;
6
6
  const SHA256 = /^sha256:[0-9a-f]{64}$/;
7
7
  const MAX_SKILL_PACKAGES = 32;
@@ -20,13 +20,19 @@ export function applyRunRuntimeProfile(payload, raw) {
20
20
  if (payload.context.profileHash !== profile.profileHash) {
21
21
  throw new RuntimeProfileApplyError("run.start profileHash does not match runtime profile");
22
22
  }
23
- const runDir = runRootDir(payload.agent_run_id);
23
+ const runDir = runtimeProfileRunRootDir(payload.agent_run_id);
24
24
  const target = runtimeProfileDir(payload.agent_run_id);
25
- mkdirSync(runDir, { recursive: true, mode: 0o700 });
25
+ const managedReadView = Boolean(process.env.BOTLEARN_AGENT_SERVICE_PROFILE_ROOT?.trim());
26
+ const directoryMode = managedReadView ? 0o750 : 0o700;
27
+ const fileMode = managedReadView ? 0o640 : 0o600;
28
+ mkdirSync(runDir, { recursive: true, mode: directoryMode });
29
+ chmodSync(runDir, directoryMode);
26
30
  const staging = mkdtempSync(path.join(runDir, ".runtime-profile-"));
31
+ chmodSync(staging, directoryMode);
27
32
  try {
28
33
  const skillsRoot = path.join(staging, "skills");
29
- mkdirSync(skillsRoot, { recursive: true, mode: 0o700 });
34
+ mkdirSync(skillsRoot, { recursive: true, mode: directoryMode });
35
+ chmodSync(skillsRoot, directoryMode);
30
36
  const skillRefs = [];
31
37
  const skillIds = new Set();
32
38
  for (const skill of profile.skillPackages) {
@@ -34,12 +40,14 @@ export function applyRunRuntimeProfile(payload, raw) {
34
40
  throw new RuntimeProfileApplyError(`duplicate runtime Skill id: ${skill.id}`);
35
41
  }
36
42
  skillIds.add(skill.id);
37
- installSkillPackage(skillsRoot, skill);
43
+ installSkillPackage(skillsRoot, skill, { directoryMode, fileMode });
38
44
  skillRefs.push(`${skill.id}@${skill.version}`);
39
45
  }
40
46
  const promptPackPath = path.join(staging, "prompt-pack.md");
41
- writeFileSync(promptPackPath, profile.promptPack.systemInstructions, { mode: 0o600 });
42
- writeFileSync(path.join(staging, "profile.json"), `${JSON.stringify({
47
+ writeFileSync(promptPackPath, profile.promptPack.systemInstructions, { mode: fileMode });
48
+ chmodSync(promptPackPath, fileMode);
49
+ const manifestPath = path.join(staging, "profile.json");
50
+ writeFileSync(manifestPath, `${JSON.stringify({
43
51
  schemaVersion: profile.schemaVersion,
44
52
  profileId: profile.profileId,
45
53
  profileHash: profile.profileHash,
@@ -51,7 +59,8 @@ export function applyRunRuntimeProfile(payload, raw) {
51
59
  },
52
60
  skillRefs,
53
61
  requiredCapabilities: profile.requiredCapabilities,
54
- }, null, 2)}\n`, { mode: 0o600 });
62
+ }, null, 2)}\n`, { mode: fileMode });
63
+ chmodSync(manifestPath, fileMode);
55
64
  rmSync(target, { recursive: true, force: true });
56
65
  renameSync(staging, target);
57
66
  return {
@@ -94,7 +103,7 @@ export function runtimeProfileInstructions(payload, applied) {
94
103
  : []),
95
104
  ];
96
105
  }
97
- function installSkillPackage(skillsRoot, skill) {
106
+ function installSkillPackage(skillsRoot, skill, modes) {
98
107
  assertSafeAssetId(skill.id, "Skill id");
99
108
  if (!SHA256.test(skill.digest)) {
100
109
  throw new RuntimeProfileApplyError(`Skill ${skill.id} has an invalid digest`);
@@ -104,11 +113,14 @@ function installSkillPackage(skillsRoot, skill) {
104
113
  throw new RuntimeProfileApplyError(`Skill ${skill.id}@${skill.version} digest mismatch`);
105
114
  }
106
115
  const skillDir = path.join(skillsRoot, skill.id);
107
- mkdirSync(skillDir, { recursive: true, mode: 0o700 });
116
+ mkdirSync(skillDir, { recursive: true, mode: modes.directoryMode });
117
+ chmodSync(skillDir, modes.directoryMode);
108
118
  for (const entry of entries) {
109
119
  const destination = path.join(skillDir, entry.path);
110
- mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
111
- writeFileSync(destination, entry.content, { mode: 0o600 });
120
+ mkdirSync(path.dirname(destination), { recursive: true, mode: modes.directoryMode });
121
+ chmodSync(path.dirname(destination), modes.directoryMode);
122
+ writeFileSync(destination, entry.content, { mode: modes.fileMode });
123
+ chmodSync(destination, modes.fileMode);
112
124
  }
113
125
  }
114
126
  function archiveEntries(skill) {
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { runtimeChildIdentity } from "../runtime-env.js";
2
3
  import { sanitizeRuntimeFailureText } from "../redaction.js";
3
4
  import { sliceUtf8Bytes, utf8ByteLength } from "./text-cap.js";
4
5
  import { consoleLogger, } from "./engine.js";
@@ -206,6 +207,7 @@ export class AcpRuntimeAdapter {
206
207
  const child = spawn(binary, args, {
207
208
  cwd: opts.cwd,
208
209
  env: this.spawnEnv(opts),
210
+ ...runtimeChildIdentity(),
209
211
  stdio: ["pipe", "pipe", "pipe"],
210
212
  });
211
213
  let killTimer = null;
@@ -38,7 +38,7 @@ export declare class CodexAdapter extends NdjsonStreamAdapter {
38
38
  * `--` 隔开 flags 与 positionals,防止以 `-` 开头的 prompt 被解析成选项。
39
39
  */
40
40
  protected buildArgs(opts: EngineRunOptions): string[];
41
- protected spawnEnv(_opts: EngineRunOptions): NodeJS.ProcessEnv;
41
+ protected spawnEnv(opts: EngineRunOptions): NodeJS.ProcessEnv;
42
42
  protected handleEvent(raw: unknown, ctx: NdjsonEventCtx): void;
43
43
  }
44
44
  export declare const codexModule: RuntimeModule;
@@ -181,9 +181,9 @@ export class CodexAdapter extends NdjsonStreamAdapter {
181
181
  }
182
182
  return ["exec", ...tail, "--", prompt];
183
183
  }
184
- spawnEnv(_opts) {
184
+ spawnEnv(opts) {
185
185
  return {
186
- ...process.env,
186
+ ...super.spawnEnv(opts),
187
187
  // 保证 JSONL 输出不混入 ANSI 转义。
188
188
  FORCE_COLOR: "0",
189
189
  NO_COLOR: "1",
@@ -2,13 +2,15 @@ import { spawn } from "node:child_process";
2
2
  import { type ProbeDeps } from "./probe.js";
3
3
  import { type EngineAdapter, type EngineRunOptions, type EngineRunResult } from "./engine.js";
4
4
  import type { RuntimeModule, RuntimeProbe } from "../types.js";
5
- interface DeepseekAdapterDeps {
5
+ export interface DeepseekAdapterDeps {
6
6
  binary?: string;
7
7
  /** 测试注入:使用现成的兼容 server,不 spawn `deepseek`。 */
8
8
  serverUrl?: string;
9
9
  authToken?: string;
10
10
  fetchFn?: typeof fetch;
11
11
  spawnFn?: typeof spawn;
12
+ /** External servers must explicitly attest compatible progress tool events. */
13
+ progressEventMapping?: boolean;
12
14
  }
13
15
  /** 解析 PATH 上的 `deepseek` dispatcher CLI。 */
14
16
  export declare function resolveDeepseekCommand(deps?: ProbeDeps): string | null;
@@ -28,6 +30,8 @@ export declare class DeepseekTuiAdapter implements EngineAdapter {
28
30
  private readonly explicitAuthToken;
29
31
  private readonly fetchFn;
30
32
  private readonly spawnFn;
33
+ readonly progressEventMappingEnabled: boolean;
34
+ readonly progressPromptInjectionEnabled: boolean;
31
35
  private resolvedBinary;
32
36
  constructor(deps?: DeepseekAdapterDeps);
33
37
  run(opts: EngineRunOptions): Promise<EngineRunResult>;
@@ -41,10 +45,10 @@ export declare class DeepseekTuiAdapter implements EngineAdapter {
41
45
  private createThread;
42
46
  private patchThreadSystemContext;
43
47
  private startTurnAndReadEvents;
48
+ private interruptTurn;
44
49
  private readEvents;
45
50
  private requestJson;
46
51
  }
47
52
  /** 仅测试用:清空进程池。 */
48
53
  export declare function __resetDeepseekTuiPoolForTests(): void;
49
54
  export declare const deepseekTuiModule: RuntimeModule;
50
- export {};