blun-king-cli 9.1.589 → 9.1.595

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 (46) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +12 -0
  3. package/agent-spine-plugin/CHANGELOG.md +1 -0
  4. package/agent-spine-plugin/docs/host-integration.md +15 -1
  5. package/agent-spine-plugin/docs/preflight-recall.md +1 -1
  6. package/agent-spine-plugin/scripts/check-install-hook.js +6 -5
  7. package/agent-spine-plugin/scripts/check-install-king.js +37 -0
  8. package/agent-spine-plugin/scripts/check-install.js +6 -1
  9. package/agent-spine-plugin/scripts/release-check.js +3 -2
  10. package/agent-spine-plugin/src/cli-agent.js +20 -1
  11. package/agent-spine-plugin/src/cli.js +1 -0
  12. package/agent-spine-plugin/src/index.js +1 -1
  13. package/agent-spine-plugin/src/lib/delivery-command-actions.js +16 -7
  14. package/agent-spine-plugin/src/lib/gateway-control.js +69 -1
  15. package/agent-spine-plugin/src/lib/gateway-host-fencing.js +92 -0
  16. package/agent-spine-plugin/src/lib/gateway-host-lifecycle.js +9 -1
  17. package/agent-spine-plugin/src/lib/gateway-prepared-host.js +28 -0
  18. package/agent-spine-plugin/src/lib/gateway-runs.js +46 -10
  19. package/agent-spine-plugin/src/lib/gateway-runtime.js +1 -1
  20. package/agent-spine-plugin/src/lib/gateway-state.js +3 -1
  21. package/agent-spine-plugin/src/lib/hook-context.js +8 -3
  22. package/agent-spine-plugin/src/lib/hook-output.js +2 -3
  23. package/agent-spine-plugin/src/lib/hook-process-advisory.js +1 -2
  24. package/agent-spine-plugin/src/lib/host-instruction-budget.js +17 -0
  25. package/agent-spine-plugin/src/lib/preflight.js +5 -19
  26. package/agent-spine-plugin/src/lib/source-roots.js +3 -2
  27. package/agent-spine-plugin/src/worker.js +30 -12
  28. package/bin/agentspine-king-goal-inbox.mjs +127 -0
  29. package/bin/agentspine-king-goal-intake.mjs +106 -0
  30. package/bin/agentspine-king-host-runner.mjs +109 -0
  31. package/bin/agentspine-king-snapshot-policy.cjs +80 -0
  32. package/bin/agentspine-king-status-policy.mjs +226 -0
  33. package/bin/agentspine-king-worker-host.mjs +160 -0
  34. package/bin/core-bootstrap.js +20 -3
  35. package/bin/launcher-mode.js +38 -1
  36. package/bin/launcher-restart-policy.cjs +131 -0
  37. package/bin/launcher-runtime.js +48 -9
  38. package/bin/runtime-exit-ledger.cjs +144 -0
  39. package/bin/runtime-exit-ledger.d.cts +23 -0
  40. package/bin/windows-node-crash-dump.cjs +289 -0
  41. package/blun.mjs +83241 -74234
  42. package/bundled-agent-sources.json +109 -34
  43. package/codebase-index/codebase_index.py +470 -0
  44. package/package.json +6 -1
  45. package/telegram-plugin/dist/bridge.mjs +390 -4
  46. package/worker-host.mjs +348023 -0
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ import { appendFile, mkdir, readFile, realpath } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { createGoalHostFactory } from './agentspine-king-host-runner.mjs';
7
+ import { ingestGoalInbox } from './agentspine-king-goal-inbox.mjs';
8
+ import { createGoalStatusReporter, goalResultRejectionCode } from './agentspine-king-status-policy.mjs';
9
+ import snapshotPolicy from './agentspine-king-snapshot-policy.cjs';
10
+
11
+ const packageRoot = path.resolve(import.meta.dirname, '..');
12
+ const required = ['blunHome', 'profile', 'projectRoot', 'projectId', 'standFile', 'expectedStandSha256',
13
+ 'expectedSnapshotSha256', 'exchangeDirectory', 'telegramQueueFile', 'instructionSenderId', 'instructionChatId',
14
+ 'logicalPersonaId', 'runtimePersonaId', 'groupId', 'ownerSubjectId', 'permission', 'gatewayStateDirectory'];
15
+
16
+ export async function loadWorkerConfig(file) {
17
+ const config = JSON.parse(await readFile(path.resolve(file), 'utf8'));
18
+ if (config?.schema !== 'blun.king-goal-worker-config/v1'
19
+ || required.some(key => typeof config[key] !== 'string' || !config[key])
20
+ || (config.instructionThreadId !== null && config.instructionThreadId !== undefined && (typeof config.instructionThreadId !== 'string'
21
+ || !/^[1-9][0-9]*$/.test(config.instructionThreadId) || !Number.isSafeInteger(Number(config.instructionThreadId))))
22
+ || !['manual', 'auto', 'yolo'].includes(config.permission)) throw new Error('KING_WORKER_CONFIG_INVALID');
23
+ for (const key of ['blunHome', 'projectRoot', 'exchangeDirectory', 'telegramQueueFile', 'gatewayStateDirectory']) {
24
+ if (!path.isAbsolute(config[key])) throw new Error('KING_WORKER_ABSOLUTE_PATH_REQUIRED');
25
+ }
26
+ const blunHome = await realpath(config.blunHome);
27
+ const relative = path.relative(blunHome, path.resolve(config.gatewayStateDirectory));
28
+ if (path.isAbsolute(relative) || relative === '..' || relative.startsWith('..' + path.sep)) throw new Error('KING_WORKER_STATE_OUTSIDE_APP_HOME');
29
+ const state = await realpath(config.gatewayStateDirectory);
30
+ const canonicalRelative = path.relative(blunHome, state);
31
+ if (path.isAbsolute(canonicalRelative) || canonicalRelative === '..' || canonicalRelative.startsWith('..' + path.sep)) {
32
+ throw new Error('KING_WORKER_STATE_OUTSIDE_APP_HOME');
33
+ }
34
+ return Object.freeze({ ...config, blunHome, gatewayStateDirectory: state, projectRoot: await realpath(config.projectRoot) });
35
+ }
36
+
37
+ export async function runGuardedWorkerTick(context) {
38
+ const { config, gateway, hostFactory, env, adapter } = context;
39
+ const snapshot = snapshotPolicy.verifyWorkspaceSnapshot({ root: config.projectRoot, projectId: config.projectId,
40
+ standFile: config.standFile, expectedStandSha256: config.expectedStandSha256,
41
+ expectedSnapshotSha256: config.expectedSnapshotSha256 });
42
+ const personas = await gateway.loadPersonaRuntime(snapshot.root);
43
+ const persona = personas.runtime.personas.find(item => item.personaId === config.runtimePersonaId && item.status === 'active');
44
+ const binding = personas.policy.bindings.find(item => item.id === persona?.bindingId && item.active);
45
+ if (!binding || binding.profileId !== config.profile || binding.groupId !== config.groupId) {
46
+ throw new Error('KING_WORKER_IDENTITY_MISMATCH');
47
+ }
48
+ const { withOwnedFileLock } = await import(pathToFileURL(path.join(packageRoot,
49
+ 'agent-spine-plugin/src/lib/owned-file-lock.js')).href);
50
+ const statusReporter = createGoalStatusReporter({
51
+ stateFile: path.join(config.blunHome, 'worker', 'goal-status-v2.json'),
52
+ scope: { logicalPersonaId: config.logicalPersonaId, runtimePersonaId: config.runtimePersonaId,
53
+ projectId: config.projectId, groupId: config.groupId, ownerSubjectId: config.ownerSubjectId,
54
+ chatId: config.instructionChatId, senderId: config.instructionSenderId,
55
+ threadId: config.instructionThreadId ?? null },
56
+ withOwnedFileLock, loadPolicy: () => gateway.loadChannelPolicy(snapshot.root), adapter,
57
+ });
58
+ const statusReports = [];
59
+ const reportCommittedStatus = async () => {
60
+ const context = await gateway.gatewayContext({ root: snapshot.root, agentId: config.runtimePersonaId });
61
+ if (!context.enabled || context.killSwitch) return;
62
+ await statusReporter.reconcile(context);
63
+ statusReports.push(...await statusReporter.flush());
64
+ };
65
+ // Reconcile the saved goal state first: a prior process may have stopped after committing its tick.
66
+ await reportCommittedStatus();
67
+ const intake = await ingestGoalInbox({ exchangeDirectory: config.exchangeDirectory,
68
+ queueFile: config.telegramQueueFile, checkpointFile: path.join(config.blunHome, 'worker', 'goal-queue-checkpoint.json'),
69
+ instructionSenderId: config.instructionSenderId, instructionChatId: config.instructionChatId,
70
+ instructionThreadId: config.instructionThreadId,
71
+ logicalPersonaId: config.logicalPersonaId, runtimePersonaId: config.runtimePersonaId,
72
+ projectId: config.projectId, groupId: config.groupId, ownerSubjectId: config.ownerSubjectId,
73
+ snapshot, assignGoal: gateway.assignGoal,
74
+ recordResult: result => result.status === 'rejected'
75
+ ? statusReporter.record({ kind: 'intake-rejected', eventId: result.eventId,
76
+ messageId: result.messageId, error: result.error }) : undefined });
77
+ const result = await gateway.runWorkerTick({ root: snapshot.root, workerId: 'gateway-worker:blun:' + process.pid,
78
+ agentId: config.runtimePersonaId, projectId: config.projectId, groupId: config.groupId,
79
+ goalOnly: true, env,
80
+ hostFactory: async item => {
81
+ const host = await hostFactory(item);
82
+ return { ...host, async run() {
83
+ try { return await host.run(); }
84
+ catch (error) {
85
+ const rejection = goalResultRejectionCode(error);
86
+ if (rejection) {
87
+ // Record the concrete parser failure before the gateway replaces it with an execution hold.
88
+ await statusReporter.record({ kind: 'result-rejected', eventId: `${item.queueId}:${item.attempts}`,
89
+ goalId: item.goal.goalId, goalStepId: item.goalStep?.stepId ?? null,
90
+ attempt: item.attempts, error: rejection });
91
+ }
92
+ throw error;
93
+ }
94
+ } };
95
+ },
96
+ adapter: { send: item => adapter.send(item) } });
97
+ await reportCommittedStatus();
98
+ return { ...result, intake, statusReports };
99
+ }
100
+
101
+ export async function runWorkerHost(config, { once = false, signal, onResult = () => {} } = {}) {
102
+ const gateway = await import(pathToFileURL(path.join(packageRoot, 'agent-spine-plugin/src/index.js')).href);
103
+ const { createKingWorkerHarness, LocalKaos } = await import(pathToFileURL(path.join(packageRoot, 'worker-host.mjs')).href);
104
+ const env = { ...process.env, BLUN_HOME: config.blunHome, BLUN_PROFILE: config.profile };
105
+ const harness = createKingWorkerHarness({ homeDir: config.blunHome, configPath: config.settingsPath });
106
+ const hostFactory = createGoalHostFactory({ harness, workDir: config.projectRoot, model: config.model,
107
+ permission: config.permission, tickTimeoutMs: config.tickTimeoutMs, cleanupTimeoutMs: config.cleanupTimeoutMs,
108
+ sessionOptions: { kaos: (await LocalKaos.create()).withCwd(config.projectRoot).withEnv({
109
+ BLUN_HOME: config.blunHome, BLUN_PROFILE: config.profile, AGENTSPINE_STATE_DIR: config.gatewayStateDirectory,
110
+ }) },
111
+ signal });
112
+ const adapter = gateway.createTelegramAdapter({ root: config.projectRoot, env });
113
+ try {
114
+ do {
115
+ if (signal?.aborted) return { status: 'stopped', processed: false };
116
+ const result = await runGuardedWorkerTick({ config, gateway, hostFactory, env, adapter });
117
+ await mkdir(path.join(config.blunHome, 'worker'), { recursive: true });
118
+ await appendFile(path.join(config.blunHome, 'worker', 'worker.log'), JSON.stringify({
119
+ schema: 'blun.king-goal-worker-log/v1', at: new Date().toISOString(), status: result.status,
120
+ queueId: result.queueId ?? null, hostSettlement: result.hostSettlement ?? null,
121
+ statusReports: result.statusReports,
122
+ intake: result.intake.map(item => ({ status: item.status, messageId: item.messageId, error: item.error }))
123
+ }) + '\n', { mode: 0o600 });
124
+ await onResult(result);
125
+ if (once || result.status === 'stopped' || result.status === 'blocked') return result;
126
+ await delay(result.processed ? 1000 : 15000, undefined, { signal }).catch(error => {
127
+ if (error.name !== 'AbortError') throw error;
128
+ });
129
+ } while (!signal?.aborted);
130
+ return { status: 'stopped', processed: false };
131
+ } finally {
132
+ let quiet = true;
133
+ for (const session of harness.sessions.values()) {
134
+ const proof = await session.quiesce({ deadlineAt: Date.now() + (config.cleanupTimeoutMs ?? 10000) }).catch(() => null);
135
+ if (!proof || proof.status !== 'quiescent' || proof.remaining.length) quiet = false;
136
+ }
137
+ // Never turn a cleanup deadline into a successful host termination acknowledgement.
138
+ if (quiet) await harness.close();
139
+ }
140
+ }
141
+
142
+ async function main() {
143
+ const args = process.argv.slice(2);
144
+ const index = args.indexOf('--config');
145
+ if (index < 0 || !args[index + 1] || args[index + 1].startsWith('--')) throw new Error('--config requires a path');
146
+ const config = await loadWorkerConfig(args[index + 1]);
147
+ process.env.BLUN_HOME = config.blunHome;
148
+ process.env.BLUN_PROFILE = config.profile;
149
+ process.env.AGENTSPINE_STATE_DIR = config.gatewayStateDirectory;
150
+ const controller = new AbortController();
151
+ const stop = () => controller.abort();
152
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
153
+ try { await runWorkerHost(config, { once: args.includes('--once'), signal: controller.signal,
154
+ onResult: result => process.stdout.write(JSON.stringify(result) + '\n') }); }
155
+ finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); }
156
+ }
157
+
158
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
159
+ main().catch(error => { process.stderr.write(String(error.message).slice(0, 500) + '\n'); process.exitCode = 1; });
160
+ }
@@ -5,22 +5,39 @@ const path = require('node:path');
5
5
  const { pathToFileURL } = require('node:url');
