@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
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { runtimeChildEnv, runtimeChildIdentity } from "../runtime-env.js";
2
3
  import { readCommandVersion, resolveCommandOnPath } from "./probe.js";
3
4
  import { sliceUtf8Bytes } from "./text-cap.js";
4
5
  import { consoleLogger, wrapEngineAdapter, } from "./engine.js";
@@ -384,7 +385,8 @@ export class OpenclawAcpAdapter {
384
385
  args.push("--token", gateway.token);
385
386
  const child = this.spawnFn(command, args, {
386
387
  stdio: ["pipe", "pipe", "pipe"],
387
- env: { ...process.env },
388
+ env: runtimeChildEnv(this.env ?? process.env),
389
+ ...runtimeChildIdentity(),
388
390
  });
389
391
  installExitCleanupHook();
390
392
  const handle = {
@@ -0,0 +1,50 @@
1
+ import type { ProgressStreamBlock } from "./engine.js";
2
+ import type { RuntimeProgressDispositions } from "../types.js";
3
+ export declare const DEEPSEEK_PROGRESS_TOOL_ALIASES: Set<string>;
4
+ export declare const DEEPSEEK_PROGRESS_SYSTEM_INSTRUCTION: string;
5
+ export interface DeepseekProgressState {
6
+ emitted: number;
7
+ lastProgressKey: string | null;
8
+ callIds: Set<string>;
9
+ limitReported: boolean;
10
+ invalid: number;
11
+ duplicate: number;
12
+ overLimit: number;
13
+ }
14
+ export interface DeepseekProgressStartedResult {
15
+ matched: boolean;
16
+ block?: ProgressStreamBlock;
17
+ limitExceeded?: true;
18
+ }
19
+ export interface ProgressMcpConfig {
20
+ dir: string;
21
+ path: string;
22
+ }
23
+ export interface ProgressMcpConfigOptions {
24
+ /** undefined auto-discovers explicit/default config; null creates a progress-only config. */
25
+ baseConfigPath?: string | null;
26
+ platform?: NodeJS.Platform;
27
+ }
28
+ export declare class ProgressMcpConfigError extends Error {
29
+ constructor(message: string);
30
+ }
31
+ export declare function createDeepseekProgressState(): DeepseekProgressState;
32
+ /** Return only adapter-side drops; emitted blocks are counted by RunDispatcher after reporting. */
33
+ export declare function deepseekProgressDispositions(state: DeepseekProgressState): RuntimeProgressDispositions | undefined;
34
+ export declare function progressSystemContext(systemContext: string | undefined): string;
35
+ /**
36
+ * Recognize a progress tool start and emit only a typed, whitelisted block. Invalid,
37
+ * duplicate, and over-budget calls remain handled so no generic tool card leaks arguments.
38
+ */
39
+ export declare function adaptDeepseekProgressStarted(payload: unknown, seq: number, state: DeepseekProgressState): DeepseekProgressStartedResult;
40
+ /** Suppress the result for a recognized progress call, including result envelopes with only ids. */
41
+ export declare function isDeepseekProgressCompletion(payload: unknown, state: DeepseekProgressState): boolean;
42
+ /**
43
+ * DeepSeek MCP auto-injection uses a stdio server launched through `env -i`, so it cannot
44
+ * inherit Course or model credentials from the DeepSeek process.
45
+ */
46
+ export declare function progressMcpAutoInjectionSupported(platform?: NodeJS.Platform): boolean;
47
+ 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. */
49
+ export declare function createProgressMcpConfig(options?: ProgressMcpConfigOptions): ProgressMcpConfig;
50
+ export declare function cleanupProgressMcpConfig(config: ProgressMcpConfig | undefined): void;
@@ -0,0 +1,339 @@
1
+ import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { MAX_PROGRESS_EVENTS_PER_ATTEMPT, tryNormalizeProgressReport, } from "../mcp/report-progress.js";
6
+ export const DEEPSEEK_PROGRESS_TOOL_ALIASES = new Set([
7
+ "report_progress",
8
+ "mcp_botlearn_report_progress",
9
+ "mcp__botlearn__report_progress",
10
+ ]);
11
+ export const DEEPSEEK_PROGRESS_SYSTEM_INSTRUCTION = [
12
+ "BotLearn execution progress reporting:",
13
+ "- Use report_progress only when a meaningful user-visible execution phase starts or completes.",
14
+ "- Use status in_progress at phase start and completed only when that execution phase actually ends.",
15
+ "- Short tasks need no progress report; do not report every command, file read, or retry.",
16
+ "- Keep summary concise and user-facing. Never include hidden reasoning, chain of thought, secrets, tokens, prompts, raw command output, or large code excerpts.",
17
+ "- completed means only that an execution phase ended. It does not complete a course task, checkpoint, learning objective, or human review.",
18
+ "- If report_progress fails, continue the main task and do not bypass it through another tool.",
19
+ ].join("\n");
20
+ export class ProgressMcpConfigError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = "ProgressMcpConfigError";
24
+ }
25
+ }
26
+ export function createDeepseekProgressState() {
27
+ return {
28
+ emitted: 0,
29
+ lastProgressKey: null,
30
+ callIds: new Set(),
31
+ limitReported: false,
32
+ invalid: 0,
33
+ duplicate: 0,
34
+ overLimit: 0,
35
+ };
36
+ }
37
+ /** Return only adapter-side drops; emitted blocks are counted by RunDispatcher after reporting. */
38
+ export function deepseekProgressDispositions(state) {
39
+ if (state.invalid + state.duplicate + state.overLimit === 0)
40
+ return undefined;
41
+ return {
42
+ invalid: state.invalid,
43
+ deduplicated: state.duplicate,
44
+ over_limit: state.overLimit,
45
+ };
46
+ }
47
+ export function progressSystemContext(systemContext) {
48
+ const context = systemContext?.trim();
49
+ return context
50
+ ? `${context}\n\n${DEEPSEEK_PROGRESS_SYSTEM_INSTRUCTION}`
51
+ : DEEPSEEK_PROGRESS_SYSTEM_INSTRUCTION;
52
+ }
53
+ /**
54
+ * Recognize a progress tool start and emit only a typed, whitelisted block. Invalid,
55
+ * duplicate, and over-budget calls remain handled so no generic tool card leaks arguments.
56
+ */
57
+ export function adaptDeepseekProgressStarted(payload, seq, state) {
58
+ const tool = extractTool(payload);
59
+ if (!tool.name || !DEEPSEEK_PROGRESS_TOOL_ALIASES.has(tool.name)) {
60
+ return { matched: false };
61
+ }
62
+ for (const id of tool.ids)
63
+ state.callIds.add(id);
64
+ const progress = tryNormalizeProgressReport(tool.arguments);
65
+ if (!progress) {
66
+ state.invalid += 1;
67
+ return { matched: true };
68
+ }
69
+ const key = progressKey(progress);
70
+ if (state.lastProgressKey === key) {
71
+ state.duplicate += 1;
72
+ return { matched: true };
73
+ }
74
+ if (state.emitted >= MAX_PROGRESS_EVENTS_PER_ATTEMPT) {
75
+ state.overLimit += 1;
76
+ if (!state.limitReported) {
77
+ state.limitReported = true;
78
+ return { matched: true, limitExceeded: true };
79
+ }
80
+ return { matched: true };
81
+ }
82
+ state.lastProgressKey = key;
83
+ state.emitted += 1;
84
+ return { matched: true, block: { kind: "progress", seq, progress } };
85
+ }
86
+ /** Suppress the result for a recognized progress call, including result envelopes with only ids. */
87
+ export function isDeepseekProgressCompletion(payload, state) {
88
+ const tool = extractTool(payload);
89
+ const namedProgress = Boolean(tool.name && DEEPSEEK_PROGRESS_TOOL_ALIASES.has(tool.name));
90
+ const matchingIds = tool.ids.filter((id) => state.callIds.has(id));
91
+ for (const id of matchingIds)
92
+ state.callIds.delete(id);
93
+ return namedProgress || matchingIds.length > 0;
94
+ }
95
+ /**
96
+ * DeepSeek MCP auto-injection uses a stdio server launched through `env -i`, so it cannot
97
+ * inherit Course or model credentials from the DeepSeek process.
98
+ */
99
+ export function progressMcpAutoInjectionSupported(platform = process.platform) {
100
+ // DeepSeek overlays config.env on an inherited process environment. Windows has no
101
+ // env -i equivalent in the fixed runtime, so fail closed instead of handing provider
102
+ // credentials to the progress MCP process.
103
+ return platform !== "win32";
104
+ }
105
+ export function resolveExistingDeepseekMcpConfig(env = process.env, home = homedir()) {
106
+ const explicit = env.DEEPSEEK_MCP_CONFIG?.trim();
107
+ if (explicit)
108
+ return resolveMcpPath(explicit, home);
109
+ const defaultConfigPath = path.join(home, ".deepseek", "config.toml");
110
+ const explicitConfigPath = env.DEEPSEEK_CONFIG_PATH?.trim()
111
+ ? resolveMcpPath(env.DEEPSEEK_CONFIG_PATH.trim(), home)
112
+ : null;
113
+ const configPath = explicitConfigPath
114
+ && (existsSync(explicitConfigPath) || !existsSync(defaultConfigPath))
115
+ ? explicitConfigPath
116
+ : defaultConfigPath;
117
+ const configured = readConfiguredMcpPath(configPath, home, env.DEEPSEEK_PROFILE?.trim());
118
+ if (configured)
119
+ return configured;
120
+ const defaultPath = path.join(home, ".deepseek", "mcp.json");
121
+ return existsSync(defaultPath) ? defaultPath : null;
122
+ }
123
+ /** Create an ephemeral config that preserves a user's existing MCP servers and settings. */
124
+ export function createProgressMcpConfig(options = {}) {
125
+ const platform = options.platform ?? process.platform;
126
+ if (!progressMcpAutoInjectionSupported(platform)) {
127
+ throw new ProgressMcpConfigError("report_progress MCP auto-injection is unavailable on Windows because clean child env isolation cannot be guaranteed");
128
+ }
129
+ const serverPath = fileURLToPath(new URL("../mcp/report-progress-server.js", import.meta.url));
130
+ const baseConfigPath = options.baseConfigPath === undefined
131
+ ? resolveExistingDeepseekMcpConfig()
132
+ : options.baseConfigPath;
133
+ const baseConfig = loadBaseMcpConfig(baseConfigPath);
134
+ const baseServers = mergeMcpServerFields(baseConfig);
135
+ if (Object.hasOwn(baseServers, "botlearn")) {
136
+ throw new ProgressMcpConfigError("DeepSeek MCP server key 'botlearn' is reserved for BotLearn progress reporting");
137
+ }
138
+ const dir = mkdtempSync(path.join(tmpdir(), "botlearn-progress-mcp-"));
139
+ const configPath = path.join(dir, "mcp.json");
140
+ const stagingPath = path.join(dir, ".mcp.json.tmp");
141
+ const minimalPath = "/usr/bin:/bin";
142
+ const baseSettings = { ...baseConfig };
143
+ delete baseSettings.servers;
144
+ delete baseSettings.mcpServers;
145
+ const config = {
146
+ ...baseSettings,
147
+ servers: {
148
+ ...baseServers,
149
+ botlearn: {
150
+ command: "/usr/bin/env",
151
+ args: ["-i", `PATH=${minimalPath}`, process.execPath, serverPath],
152
+ env: {},
153
+ disabled: false,
154
+ enabled: true,
155
+ required: false,
156
+ },
157
+ },
158
+ };
159
+ try {
160
+ writeFileSync(stagingPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
161
+ renameSync(stagingPath, configPath);
162
+ return { dir, path: configPath };
163
+ }
164
+ catch (error) {
165
+ rmSync(dir, { recursive: true, force: true });
166
+ throw error;
167
+ }
168
+ }
169
+ function loadBaseMcpConfig(configPath) {
170
+ if (!configPath)
171
+ return {};
172
+ try {
173
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
174
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
175
+ throw new Error("root must be a JSON object");
176
+ }
177
+ const record = parsed;
178
+ if (record.servers !== undefined
179
+ && (!record.servers || typeof record.servers !== "object" || Array.isArray(record.servers))) {
180
+ throw new Error("servers must be a JSON object");
181
+ }
182
+ if (record.mcpServers !== undefined
183
+ && (!record.mcpServers
184
+ || typeof record.mcpServers !== "object"
185
+ || Array.isArray(record.mcpServers))) {
186
+ throw new Error("mcpServers must be a JSON object");
187
+ }
188
+ return record;
189
+ }
190
+ catch (error) {
191
+ const message = error instanceof Error ? error.message : String(error);
192
+ throw new ProgressMcpConfigError(`Cannot preserve existing DeepSeek MCP config ${configPath}: ${message}`);
193
+ }
194
+ }
195
+ function mergeMcpServerFields(config) {
196
+ const servers = objectField(config, "servers") ?? {};
197
+ const compatibleServers = objectField(config, "mcpServers") ?? {};
198
+ const duplicate = Object.keys(servers).find((name) => Object.hasOwn(compatibleServers, name));
199
+ if (duplicate) {
200
+ throw new ProgressMcpConfigError(`DeepSeek MCP server '${duplicate}' is defined in both servers and mcpServers`);
201
+ }
202
+ return { ...compatibleServers, ...servers };
203
+ }
204
+ function readConfiguredMcpPath(configPath, home, selectedProfile) {
205
+ if (!existsSync(configPath))
206
+ return null;
207
+ let raw;
208
+ try {
209
+ raw = readFileSync(configPath, "utf8");
210
+ }
211
+ catch (error) {
212
+ const message = error instanceof Error ? error.message : String(error);
213
+ throw new ProgressMcpConfigError(`Cannot read DeepSeek config ${configPath}: ${message}`);
214
+ }
215
+ let section = "root";
216
+ let rootValue = null;
217
+ let profileValue = null;
218
+ for (const line of raw.split(/\r?\n/)) {
219
+ const trimmed = line.trim();
220
+ if (!trimmed || trimmed.startsWith("#"))
221
+ continue;
222
+ const table = /^\[\s*([^\]]+?)\s*\]\s*(?:#.*)?$/.exec(trimmed);
223
+ if (table) {
224
+ const profile = parseProfileTableName(table[1] ?? "", configPath);
225
+ section = selectedProfile && profile === selectedProfile ? "selected_profile" : "other";
226
+ continue;
227
+ }
228
+ if (section === "other")
229
+ continue;
230
+ const match = /^mcp_config_path\s*=\s*(.*?)\s*$/.exec(trimmed);
231
+ if (!match)
232
+ continue;
233
+ const value = parseTomlPathValue(match[1] ?? "", configPath);
234
+ const resolved = value ? resolveMcpPath(value, home) : null;
235
+ if (section === "selected_profile")
236
+ profileValue = resolved;
237
+ else
238
+ rootValue = resolved;
239
+ }
240
+ return profileValue ?? rootValue;
241
+ }
242
+ function parseProfileTableName(raw, configPath) {
243
+ const bare = /^profiles\s*\.\s*([A-Za-z0-9_-]+)$/.exec(raw);
244
+ if (bare)
245
+ return bare[1] ?? null;
246
+ const doubleQuoted = /^profiles\s*\.\s*("(?:\\.|[^"\\])*")$/.exec(raw);
247
+ if (doubleQuoted) {
248
+ try {
249
+ return JSON.parse(doubleQuoted[1]);
250
+ }
251
+ catch (error) {
252
+ const message = error instanceof Error ? error.message : String(error);
253
+ throw new ProgressMcpConfigError(`Invalid profile table in ${configPath}: ${message}`);
254
+ }
255
+ }
256
+ const singleQuoted = /^profiles\s*\.\s*'([^']*)'$/.exec(raw);
257
+ return singleQuoted?.[1] ?? null;
258
+ }
259
+ function parseTomlPathValue(raw, configPath) {
260
+ const doubleQuoted = /^("(?:\\.|[^"\\])*")\s*(?:#.*)?$/.exec(raw);
261
+ if (doubleQuoted) {
262
+ try {
263
+ return JSON.parse(doubleQuoted[1]);
264
+ }
265
+ catch (error) {
266
+ const message = error instanceof Error ? error.message : String(error);
267
+ throw new ProgressMcpConfigError(`Invalid mcp_config_path in ${configPath}: ${message}`);
268
+ }
269
+ }
270
+ const singleQuoted = /^'([^']*)'\s*(?:#.*)?$/.exec(raw);
271
+ if (singleQuoted)
272
+ return singleQuoted[1] ?? "";
273
+ return raw.replace(/\s+#.*$/, "").trim();
274
+ }
275
+ function resolveMcpPath(value, home) {
276
+ if (value === "~")
277
+ return home;
278
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
279
+ return path.join(home, value.slice(2));
280
+ }
281
+ return path.resolve(value);
282
+ }
283
+ export function cleanupProgressMcpConfig(config) {
284
+ if (config)
285
+ rmSync(config.dir, { recursive: true, force: true });
286
+ }
287
+ function progressKey(progress) {
288
+ return `${progress.status}\0${progress.summary}`;
289
+ }
290
+ function extractTool(payload) {
291
+ if (!payload || typeof payload !== "object")
292
+ return { ids: [] };
293
+ const root = payload;
294
+ const embedded = objectField(root, "payload");
295
+ const candidates = [
296
+ root,
297
+ objectField(root, "tool"),
298
+ embedded,
299
+ objectField(embedded, "tool"),
300
+ ].filter((candidate) => candidate !== undefined);
301
+ let name;
302
+ let args;
303
+ for (const candidate of candidates) {
304
+ name ??= stringField(candidate, "name") ?? stringField(candidate, "tool_name");
305
+ if (args === undefined) {
306
+ if (Object.hasOwn(candidate, "input"))
307
+ args = candidate.input;
308
+ else if (Object.hasOwn(candidate, "arguments"))
309
+ args = candidate.arguments;
310
+ }
311
+ }
312
+ const ids = new Set();
313
+ for (const candidate of candidates) {
314
+ for (const key of ["id", "call_id", "tool_call_id", "item_id"]) {
315
+ const value = stringField(candidate, key);
316
+ if (value)
317
+ ids.add(value);
318
+ }
319
+ const item = objectField(candidate, "item");
320
+ const itemId = stringField(item, "id");
321
+ if (itemId)
322
+ ids.add(itemId);
323
+ }
324
+ return {
325
+ ...(name ? { name } : {}),
326
+ ...(args !== undefined ? { arguments: args } : {}),
327
+ ids: [...ids],
328
+ };
329
+ }
330
+ function objectField(value, key) {
331
+ const field = value?.[key];
332
+ return field && typeof field === "object" && !Array.isArray(field)
333
+ ? field
334
+ : undefined;
335
+ }
336
+ function stringField(value, key) {
337
+ const field = value?.[key];
338
+ return typeof field === "string" ? field : undefined;
339
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export declare function acquireSessionSupervisorLock(runtimeSessionId: string, lockRoot?: string): (() => void) | null;
3
+ export declare function runSandboxSupervisor(argv: string[]): Promise<number>;
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync, spawn } from "node:child_process";
3
+ import { chmodSync, chownSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
4
+ import path from "node:path";
5
+ const MAX_BOOTSTRAP_BYTES = 64 * 1024;
6
+ const CONTROL_USER = "botlearn-control";
7
+ const RUNTIME_USER = "user";
8
+ const CONTROL_HOME = "/home/botlearn-control/.botlearn-course/daemon";
9
+ const WORKSPACE = "/workspace";
10
+ const RUNTIME_PROFILE_ROOT = "/run/botlearn-runtime-profiles";
11
+ const SUPERVISOR_LOCK_ROOT = "/run/botlearn-sandbox-supervisors";
12
+ const DAEMON_BINARY = "/usr/local/bin/botlearn-course-daemon";
13
+ function numericId(flag, user) {
14
+ const output = execFileSync("/usr/bin/id", [flag, user], {
15
+ encoding: "utf8",
16
+ stdio: ["ignore", "pipe", "ignore"],
17
+ }).trim();
18
+ const value = Number(output);
19
+ if (!Number.isInteger(value) || value < 1) {
20
+ throw new Error(`sandbox supervisor could not resolve ${flag} for ${user}`);
21
+ }
22
+ return value;
23
+ }
24
+ async function readOneShotBootstrap() {
25
+ const chunks = [];
26
+ let size = 0;
27
+ for await (const chunk of process.stdin) {
28
+ const bytes = Buffer.from(chunk);
29
+ size += bytes.length;
30
+ if (size > MAX_BOOTSTRAP_BYTES) {
31
+ throw new Error("sandbox supervisor bootstrap exceeds size limit");
32
+ }
33
+ chunks.push(bytes);
34
+ }
35
+ if (size === 0)
36
+ throw new Error("sandbox supervisor bootstrap is empty");
37
+ return Buffer.concat(chunks);
38
+ }
39
+ export function acquireSessionSupervisorLock(runtimeSessionId, lockRoot = SUPERVISOR_LOCK_ROOT) {
40
+ const lockDir = path.join(lockRoot, runtimeSessionId);
41
+ const pidFile = path.join(lockDir, "pid");
42
+ mkdirSync(lockRoot, { recursive: true, mode: 0o700 });
43
+ try {
44
+ mkdirSync(lockDir, { mode: 0o700 });
45
+ }
46
+ catch (error) {
47
+ if (!existsSync(pidFile))
48
+ throw error;
49
+ const existingPid = Number(readFileSync(pidFile, "utf8").trim());
50
+ if (Number.isInteger(existingPid) && existingPid > 1) {
51
+ try {
52
+ process.kill(existingPid, 0);
53
+ return null;
54
+ }
55
+ catch {
56
+ // Stale lock from a supervisor that no longer exists.
57
+ }
58
+ }
59
+ rmSync(lockDir, { recursive: true, force: true });
60
+ mkdirSync(lockDir, { mode: 0o700 });
61
+ }
62
+ writeFileSync(pidFile, `${process.pid}\n`, { mode: 0o600 });
63
+ return () => {
64
+ try {
65
+ if (Number(readFileSync(pidFile, "utf8").trim()) === process.pid) {
66
+ rmSync(lockDir, { recursive: true, force: true });
67
+ }
68
+ }
69
+ catch {
70
+ // A replacement supervisor already owns or removed the lock.
71
+ }
72
+ };
73
+ }
74
+ function prepareDirectories(controlUid, controlGid, runtimeUid, runtimeSessionId, sessionGeneration) {
75
+ mkdirSync(CONTROL_HOME, { recursive: true, mode: 0o700 });
76
+ chownSync("/home/botlearn-control/.botlearn-course", controlUid, controlGid);
77
+ chownSync(CONTROL_HOME, controlUid, controlGid);
78
+ chmodSync(CONTROL_HOME, 0o700);
79
+ mkdirSync(RUNTIME_PROFILE_ROOT, { recursive: true, mode: 0o750 });
80
+ chownSync(RUNTIME_PROFILE_ROOT, controlUid, controlGid);
81
+ chmodSync(RUNTIME_PROFILE_ROOT, 0o750);
82
+ mkdirSync(WORKSPACE, { recursive: true, mode: 0o770 });
83
+ chownSync(WORKSPACE, runtimeUid, controlGid);
84
+ chmodSync(WORKSPACE, 0o770);
85
+ const sessionWorkspace = path.join(WORKSPACE, runtimeSessionId, `generation-${sessionGeneration}`);
86
+ mkdirSync(sessionWorkspace, { recursive: true, mode: 0o770 });
87
+ chownSync(path.join(WORKSPACE, runtimeSessionId), runtimeUid, controlGid);
88
+ chownSync(sessionWorkspace, runtimeUid, controlGid);
89
+ chmodSync(path.join(WORKSPACE, runtimeSessionId), 0o770);
90
+ chmodSync(sessionWorkspace, 0o770);
91
+ }
92
+ export async function runSandboxSupervisor(argv) {
93
+ if (argv.length !== 2 || argv[0] !== "agent-service" || argv[1] !== "session") {
94
+ throw new Error("sandbox supervisor only permits: agent-service session");
95
+ }
96
+ if (typeof process.getuid !== "function" || process.getuid() !== 0) {
97
+ throw new Error("sandbox supervisor must start as root");
98
+ }
99
+ const controlUid = numericId("-u", CONTROL_USER);
100
+ const controlGid = numericId("-g", CONTROL_USER);
101
+ const runtimeUid = numericId("-u", RUNTIME_USER);
102
+ const bootstrap = await readOneShotBootstrap();
103
+ let child;
104
+ let releaseLock = null;
105
+ try {
106
+ const decoded = JSON.parse(bootstrap.toString("utf8"));
107
+ const runtimeSessionId = decoded.runtimeSessionId;
108
+ const sessionGeneration = Number(decoded.sessionGeneration ?? 0);
109
+ if (typeof runtimeSessionId !== "string" ||
110
+ !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(runtimeSessionId) ||
111
+ !Number.isInteger(sessionGeneration) ||
112
+ sessionGeneration < 1) {
113
+ throw new Error("sandbox supervisor bootstrap session scope is invalid");
114
+ }
115
+ releaseLock = acquireSessionSupervisorLock(runtimeSessionId);
116
+ if (releaseLock === null)
117
+ return 0;
118
+ prepareDirectories(controlUid, controlGid, runtimeUid, runtimeSessionId, sessionGeneration);
119
+ child = spawn(DAEMON_BINARY, ["agent-service", "session", "--bootstrap-stdin"], {
120
+ uid: controlUid,
121
+ gid: controlGid,
122
+ env: {
123
+ HOME: "/home/botlearn-control",
124
+ PATH: "/usr/local/bin:/usr/bin:/bin",
125
+ BOTLEARN_DAEMON_HOME: CONTROL_HOME,
126
+ BOTLEARN_RUNTIME_UID: String(runtimeUid),
127
+ BOTLEARN_RUNTIME_GID: String(controlGid),
128
+ BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT: WORKSPACE,
129
+ BOTLEARN_AGENT_SERVICE_PROFILE_ROOT: RUNTIME_PROFILE_ROOT,
130
+ },
131
+ stdio: ["pipe", "inherit", "inherit"],
132
+ });
133
+ if (child.stdin === null) {
134
+ child.kill("SIGKILL");
135
+ throw new Error("sandbox supervisor could not open daemon bootstrap pipe");
136
+ }
137
+ child.stdin.end(bootstrap);
138
+ }
139
+ catch (error) {
140
+ releaseLock?.();
141
+ throw error;
142
+ }
143
+ finally {
144
+ bootstrap.fill(0);
145
+ }
146
+ const forward = (signal) => {
147
+ if (!child.killed)
148
+ child.kill(signal);
149
+ };
150
+ process.once("SIGTERM", () => forward("SIGTERM"));
151
+ process.once("SIGINT", () => forward("SIGINT"));
152
+ try {
153
+ return await new Promise((resolve, reject) => {
154
+ child.once("error", reject);
155
+ child.once("exit", (code, signal) => {
156
+ if (signal)
157
+ resolve(128);
158
+ else
159
+ resolve(code ?? 1);
160
+ });
161
+ });
162
+ }
163
+ finally {
164
+ releaseLock?.();
165
+ }
166
+ }
167
+ if (process.argv[1]?.endsWith("sandbox-supervisor.js")) {
168
+ runSandboxSupervisor(process.argv.slice(2))
169
+ .then((code) => {
170
+ process.exitCode = code;
171
+ })
172
+ .catch((error) => {
173
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
174
+ process.exitCode = 1;
175
+ });
176
+ }
@@ -15,6 +15,12 @@ export class TranscriptWriter {
15
15
  }
16
16
  writeBlock(block) {
17
17
  const record = { type: "block", kind: block.kind };
18
+ if (block.kind === "progress") {
19
+ record.summary = redactSecretString(block.summary);
20
+ record.status = block.status;
21
+ this.append(record);
22
+ return;
23
+ }
18
24
  if (block.text !== undefined)
19
25
  record.text = redactSecretString(block.text);
20
26
  if (block.raw !== undefined)
package/dist/types.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * 本包不依赖 BotCord Hub/room/owner-chat 语义;wire 类型与后端
5
5
  * `backend/botlearn_course/schemas.py` 的 daemon 契约严格对齐。
6
6
  */
7
+ import type { ProgressStatus } from "./mcp/report-progress.js";
7
8
  /** `GET /daemon/runs/next` 下发的 run.start 载荷(snake_case,与后端 RunStartPayloadOut 一致)。 */
8
9
  export interface RunStartPayload {
9
10
  agent_run_id: string;
@@ -106,12 +107,27 @@ export interface AppliedRunRuntimeProfile {
106
107
  skillsRoot: string;
107
108
  skillRefs: string[];
108
109
  }
109
- /** runtime adapter 产出的归一化块。wire 上只透传 text 与 kind;raw 仅进本地 transcript。 */
110
- export interface RuntimeBlock {
110
+ /** runtime adapter 产出的普通归一化块;raw 仅进本地 transcript。 */
111
+ export interface RuntimeContentBlock {
111
112
  kind: "text_delta" | "text" | "thinking" | "tool_call" | "tool_result" | "status" | "error";
112
113
  text?: string;
113
114
  raw?: unknown;
114
115
  }
116
+ /** 已由 provider adapter 严格归一化、无 provider raw envelope 的进度遥测。 */
117
+ export interface RuntimeProgressBlock {
118
+ kind: "progress";
119
+ runtime: string;
120
+ summary: string;
121
+ status: ProgressStatus;
122
+ raw?: never;
123
+ }
124
+ export type RuntimeBlock = RuntimeContentBlock | RuntimeProgressBlock;
125
+ /** runtime adapter 在发出进度块前丢弃的非内容处置计数。 */
126
+ export interface RuntimeProgressDispositions {
127
+ invalid: number;
128
+ deduplicated: number;
129
+ over_limit: number;
130
+ }
115
131
  export interface RuntimeAuthProbe {
116
132
  checked: boolean;
117
133
  ok: boolean;
@@ -129,12 +145,22 @@ export interface CourseRuntimeSink {
129
145
  block(block: RuntimeBlock): Promise<void>;
130
146
  message(text: string): Promise<void>;
131
147
  file(file: RunFileCandidate): Promise<void>;
148
+ /** 可选的 run-scoped 内部遥测;不得包含 summary 或 provider raw envelope。 */
149
+ progressDispositions?(dispositions: RuntimeProgressDispositions): Promise<void>;
150
+ /** Persist the runtime-native thread/session id before a terminal turn event is emitted. */
151
+ runtimeSession?(sessionId: string): Promise<void>;
132
152
  }
133
153
  /** 一次 run 的本地执行上下文:服务器 payload + daemon 本地准备产物。 */
134
154
  export interface RunExecution {
135
155
  payload: RunStartPayload;
136
- /** run 的隔离工作区目录(runtime cwd)。 */
156
+ /** Runtime cwd. Managed persistent sessions reuse one session-scoped workspace. */
137
157
  workspaceDir: string;
158
+ /** Runtime-native thread/session id to resume; null creates the first native session. */
159
+ nativeSessionId?: string | null;
160
+ /** Monotonic Course Service context revision accepted for this turn. */
161
+ contextRevision?: number;
162
+ /** Scoped runtime-only environment (for example a short-lived model proxy grant). */
163
+ runtimeEnv?: NodeJS.ProcessEnv;
138
164
  }
139
165
  export interface CourseRuntime {
140
166
  id: string;