helloloop 0.8.5 → 0.9.1

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.
@@ -0,0 +1,320 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { fileExists, readJson, sleep } from "./common.mjs";
5
+ import { renderHostLeaseLabel } from "./host_lease.mjs";
6
+
7
+ const FINAL_STATUSES = new Set(["completed", "failed", "stopped"]);
8
+
9
+ function readJsonIfExists(filePath) {
10
+ if (!filePath || !fileExists(filePath)) {
11
+ return null;
12
+ }
13
+
14
+ try {
15
+ return readJson(filePath);
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function writeLine(stream, message) {
22
+ stream.write(`${message}\n`);
23
+ }
24
+
25
+ function buildSessionSummary(supervisor) {
26
+ if (!supervisor?.sessionId) {
27
+ return "";
28
+ }
29
+
30
+ return [
31
+ `[HelloLoop watch] 已附着后台会话:${supervisor.sessionId}`,
32
+ `[HelloLoop watch] 宿主租约:${renderHostLeaseLabel(supervisor.lease)}`,
33
+ ].join("\n");
34
+ }
35
+
36
+ function formatSupervisorState(supervisor) {
37
+ if (!supervisor?.status) {
38
+ return "";
39
+ }
40
+
41
+ const label = {
42
+ launching: "后台 supervisor 启动中",
43
+ running: "后台 supervisor 正在运行",
44
+ completed: "后台 supervisor 已完成",
45
+ failed: "后台 supervisor 执行失败",
46
+ stopped: "后台 supervisor 已停止",
47
+ }[String(supervisor.status)] || `后台 supervisor 状态:${supervisor.status}`;
48
+
49
+ const suffix = supervisor.message ? `:${supervisor.message}` : "";
50
+ return `[HelloLoop watch] ${label}${suffix}`;
51
+ }
52
+
53
+ function formatTaskStatus(status) {
54
+ if (!status?.taskTitle) {
55
+ return "";
56
+ }
57
+
58
+ const lines = [`[HelloLoop watch] 当前任务:${status.taskTitle}`];
59
+ if (status.runDir) {
60
+ lines.push(`[HelloLoop watch] 运行目录:${status.runDir}`);
61
+ }
62
+ if (status.stage) {
63
+ lines.push(`[HelloLoop watch] 阶段:${status.stage}`);
64
+ }
65
+ return lines.join("\n");
66
+ }
67
+
68
+ function selectRuntimeFile(runDir) {
69
+ if (!runDir || !fileExists(runDir)) {
70
+ return "";
71
+ }
72
+
73
+ const candidates = [];
74
+ for (const entry of fs.readdirSync(runDir, { withFileTypes: true })) {
75
+ if (entry.isFile() && entry.name.endsWith("-runtime.json")) {
76
+ candidates.push(path.join(runDir, entry.name));
77
+ continue;
78
+ }
79
+ if (!entry.isDirectory()) {
80
+ continue;
81
+ }
82
+ const nestedDir = path.join(runDir, entry.name);
83
+ for (const nestedName of fs.readdirSync(nestedDir)) {
84
+ if (nestedName.endsWith("-runtime.json")) {
85
+ candidates.push(path.join(nestedDir, nestedName));
86
+ }
87
+ }
88
+ }
89
+
90
+ candidates.sort((left, right) => {
91
+ const rightTime = fs.statSync(right).mtimeMs;
92
+ const leftTime = fs.statSync(left).mtimeMs;
93
+ return rightTime - leftTime;
94
+ });
95
+
96
+ return candidates[0] || "";
97
+ }
98
+
99
+ function formatRuntimeState(runtime, previousRuntime) {
100
+ if (!runtime?.status) {
101
+ return "";
102
+ }
103
+
104
+ const idleSeconds = Number(runtime?.heartbeat?.idleSeconds || 0);
105
+ const idleBucket = Math.floor(idleSeconds / 30);
106
+ const previousIdleBucket = Math.floor(Number(previousRuntime?.heartbeat?.idleSeconds || 0) / 30);
107
+ const signature = [
108
+ runtime.status,
109
+ runtime.attemptPrefix || "",
110
+ runtime.recoveryCount || 0,
111
+ runtime.failureCode || "",
112
+ runtime.failureReason || "",
113
+ runtime.nextRetryAt || "",
114
+ runtime.notification?.reason || "",
115
+ ].join("|");
116
+ const previousSignature = previousRuntime
117
+ ? [
118
+ previousRuntime.status,
119
+ previousRuntime.attemptPrefix || "",
120
+ previousRuntime.recoveryCount || 0,
121
+ previousRuntime.failureCode || "",
122
+ previousRuntime.failureReason || "",
123
+ previousRuntime.nextRetryAt || "",
124
+ previousRuntime.notification?.reason || "",
125
+ ].join("|")
126
+ : "";
127
+
128
+ if (signature === previousSignature && (runtime.status !== "running" || idleBucket === previousIdleBucket || idleBucket === 0)) {
129
+ return "";
130
+ }
131
+
132
+ if (runtime.status === "running") {
133
+ if (idleBucket === 0 || idleBucket === previousIdleBucket) {
134
+ return "";
135
+ }
136
+ return `[HelloLoop watch] 仍在执行:${runtime.attemptPrefix || "当前尝试"},最近输出距今约 ${idleBucket * 30} 秒`;
137
+ }
138
+
139
+ const labels = {
140
+ recovering: "进入同引擎恢复",
141
+ suspected_stall: "疑似卡住,继续观察",
142
+ watchdog_terminating: "触发 watchdog,准备终止当前子进程",
143
+ watchdog_waiting: "watchdog 等待子进程退出",
144
+ retry_waiting: "等待自动重试",
145
+ probe_waiting: "准备执行健康探测",
146
+ probe_running: "正在执行健康探测",
147
+ paused_manual: "自动恢复预算已耗尽,任务暂停",
148
+ lease_terminating: "宿主租约失效,正在停止当前子进程",
149
+ stopped_host_closed: "宿主窗口已关闭,后台任务停止",
150
+ completed: "当前任务执行完成",
151
+ failed: "当前任务执行失败",
152
+ };
153
+ const label = labels[String(runtime.status)] || `运行状态更新:${runtime.status}`;
154
+ const details = [
155
+ runtime.attemptPrefix ? `attempt=${runtime.attemptPrefix}` : "",
156
+ Number.isFinite(Number(runtime.recoveryCount)) ? `recovery=${runtime.recoveryCount}` : "",
157
+ runtime.nextRetryAt ? `next=${runtime.nextRetryAt}` : "",
158
+ runtime.failureReason || "",
159
+ ].filter(Boolean).join(" | ");
160
+
161
+ return `[HelloLoop watch] ${label}${details ? ` | ${details}` : ""}`;
162
+ }
163
+
164
+ function readTextDelta(filePath, offset) {
165
+ if (!filePath || !fileExists(filePath)) {
166
+ return { nextOffset: 0, text: "" };
167
+ }
168
+
169
+ const stats = fs.statSync(filePath);
170
+ if (stats.size <= 0) {
171
+ return { nextOffset: 0, text: "" };
172
+ }
173
+
174
+ const start = Math.max(0, Math.min(Number(offset || 0), stats.size));
175
+ if (stats.size === start) {
176
+ return { nextOffset: start, text: "" };
177
+ }
178
+
179
+ const handle = fs.openSync(filePath, "r");
180
+ try {
181
+ const buffer = Buffer.alloc(stats.size - start);
182
+ fs.readSync(handle, buffer, 0, buffer.length, start);
183
+ return {
184
+ nextOffset: stats.size,
185
+ text: buffer.toString("utf8"),
186
+ };
187
+ } finally {
188
+ fs.closeSync(handle);
189
+ }
190
+ }
191
+
192
+ function readAndWriteDelta(cursor, filePath, stream) {
193
+ if (cursor.file !== filePath) {
194
+ cursor.file = filePath || "";
195
+ cursor.offset = 0;
196
+ }
197
+
198
+ if (!filePath) {
199
+ return;
200
+ }
201
+
202
+ const delta = readTextDelta(filePath, cursor.offset);
203
+ cursor.offset = delta.nextOffset;
204
+ if (!delta.text) {
205
+ return;
206
+ }
207
+
208
+ stream.write(delta.text);
209
+ if (!delta.text.endsWith("\n")) {
210
+ stream.write("\n");
211
+ }
212
+ }
213
+
214
+ function resolveStatusForSession(status, sessionId) {
215
+ if (!status) {
216
+ return null;
217
+ }
218
+ if (!sessionId || !status.sessionId || status.sessionId === sessionId) {
219
+ return status;
220
+ }
221
+ return null;
222
+ }
223
+
224
+ function buildWatchResult(supervisor, result) {
225
+ const exitCode = Number(result?.exitCode ?? supervisor?.exitCode ?? (supervisor?.status === "completed" ? 0 : 1));
226
+ return {
227
+ sessionId: result?.sessionId || supervisor?.sessionId || "",
228
+ status: result?.ok === true
229
+ ? "completed"
230
+ : (supervisor?.status || (exitCode === 0 ? "completed" : "failed")),
231
+ ok: result?.ok === true || exitCode === 0,
232
+ exitCode,
233
+ };
234
+ }
235
+
236
+ export async function watchSupervisorSession(context, options = {}) {
237
+ const pollMs = Math.max(200, Number(options.pollMs || 1000));
238
+ const stdoutStream = options.stdoutStream || process.stdout;
239
+ const stderrStream = options.stderrStream || process.stderr;
240
+ const expectedSessionId = String(options.sessionId || "").trim();
241
+ const stdoutCursor = { file: "", offset: 0 };
242
+ const stderrCursor = { file: "", offset: 0 };
243
+ let printedSessionId = "";
244
+ let lastSupervisorSignature = "";
245
+ let lastTaskSignature = "";
246
+ let previousRuntime = null;
247
+ let missingPolls = 0;
248
+
249
+ while (true) {
250
+ const supervisor = readJsonIfExists(context.supervisorStateFile);
251
+ const result = readJsonIfExists(context.supervisorResultFile);
252
+ const activeSessionId = expectedSessionId || supervisor?.sessionId || result?.sessionId || "";
253
+ const taskStatus = resolveStatusForSession(readJsonIfExists(context.statusFile), activeSessionId);
254
+ const runtimeFile = taskStatus?.runDir ? selectRuntimeFile(taskStatus.runDir) : "";
255
+ const runtime = readJsonIfExists(runtimeFile);
256
+
257
+ if (!supervisor && !result) {
258
+ missingPolls += 1;
259
+ if (missingPolls >= 3) {
260
+ return {
261
+ sessionId: activeSessionId,
262
+ status: "",
263
+ ok: false,
264
+ exitCode: 1,
265
+ empty: true,
266
+ };
267
+ }
268
+ await sleep(pollMs);
269
+ continue;
270
+ }
271
+ missingPolls = 0;
272
+
273
+ if (supervisor?.sessionId && supervisor.sessionId !== printedSessionId) {
274
+ printedSessionId = supervisor.sessionId;
275
+ writeLine(stdoutStream, buildSessionSummary(supervisor));
276
+ }
277
+
278
+ const supervisorSignature = supervisor
279
+ ? [supervisor.sessionId || "", supervisor.status || "", supervisor.message || ""].join("|")
280
+ : "";
281
+ if (supervisorSignature && supervisorSignature !== lastSupervisorSignature) {
282
+ lastSupervisorSignature = supervisorSignature;
283
+ writeLine(stdoutStream, formatSupervisorState(supervisor));
284
+ }
285
+
286
+ const taskSignature = taskStatus
287
+ ? [taskStatus.sessionId || "", taskStatus.taskId || "", taskStatus.taskTitle || "", taskStatus.runDir || "", taskStatus.stage || ""].join("|")
288
+ : "";
289
+ if (taskSignature && taskSignature !== lastTaskSignature) {
290
+ lastTaskSignature = taskSignature;
291
+ writeLine(stdoutStream, formatTaskStatus(taskStatus));
292
+ }
293
+
294
+ const runtimeMessage = formatRuntimeState(runtime, previousRuntime);
295
+ if (runtimeMessage) {
296
+ writeLine(stdoutStream, runtimeMessage);
297
+ }
298
+ previousRuntime = runtime || previousRuntime;
299
+
300
+ const activePrefix = runtime?.attemptPrefix || "";
301
+ const runtimeDir = runtimeFile ? path.dirname(runtimeFile) : "";
302
+ const stdoutFile = runtimeDir && activePrefix
303
+ ? path.join(runtimeDir, `${activePrefix}-stdout.log`)
304
+ : "";
305
+ const stderrFile = runtimeDir && activePrefix
306
+ ? path.join(runtimeDir, `${activePrefix}-stderr.log`)
307
+ : "";
308
+
309
+ readAndWriteDelta(stdoutCursor, stdoutFile, stdoutStream);
310
+ readAndWriteDelta(stderrCursor, stderrFile, stderrStream);
311
+
312
+ if (supervisor?.status && FINAL_STATUSES.has(String(supervisor.status))) {
313
+ readAndWriteDelta(stdoutCursor, stdoutFile, stdoutStream);
314
+ readAndWriteDelta(stderrCursor, stderrFile, stderrStream);
315
+ return buildWatchResult(supervisor, result);
316
+ }
317
+
318
+ await sleep(pollMs);
319
+ }
320
+ }
@@ -0,0 +1,394 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { ensureDir, fileExists, nowIso, readJson, timestampForFile, writeJson } from "./common.mjs";
5
+ import { loadGlobalConfig } from "./global_config.mjs";
6
+
7
+ const SESSION_DIR_NAME = "terminal-sessions";
8
+ const RUNTIME_DIR_NAME = "runtime";
9
+ const LOCK_FILE_NAME = ".lock";
10
+ const LOCK_RETRY_DELAYS_MS = [0, 20, 50, 100, 200, 300, 500];
11
+ const STALE_PREPARED_SESSION_MS = 60_000;
12
+ const TRACKED_VISIBLE_COMMANDS = new Set(["analyze", "next", "run-loop", "run-once"]);
13
+
14
+ let currentTerminalSession = null;
15
+ let cleanupRegistered = false;
16
+
17
+ function sleepSync(ms) {
18
+ const shared = new SharedArrayBuffer(4);
19
+ const view = new Int32Array(shared);
20
+ Atomics.wait(view, 0, 0, Math.max(0, ms));
21
+ }
22
+
23
+ function isPidAlive(pid) {
24
+ const value = Number(pid || 0);
25
+ if (!Number.isFinite(value) || value <= 0) {
26
+ return false;
27
+ }
28
+ try {
29
+ process.kill(value, 0);
30
+ return true;
31
+ } catch (error) {
32
+ return String(error?.code || "") === "EPERM";
33
+ }
34
+ }
35
+
36
+ function normalizeNonNegativeInteger(value, fallbackValue) {
37
+ if (value === null || value === undefined || value === "") {
38
+ return fallbackValue;
39
+ }
40
+ const parsed = Number(value);
41
+ if (!Number.isFinite(parsed) || parsed < 0) {
42
+ return fallbackValue;
43
+ }
44
+ return Math.floor(parsed);
45
+ }
46
+
47
+ export function normalizeTerminalConcurrencySettings(settings = {}) {
48
+ const source = settings && typeof settings === "object" ? settings : {};
49
+ return {
50
+ enabled: source.enabled !== false,
51
+ visibleMax: normalizeNonNegativeInteger(source.visibleMax, 8),
52
+ backgroundMax: normalizeNonNegativeInteger(source.backgroundMax, 8),
53
+ totalMax: normalizeNonNegativeInteger(source.totalMax, 8),
54
+ };
55
+ }
56
+
57
+ function resolveTerminalRuntimeConfig(options = {}) {
58
+ const globalConfig = loadGlobalConfig({
59
+ globalConfigFile: options.globalConfigFile,
60
+ });
61
+ const settingsFile = globalConfig?._meta?.configFile || "";
62
+ const settingsHome = settingsFile ? path.dirname(settingsFile) : process.cwd();
63
+ return {
64
+ settingsFile,
65
+ registryRoot: path.join(settingsHome, RUNTIME_DIR_NAME, SESSION_DIR_NAME),
66
+ limits: normalizeTerminalConcurrencySettings(globalConfig?.runtime?.terminalConcurrency || {}),
67
+ };
68
+ }
69
+
70
+ function withRegistryLock(registryRoot, callback) {
71
+ ensureDir(registryRoot);
72
+ const lockFile = path.join(registryRoot, LOCK_FILE_NAME);
73
+ let lastError = null;
74
+
75
+ for (const delayMs of LOCK_RETRY_DELAYS_MS) {
76
+ if (delayMs > 0) {
77
+ sleepSync(delayMs);
78
+ }
79
+ let lockFd = null;
80
+ try {
81
+ lockFd = fs.openSync(lockFile, "wx");
82
+ const result = callback();
83
+ fs.closeSync(lockFd);
84
+ fs.rmSync(lockFile, { force: true });
85
+ return result;
86
+ } catch (error) {
87
+ lastError = error;
88
+ if (lockFd !== null) {
89
+ try {
90
+ fs.closeSync(lockFd);
91
+ } catch {
92
+ // ignore lock close failure
93
+ }
94
+ fs.rmSync(lockFile, { force: true });
95
+ throw error;
96
+ }
97
+ if (String(error?.code || "").toUpperCase() !== "EEXIST") {
98
+ throw error;
99
+ }
100
+ }
101
+ }
102
+
103
+ throw lastError || new Error(`无法获取 HelloLoop 终端会话锁:${registryRoot}`);
104
+ }
105
+
106
+ function listSessionFiles(registryRoot) {
107
+ if (!fileExists(registryRoot)) {
108
+ return [];
109
+ }
110
+ return fs.readdirSync(registryRoot)
111
+ .filter((item) => item.endsWith(".json"))
112
+ .map((item) => path.join(registryRoot, item));
113
+ }
114
+
115
+ function readSessionRecord(sessionFile) {
116
+ try {
117
+ return readJson(sessionFile);
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ function isPreparedSessionExpired(record) {
124
+ const updatedAt = Date.parse(String(record?.updatedAt || record?.createdAt || ""));
125
+ if (!Number.isFinite(updatedAt)) {
126
+ return true;
127
+ }
128
+ return Date.now() - updatedAt > STALE_PREPARED_SESSION_MS;
129
+ }
130
+
131
+ function isStaleSession(record) {
132
+ const kind = String(record?.kind || "");
133
+ if (!["visible", "background"].includes(kind)) {
134
+ return true;
135
+ }
136
+ if (Number(record?.pid || 0) > 0) {
137
+ return !isPidAlive(record.pid);
138
+ }
139
+ if (Number(record?.ownerPid || 0) > 0 && !isPidAlive(record.ownerPid)) {
140
+ return true;
141
+ }
142
+ return isPreparedSessionExpired(record);
143
+ }
144
+
145
+ function cleanupStaleSessions(registryRoot) {
146
+ const activeSessions = [];
147
+ for (const sessionFile of listSessionFiles(registryRoot)) {
148
+ const record = readSessionRecord(sessionFile);
149
+ if (!record || isStaleSession(record)) {
150
+ fs.rmSync(sessionFile, { force: true });
151
+ continue;
152
+ }
153
+ activeSessions.push({
154
+ ...record,
155
+ file: sessionFile,
156
+ });
157
+ }
158
+ return activeSessions;
159
+ }
160
+
161
+ function countSessions(sessions, excludingId = "") {
162
+ return sessions
163
+ .filter((session) => session.id !== excludingId)
164
+ .reduce((counts, session) => {
165
+ if (session.kind === "visible") {
166
+ counts.visible += 1;
167
+ }
168
+ if (session.kind === "background") {
169
+ counts.background += 1;
170
+ }
171
+ counts.total += 1;
172
+ return counts;
173
+ }, { visible: 0, background: 0, total: 0 });
174
+ }
175
+
176
+ function throwSessionLimitError(kind, counts, limits, settingsFile, reason) {
177
+ const kindLabel = kind === "background" ? "背景终端" : "显示终端";
178
+ const scope = [
179
+ `显示终端 ${counts.visible}/${limits.visibleMax}`,
180
+ `背景终端 ${counts.background}/${limits.backgroundMax}`,
181
+ `总并发 ${counts.total}/${limits.totalMax}`,
182
+ ].join(",");
183
+ throw new Error(
184
+ `${kindLabel}${reason},当前 ${scope}。`
185
+ + ` 如需调整,请修改 ${settingsFile} 中的 runtime.terminalConcurrency.visibleMax / backgroundMax / totalMax。`,
186
+ );
187
+ }
188
+
189
+ function assertSessionLimit(kind, sessions, limits, settingsFile, excludingId = "") {
190
+ if (!limits.enabled) {
191
+ return;
192
+ }
193
+
194
+ const counts = countSessions(sessions, excludingId);
195
+ const nextCounts = {
196
+ visible: counts.visible + (kind === "visible" ? 1 : 0),
197
+ background: counts.background + (kind === "background" ? 1 : 0),
198
+ total: counts.total + 1,
199
+ };
200
+
201
+ if (kind === "visible" && nextCounts.visible > limits.visibleMax) {
202
+ throwSessionLimitError(kind, nextCounts, limits, settingsFile, "并发已达上限");
203
+ }
204
+ if (kind === "background" && nextCounts.background > limits.backgroundMax) {
205
+ throwSessionLimitError(kind, nextCounts, limits, settingsFile, "并发已达上限");
206
+ }
207
+ if (nextCounts.total > limits.totalMax) {
208
+ throwSessionLimitError(kind, nextCounts, limits, settingsFile, "启动被阻止:显示终端与背景终端合计并发已达上限");
209
+ }
210
+ }
211
+
212
+ function buildSessionRecord(kind, options = {}) {
213
+ return {
214
+ id: options.id || `${timestampForFile()}-${kind}-${process.pid}`,
215
+ kind,
216
+ pid: Number(options.pid || process.pid),
217
+ ownerPid: Number(options.ownerPid || process.pid),
218
+ command: String(options.command || "").trim(),
219
+ sessionId: String(options.sessionId || "").trim(),
220
+ repoRoot: String(options.repoRoot || "").trim(),
221
+ createdAt: options.createdAt || nowIso(),
222
+ updatedAt: nowIso(),
223
+ };
224
+ }
225
+
226
+ function writeSessionRecord(sessionFile, record) {
227
+ writeJson(sessionFile, record);
228
+ return {
229
+ ...record,
230
+ file: sessionFile,
231
+ };
232
+ }
233
+
234
+ function ensureCleanupRegistration() {
235
+ if (cleanupRegistered) {
236
+ return;
237
+ }
238
+ cleanupRegistered = true;
239
+ process.on("exit", () => {
240
+ try {
241
+ releaseCurrentTerminalSession();
242
+ } catch {
243
+ // ignore exit cleanup failure
244
+ }
245
+ });
246
+ }
247
+
248
+ function setCurrentTerminalSession(record) {
249
+ if (!record) {
250
+ return null;
251
+ }
252
+ currentTerminalSession = {
253
+ ...record,
254
+ preparedForBackground: false,
255
+ handedOff: false,
256
+ };
257
+ ensureCleanupRegistration();
258
+ return currentTerminalSession;
259
+ }
260
+
261
+ export function shouldTrackVisibleTerminalCommand(command) {
262
+ return TRACKED_VISIBLE_COMMANDS.has(String(command || "").trim());
263
+ }
264
+
265
+ export function acquireVisibleTerminalSession(options = {}) {
266
+ if (currentTerminalSession) {
267
+ return currentTerminalSession;
268
+ }
269
+
270
+ const runtime = resolveTerminalRuntimeConfig(options);
271
+ if (!runtime.limits.enabled) {
272
+ return null;
273
+ }
274
+
275
+ return withRegistryLock(runtime.registryRoot, () => {
276
+ const sessions = cleanupStaleSessions(runtime.registryRoot);
277
+ assertSessionLimit("visible", sessions, runtime.limits, runtime.settingsFile);
278
+ const record = buildSessionRecord("visible", options);
279
+ const sessionFile = path.join(runtime.registryRoot, `${record.id}.json`);
280
+ return setCurrentTerminalSession(writeSessionRecord(sessionFile, record));
281
+ });
282
+ }
283
+
284
+ export function prepareCurrentTerminalSessionForBackground(options = {}) {
285
+ const runtime = resolveTerminalRuntimeConfig(options);
286
+ if (!runtime.limits.enabled) {
287
+ return null;
288
+ }
289
+
290
+ return withRegistryLock(runtime.registryRoot, () => {
291
+ const sessions = cleanupStaleSessions(runtime.registryRoot);
292
+
293
+ if (!currentTerminalSession) {
294
+ assertSessionLimit("background", sessions, runtime.limits, runtime.settingsFile);
295
+ const record = buildSessionRecord("background", {
296
+ ...options,
297
+ pid: 0,
298
+ });
299
+ const sessionFile = path.join(runtime.registryRoot, `${record.id}.json`);
300
+ const next = writeSessionRecord(sessionFile, record);
301
+ currentTerminalSession = {
302
+ ...next,
303
+ preparedForBackground: true,
304
+ handedOff: false,
305
+ };
306
+ ensureCleanupRegistration();
307
+ return currentTerminalSession;
308
+ }
309
+
310
+ assertSessionLimit("background", sessions, runtime.limits, runtime.settingsFile, currentTerminalSession.id);
311
+ const record = buildSessionRecord("background", {
312
+ ...currentTerminalSession,
313
+ ...options,
314
+ pid: 0,
315
+ ownerPid: process.pid,
316
+ });
317
+ const next = writeSessionRecord(currentTerminalSession.file, record);
318
+ currentTerminalSession = {
319
+ ...next,
320
+ preparedForBackground: true,
321
+ handedOff: false,
322
+ };
323
+ return currentTerminalSession;
324
+ });
325
+ }
326
+
327
+ export function finalizePreparedTerminalSessionBackground(pid, options = {}) {
328
+ if (!currentTerminalSession?.file) {
329
+ return null;
330
+ }
331
+
332
+ const runtime = resolveTerminalRuntimeConfig(options);
333
+ return withRegistryLock(runtime.registryRoot, () => {
334
+ const record = buildSessionRecord("background", {
335
+ ...currentTerminalSession,
336
+ ...options,
337
+ pid,
338
+ ownerPid: pid,
339
+ createdAt: currentTerminalSession.createdAt,
340
+ });
341
+ const next = writeSessionRecord(currentTerminalSession.file, record);
342
+ currentTerminalSession = {
343
+ ...next,
344
+ preparedForBackground: false,
345
+ handedOff: true,
346
+ };
347
+ return currentTerminalSession;
348
+ });
349
+ }
350
+
351
+ export function cancelPreparedTerminalSessionBackground(options = {}) {
352
+ if (!currentTerminalSession?.file || !currentTerminalSession.preparedForBackground) {
353
+ return false;
354
+ }
355
+
356
+ const runtime = resolveTerminalRuntimeConfig(options);
357
+ return withRegistryLock(runtime.registryRoot, () => {
358
+ fs.rmSync(currentTerminalSession.file, { force: true });
359
+ currentTerminalSession = null;
360
+ return true;
361
+ });
362
+ }
363
+
364
+ export function bindBackgroundTerminalSession(sessionFile, options = {}) {
365
+ if (!sessionFile) {
366
+ return null;
367
+ }
368
+
369
+ const registryRoot = path.dirname(sessionFile);
370
+ return withRegistryLock(registryRoot, () => {
371
+ const existing = fileExists(sessionFile) ? readSessionRecord(sessionFile) : null;
372
+ const record = buildSessionRecord("background", {
373
+ ...existing,
374
+ ...options,
375
+ pid: process.pid,
376
+ ownerPid: process.pid,
377
+ });
378
+ return setCurrentTerminalSession(writeSessionRecord(sessionFile, record));
379
+ });
380
+ }
381
+
382
+ export function releaseCurrentTerminalSession() {
383
+ if (!currentTerminalSession?.file || currentTerminalSession.handedOff) {
384
+ return false;
385
+ }
386
+
387
+ const sessionFile = currentTerminalSession.file;
388
+ const registryRoot = path.dirname(sessionFile);
389
+ withRegistryLock(registryRoot, () => {
390
+ fs.rmSync(sessionFile, { force: true });
391
+ });
392
+ currentTerminalSession = null;
393
+ return true;
394
+ }