6
6
 
7
7
  const { acquireSharedRuntimeLease } = require('./update-lease');
8
+ const { installWindowsNodeCrashDump } = require('./windows-node-crash-dump.cjs');
9
+ const { recordRuntimeExit } = require('./runtime-exit-ledger.cjs');
8
10
 
9
11
  const CORE_LOADED_MESSAGE = 'blun-core-bootstrap-loaded';
10
12
 
11
13
  async function runCoreBootstrap() {
14
+ let diagnostics;
15
+ try { diagnostics = installWindowsNodeCrashDump({ homeDir: process.env.BLUN_HOME }); } catch {}
12
16
  const packageRoot = path.resolve(__dirname, '..');
13
17
  const mainPath = path.join(packageRoot, 'blun.mjs');
14
- const leaseResult = await acquireSharedRuntimeLease({ packageRoot });
15
- if (!leaseResult.acquired) throw new Error('RUNTIME_PROTECTION_UNAVAILABLE');
18
+ let leaseResult;
16
19
 
17
20
  try {
21
+ leaseResult = await acquireSharedRuntimeLease({ packageRoot });
22
+ if (!leaseResult.acquired) throw new Error('RUNTIME_PROTECTION_UNAVAILABLE');
18
23
  leaseResult.lease.assertOwned();
19
24
  process.argv = [process.execPath, mainPath, ...process.argv.slice(2)];
20
25
  await import(pathToFileURL(mainPath).href);
21
26
  leaseResult.lease.assertOwned();
22
27
  } catch (error) {
23
- await leaseResult.lease.release();
28
+ try {
29
+ if (diagnostics?.enabled) diagnostics.write(error, 'bootstrap-failure');
30
+ else recordRuntimeExit({ homeDir: process.env.BLUN_HOME, source: 'bootstrap',
31
+ kind: 'bootstrap-failure', phase: 'startup', error });
32
+ } catch {}
33
+ try { diagnostics?.dispose(); } catch {}
34
+ try { await leaseResult?.lease?.release(); }
35
+ catch (leaseError) {
36
+ try {
37
+ recordRuntimeExit({ homeDir: process.env.BLUN_HOME, source: 'bootstrap',
38
+ kind: 'lease-release-failure', phase: 'cleanup', error: leaseError });
39
+ } catch {}
40
+ }
24
41
  throw error;
25
42
  }
26
43
 
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
3
6
  const LAUNCHER_MODES = Object.freeze({
4
7
  BLUN: 'blun',
5
8
  KING: 'king',
@@ -22,9 +25,43 @@ function launcherModeFromArgv(argv) {
22
25
  return command === LAUNCHER_MODES.KING ? LAUNCHER_MODES.KING : LAUNCHER_MODES.BLUN;
23
26
  }
24
27
 
25
- function createLauncherEnvironment(sourceEnv, mode, publicPackageVersion) {
28
+ function runtimePolicyEnvironment(blunDir) {
29
+ if (blunDir === undefined) return {};
30
+ let policy;
31
+ try {
32
+ if (!path.isAbsolute(blunDir)) throw new Error('Invalid home');
33
+ const file = path.join(blunDir, 'runtime-policy.json');
34
+ const stat = fs.lstatSync(file);
35
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error('Invalid file');
36
+ policy = JSON.parse(fs.readFileSync(file, 'utf8'));
37
+ } catch (error) {
38
+ if (error.code === 'ENOENT') return {};
39
+ throw new Error('Invalid BLUN runtime policy');
40
+ }
41
+ const review = policy?.responseReview;
42
+ if (policy?.schema !== 'blun.runtime-policy/v1'
43
+ || Object.keys(policy).sort().join(',') !== 'responseReview,schema'
44
+ || !review || Object.keys(review).sort().join(',') !== 'guardVersion,language,required'
45
+ || review.required !== true || typeof review.language !== 'string'
46
+ || !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(review.language)
47
+ || /^(auto|all)$/i.test(review.language)
48
+ || typeof review.guardVersion !== 'string' || !/^\d+\.\d+\.\d+$/.test(review.guardVersion)) {
49
+ throw new Error('Invalid BLUN runtime policy');
50
+ }
51
+ return {
52
+ BLUN_LANGUAGE_GUARD_MANDATORY: '1',
53
+ BLUN_LANGUAGE_GUARD_LANGUAGE: review.language,
54
+ BLUN_LANGUAGE_GUARD_VERSION: review.guardVersion,
55
+ BLUN_TELEGRAM_LANGUAGE_GATE: 'required',
56
+ BLUN_TELEGRAM_OUTPUT_LOCALE: review.language,
57
+ AGENTSPINE_STATE_DIR: path.join(blunDir, 'state', 'agent-spine'),
58
+ };
59
+ }
60
+
61
+ function createLauncherEnvironment(sourceEnv, mode, publicPackageVersion, blunDir) {
26
62
  return {
27
63
  ...sourceEnv,
64
+ ...runtimePolicyEnvironment(blunDir),
28
65
  BLUN_NO_AUTO_UPDATE: '1',
29
66
  BLUN_PUBLIC_PACKAGE_VERSION: publicPackageVersion,
30
67
  BLUN_TELEGRAM_ATTACH: mode === LAUNCHER_MODES.KING ? 'on' : 'off',
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const RESTART_WINDOW_MS = 60 * 60 * 1000;
8
+ const MAX_RESTARTS_PER_WINDOW = 3;
9
+ const RESTART_DELAYS_MS = Object.freeze([1000, 3000, 5000]);
10
+ const INTENTIONAL_STOP_TTL_MS = 2 * 60 * 1000;
11
+ const OPERATOR_SIGNALS = new Set(['SIGHUP', 'SIGINT', 'SIGTERM']);
12
+
13
+ function restartBudgetPath(homeDir) {
14
+ return path.join(path.resolve(homeDir), 'diagnostics', 'launcher-restart-budget.json');
15
+ }
16
+
17
+ function intentionalStopMarkerPath(homeDir) {
18
+ return path.join(path.resolve(homeDir), 'diagnostics', 'intentional-core-stop.json');
19
+ }
20
+
21
+ function launcherStatusQueuePath(homeDir, stateDir) {
22
+ return path.join(stateDir ? path.resolve(stateDir) : path.join(path.resolve(homeDir), 'channels', 'telegram'),
23
+ 'launcher-status-queue.jsonl');
24
+ }
25
+
26
+ function atomicWriteJson(file, value, fsImpl = fs) {
27
+ fsImpl.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
28
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
29
+ try {
30
+ fsImpl.writeFileSync(temporary, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
31
+ fsImpl.renameSync(temporary, file);
32
+ } finally {
33
+ try { fsImpl.rmSync(temporary, { force: true }); } catch {}
34
+ }
35
+ }
36
+
37
+ function claimLauncherRestart(options) {
38
+ const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
39
+ const fsImpl = options.fsImpl || fs;
40
+ const file = restartBudgetPath(options.homeDir);
41
+ const denied = { allowed: false, attempt: MAX_RESTARTS_PER_WINDOW, limit: MAX_RESTARTS_PER_WINDOW, windowMs: RESTART_WINDOW_MS };
42
+ let attempts = [];
43
+ try {
44
+ const stat = fsImpl.lstatSync(file);
45
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) return denied;
46
+ const saved = JSON.parse(fsImpl.readFileSync(file, 'utf8'));
47
+ if (saved?.schema !== 'blun.launcher-restart-budget/v1' || !Array.isArray(saved.attempts)
48
+ || saved.attempts.some((value) => !Number.isFinite(value) || value > nowMs)) return denied;
49
+ attempts = saved.attempts.filter((value) => value > nowMs - RESTART_WINDOW_MS);
50
+ } catch (error) {
51
+ if (error.code !== 'ENOENT') return denied;
52
+ }
53
+ if (attempts.length >= MAX_RESTARTS_PER_WINDOW) return { ...denied, attempt: attempts.length };
54
+ attempts.push(nowMs);
55
+ try {
56
+ atomicWriteJson(file, {
57
+ schema: 'blun.launcher-restart-budget/v1', updatedAt: new Date(nowMs).toISOString(), attempts,
58
+ }, fsImpl);
59
+ } catch {
60
+ // Never restart without a durable budget claim.
61
+ return denied;
62
+ }
63
+ return { allowed: true, attempt: attempts.length, limit: MAX_RESTARTS_PER_WINDOW, windowMs: RESTART_WINDOW_MS };
64
+ }
65
+
66
+ function isWindowsFailFastExitCode(code) {
67
+ if (!Number.isInteger(code)) return false;
68
+ const unsigned = code >>> 0;
69
+ return unsigned >= 0xC0000000 && unsigned !== 0xC000013A;
70
+ }
71
+
72
+ function isAbnormalCoreExit(result, loaded) {
73
+ if (OPERATOR_SIGNALS.has(result?.signal)) return false;
74
+ return Boolean(result?.error) || (loaded === true && isWindowsFailFastExitCode(result?.code));
75
+ }
76
+
77
+ function writeIntentionalStopMarker(options) {
78
+ const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
79
+ const file = intentionalStopMarkerPath(options.homeDir);
80
+ atomicWriteJson(file, {
81
+ schema: 'blun.intentional-core-stop/v1', pid: options.pid,
82
+ createdAtMs: nowMs, expiresAtMs: nowMs + INTENTIONAL_STOP_TTL_MS,
83
+ }, options.fsImpl);
84
+ return file;
85
+ }
86
+
87
+ function consumeIntentionalStopMarker(options) {
88
+ const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
89
+ const file = intentionalStopMarkerPath(options.homeDir);
90
+ const fsImpl = options.fsImpl || fs;
91
+ let marker;
92
+ try {
93
+ const stat = fsImpl.lstatSync(file);
94
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) return false;
95
+ marker = JSON.parse(fsImpl.readFileSync(file, 'utf8'));
96
+ fsImpl.rmSync(file);
97
+ } catch { return false; }
98
+ return marker?.schema === 'blun.intentional-core-stop/v1'
99
+ && Number.isInteger(marker.pid) && marker.pid === options.childPid
100
+ && Number.isFinite(marker.createdAtMs) && Number.isFinite(marker.expiresAtMs)
101
+ && marker.createdAtMs <= nowMs && marker.expiresAtMs >= nowMs
102
+ && marker.expiresAtMs - marker.createdAtMs <= INTENTIONAL_STOP_TTL_MS;
103
+ }
104
+
105
+ function restartDelayMs(attempt) {
106
+ return RESTART_DELAYS_MS[Math.max(0, Math.min(RESTART_DELAYS_MS.length - 1, attempt - 1))];
107
+ }
108
+
109
+ function appendLauncherStatus(options) {
110
+ const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
111
+ const exhausted = options.kind === 'launcher-restart-exhausted';
112
+ const signal = options.signal ? ` signal=${options.signal}` : '';
113
+ const event = {
114
+ schema: 'blun.launcher-status/v1', id: crypto.randomUUID(), createdAt: new Date(nowMs).toISOString(),
115
+ kind: options.kind, attempt: options.attempt, limit: options.limit,
116
+ exitCode: options.exitCode, signal: options.signal,
117
+ text: `[launcher.restart] ${exhausted ? 'stopped ' : ''}${options.attempt}/${options.limit} exit=${options.exitCode}${signal}`,
118
+ };
119
+ const file = launcherStatusQueuePath(options.homeDir, options.stateDir);
120
+ const fsImpl = options.fsImpl || fs;
121
+ fsImpl.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
122
+ fsImpl.appendFileSync(file, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600 });
123
+ return event;
124
+ }
125
+
126
+ module.exports = {
127
+ INTENTIONAL_STOP_TTL_MS, MAX_RESTARTS_PER_WINDOW, RESTART_DELAYS_MS, RESTART_WINDOW_MS,
128
+ appendLauncherStatus, claimLauncherRestart, consumeIntentionalStopMarker, intentionalStopMarkerPath,
129
+ isAbnormalCoreExit, isWindowsFailFastExitCode, launcherStatusQueuePath, restartBudgetPath,
130
+ restartDelayMs, writeIntentionalStopMarker,
131
+ };
@@ -22,6 +22,11 @@ const {
22
22
  seedStandardTools,
23
23
  } = require('./standard-tools-bootstrap');
24
24
  const { CORE_LOADED_MESSAGE } = require('./core-bootstrap');
25
+ const {
26
+ appendLauncherStatus, claimLauncherRestart, consumeIntentionalStopMarker,
27
+ isAbnormalCoreExit, restartDelayMs,
28
+ } = require('./launcher-restart-policy.cjs');
29
+ const { recordRuntimeExit } = require('./runtime-exit-ledger.cjs');
25
30
  const { prepareManagedNodeRuntime } = require('./node-runtime');
26
31
  const { repairConfiguredNativeModules } = require('./native-module-repair');
27
32
  const { acquireSharedRuntimeLease, tryAcquireUpdateLease } = require('./update-lease');
@@ -109,6 +114,7 @@ function spawnProtectedCore(args, env, cwd) {
109
114
  );
110
115
  let loadedSettled = false;
111
116
  let completedSettled = false;
117
+ let operatorSignal;
112
118
  let resolveLoaded;
113
119
  let resolveCompleted;
114
120
  const loadTimer = setTimeout(() => {
@@ -129,11 +135,12 @@ function spawnProtectedCore(args, env, cwd) {
129
135
  completedSettled = true;
130
136
  for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
131
137
  settleLoaded(false);
132
- resolveCompleted(result);
138
+ resolveCompleted(operatorSignal === undefined ? result : { ...result, signal: operatorSignal });
133
139
  };
134
140
  const signalHandlers = new Map();
135
141
  for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
136
142
  const handler = () => {
143
+ operatorSignal = signal;
137
144
  try {
138
145
  child.kill(signal);
139
146
  } catch {}
@@ -154,12 +161,45 @@ function spawnProtectedCore(args, env, cwd) {
154
161
  }
155
162
 
156
163
  async function superviseProtectedCore(args, env, cwd, releaseNotice) {
157
- const core = spawnProtectedCore(args, env, cwd);
158
- const loaded = await core.loaded;
159
- await releaseNotice();
160
- const result = await core.completed;
161
- if (result.error) throw result.error;
162
- return exitCodeForChild(result, loaded);
164
+ let noticeReleased = false;
165
+ for (;;) {
166
+ const core = spawnProtectedCore(args, env, cwd);
167
+ const loaded = await core.loaded;
168
+ if (!noticeReleased) {
169
+ noticeReleased = true;
170
+ await releaseNotice();
171
+ }
172
+ const result = await core.completed;
173
+ const exitCode = result.error ? 1 : exitCodeForChild(result, loaded);
174
+ const intentionalStop = consumeIntentionalStopMarker({
175
+ homeDir: env.BLUN_HOME, childPid: core.child.pid, nowMs: Date.now(),
176
+ });
177
+ recordRuntimeExit({
178
+ homeDir: env.BLUN_HOME, source: 'launcher', kind: result.error ? 'child-error' : 'child-exit',
179
+ exitCode, signal: result.signal, phase: loaded ? 'runtime' : 'startup',
180
+ cliVersion: env.BLUN_PUBLIC_PACKAGE_VERSION, profile: env.BLUN_PROFILE,
181
+ loaded, childPid: core.child.pid, error: result.error,
182
+ });
183
+ if (intentionalStop || !isAbnormalCoreExit(result, loaded)) {
184
+ if (result.error) throw result.error;
185
+ return exitCode;
186
+ }
187
+ const claim = claimLauncherRestart({ homeDir: env.BLUN_HOME, nowMs: Date.now() });
188
+ try {
189
+ appendLauncherStatus({
190
+ homeDir: env.BLUN_HOME,
191
+ stateDir: env.BLUN_TELEGRAM_STATE_DIR
192
+ || path.join(env.BLUN_SHARED_HOME || env.BLUN_HOME, 'channels', 'telegram'),
193
+ nowMs: Date.now(), attempt: claim.attempt, limit: claim.limit, exitCode, signal: result.signal,
194
+ kind: claim.allowed ? 'launcher-restart' : 'launcher-restart-exhausted',
195
+ });
196
+ } catch {}
197
+ if (!claim.allowed) {
198
+ if (result.error) throw result.error;
199
+ return exitCode;
200
+ }
201
+ await new Promise((resolve) => setTimeout(resolve, restartDelayMs(claim.attempt)));
202
+ }
163
203
  }
164
204
 
165
205
  function exitCodeForChild(result, loaded) {
@@ -403,9 +443,8 @@ async function runLauncher(options = {}) {
403
443
  }
404
444
 
405
445
  // --- 6. Start -----------------------------------------------------------
406
- const env = createLauncherEnvironment(process.env, mode, readPackageVersion());
446
+ const env = createLauncherEnvironment(process.env, mode, readPackageVersion(), blunDir);
407
447
  env.BLUN_HOME = blunDir;
408
- env.BLUN_MODEL_MAX_COMPLETION_TOKENS = env.BLUN_MODEL_MAX_COMPLETION_TOKENS || '32768';
409
448
  process.exitCode = await superviseProtectedCore(
410
449
  ARGS,
411
450
  env,
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ const DEFAULT_MAX_BYTES = 1024 * 1024;
8
+ const DEFAULT_RETAIN_BYTES = 512 * 1024;
9
+
10
+ function runtimeExitLedgerPath(homeDir) {
11
+ return path.join(homeDir, 'diagnostics', 'runtime-exits.jsonl');
12
+ }
13
+
14
+ function sanitizeExitText(input, maxChars = 2048) {
15
+ let value = String(input ?? '');
16
+ const homes = new Set([os.homedir(), process.env.USERPROFILE, process.env.HOME]
17
+ .filter((entry) => typeof entry === 'string' && entry.length > 2));
18
+ for (const home of homes) {
19
+ for (const variant of [path.normalize(home), home.replaceAll('\\', '/')]) {
20
+ const escaped = variant.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
21
+ value = value.replace(new RegExp(escaped, process.platform === 'win32' ? 'giu' : 'gu'), '$HOME');
22
+ }
23
+ }
24
+ value = value
25
+ .replace(/[A-Za-z]:[\\/]Users[\\/][^\\/\s"']+/giu, '$HOME')
26
+ .replace(/\/home\/[^/\s"']+/gu, '$HOME')
27
+ .replace(/(Authorization\s*:\s*Bearer\s+)[^\s,;]+/giu, '$1<redacted>')
28
+ .replace(/\bsk-[A-Za-z0-9_-]{10,}\b/gu, '<redacted>')
29
+ .replace(/\b\d{6,}:[A-Za-z0-9_-]{20,}\b/gu, '<redacted>')
30
+ .replace(/("(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password)"\s*:\s*)"(?:\\.|[^"\\])*"/giu, '$1"<redacted>"')
31
+ .replace(/((?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password)\s*[=:]\s*)[^\s,;}]+/giu, '$1<redacted>')
32
+ .replace(/(https?:\/\/[^\s/:@]+:)[^\s/@]+@/giu, '$1<redacted>@');
33
+ return value.length <= maxChars ? value : `${value.slice(0, maxChars)}...[truncated]`;
34
+ }
35
+
36
+ function buildRuntimeExitRecord(input) {
37
+ const now = typeof input.now === 'function' ? input.now() : new Date();
38
+ const record = { schemaVersion: 1, ts: new Date(now).toISOString() };
39
+ for (const key of ['source', 'kind', 'signal', 'phase', 'cliVersion', 'profile', 'sessionId', 'lastTool', 'detail']) {
40
+ if (input[key] !== undefined && input[key] !== '') record[key] = sanitizeExitText(input[key]);
41
+ }
42
+ for (const [key, value] of Object.entries({ exitCode: input.exitCode, pid: input.pid ?? process.pid,
43
+ ppid: input.ppid ?? process.ppid, childPid: input.childPid })) {
44
+ if (Number.isInteger(value)) record[key] = value;
45
+ }
46
+ if (typeof input.loaded === 'boolean') record.loaded = input.loaded;
47
+ if (input.error !== undefined && input.error !== null) {
48
+ record.error = {
49
+ name: sanitizeExitText(input.error.name || 'Error', 160),
50
+ message: sanitizeExitText(input.error.message || input.error),
51
+ stack: sanitizeExitText(input.error.stack || '', 8192),
52
+ };
53
+ }
54
+ return record;
55
+ }
56
+
57
+ function directoryIdentity(directory) {
58
+ const stat = fs.lstatSync(directory, { bigint: true });
59
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Unsafe exit directory');
60
+ return stat;
61
+ }
62
+
63
+ function sameIdentity(left, right) {
64
+ return left.dev === right.dev && left.ino === right.ino;
65
+ }
66
+
67
+ function regularLedger(stat) {
68
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n) throw new Error('Unsafe exit ledger');
69
+ return stat;
70
+ }
71
+
72
+ function trimLedger(descriptor, maxBytes, retainBytes, incomingBytes) {
73
+ const size = Number(regularLedger(fs.fstatSync(descriptor, { bigint: true })).size);
74
+ if (!Number.isSafeInteger(size)) throw new Error('Invalid exit ledger size');
75
+ if (size + incomingBytes <= maxBytes) return;
76
+ const keepBytes = Math.max(0, Math.min(size, retainBytes, maxBytes - incomingBytes));
77
+ const contents = Buffer.alloc(keepBytes);
78
+ const read = fs.readSync(descriptor, contents, 0, keepBytes, size - keepBytes);
79
+ const tail = contents.subarray(0, read);
80
+ const lineStart = size > keepBytes ? tail.indexOf(0x0a) + 1 : 0;
81
+ fs.ftruncateSync(descriptor, 0);
82
+ fs.writeFileSync(descriptor, lineStart > 0 || size === keepBytes ? tail.subarray(lineStart) : '');
83
+ }
84
+
85
+ function recordRuntimeExit(input = {}) {
86
+ let descriptor;
87
+ let appendDescriptor;
88
+ try {
89
+ const homeDir = typeof input.homeDir === 'string' ? input.homeDir.trim() : '';
90
+ if (!homeDir || !path.isAbsolute(homeDir)) return false;
91
+ // Pin the private home and diagnostic directory before opening any output.
92
+ fs.mkdirSync(homeDir, { recursive: true, mode: 0o700 });
93
+ const homeIdentity = directoryIdentity(homeDir);
94
+ const file = runtimeExitLedgerPath(homeDir);
95
+ const directory = path.dirname(file);
96
+ try { fs.mkdirSync(directory, { mode: 0o700 }); }
97
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
98
+ const directoryStat = directoryIdentity(directory);
99
+ let before;
100
+ try { before = regularLedger(fs.lstatSync(file, { bigint: true })); }
101
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
102
+ const flags = fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW ?? 0)
103
+ | (fs.constants.O_NONBLOCK ?? 0) | (before ? 0 : fs.constants.O_CREAT | fs.constants.O_EXCL);
104
+ descriptor = fs.openSync(file, flags, 0o600);
105
+ const opened = regularLedger(fs.fstatSync(descriptor, { bigint: true }));
106
+ if ((before && !sameIdentity(before, opened))
107
+ || !sameIdentity(opened, regularLedger(fs.lstatSync(file, { bigint: true })))
108
+ || !sameIdentity(homeIdentity, directoryIdentity(homeDir))
109
+ || !sameIdentity(directoryStat, directoryIdentity(directory))) throw new Error('Exit ledger changed');
110
+ const maxBytes = Math.max(1024, Number.isInteger(input.maxBytes) ? input.maxBytes : DEFAULT_MAX_BYTES);
111
+ const retainBytes = Math.max(0, Number.isInteger(input.retainBytes) ? input.retainBytes : DEFAULT_RETAIN_BYTES);
112
+ const record = buildRuntimeExitRecord(input);
113
+ let line = `${JSON.stringify(record)}\n`;
114
+ // Retain identity/exit fields even when a diagnostic detail exceeds the cap.
115
+ for (const key of ['error', 'detail', 'lastTool', 'sessionId', 'profile', 'cliVersion', 'phase', 'source', 'kind', 'signal']) {
116
+ if (Buffer.byteLength(line) <= maxBytes) break;
117
+ delete record[key];
118
+ record.truncated = true;
119
+ line = `${JSON.stringify(record)}\n`;
120
+ }
121
+ trimLedger(descriptor, maxBytes, retainBytes, Buffer.byteLength(line));
122
+ // Windows append-only handles cannot truncate. Keep both handles bound to
123
+ // the same checked inode, using the second handle only for atomic append.
124
+ appendDescriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_APPEND
125
+ | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
126
+ if (!sameIdentity(opened, regularLedger(fs.fstatSync(appendDescriptor, { bigint: true })))
127
+ || !sameIdentity(opened, regularLedger(fs.lstatSync(file, { bigint: true })))
128
+ || !sameIdentity(homeIdentity, directoryIdentity(homeDir))
129
+ || !sameIdentity(directoryStat, directoryIdentity(directory))) throw new Error('Exit ledger changed');
130
+ fs.appendFileSync(appendDescriptor, line, { encoding: 'utf8' });
131
+ try { fs.fchmodSync(descriptor, 0o600); } catch {}
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ } finally {
136
+ if (appendDescriptor !== undefined) { try { fs.closeSync(appendDescriptor); } catch {} }
137
+ if (descriptor !== undefined) { try { fs.closeSync(descriptor); } catch {} }
138
+ }
139
+ }
140
+
141
+ module.exports = {
142
+ DEFAULT_MAX_BYTES, DEFAULT_RETAIN_BYTES, buildRuntimeExitRecord, recordRuntimeExit,
143
+ runtimeExitLedgerPath, sanitizeExitText,
144
+ };
@@ -0,0 +1,23 @@
1
+ export interface RuntimeExitInput {
2
+ homeDir?: string;
3
+ source?: string;
4
+ kind?: string;
5
+ signal?: string;
6
+ phase?: string;
7
+ cliVersion?: string;
8
+ profile?: string;
9
+ sessionId?: string;
10
+ lastTool?: string;
11
+ detail?: string;
12
+ exitCode?: number;
13
+ pid?: number;
14
+ ppid?: number;
15
+ childPid?: number;
16
+ loaded?: boolean;
17
+ error?: unknown;
18
+ maxBytes?: number;
19
+ retainBytes?: number;
20
+ now?: () => Date | number | string;
21
+ }
22
+
23
+ export function recordRuntimeExit(input?: RuntimeExitInput): boolean;