@kin-tio/cli 0.6.0

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 (66) hide show
  1. package/.env.example +46 -0
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +202 -0
  4. package/README.md +150 -0
  5. package/README.zh-CN.md +79 -0
  6. package/THIRD_PARTY_NOTICES +31 -0
  7. package/assets/ilink-login-card.png +0 -0
  8. package/bin/kintio.js +3 -0
  9. package/codex-workspace/.agents/skills/wechat-kf-reply-sop/SKILL.md +58 -0
  10. package/dist/cli.js +3 -0
  11. package/dist/daemon.js +28 -0
  12. package/dist/index.js +70 -0
  13. package/dist/mcp-relay.js +11 -0
  14. package/dist/src/agent/runtime.js +1 -0
  15. package/dist/src/app.js +34 -0
  16. package/dist/src/cli.js +578 -0
  17. package/dist/src/config.js +237 -0
  18. package/dist/src/domain/message.js +23 -0
  19. package/dist/src/domain/send-contract.js +205 -0
  20. package/dist/src/domain/wecom-message.js +281 -0
  21. package/dist/src/ilink/executor.js +306 -0
  22. package/dist/src/ilink/inbound-image.js +310 -0
  23. package/dist/src/ilink/listener.js +306 -0
  24. package/dist/src/ilink/login-manager.js +198 -0
  25. package/dist/src/ilink/login-store.js +197 -0
  26. package/dist/src/ilink/media-gateway.js +83 -0
  27. package/dist/src/ilink/media.js +267 -0
  28. package/dist/src/ilink/message.js +247 -0
  29. package/dist/src/ilink/protocol/client.js +464 -0
  30. package/dist/src/ilink/protocol/types.js +35 -0
  31. package/dist/src/ilink/qr.js +109 -0
  32. package/dist/src/ilink/secret-box.js +143 -0
  33. package/dist/src/ilink/sqlite-store.js +1194 -0
  34. package/dist/src/ilink/store-types.js +63 -0
  35. package/dist/src/lib/image-format.js +23 -0
  36. package/dist/src/lib/path-identity.js +38 -0
  37. package/dist/src/lib/private-directory.js +51 -0
  38. package/dist/src/lib/text.js +19 -0
  39. package/dist/src/lib/wecom-crypto.js +74 -0
  40. package/dist/src/lib/xml.js +8 -0
  41. package/dist/src/mcp/conversation-memory-server.js +179 -0
  42. package/dist/src/mcp/ilink-server.js +158 -0
  43. package/dist/src/mcp/ipc-host.js +275 -0
  44. package/dist/src/mcp/ipc-protocol.js +226 -0
  45. package/dist/src/mcp/stdio-relay.js +122 -0
  46. package/dist/src/mcp/wechat-kf-executor.js +295 -0
  47. package/dist/src/mcp/wechat-kf-server.js +208 -0
  48. package/dist/src/routes/wecom.js +89 -0
  49. package/dist/src/runtime/daemon-protocol.js +202 -0
  50. package/dist/src/runtime/managed-skill.js +49 -0
  51. package/dist/src/runtime/native-daemon.js +325 -0
  52. package/dist/src/runtime/single-instance-lock.js +167 -0
  53. package/dist/src/runtime.js +503 -0
  54. package/dist/src/services/codex-agent.js +542 -0
  55. package/dist/src/services/codex-app-server.js +436 -0
  56. package/dist/src/services/conversation-processor.js +762 -0
  57. package/dist/src/services/image-stager.js +49 -0
  58. package/dist/src/services/media-gateway.js +83 -0
  59. package/dist/src/services/wecom-api.js +311 -0
  60. package/dist/src/services/wecom-sync.js +316 -0
  61. package/dist/src/state/persistence.js +124 -0
  62. package/dist/src/state/sqlite-store.js +3102 -0
  63. package/dist/src/supervisor.js +212 -0
  64. package/dist/src/types.js +1 -0
  65. package/dist/src/version.js +1 -0
  66. package/package.json +72 -0
