@logicseek/hello-cc 0.1.9 → 1.0.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +50 -3
  3. package/README.zh-CN.md +48 -3
  4. package/bin/hcc.mjs +3 -5656
  5. package/docs/README.md +8 -0
  6. package/docs/README.zh-CN.md +6 -0
  7. package/docs/architecture.md +29 -18
  8. package/docs/architecture.zh-CN.md +20 -10
  9. package/docs/commands.md +13 -4
  10. package/docs/commands.zh-CN.md +13 -4
  11. package/docs/guide.md +97 -3
  12. package/docs/guide.zh-CN.md +89 -2
  13. package/lib/cli/app.mjs +644 -0
  14. package/lib/cli/commands/coordination.mjs +249 -0
  15. package/lib/cli/commands/doctor.mjs +81 -0
  16. package/lib/cli/commands/gc.mjs +1002 -0
  17. package/lib/cli/commands/hook.mjs +205 -0
  18. package/lib/cli/commands/install.mjs +294 -0
  19. package/lib/cli/commands/lifecycle.mjs +101 -0
  20. package/lib/cli/commands/lock.mjs +197 -0
  21. package/lib/cli/commands/misc.mjs +76 -0
  22. package/lib/cli/commands/msg.mjs +152 -0
  23. package/lib/cli/commands/peer.mjs +249 -0
  24. package/lib/cli/commands/query.mjs +96 -0
  25. package/lib/cli/commands/run.mjs +463 -0
  26. package/lib/cli/commands/task.mjs +405 -0
  27. package/lib/cli/commands/team.mjs +131 -0
  28. package/lib/cli/commands/tmux.mjs +838 -0
  29. package/lib/cli/commands/up.mjs +135 -0
  30. package/lib/cli-args.mjs +16 -2
  31. package/lib/coordination-state.mjs +112 -51
  32. package/lib/core/coordination/automation.mjs +5 -8
  33. package/lib/core/coordination/clock-safety.mjs +323 -0
  34. package/lib/core/coordination/event-retention.mjs +17 -0
  35. package/lib/core/coordination/gc-plan.mjs +614 -0
  36. package/lib/core/coordination/lease-renewal.mjs +102 -0
  37. package/lib/core/coordination/lock-evidence.mjs +77 -0
  38. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  39. package/lib/core/coordination/tasks.mjs +156 -36
  40. package/lib/core/peers/evidence-runtime.mjs +312 -0
  41. package/lib/core/peers/evidence.mjs +103 -0
  42. package/lib/core/peers/hook-owner.mjs +32 -0
  43. package/lib/core/peers/liveness.mjs +44 -4
  44. package/lib/core/peers/peer-helpers.mjs +121 -0
  45. package/lib/core/peers/session.mjs +8 -2
  46. package/lib/core/peers/tmux-safety.mjs +290 -0
  47. package/lib/core/sessions/launch.mjs +13 -1
  48. package/lib/db/connection.mjs +104 -0
  49. package/lib/db/events.mjs +34 -0
  50. package/lib/db/migration-backup.mjs +225 -0
  51. package/lib/db/schema.mjs +102 -5
  52. package/lib/db/stores/peers.mjs +34 -0
  53. package/lib/integrations/peers/identity.mjs +62 -16
  54. package/lib/integrations/shims/script.mjs +111 -9
  55. package/lib/process/identity.mjs +222 -0
  56. package/lib/process/pty-lifecycle.mjs +149 -0
  57. package/lib/runtime/buffer-directory-lease.mjs +48 -0
  58. package/lib/runtime/buffer-evidence.mjs +336 -0
  59. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  60. package/lib/runtime/buffer-gc.mjs +270 -0
  61. package/lib/runtime/client.mjs +63 -7
  62. package/lib/runtime/fatal-shutdown.mjs +54 -0
  63. package/lib/runtime/project-path.mjs +139 -0
  64. package/lib/runtime/projects.mjs +320 -40
  65. package/lib/runtime/state.mjs +227 -27
  66. package/lib/shared/cleanup-error.mjs +54 -0
  67. package/lib/shared/clock-grace.mjs +106 -0
  68. package/lib/shared/errors.mjs +9 -0
  69. package/lib/shared/file-lock.mjs +377 -0
  70. package/lib/shared/json-file.mjs +6 -2
  71. package/lib/shared/redact.mjs +60 -0
  72. package/lib/shared/socket-lock-worker.mjs +250 -0
  73. package/lib/terminal/tmux.mjs +131 -27
  74. package/lib/tmux.mjs +4 -0
  75. package/lib/ui/help.mjs +53 -15
  76. package/lib/web/api-version.mjs +42 -0
  77. package/lib/web/auto-attach.mjs +185 -0
  78. package/lib/web/buffer-gc-runtime.mjs +175 -0
  79. package/lib/web/cookie-auth.mjs +119 -0
  80. package/lib/web/external-sessions.mjs +254 -0
  81. package/lib/web/http-routes.mjs +542 -0
  82. package/lib/web/http.mjs +135 -6
  83. package/lib/web/liveness-reaper.mjs +145 -0
  84. package/lib/web/peer-actions.mjs +132 -23
  85. package/lib/web/project-contexts.mjs +183 -0
  86. package/lib/web/pty-sessions.mjs +203 -0
  87. package/lib/web/runtime-main.mjs +717 -0
  88. package/lib/web/runtime.mjs +91 -36
  89. package/lib/web/session-serialize.mjs +0 -0
  90. package/lib/web/startup.mjs +335 -0
  91. package/lib/web/tls.mjs +365 -0
  92. package/lib/web/tmux-sessions.mjs +1361 -0
  93. package/lib/web/tmux-stream.mjs +166 -0
  94. package/lib/web/ui-template.mjs +152 -20
  95. package/package.json +6 -3
  96. package/scripts/check-free-identifiers.mjs +290 -0
  97. package/scripts/regression.mjs +5404 -273
  98. package/docs/architecture-split-review.md +0 -217
  99. package/docs/layout-split-stack-audit.md +0 -298
@@ -0,0 +1,175 @@
1
+ // Runtime-side automatic buffer GC, extracted from lib/web/runtime-main.mjs.
2
+ // Plans and applies buffer-file GC with runtime arbitration: live-session
3
+ // path snapshots, cross-project evidence collection, fenced plan tokens,
4
+ // and the periodic auto-GC sweep.
5
+
6
+ import path from 'node:path';
7
+ import { CliError } from '../shared/errors.mjs';
8
+ import { clockSafetyUnavailable } from '../core/coordination/clock-safety.mjs';
9
+ import { collectBufferEvidence } from '../runtime/buffer-evidence.mjs';
10
+ import { applyClockSafeBufferPlan, createBufferGcPlanStore } from '../runtime/buffer-gc-protocol.mjs';
11
+ import { bufferPlanGcCutoffs, planBufferFiles } from '../runtime/buffer-gc.mjs';
12
+
13
+ export function createBufferGcRuntime(deps) {
14
+ const {
15
+ sessions,
16
+ connectWebProject, runtimeProjectContexts,
17
+ now, runGc, bufferDirectory,
18
+ canonicalRoot, observePeerEvidence, redactedLogText, sameResolvedPath
19
+ } = deps;
20
+
21
+ const bufferGcPlanStore = createBufferGcPlanStore();
22
+ const bufferUnknownTracker = new Map();
23
+ const bufferDirectoriesByProject = new Map();
24
+
25
+ // ── Automatic GC ──────────────────────────────────────────────────────────
26
+ // Bound events/WAL growth without manual `hcc gc` (conc-05). Auto scope only
27
+ // prunes high-volume/ephemeral items (events >14d, expired locks, stale bufs),
28
+ // never peers/tasks/messages/handoffs. Runs once at startup then every 6h.
29
+ let autoGcInFlight = false;
30
+ function runningBufferPathSnapshot(projectCtx) {
31
+ const protectedPaths = new Set();
32
+ const projectKey = canonicalRoot(projectCtx.root) || path.resolve(projectCtx.root);
33
+ const projectDirectories = bufferDirectoriesByProject.get(projectKey) || new Set();
34
+ for (const session of sessions.values()) {
35
+ for (const file of [session.outFile, session.inFile, session.resizeFile, session.pipeFile, session.metaFile]) {
36
+ if (!file) continue;
37
+ const resolved = path.resolve(file);
38
+ if (sameResolvedPath(session.root, projectCtx.root)) projectDirectories.add(path.dirname(resolved));
39
+ if (session.status === 'running') protectedPaths.add(resolved);
40
+ }
41
+ }
42
+ bufferDirectoriesByProject.set(projectKey, projectDirectories);
43
+ return { protectedPaths, projectDirectories };
44
+ }
45
+
46
+ function collectRuntimeBufferEvidence(directories, candidatePaths = null) {
47
+ const projectDbs = [];
48
+ const opened = [];
49
+ try {
50
+ for (const projectCtx of runtimeProjectContexts()) {
51
+ try {
52
+ const db = connectWebProject(projectCtx, { migrateRegistered: false, create: false });
53
+ opened.push(db);
54
+ projectDbs.push({ ctx: projectCtx, db });
55
+ } catch (error) {
56
+ projectDbs.push({
57
+ ctx: projectCtx,
58
+ db: { prepare() { throw error; } }
59
+ });
60
+ }
61
+ }
62
+ return collectBufferEvidence({
63
+ directories,
64
+ projectDbs,
65
+ sessions: [...sessions.values()],
66
+ observePeer: observePeerEvidence,
67
+ unknownTracker: bufferUnknownTracker,
68
+ candidatePaths
69
+ });
70
+ } finally {
71
+ for (const db of opened) {
72
+ try { db.close(); } catch {}
73
+ }
74
+ }
75
+ }
76
+
77
+ function prepareRuntimeBufferGc(projectCtx, { dryRun, retentionSec }) {
78
+ const observedAt = now();
79
+ const cutoffMs = observedAt * 1000 - retentionSec * 1000;
80
+ const { projectDirectories } = runningBufferPathSnapshot(projectCtx);
81
+ const directories = new Set([bufferDirectory(projectCtx), ...projectDirectories]);
82
+ const { protectedPaths, unknownPaths, gcCutoffs } = collectRuntimeBufferEvidence(directories);
83
+ const plan = planBufferFiles({
84
+ directories,
85
+ cutoffMs,
86
+ protectedPaths,
87
+ unknownPaths,
88
+ evidenceGcCutoffs: gcCutoffs
89
+ });
90
+ const prepared = {
91
+ observedAt,
92
+ cutoffMs,
93
+ retentionSec,
94
+ deleted: plan.deleteEntries.length,
95
+ protected: plan.protectedEntries.length,
96
+ deferred: plan.unknownEntries.length,
97
+ gcCutoffs: bufferPlanGcCutoffs(plan, retentionSec)
98
+ };
99
+ if (!dryRun) {
100
+ prepared.token = bufferGcPlanStore.prepare({
101
+ root: projectCtx.root,
102
+ dbPath: projectCtx.dbPath,
103
+ observedAt,
104
+ retentionSec,
105
+ plan
106
+ });
107
+ }
108
+ return prepared;
109
+ }
110
+
111
+ function applyPreparedRuntimeBufferGc(projectCtx, token) {
112
+ const prepared = bufferGcPlanStore.take({
113
+ token,
114
+ root: projectCtx.root,
115
+ dbPath: projectCtx.dbPath
116
+ });
117
+ let db = null;
118
+ try {
119
+ db = connectWebProject(projectCtx);
120
+ return applyClockSafeBufferPlan({
121
+ db,
122
+ plan: prepared.plan,
123
+ retentionSec: prepared.retentionSec,
124
+ nowSec: now,
125
+ collectEvidence: ({ entries }) => collectRuntimeBufferEvidence(
126
+ new Set(entries.map((entry) => entry.directory.path)),
127
+ entries.map((entry) => entry.path)
128
+ )
129
+ });
130
+ } catch (error) {
131
+ if (error instanceof CliError) throw error;
132
+ throw clockSafetyUnavailable(error);
133
+ } finally {
134
+ try { db?.close(); } catch {}
135
+ }
136
+ }
137
+
138
+ function runAutoGc() {
139
+ if (autoGcInFlight) return;
140
+ autoGcInFlight = true;
141
+ try {
142
+ for (const projectCtx of runtimeProjectContexts()) {
143
+ let db = null;
144
+ try {
145
+ db = connectWebProject(projectCtx, { migrateRegistered: false, create: false });
146
+ const result = runGc(projectCtx, db, {
147
+ olderThanDays: 14,
148
+ dryRun: false,
149
+ scope: 'auto',
150
+ collectBufferEvidenceNow: (directory) => collectRuntimeBufferEvidence([
151
+ directory
152
+ ])
153
+ });
154
+ const appliedDatabaseRows = result.expired_locks + result.old_events;
155
+ if (appliedDatabaseRows > 0) {
156
+ try { db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get(); } catch {}
157
+ }
158
+ } catch (err) {
159
+ console.error(redactedLogText(`[${new Date().toISOString()}] auto-gc failed for ${projectCtx.root}: ${err?.message || err}`));
160
+ } finally {
161
+ try { db?.close(); } catch {}
162
+ }
163
+ }
164
+ } finally {
165
+ autoGcInFlight = false;
166
+ }
167
+ }
168
+ const gcPoller = setInterval(runAutoGc, 6 * 60 * 60 * 1000);
169
+
170
+ return {
171
+ gcPoller, runAutoGc,
172
+ collectRuntimeBufferEvidence, prepareRuntimeBufferGc, applyPreparedRuntimeBufferGc,
173
+ runningBufferPathSnapshot, bufferGcPlanStore
174
+ };
175
+ }
@@ -0,0 +1,119 @@
1
+ // Web cookie/session authentication helpers extracted from cmdWeb.
2
+ // Factory pattern: the webSessions Map and config are created by the factory
3
+ // and shared across all returned functions.
4
+
5
+ import { randomBytes } from 'node:crypto';
6
+
7
+ export function createCookieAuth({
8
+ now,
9
+ ttlSec,
10
+ maxSessions,
11
+ requestIsSecure,
12
+ trustProxy,
13
+ proxyOrigin,
14
+ authOk,
15
+ token
16
+ }) {
17
+ const webSessions = new Map();
18
+
19
+ function parseCookieSid(req) {
20
+ const header = req.headers.cookie || '';
21
+ for (const part of header.split(';')) {
22
+ const [k, ...rest] = part.trim().split('=');
23
+ if (k === 'hcc_sid') {
24
+ const raw = rest.join('=');
25
+ if (raw.length > 128) return '';
26
+ try { return decodeURIComponent(raw); } catch { return ''; }
27
+ }
28
+ }
29
+ return '';
30
+ }
31
+
32
+ function closeWebSession(sid, reason = 'session revoked') {
33
+ const session = webSessions.get(sid);
34
+ webSessions.delete(sid);
35
+ for (const ws of session?.sockets || []) {
36
+ try { ws.close(4001, reason); } catch {}
37
+ }
38
+ session?.sockets?.clear();
39
+ }
40
+
41
+ function pruneWebSessions(t = now()) {
42
+ for (const [sid, session] of webSessions) {
43
+ if (session.expiresAt <= t) closeWebSession(sid, 'session expired');
44
+ }
45
+ }
46
+
47
+ function issueSession() {
48
+ pruneWebSessions();
49
+ while (webSessions.size >= maxSessions) {
50
+ const oldest = webSessions.keys().next().value;
51
+ if (!oldest) break;
52
+ closeWebSession(oldest, 'session limit reached');
53
+ }
54
+ const sid = randomBytes(24).toString('base64url');
55
+ webSessions.set(sid, { expiresAt: now() + ttlSec, sockets: new Set() });
56
+ return sid;
57
+ }
58
+
59
+ function sessionCookieHeader(sid, req) {
60
+ const parts = [`hcc_sid=${sid}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${ttlSec}`];
61
+ if (requestIsSecure(req, { trustProxy, proxyOrigin })) parts.push('Secure');
62
+ return parts.join('; ');
63
+ }
64
+
65
+ function expiredSessionCookieHeader(req) {
66
+ const parts = ['hcc_sid=', 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
67
+ if (requestIsSecure(req, { trustProxy, proxyOrigin })) parts.push('Secure');
68
+ return parts.join('; ');
69
+ }
70
+
71
+ function cookieSessionRecord(req) {
72
+ const sid = parseCookieSid(req);
73
+ if (!sid) return null;
74
+ const session = webSessions.get(sid);
75
+ if (!session || session.expiresAt <= now()) {
76
+ if (session) closeWebSession(sid, 'session expired');
77
+ return null;
78
+ }
79
+ return { sid, session };
80
+ }
81
+
82
+ function cookieSessionOk(req) {
83
+ return Boolean(cookieSessionRecord(req));
84
+ }
85
+
86
+ function cookieSocketValid(ws) {
87
+ const auth = ws?.hccCookieAuth;
88
+ if (!auth) return true;
89
+ const current = webSessions.get(auth.sid);
90
+ if (current === auth.session && current.expiresAt > now()) return true;
91
+ const reason = current === auth.session ? 'session expired' : 'session revoked';
92
+ if (current === auth.session) {
93
+ closeWebSession(auth.sid, reason);
94
+ } else {
95
+ auth.session.sockets.delete(ws);
96
+ try { ws.close(4001, reason); } catch {}
97
+ }
98
+ return false;
99
+ }
100
+
101
+ function webAuthMode(url, req) {
102
+ if (authOk(url, req, token)) return 'token';
103
+ return cookieSessionOk(req) ? 'cookie' : null;
104
+ }
105
+
106
+ return {
107
+ webSessions,
108
+ parseCookieSid,
109
+ closeWebSession,
110
+ pruneWebSessions,
111
+ issueSession,
112
+ sessionCookieHeader,
113
+ expiredSessionCookieHeader,
114
+ cookieSessionRecord,
115
+ cookieSessionOk,
116
+ cookieSocketValid,
117
+ webAuthMode
118
+ };
119
+ }
@@ -0,0 +1,254 @@
1
+ // External buffer-file session adoption, extracted from lib/web/runtime-main.mjs.
2
+ // Adopts hcc-run PTY bridges from .hello-cc/bufs/<id>.* files: owner-evidence
3
+ // checks under the producer lease, output polling, exit reconciliation, and
4
+ // per-project directory watchers.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { withBufferDirectoryLease } from '../runtime/buffer-directory-lease.mjs';
9
+ import {
10
+ externalBufferEvidence,
11
+ externalBufferOwnerKey,
12
+ externalBufferSessionIds,
13
+ readExternalBufferMetadata
14
+ } from '../runtime/buffer-evidence.mjs';
15
+ import { inspectProcessIdentity } from '../process/identity.mjs';
16
+
17
+ export function createExternalSessions(deps) {
18
+ const {
19
+ ctx, sessions,
20
+ sessionKey, broadcast,
21
+ now, tx, connectWebProject,
22
+ bufferDirectory, runtimeProjectContexts,
23
+ refreshPeerIoHeartbeat, redactedLogText, BUFS_DIR_NAME
24
+ } = deps;
25
+
26
+ // ── Optional external buffer-file session adoption ───────────────────────
27
+ const bufsDir = path.join(ctx.root, '.hello-cc', BUFS_DIR_NAME);
28
+ fs.mkdirSync(bufsDir, { recursive: true });
29
+
30
+ function removeExternalBufferFiles(id, directory = bufsDir) {
31
+ for (const suffix of ['out', 'in', 'resize', 'meta']) {
32
+ fs.rmSync(path.join(directory, `${id}.${suffix}`), { force: true });
33
+ }
34
+ }
35
+
36
+ function adoptExternalSession(id, pctx = ctx, directory = bufsDir) {
37
+ const key = sessionKey(pctx, id);
38
+ if (sessions.has(key)) return;
39
+ const outFile = path.join(directory, `${id}.out`);
40
+ const inFile = path.join(directory, `${id}.in`);
41
+ const resizeFile = path.join(directory, `${id}.resize`);
42
+ const metaFile = path.join(directory, `${id}.meta`);
43
+ let adopted = null;
44
+ try {
45
+ adopted = withBufferDirectoryLease(directory, () => {
46
+ if (!fs.existsSync(outFile) || !fs.existsSync(metaFile)) return null;
47
+ let meta;
48
+ try { meta = readExternalBufferMetadata(metaFile); } catch { return null; }
49
+ // The producer publishes wrapper evidence before the PTY child identity
50
+ // is complete. Do not cache that transitional snapshot as a session;
51
+ // the next scan adopts the final metadata instead.
52
+ if (meta.publishing === true) return null;
53
+ const ownerKey = externalBufferOwnerKey(meta);
54
+ if (!ownerKey) return null;
55
+ const evidence = externalBufferEvidence(meta, inspectProcessIdentity);
56
+ // Re-read and remove in one producer-coordinated lease. A new producer
57
+ // with the same id cannot publish between this decision and deletion.
58
+ if (evidence.state === 'dead') {
59
+ removeExternalBufferFiles(id, directory);
60
+ return null;
61
+ }
62
+ return { meta, ownerKey };
63
+ });
64
+ } catch { return; }
65
+ if (!adopted) return;
66
+ const { meta, ownerKey } = adopted;
67
+ const wrapperOwnerPid = meta.wrapper_pid || meta.wrapperPid || null;
68
+ const wrapperOwnerIdentity = meta.wrapper_identity || meta.wrapperIdentity || null;
69
+ const dbOwnerPid = wrapperOwnerPid || meta.pid || null;
70
+ const dbOwnerIdentity = wrapperOwnerPid
71
+ ? wrapperOwnerIdentity
72
+ : meta.child_identity || meta.childIdentity || null;
73
+
74
+ const session = {
75
+ id,
76
+ peerId: id,
77
+ actionTokens: new Set(),
78
+ root: pctx.root,
79
+ ctx: pctx,
80
+ kind: meta.kind || 'external',
81
+ role: meta.role || 'peer',
82
+ command: meta.command || '(shim)',
83
+ cwd: meta.cwd || pctx.root,
84
+ pid: meta.pid || null,
85
+ wrapperPid: meta.wrapper_pid || null,
86
+ childIdentity: meta.child_identity || null,
87
+ wrapperIdentity: meta.wrapper_identity || null,
88
+ externalOwnerKey: ownerKey,
89
+ externalDbOwner: Number.isInteger(Number(dbOwnerPid)) && dbOwnerIdentity?.startToken &&
90
+ dbOwnerIdentity?.commandHash
91
+ ? {
92
+ pid: Number(dbOwnerPid),
93
+ startToken: dbOwnerIdentity.startToken,
94
+ commandHash: dbOwnerIdentity.commandHash
95
+ }
96
+ : null,
97
+ type: 'external',
98
+ outFile, inFile, resizeFile, metaFile,
99
+ status: 'running',
100
+ createdAt: now(),
101
+ exitedAt: null,
102
+ buffer: '',
103
+ clients: new Set(),
104
+ outputPoller: null,
105
+ outputFd: null,
106
+ exitPoller: null,
107
+ };
108
+ // Load existing output as initial snapshot
109
+ try { session.buffer = fs.readFileSync(outFile, 'utf8'); } catch {}
110
+ sessions.set(key, session);
111
+
112
+ // Open a persistent fd for polling output; fstatSync is cheap.
113
+ let outputOffset = 0;
114
+ try {
115
+ session.outputFd = fs.openSync(outFile, 'r');
116
+ outputOffset = fs.fstatSync(session.outputFd).size;
117
+ } catch {}
118
+ session.outputPoller = setInterval(() => {
119
+ try {
120
+ if (session.outputFd === null) return;
121
+ const stat = fs.fstatSync(session.outputFd);
122
+ if (stat.size < outputOffset) outputOffset = 0;
123
+ if (stat.size <= outputOffset) return;
124
+ const buf = Buffer.alloc(stat.size - outputOffset);
125
+ fs.readSync(session.outputFd, buf, 0, buf.length, outputOffset);
126
+ outputOffset = stat.size;
127
+ const data = buf.toString();
128
+ session.buffer += data;
129
+ if (session.buffer.length > 250000) session.buffer = session.buffer.slice(-200000);
130
+ broadcast(session, { type: 'data', data });
131
+ refreshPeerIoHeartbeat(session);
132
+ } catch {
133
+ // File removed or truncated — close and stop polling
134
+ if (session.outputFd) { try { fs.closeSync(session.outputFd); } catch {} session.outputFd = null; }
135
+ }
136
+ }, 100);
137
+
138
+ function finalizeExternalSession({ updateDatabase }) {
139
+ session.status = 'exited';
140
+ session.exitedAt = now();
141
+ broadcast(session, { type: 'exit', event: {} });
142
+ if (updateDatabase) {
143
+ try {
144
+ const exitDb = connectWebProject(session.ctx || ctx);
145
+ try {
146
+ const exitPeerId = session.peerId || session.id;
147
+ if (session.externalDbOwner) {
148
+ tx(exitDb, () => {
149
+ const mutation = exitDb.prepare(`
150
+ UPDATE peers SET status = ?
151
+ WHERE id = ? AND pid = ? AND pid_start_token = ? AND pid_command_hash = ?
152
+ `).run(
153
+ 'exited',
154
+ exitPeerId,
155
+ session.externalDbOwner.pid,
156
+ session.externalDbOwner.startToken,
157
+ session.externalDbOwner.commandHash
158
+ );
159
+ if (Number(mutation.changes || 0) > 0) {
160
+ exitDb.prepare('UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer = ?')
161
+ .run(now(), exitPeerId);
162
+ }
163
+ });
164
+ }
165
+ } finally { exitDb.close(); }
166
+ } catch {}
167
+ }
168
+ if (session.outputFd) { try { fs.closeSync(session.outputFd); } catch {} session.outputFd = null; }
169
+ if (session.outputPoller) clearInterval(session.outputPoller);
170
+ if (session.exitPoller) clearInterval(session.exitPoller);
171
+ sessions.delete(key);
172
+ }
173
+
174
+ // Reconcile and mutate the external group under the same lease used by its
175
+ // producer. Owner changes detach only this stale in-memory view; they never
176
+ // mark the replacement producer exited or remove its files.
177
+ session.exitPoller = setInterval(() => {
178
+ try {
179
+ withBufferDirectoryLease(directory, () => {
180
+ const outExists = fs.existsSync(outFile);
181
+ let currentMeta;
182
+ try {
183
+ currentMeta = readExternalBufferMetadata(metaFile);
184
+ } catch {
185
+ // A producer removes the whole group in one lease. No out and no
186
+ // metadata is therefore a clean exit; an unreadable metadata file
187
+ // is unknown and must not mutate DB ownership.
188
+ if (!outExists && !fs.existsSync(metaFile)) {
189
+ finalizeExternalSession({ updateDatabase: true });
190
+ }
191
+ return;
192
+ }
193
+ const currentOwnerKey = externalBufferOwnerKey(currentMeta);
194
+ if (!currentOwnerKey) return;
195
+ if (currentOwnerKey !== session.externalOwnerKey) {
196
+ finalizeExternalSession({ updateDatabase: false });
197
+ return;
198
+ }
199
+ if (!outExists) {
200
+ finalizeExternalSession({ updateDatabase: true });
201
+ return;
202
+ }
203
+ if (externalBufferEvidence(currentMeta, inspectProcessIdentity).state !== 'dead') return;
204
+ removeExternalBufferFiles(id, directory);
205
+ finalizeExternalSession({ updateDatabase: true });
206
+ });
207
+ } catch {}
208
+ }, 2000);
209
+ }
210
+
211
+ // Adopt any already-running external sessions
212
+ function scanExternalSessions() {
213
+ for (const projectCtx of runtimeProjectContexts()) {
214
+ const directory = bufferDirectory(projectCtx);
215
+ for (const id of externalBufferSessionIds(directory)) {
216
+ adoptExternalSession(id, projectCtx, directory);
217
+ }
218
+ }
219
+ }
220
+
221
+ scanExternalSessions();
222
+
223
+ const externalScanPoller = setInterval(scanExternalSessions, 1000);
224
+
225
+ // bg-04: watch for new external sessions appearing in EVERY registered
226
+ // project's bufsDir, not just the primary. New projects discovered by
227
+ // scanExternalSessions get their own watcher on the next scan tick.
228
+ const bufsWatchers = new Map();
229
+ function ensureBufsWatchers() {
230
+ for (const projectCtx of runtimeProjectContexts()) {
231
+ const root = path.resolve(projectCtx.root);
232
+ if (bufsWatchers.has(root)) continue;
233
+ const directory = bufferDirectory(projectCtx);
234
+ try {
235
+ const watcher = fs.watch(directory, { persistent: false }, (event, filename) => {
236
+ if (filename?.endsWith('.out')) {
237
+ setTimeout(() => adoptExternalSession(path.basename(filename, '.out'), projectCtx, directory), 300);
238
+ }
239
+ });
240
+ watcher.on('error', (err) => {
241
+ console.error(redactedLogText(`[${new Date().toISOString()}] bufs watcher error for ${root}: ${err?.message || err}`));
242
+ });
243
+ bufsWatchers.set(root, watcher);
244
+ } catch {}
245
+ }
246
+ }
247
+ ensureBufsWatchers();
248
+ const bufsWatcherSyncPoller = setInterval(ensureBufsWatchers, 5000);
249
+
250
+ return {
251
+ bufsWatchers, externalScanPoller, bufsWatcherSyncPoller,
252
+ scanExternalSessions, adoptExternalSession, removeExternalBufferFiles
253
+ };
254
+ }