@@ -0,0 +1,202 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import net from 'node:net';
4
+ import path from 'node:path';
5
+ import * as z from 'zod/v4';
6
+ import { canonicalPath } from '../lib/path-identity.js';
7
+ import { ensurePrivateDirectory } from '../lib/private-directory.js';
8
+ export const CONTROL_MAX_BYTES = 4 * 1024;
9
+ export const CONTROL_TIMEOUT_MS = 2_000;
10
+ const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/u;
11
+ const positiveInteger = z.number().int().positive();
12
+ const runId = z.string().regex(ID_PATTERN, 'runId is invalid');
13
+ const token = z.string()
14
+ .regex(/^[A-Za-z0-9_-]{32,128}$/u, 'control token is invalid');
15
+ const phase = z.enum(['starting', 'running', 'backoff', 'stopping', 'failed']);
16
+ const absolutePath = z.string().min(1).max(4_096).refine((value) => path.isAbsolute(value) && !value.includes('\0'), 'path must be absolute').transform((value) => path.normalize(value));
17
+ const daemonRecordSchema = z.strictObject({
18
+ version: z.literal(1),
19
+ runId,
20
+ daemonPid: positiveInteger,
21
+ configFile: absolutePath,
22
+ packageRoot: absolutePath,
23
+ token,
24
+ });
25
+ const controlRequestSchema = z.strictObject({
26
+ version: z.literal(1),
27
+ command: z.enum(['ping', 'stop']),
28
+ token,
29
+ });
30
+ const controlResponseSchema = z.strictObject({
31
+ ok: z.boolean(),
32
+ runId,
33
+ daemonPid: positiveInteger,
34
+ workerPid: positiveInteger.optional(),
35
+ phase,
36
+ message: z.string().min(1).max(2_048).optional(),
37
+ });
38
+ export function parseDaemonRecord(value) {
39
+ return Object.freeze(daemonRecordSchema.parse(value));
40
+ }
41
+ export function parseControlRequest(value) {
42
+ return Object.freeze(controlRequestSchema.parse(value));
43
+ }
44
+ export function parseControlResponse(value) {
45
+ return Object.freeze(controlResponseSchema.parse(value));
46
+ }
47
+ export function daemonRecordPath(home) {
48
+ return path.join(path.resolve(home), 'data/daemon.json');
49
+ }
50
+ export function controlAddress(home, platform = process.platform, nonce = '') {
51
+ const identity = canonicalPath(home).replaceAll('/', '\\').toLowerCase();
52
+ if (nonce && !ID_PATTERN.test(nonce))
53
+ throw new Error('control address nonce is invalid');
54
+ const digest = createHash('sha256')
55
+ .update(`${identity}\0${nonce}`)
56
+ .digest('hex')
57
+ .slice(0, 32);
58
+ const name = `kintio-${digest}`;
59
+ return platform === 'win32'
60
+ ? `\\\\.\\pipe\\${name}`
61
+ : path.join('/tmp', `${name}.sock`);
62
+ }
63
+ function errorCode(error) {
64
+ if (!error || typeof error !== 'object' || !('code' in error))
65
+ return '';
66
+ return String(error.code ?? '');
67
+ }
68
+ function assertPrivateFile(filePath) {
69
+ const stat = fs.lstatSync(filePath);
70
+ if (!stat.isFile() || stat.isSymbolicLink()) {
71
+ throw new Error(`Daemon metadata is not a regular file: ${filePath}`);
72
+ }
73
+ if (process.platform !== 'win32') {
74
+ const uid = process.getuid?.();
75
+ if ((uid !== undefined && stat.uid !== uid) || (stat.mode & 0o077) !== 0) {
76
+ throw new Error(`Daemon metadata has unsafe permissions: ${filePath}`);
77
+ }
78
+ }
79
+ if (stat.size > CONTROL_MAX_BYTES) {
80
+ throw new Error(`Daemon metadata exceeds ${CONTROL_MAX_BYTES} bytes: ${filePath}`);
81
+ }
82
+ }
83
+ function readJson(filePath, parse) {
84
+ try {
85
+ assertPrivateFile(filePath);
86
+ const source = fs.readFileSync(filePath, 'utf8');
87
+ if (Buffer.byteLength(source) > CONTROL_MAX_BYTES) {
88
+ throw new Error(`Daemon metadata exceeds ${CONTROL_MAX_BYTES} bytes: ${filePath}`);
89
+ }
90
+ return parse(JSON.parse(source));
91
+ }
92
+ catch (error) {
93
+ if (errorCode(error) === 'ENOENT')
94
+ return null;
95
+ throw error;
96
+ }
97
+ }
98
+ function writeJson(filePath, value) {
99
+ const directory = ensurePrivateDirectory(path.dirname(filePath));
100
+ const source = `${JSON.stringify(value)}\n`;
101
+ if (Buffer.byteLength(source) > CONTROL_MAX_BYTES) {
102
+ throw new Error(`Daemon metadata exceeds ${CONTROL_MAX_BYTES} bytes: ${filePath}`);
103
+ }
104
+ const temporary = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
105
+ const descriptor = fs.openSync(temporary, 'wx', 0o600);
106
+ try {
107
+ fs.writeFileSync(descriptor, source, 'utf8');
108
+ fs.fsyncSync(descriptor);
109
+ }
110
+ finally {
111
+ fs.closeSync(descriptor);
112
+ }
113
+ try {
114
+ fs.renameSync(temporary, filePath);
115
+ }
116
+ finally {
117
+ fs.rmSync(temporary, { force: true });
118
+ }
119
+ }
120
+ export function readDaemonRecord(home) {
121
+ return readJson(daemonRecordPath(home), parseDaemonRecord);
122
+ }
123
+ export function writeDaemonRecord(home, record) {
124
+ writeJson(daemonRecordPath(home), parseDaemonRecord(record));
125
+ }
126
+ function parseResponseLine(source) {
127
+ const newline = source.indexOf(0x0a);
128
+ if (newline === -1)
129
+ throw new Error('control response ended without a newline');
130
+ if (newline !== source.length - 1) {
131
+ throw new Error('control response contains more than one message');
132
+ }
133
+ const line = source.subarray(0, newline).toString('utf8').replace(/\r$/u, '');
134
+ return parseControlResponse(JSON.parse(line));
135
+ }
136
+ export async function requestControl(home, command, timeoutMs = CONTROL_TIMEOUT_MS) {
137
+ if (command !== 'ping' && command !== 'stop') {
138
+ throw new Error('control command is invalid');
139
+ }
140
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
141
+ throw new Error('control timeout must be a positive number');
142
+ }
143
+ const record = readDaemonRecord(home);
144
+ if (!record)
145
+ throw new Error('Kintio daemon record does not exist');
146
+ const source = `${JSON.stringify({
147
+ version: 1,
148
+ command,
149
+ token: record.token,
150
+ })}\n`;
151
+ return await new Promise((resolve, reject) => {
152
+ const socket = net.createConnection(controlAddress(home, process.platform, record.runId));
153
+ const chunks = [];
154
+ let size = 0;
155
+ let settled = false;
156
+ const finish = () => {
157
+ if (settled)
158
+ return false;
159
+ settled = true;
160
+ clearTimeout(timer);
161
+ socket.destroy();
162
+ return true;
163
+ };
164
+ const fail = (error) => {
165
+ if (finish())
166
+ reject(error);
167
+ };
168
+ const timer = setTimeout(() => fail(new Error('Kintio control request timed out')), Math.min(timeoutMs, CONTROL_TIMEOUT_MS));
169
+ timer.unref?.();
170
+ socket.once('connect', () => socket.write(source));
171
+ socket.on('data', (chunk) => {
172
+ size += chunk.length;
173
+ if (size > CONTROL_MAX_BYTES) {
174
+ fail(new Error(`control response exceeds ${CONTROL_MAX_BYTES} bytes`));
175
+ return;
176
+ }
177
+ chunks.push(chunk);
178
+ const complete = Buffer.concat(chunks, size);
179
+ if (!complete.includes(0x0a))
180
+ return;
181
+ try {
182
+ const response = parseResponseLine(complete);
183
+ if (response.runId !== record.runId || response.daemonPid !== record.daemonPid) {
184
+ throw new Error('control response identity does not match the daemon record');
185
+ }
186
+ if (!response.ok) {
187
+ throw new Error(response.message || 'Kintio control request was rejected');
188
+ }
189
+ if (finish())
190
+ resolve(response);
191
+ }
192
+ catch (error) {
193
+ fail(error);
194
+ }
195
+ });
196
+ socket.once('end', () => {
197
+ if (!settled)
198
+ fail(new Error('control response ended before one message arrived'));
199
+ });
200
+ socket.once('error', fail);
201
+ });
202
+ }
@@ -0,0 +1,49 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { assertTrustedDirectory, ensureContainedDirectory, ensurePrivateDirectory, } from '../lib/private-directory.js';
5
+ import { samePath } from '../lib/path-identity.js';
6
+ const SKILL_PATH = '.agents/skills/wechat-kf-reply-sop/SKILL.md';
7
+ export function installManagedSkill({ packageRoot, workingDirectory, }) {
8
+ const workspace = path.resolve(workingDirectory);
9
+ const file = path.join(workspace, SKILL_PATH);
10
+ const bundled = path.join(packageRoot, 'codex-workspace', SKILL_PATH);
11
+ if (samePath(file, bundled)) {
12
+ if (!regularFile(bundled)) {
13
+ throw new Error(`Bundled managed Skill is missing: ${bundled}`);
14
+ }
15
+ return { file, state: 'current' };
16
+ }
17
+ assertTrustedDirectory(ensurePrivateDirectory(workspace), 'Agent working directory', false);
18
+ const directory = ensureContainedDirectory(workspace, path.dirname(file));
19
+ assertTrustedDirectory(directory, 'Managed Skill directory', true);
20
+ const existing = regularFile(file);
21
+ const content = fs.readFileSync(bundled, 'utf8');
22
+ if (existing && fs.readFileSync(file, 'utf8') === content) {
23
+ return { file, state: 'current' };
24
+ }
25
+ const temporary = path.join(directory, `.${path.basename(file)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
26
+ fs.writeFileSync(temporary, content, { flag: 'wx', mode: 0o600 });
27
+ try {
28
+ fs.renameSync(temporary, file);
29
+ }
30
+ finally {
31
+ fs.rmSync(temporary, { force: true });
32
+ }
33
+ return { file, state: existing ? 'updated' : 'created' };
34
+ }
35
+ function regularFile(filePath) {
36
+ try {
37
+ const stat = fs.lstatSync(filePath);
38
+ if (!stat.isFile() || stat.isSymbolicLink()) {
39
+ throw new Error(`Managed Skill is not a regular file: ${filePath}`);
40
+ }
41
+ return stat;
42
+ }
43
+ catch (error) {
44
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
45
+ return undefined;
46
+ }
47
+ throw error;
48
+ }
49
+ }
@@ -0,0 +1,325 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import net from 'node:net';
5
+ import path from 'node:path';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+ import { parseStartTimeout, WORKER_GRACEFUL_TIMEOUT_MS, } from '../config.js';
8
+ import { ensurePrivateDirectory } from '../lib/private-directory.js';
9
+ import { acquireSingleInstanceLock } from './single-instance-lock.js';
10
+ import { controlAddress, CONTROL_MAX_BYTES, CONTROL_TIMEOUT_MS, daemonRecordPath, parseControlRequest, writeDaemonRecord, } from './daemon-protocol.js';
11
+ const MAX_RESTARTS = 10;
12
+ const RESTART_WINDOW_MS = 5 * 60_000;
13
+ const RESTART_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
14
+ const LOG_LIMIT_BYTES = 10 * 1024 * 1024;
15
+ const LOG_GENERATIONS = 5;
16
+ function sleep(milliseconds) {
17
+ return delay(milliseconds, undefined, { ref: false });
18
+ }
19
+ function errorMessage(error) {
20
+ return error instanceof Error ? error.message : String(error);
21
+ }
22
+ class RotatingLog {
23
+ #filePath;
24
+ #size;
25
+ constructor(home) {
26
+ const directory = ensurePrivateDirectory(path.join(home, 'data/logs'));
27
+ this.#filePath = path.join(directory, 'kintio.log');
28
+ this.#size = fs.existsSync(this.#filePath) ? fs.statSync(this.#filePath).size : 0;
29
+ }
30
+ write(value) {
31
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value);
32
+ if (this.#size + bytes.length > LOG_LIMIT_BYTES)
33
+ this.#rotate();
34
+ fs.appendFileSync(this.#filePath, bytes, { mode: 0o600 });
35
+ this.#size += bytes.length;
36
+ }
37
+ line(value) {
38
+ this.write(`[daemon] ${new Date().toISOString()} ${value}\n`);
39
+ }
40
+ #rotate() {
41
+ for (let generation = LOG_GENERATIONS; generation >= 1; generation -= 1) {
42
+ const target = `${this.#filePath}.${generation}`;
43
+ const source = generation === 1
44
+ ? this.#filePath
45
+ : `${this.#filePath}.${generation - 1}`;
46
+ fs.rmSync(target, { force: true });
47
+ if (fs.existsSync(source))
48
+ fs.renameSync(source, target);
49
+ }
50
+ this.#size = 0;
51
+ }
52
+ }
53
+ function forceKill(worker) {
54
+ // Tree-killing by a reusable numeric PID can terminate unrelated host processes.
55
+ // Current Agent children use stdio/IPC and receive EOF when this exact handle exits.
56
+ if (worker.exitCode === null && worker.signalCode === null)
57
+ worker.kill('SIGKILL');
58
+ }
59
+ export async function runNativeDaemon({ home, configFile, packageRoot, environment = process.env, }) {
60
+ const instanceHome = path.resolve(home);
61
+ const instanceConfig = path.resolve(configFile);
62
+ const dataDirectory = ensurePrivateDirectory(path.join(instanceHome, 'data'));
63
+ const log = new RotatingLog(instanceHome);
64
+ const daemonLock = acquireSingleInstanceLock({
65
+ filePath: path.join(dataDirectory, 'daemon.lock'),
66
+ });
67
+ const runId = randomBytes(24).toString('base64url');
68
+ const token = randomBytes(32).toString('base64url');
69
+ const address = controlAddress(instanceHome, process.platform, runId);
70
+ const instancePackageRoot = path.resolve(packageRoot);
71
+ let phase = 'starting';
72
+ let lastError;
73
+ let worker;
74
+ let workerExit = Promise.resolve();
75
+ let stopping;
76
+ let cleanup;
77
+ const sockets = new Set();
78
+ const restartTimes = [];
79
+ let resolveFinished;
80
+ const finished = new Promise((resolve) => { resolveFinished = resolve; });
81
+ const startupTimeoutMs = parseStartTimeout(environment.KINTIO_START_TIMEOUT_MS);
82
+ const handleSignal = () => { void shutdown(); };
83
+ const response = (ok, message) => ({
84
+ ok,
85
+ runId,
86
+ daemonPid: process.pid,
87
+ ...(worker?.pid ? { workerPid: worker.pid } : {}),
88
+ phase,
89
+ ...((message || (ok ? lastError : undefined))
90
+ ? { message: (message || lastError).slice(0, 2_048) }
91
+ : {}),
92
+ });
93
+ async function stopWorker() {
94
+ const active = worker;
95
+ if (!active || active.exitCode !== null || active.signalCode !== null)
96
+ return;
97
+ try {
98
+ if (active.connected)
99
+ active.send('shutdown');
100
+ }
101
+ catch {
102
+ // The exit listener below remains authoritative.
103
+ }
104
+ const graceful = await Promise.race([
105
+ workerExit.then(() => true),
106
+ sleep(WORKER_GRACEFUL_TIMEOUT_MS).then(() => false),
107
+ ]);
108
+ if (!graceful) {
109
+ log.line(`worker ${active.pid || 'unknown'} exceeded shutdown timeout; forcing exit`);
110
+ forceKill(active);
111
+ const forced = await Promise.race([
112
+ workerExit.then(() => true),
113
+ sleep(5_000).then(() => false),
114
+ ]);
115
+ if (!forced)
116
+ throw new Error('worker did not exit after forced termination');
117
+ }
118
+ }
119
+ async function shutdown() {
120
+ if (stopping)
121
+ return stopping;
122
+ stopping = (async () => {
123
+ phase = 'stopping';
124
+ try {
125
+ await stopWorker();
126
+ }
127
+ catch (error) {
128
+ lastError = `worker shutdown failed: ${errorMessage(error)}`;
129
+ phase = 'failed';
130
+ log.line(lastError);
131
+ stopping = undefined;
132
+ return;
133
+ }
134
+ await cleanupControl();
135
+ resolveFinished();
136
+ })();
137
+ return stopping;
138
+ }
139
+ function startWorker() {
140
+ if (phase === 'stopping' || phase === 'failed')
141
+ return;
142
+ phase = 'starting';
143
+ const child = spawn(process.execPath, [path.join(instancePackageRoot, 'dist/index.js')], {
144
+ cwd: instanceHome,
145
+ env: {
146
+ ...environment,
147
+ KINTIO_HOME: instanceHome,
148
+ KINTIO_CONFIG_FILE: instanceConfig,
149
+ KINTIO_MANAGED_WORKER: '1',
150
+ NODE_ENV: 'production',
151
+ },
152
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
153
+ detached: true,
154
+ windowsHide: true,
155
+ });
156
+ worker = child;
157
+ lastError = undefined;
158
+ let readinessExpired = false;
159
+ const readyTimer = setTimeout(() => {
160
+ if (worker !== child || phase === 'running' || phase === 'stopping')
161
+ return;
162
+ readinessExpired = true;
163
+ log.line(`worker ${child.pid || 'unknown'} did not publish readiness in time`);
164
+ void (async () => {
165
+ forceKill(child);
166
+ const exited = await Promise.race([
167
+ workerExit.then(() => true),
168
+ sleep(5_000).then(() => false),
169
+ ]);
170
+ if (!exited && worker === child) {
171
+ phase = 'failed';
172
+ lastError = 'worker did not exit after readiness timeout';
173
+ }
174
+ })().catch((error) => {
175
+ if (worker !== child)
176
+ return;
177
+ phase = 'failed';
178
+ lastError = errorMessage(error);
179
+ });
180
+ }, startupTimeoutMs);
181
+ readyTimer.unref?.();
182
+ child.stdout?.on('data', (chunk) => log.write(chunk));
183
+ child.stderr?.on('data', (chunk) => log.write(chunk));
184
+ child.once('message', (message) => {
185
+ if (worker === child &&
186
+ !readinessExpired &&
187
+ phase !== 'stopping' &&
188
+ phase !== 'failed' &&
189
+ child.exitCode === null &&
190
+ child.signalCode === null &&
191
+ message && typeof message === 'object' &&
192
+ 'type' in message && message.type === 'ready' &&
193
+ 'pid' in message && message.pid === child.pid) {
194
+ clearTimeout(readyTimer);
195
+ phase = 'running';
196
+ lastError = undefined;
197
+ }
198
+ });
199
+ child.once('error', (error) => log.line(`worker spawn error: ${error.message}`));
200
+ workerExit = new Promise((resolve) => {
201
+ child.once('close', (code, signal) => {
202
+ clearTimeout(readyTimer);
203
+ resolve();
204
+ if (worker !== child)
205
+ return;
206
+ worker = undefined;
207
+ if (phase === 'stopping' || phase === 'failed')
208
+ return;
209
+ const now = Date.now();
210
+ restartTimes.push(now);
211
+ while (restartTimes[0] !== undefined && now - restartTimes[0] > RESTART_WINDOW_MS) {
212
+ restartTimes.shift();
213
+ }
214
+ const detail = `worker exited code=${code} signal=${signal}`;
215
+ log.line(detail);
216
+ if (restartTimes.length > MAX_RESTARTS) {
217
+ phase = 'failed';
218
+ lastError = 'worker restart limit exceeded';
219
+ return;
220
+ }
221
+ phase = 'backoff';
222
+ lastError = detail;
223
+ const restartDelay = RESTART_DELAYS_MS[Math.min(restartTimes.length - 1, RESTART_DELAYS_MS.length - 1)] || RESTART_DELAYS_MS.at(-1);
224
+ setTimeout(startWorker, restartDelay).unref?.();
225
+ });
226
+ });
227
+ }
228
+ if (process.platform !== 'win32')
229
+ fs.rmSync(address, { force: true });
230
+ const server = net.createServer((socket) => {
231
+ sockets.add(socket);
232
+ socket.once('close', () => sockets.delete(socket));
233
+ socket.setTimeout(CONTROL_TIMEOUT_MS);
234
+ const chunks = [];
235
+ let size = 0;
236
+ let handled = false;
237
+ const reject = (message) => {
238
+ if (handled)
239
+ return;
240
+ handled = true;
241
+ socket.end(`${JSON.stringify(response(false, message))}\n`);
242
+ };
243
+ socket.on('data', (chunk) => {
244
+ if (handled)
245
+ return;
246
+ size += chunk.length;
247
+ if (size > CONTROL_MAX_BYTES) {
248
+ reject('control request exceeds protocol limit');
249
+ return;
250
+ }
251
+ chunks.push(chunk);
252
+ const source = Buffer.concat(chunks, size);
253
+ const newline = source.indexOf(0x0a);
254
+ if (newline < 0)
255
+ return;
256
+ try {
257
+ const request = parseControlRequest(JSON.parse(source.subarray(0, newline).toString('utf8')));
258
+ if (request.token !== token) {
259
+ reject('control authentication failed');
260
+ return;
261
+ }
262
+ handled = true;
263
+ const output = `${JSON.stringify(response(true))}\n`;
264
+ if (request.command === 'stop') {
265
+ socket.end(output, () => { void shutdown(); });
266
+ }
267
+ else {
268
+ socket.end(output);
269
+ }
270
+ }
271
+ catch (error) {
272
+ reject(errorMessage(error));
273
+ }
274
+ });
275
+ socket.once('timeout', () => reject('control request timed out'));
276
+ socket.once('error', () => undefined);
277
+ });
278
+ function cleanupControl() {
279
+ cleanup ??= (async () => {
280
+ if (server.listening) {
281
+ await new Promise((resolve, reject) => {
282
+ server.close((error) => error ? reject(error) : resolve());
283
+ for (const socket of sockets)
284
+ socket.destroy();
285
+ });
286
+ }
287
+ else {
288
+ for (const socket of sockets)
289
+ socket.destroy();
290
+ }
291
+ fs.rmSync(daemonRecordPath(instanceHome), { force: true });
292
+ if (process.platform !== 'win32')
293
+ fs.rmSync(address, { force: true });
294
+ daemonLock.release();
295
+ })();
296
+ return cleanup;
297
+ }
298
+ try {
299
+ await new Promise((resolve, reject) => {
300
+ server.once('error', reject);
301
+ server.listen(address, () => resolve());
302
+ });
303
+ if (process.platform !== 'win32')
304
+ fs.chmodSync(address, 0o600);
305
+ writeDaemonRecord(instanceHome, {
306
+ version: 1,
307
+ runId,
308
+ daemonPid: process.pid,
309
+ configFile: instanceConfig,
310
+ packageRoot: instancePackageRoot,
311
+ token,
312
+ });
313
+ log.line(`daemon started pid=${process.pid}`);
314
+ startWorker();
315
+ process.once('SIGINT', handleSignal);
316
+ process.once('SIGTERM', handleSignal);
317
+ await finished;
318
+ }
319
+ finally {
320
+ process.off('SIGINT', handleSignal);
321
+ process.off('SIGTERM', handleSignal);
322
+ await cleanupControl();
323
+ log.line('daemon stopped');
324
+ }
325
+ }