mixdog 0.7.6 → 0.7.7

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.
@@ -1305,3 +1305,99 @@ export class OpenAIOAuthProvider {
1305
1305
  return this.tokens !== null;
1306
1306
  }
1307
1307
  }
1308
+
1309
+ const AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
1310
+ const CODEX_OAUTH_SCOPE = 'openid profile email offline_access api.connectors.read api.connectors.invoke';
1311
+ const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs';
1312
+ const CALLBACK_HOST = '127.0.0.1';
1313
+ const CALLBACK_PORT = 1455;
1314
+ const CALLBACK_PATH = '/auth/callback';
1315
+ const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
1316
+ const LOGIN_TIMEOUT_MS = 5 * 60_000;
1317
+ const TOKEN_TIMEOUT_MS = 30_000;
1318
+
1319
+ function generatePKCE() {
1320
+ const verifier = randomBytes(64).toString('base64url');
1321
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
1322
+ return { verifier, challenge };
1323
+ }
1324
+
1325
+ export async function loginOAuth() {
1326
+ const pkce = generatePKCE();
1327
+ const state = randomBytes(16).toString('hex');
1328
+ const url = new URL(AUTHORIZE_URL);
1329
+ url.searchParams.set('response_type', 'code');
1330
+ url.searchParams.set('client_id', CLIENT_ID);
1331
+ url.searchParams.set('redirect_uri', REDIRECT_URI);
1332
+ url.searchParams.set('scope', CODEX_OAUTH_SCOPE);
1333
+ url.searchParams.set('code_challenge', pkce.challenge);
1334
+ url.searchParams.set('code_challenge_method', 'S256');
1335
+ url.searchParams.set('id_token_add_organizations', 'true');
1336
+ url.searchParams.set('codex_cli_simplified_flow', 'true');
1337
+ url.searchParams.set('state', state);
1338
+ url.searchParams.set('originator', CODEX_OAUTH_ORIGINATOR);
1339
+ process.stderr.write(`\n[openai-oauth] Open this URL to log in to ChatGPT (Codex):\n${url.toString()}\n\n`);
1340
+ try {
1341
+ const { exec } = await import('child_process');
1342
+ const opener = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
1343
+ exec(`${opener} "${url.toString()}"`, { windowsHide: true });
1344
+ } catch { /* user opens manually */ }
1345
+
1346
+ return new Promise((resolve) => {
1347
+ const timeout = setTimeout(() => { server.close(); resolve(null); }, LOGIN_TIMEOUT_MS);
1348
+ const server = createServer(async (req, res) => {
1349
+ const u = new URL(req.url || '/', `http://${CALLBACK_HOST}:${CALLBACK_PORT}`);
1350
+ if (u.pathname !== CALLBACK_PATH) {
1351
+ res.writeHead(404);
1352
+ res.end();
1353
+ return;
1354
+ }
1355
+ const code = u.searchParams.get('code');
1356
+ if (!code || u.searchParams.get('state') !== state) {
1357
+ res.writeHead(400);
1358
+ res.end('Invalid');
1359
+ clearTimeout(timeout);
1360
+ server.close();
1361
+ resolve(null);
1362
+ return;
1363
+ }
1364
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1365
+ res.end('<html><body><h2>Codex login successful! You can close this tab.</h2></body></html>');
1366
+ clearTimeout(timeout);
1367
+ server.close();
1368
+ try {
1369
+ const tokenRes = await fetch(TOKEN_URL, {
1370
+ method: 'POST',
1371
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1372
+ body: new URLSearchParams({
1373
+ grant_type: 'authorization_code',
1374
+ code,
1375
+ redirect_uri: REDIRECT_URI,
1376
+ client_id: CLIENT_ID,
1377
+ code_verifier: pkce.verifier,
1378
+ }),
1379
+ redirect: 'error',
1380
+ signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
1381
+ });
1382
+ if (!tokenRes.ok) { resolve(null); return; }
1383
+ const json = await tokenRes.json();
1384
+ if (!json.access_token || !json.refresh_token) { resolve(null); return; }
1385
+ const expiresAt = (typeof json.expires_in === 'number'
1386
+ ? Date.now() + json.expires_in * 1000
1387
+ : 0) || _expiryFromAccessToken(json.access_token);
1388
+ const tokens = {
1389
+ access_token: json.access_token,
1390
+ refresh_token: json.refresh_token,
1391
+ expires_at: expiresAt,
1392
+ account_id: extractAccountId(json.access_token),
1393
+ };
1394
+ saveTokens(tokens);
1395
+ resolve(tokens);
1396
+ } catch {
1397
+ resolve(null);
1398
+ }
1399
+ });
1400
+ server.listen(CALLBACK_PORT, CALLBACK_HOST);
1401
+ server.on('error', () => { clearTimeout(timeout); resolve(null); });
1402
+ });
1403
+ }
@@ -1780,11 +1780,13 @@ export async function resumeSession(sessionId, preset) {
1780
1780
  export function getSession(id) {
1781
1781
  return loadSession(id);
1782
1782
  }
1783
- export function listSessions() {
1783
+ export function listSessions(opts = {}) {
1784
+ const includeClosed = opts.includeClosed === true;
1784
1785
  const sessions = listStoredSessions();
1785
1786
  const hiddenIds = new Set([..._runtimeState.entries()].filter(([, e]) => e.listHidden).map(([id]) => id));
1786
- // Always exclude tombstoned sessions (closed===true) closeSession plants the tombstone.
1787
- return sessions.filter(s => s.closed !== true && !hiddenIds.has(s.id));
1787
+ // Tombstoned sessions (closed===true) are excluded unless the caller opts in
1788
+ // (e.g. bridge list includeClosed:true).
1789
+ return sessions.filter(s => !hiddenIds.has(s.id) && (includeClosed || s.closed !== true));
1788
1790
  }
1789
1791
  // --- Clear messages (keep system prompt + provider/model/cwd) ---
1790
1792
  export async function clearSessionMessages(sessionId) {
@@ -498,6 +498,11 @@ export function deleteSession(id) {
498
498
  return removed;
499
499
  }
500
500
  const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
501
+ // Completed bridge workers (idle/done/error) live until terminal reap — must
502
+ // match TERMINAL_REAP_MS / _scheduleBridgeReap (3_600_000) in index.mjs and
503
+ // bridge-stall-watchdog.mjs so the store sweep is the durable 1h reaper.
504
+ const BRIDGE_TERMINAL_TTL_MS = 60 * 60 * 1000;
505
+ const BRIDGE_TERMINAL_STATUSES = new Set(['idle', 'done', 'error']);
501
506
  // Hard wall-clock ceiling for sessions stuck in status='running'. The
502
507
  // stream-watchdog should abort stalled streams within ~120s, but if it misses
503
508
  // one (process crash, watchdog not started, provider never returned), this
@@ -586,7 +591,10 @@ export function sweepStaleSessions(ttlMs) {
586
591
  remaining++;
587
592
  continue;
588
593
  }
589
- if (now - lastActive > maxAge) {
594
+ const isCompletedBridge = session.owner === 'bridge'
595
+ && BRIDGE_TERMINAL_STATUSES.has(session.status);
596
+ const sessionMaxAge = isCompletedBridge ? BRIDGE_TERMINAL_TTL_MS : maxAge;
597
+ if (now - lastActive > sessionMaxAge) {
590
598
  try { markSessionClosed(session.id, 'idle-sweep'); }
591
599
  catch (err) {
592
600
  process.stderr.write(`[session-store] idle-sweep close failed for ${session.id}: ${err?.message}\n`);
@@ -1,8 +1,9 @@
1
1
  import { readFileSync, readdirSync, statSync, writeFileSync } from "fs";
2
2
  import { execFileSync } from "child_process";
3
3
  import { tmpdir } from "os";
4
- import { join, resolve } from "path";
4
+ import { basename, join, resolve } from "path";
5
5
  import { ensureDir, readJsonFile, removeFileIfExists, writeJsonFile } from "./state-file.mjs";
6
+ import { updateJsonAtomicSync, withFileLockSync } from "../../shared/atomic-file.mjs";
6
7
  const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
7
8
  ? resolve(process.env.MIXDOG_RUNTIME_ROOT)
8
9
  : join(tmpdir(), "mixdog");
@@ -152,7 +153,36 @@ function readActiveInstance() {
152
153
  && _staleSup === undefined;
153
154
  if (!ownerFieldsAlreadyEmpty) {
154
155
  process.stderr.write(`mixdog: stale active-instance.json (${staleReason}), clearing owner fields\n`);
155
- try { writeActiveInstance({ ...stableRest, updatedAt: Date.now() }); } catch {}
156
+ try {
157
+ updateJsonAtomicSync(ACTIVE_INSTANCE_FILE, (curRaw) => {
158
+ if (!curRaw) return undefined;
159
+ const liveReason = activeInstanceStaleReason(curRaw);
160
+ if (!liveReason) return undefined;
161
+ const {
162
+ pinned: _stalePinned2,
163
+ instanceId: _staleId2,
164
+ ownerLeadPid: _staleOwner2,
165
+ terminalLeadPid: _staleTerm2,
166
+ supervisor_pid: _staleSup2,
167
+ server_pid: _staleServer2,
168
+ worker_pid: _staleWorker2,
169
+ channels_pid: _staleChannels2,
170
+ supervisor_started_at: _staleStart2,
171
+ server_started_at: _staleServerStart2,
172
+ httpPort: _staleHttpPort2,
173
+ backendReady: _staleBackendReady2,
174
+ turnEndFile: _staleTurnEnd2,
175
+ statusFile: _staleStatus2,
176
+ ...stableRestLocked
177
+ } = curRaw ?? {};
178
+ const ownerEmpty = _staleId2 === undefined
179
+ && _staleOwner2 === undefined
180
+ && _staleTerm2 === undefined
181
+ && _staleSup2 === undefined;
182
+ if (ownerEmpty) return undefined;
183
+ return { ...stableRestLocked, updatedAt: Date.now() };
184
+ }, { compact: true, fsyncDir: true });
185
+ } catch {}
156
186
  }
157
187
  return null;
158
188
  }
@@ -179,77 +209,82 @@ function buildActiveInstanceState(instanceId, meta) {
179
209
  };
180
210
  }
181
211
  function refreshActiveInstance(instanceId, meta) {
182
- const prev = readActiveInstance();
183
- // readActiveInstance returns null when the recorded owner PID is dead,
184
- // even after the dead-owner fix preserves stable fields on disk. For
185
- // the preservedExtra spread below we still need those fields, so read
186
- // the raw file directly as a fallback when prev is null.
187
- const prevForPreserve = prev ?? readJsonFile(ACTIVE_INSTANCE_FILE, null);
188
- // Drop stale fields (pid/startedAt) written by older server versions.
189
- const { pid: _legacyPid, startedAt: _legacyStartedAt, ...prevRest } = prev ?? {};
190
- const identity = buildRuntimeIdentity();
191
- // server_started_at tracks the CURRENT server_pid's start time so the
192
- // dev-sync barrier can verify the CHILD's freshness (the supervisor's
193
- // supervisor_started_at is stable across child respawns and cannot).
194
- // Preserve across refreshes when server_pid is unchanged; stamp fresh
195
- // when server_pid is new/changed or there is no prev advert.
196
- const prevServerPid = parsePositivePid(prevForPreserve?.server_pid);
197
- const prevServerStartedAt = Number(prevForPreserve?.server_started_at);
198
- const serverStartedAt = (
199
- prevServerPid !== null
200
- && identity.server_pid !== null
201
- && prevServerPid === identity.server_pid
202
- && Number.isFinite(prevServerStartedAt)
203
- ) ? prevServerStartedAt : Date.now();
204
- const next = {
205
- ...(prev?.instanceId === instanceId ? prevRest : buildActiveInstanceState(instanceId)),
206
- ...identity,
207
- server_started_at: serverStartedAt,
208
- updatedAt: Date.now(),
209
- ...meta?.channelId ? { channelId: meta.channelId } : {},
210
- ...meta?.transcriptPath ? { transcriptPath: meta.transcriptPath } : {},
211
- ...meta?.httpPort ? { httpPort: meta.httpPort } : {},
212
- ...meta?.memory_port ? { memory_port: meta.memory_port } : {},
213
- ...typeof meta?.backendReady === "boolean" ? { backendReady: meta.backendReady } : {},
214
- };
215
- // Pinned ownership (default ON): each refresh reasserts the pinned flag
216
- // from the current process's env. Other processes refreshing carry their
217
- // own env, so the flag never outlives the pinned process. Set
218
- // MIXDOG_PIN_OWNER=0 to opt out and revert to stale-window takeover.
219
- if (isOwnerPinEnabled()) next.pinned = true;
220
- else delete next.pinned;
221
- // I1: pg_* spreads FIRST so newFields above win on conflict.
222
- // prev.pg_port='A', meta.httpPort-adjacent pg_port='B' → result.pg_port='B'.
223
- // memory_port: preserve when the advertising memory worker is still alive
224
- // (sync process.kill(pid,0); ESRCH=dead). Same server_pid restart still
225
- // preserves; a live owner from another session must not be dropped on handoff.
226
- const preservedExtra = Object.fromEntries(
227
- Object.entries(prevForPreserve ?? {}).filter(([k]) => k.startsWith('pg_'))
228
- );
229
- const prevMemoryServerPid = parsePositivePid(prevForPreserve?.memory_server_pid);
230
- const prevMemoryOwnerAlive = (() => {
231
- if (prevMemoryServerPid === null) return false;
232
- try {
233
- process.kill(prevMemoryServerPid, 0);
234
- return true;
235
- } catch (e) {
236
- if (e && e.code === "ESRCH") return false;
237
- return true;
212
+ ensureRuntimeDirs();
213
+ return updateJsonAtomicSync(ACTIVE_INSTANCE_FILE, (curRaw) => {
214
+ const prevForPreserve = curRaw;
215
+ const prev = activeInstanceStaleReason(curRaw) ? null : curRaw;
216
+ // Drop stale fields (pid/startedAt) written by older server versions.
217
+ const { pid: _legacyPid, startedAt: _legacyStartedAt, ...prevRest } = prev ?? {};
218
+ const identity = buildRuntimeIdentity();
219
+ // server_started_at tracks the CURRENT server_pid's start time so the
220
+ // dev-sync barrier can verify the CHILD's freshness (the supervisor's
221
+ // supervisor_started_at is stable across child respawns and cannot).
222
+ // Preserve across refreshes when server_pid is unchanged; stamp fresh
223
+ // when server_pid is new/changed or there is no prev advert.
224
+ const prevServerPid = parsePositivePid(prevForPreserve?.server_pid);
225
+ const prevServerStartedAt = Number(prevForPreserve?.server_started_at);
226
+ const serverStartedAt = (
227
+ prevServerPid !== null
228
+ && identity.server_pid !== null
229
+ && prevServerPid === identity.server_pid
230
+ && Number.isFinite(prevServerStartedAt)
231
+ ) ? prevServerStartedAt : Date.now();
232
+ const next = {
233
+ ...(prev?.instanceId === instanceId ? prevRest : buildActiveInstanceState(instanceId)),
234
+ ...identity,
235
+ server_started_at: serverStartedAt,
236
+ updatedAt: Date.now(),
237
+ ...meta?.channelId ? { channelId: meta.channelId } : {},
238
+ ...meta?.transcriptPath ? { transcriptPath: meta.transcriptPath } : {},
239
+ ...meta?.httpPort ? { httpPort: meta.httpPort } : {},
240
+ ...meta?.memory_port ? { memory_port: meta.memory_port } : {},
241
+ ...typeof meta?.backendReady === "boolean" ? { backendReady: meta.backendReady } : {},
242
+ };
243
+ if (typeof meta?.transcriptPath === "string" && meta.transcriptPath) {
244
+ const outgoing = prevForPreserve?.transcriptPath;
245
+ if (typeof outgoing === "string" && outgoing) {
246
+ const outBase = basename(outgoing, ".jsonl");
247
+ const newBase = basename(meta.transcriptPath, ".jsonl");
248
+ if (outBase !== newBase) next.priorTranscriptPath = outgoing;
249
+ }
238
250
  }
239
- })();
240
- const sameMemoryAdvertiser =
241
- prevMemoryServerPid !== null &&
242
- identity.server_pid !== null &&
243
- prevMemoryServerPid === identity.server_pid;
244
- if (sameMemoryAdvertiser || prevMemoryOwnerAlive) {
245
- if (prevForPreserve && Object.prototype.hasOwnProperty.call(prevForPreserve, 'memory_port')) {
246
- preservedExtra.memory_port = prevForPreserve.memory_port;
247
- preservedExtra.memory_server_pid = prevMemoryServerPid;
251
+ // Pinned ownership (default ON): each refresh reasserts the pinned flag
252
+ // from the current process's env. Other processes refreshing carry their
253
+ // own env, so the flag never outlives the pinned process. Set
254
+ // MIXDOG_PIN_OWNER=0 to opt out and revert to stale-window takeover.
255
+ if (isOwnerPinEnabled()) next.pinned = true;
256
+ else delete next.pinned;
257
+ // I1: pg_* spreads FIRST so newFields above win on conflict.
258
+ // prev.pg_port='A', meta.httpPort-adjacent pg_port='B' → result.pg_port='B'.
259
+ // memory_port: preserve when the advertising memory worker is still alive
260
+ // (sync process.kill(pid,0); ESRCH=dead). Same server_pid restart still
261
+ // preserves; a live owner from another session must not be dropped on handoff.
262
+ const preservedExtra = Object.fromEntries(
263
+ Object.entries(prevForPreserve ?? {}).filter(([k]) => k.startsWith('pg_'))
264
+ );
265
+ const prevMemoryServerPid = parsePositivePid(prevForPreserve?.memory_server_pid);
266
+ const prevMemoryOwnerAlive = (() => {
267
+ if (prevMemoryServerPid === null) return false;
268
+ try {
269
+ process.kill(prevMemoryServerPid, 0);
270
+ return true;
271
+ } catch (e) {
272
+ if (e && e.code === "ESRCH") return false;
273
+ return true;
274
+ }
275
+ })();
276
+ const sameMemoryAdvertiser =
277
+ prevMemoryServerPid !== null &&
278
+ identity.server_pid !== null &&
279
+ prevMemoryServerPid === identity.server_pid;
280
+ if (sameMemoryAdvertiser || prevMemoryOwnerAlive) {
281
+ if (prevForPreserve && Object.prototype.hasOwnProperty.call(prevForPreserve, 'memory_port')) {
282
+ preservedExtra.memory_port = prevForPreserve.memory_port;
283
+ preservedExtra.memory_server_pid = prevMemoryServerPid;
284
+ }
248
285
  }
249
- }
250
- const nextWithExtra = { ...preservedExtra, ...next };
251
- writeActiveInstance(nextWithExtra);
252
- return nextWithExtra;
286
+ return { ...preservedExtra, ...next };
287
+ }, { compact: true, fsyncDir: true });
253
288
  }
254
289
  const SERVER_PID_FILE = join(
255
290
  RUNTIME_ROOT,
@@ -445,9 +480,12 @@ function releaseOwnedChannelLocks(instanceId) {
445
480
  });
446
481
  }
447
482
  function clearActiveInstance(instanceId) {
448
- const active = readActiveInstance();
449
- if (active?.instanceId !== instanceId) return;
450
- removeFileIfExists(ACTIVE_INSTANCE_FILE);
483
+ withFileLockSync(`${ACTIVE_INSTANCE_FILE}.lock`, () => {
484
+ const curRaw = readJsonFile(ACTIVE_INSTANCE_FILE, null);
485
+ const prev = curRaw && !activeInstanceStaleReason(curRaw) ? curRaw : null;
486
+ if (prev?.instanceId !== instanceId) return;
487
+ removeFileIfExists(ACTIVE_INSTANCE_FILE);
488
+ });
451
489
  }
452
490
  export {
453
491
  ACTIVE_INSTANCE_FILE,
@@ -94,7 +94,7 @@ import { runFullBackfill } from './lib/memory-ops-policy.mjs'
94
94
  import { listCore, addCore, editCore, deleteCore, compactCoreIds, CORE_SUMMARY_MAX } from './lib/core-memory-store.mjs'
95
95
  import { resolveProjectId, resolveProjectScope } from './lib/project-id-resolver.mjs'
96
96
  import { openTraceDatabase, insertTraceEvents, enqueueTraceEvents, insertBridgeCalls, registerTraceExitDrain } from './lib/trace-store.mjs'
97
- import { withFileLockSync, writeJsonAtomicSync } from '../shared/atomic-file.mjs'
97
+ import { updateJsonAtomicSync, writeJsonAtomicSync } from '../shared/atomic-file.mjs'
98
98
  const DATA_DIR = process.env.CLAUDE_PLUGIN_DATA || process.argv[2] || null
99
99
  if (!DATA_DIR) {
100
100
  process.stderr.write('[memory-service] CLAUDE_PLUGIN_DATA not set and no explicit data dir provided\n')
@@ -129,9 +129,8 @@ function advertiseMemoryPort(boundPort, attempt = 0) {
129
129
  const file = path.join(dir, 'active-instance.json')
130
130
  try {
131
131
  fs.mkdirSync(dir, { recursive: true })
132
- withFileLockSync(`${file}.lock`, () => {
133
- let cur = {}
134
- try { cur = JSON.parse(fs.readFileSync(file, 'utf8')) } catch {}
132
+ updateJsonAtomicSync(file, (curRaw) => {
133
+ const cur = curRaw ?? {}
135
134
  const curMemPort = Number(cur?.memory_port)
136
135
  const curMemPid = parsePositivePid(cur?.memory_server_pid)
137
136
  const portConflict = Number.isFinite(curMemPort) && curMemPort > 0 && curMemPort !== boundPort
@@ -141,7 +140,7 @@ function advertiseMemoryPort(boundPort, attempt = 0) {
141
140
  _isPidAliveLocal(curMemPid)
142
141
  if (portConflict && otherOwnerAlive) {
143
142
  process.stderr.write(`[memory-service] skip memory_port advertise port=${boundPort} curMemPort=${curMemPort} curMemPid=${curMemPid} memoryServerPid=${MEMORY_SERVER_PID}\n`)
144
- return
143
+ return undefined
145
144
  }
146
145
  const next = {
147
146
  ...cur,
@@ -149,8 +148,8 @@ function advertiseMemoryPort(boundPort, attempt = 0) {
149
148
  ...(MEMORY_SERVER_PID ? { memory_server_pid: MEMORY_SERVER_PID } : {}),
150
149
  updatedAt: Date.now(),
151
150
  }
152
- writeJsonAtomicSync(file, next, { compact: true, fsyncDir: true })
153
- })
151
+ return next
152
+ }, { compact: true, fsyncDir: true })
154
153
  if (!_periodicAdvertiseInstalled) {
155
154
  _periodicAdvertiseInstalled = true
156
155
  setInterval(() => {
@@ -3103,6 +3102,30 @@ const httpServer = http.createServer(async (req, res) => {
3103
3102
  return
3104
3103
  }
3105
3104
 
3105
+ if (req.url === '/transcript/ingest-sync') {
3106
+ const filePath = body.path
3107
+ if (!filePath || typeof filePath !== 'string') {
3108
+ sendJson(res, { error: 'path required' }, 400)
3109
+ return
3110
+ }
3111
+ try {
3112
+ let stat
3113
+ try { stat = await fs.promises.stat(filePath) } catch {
3114
+ sendJson(res, { ok: true, complete: true, fileSize: 0, offsetBytes: 0 })
3115
+ return
3116
+ }
3117
+ const fileSize = stat.size
3118
+ await ingestTranscriptFile(filePath, { cwd: body.cwd })
3119
+ const off = _transcriptOffsets.get(filePath)
3120
+ const offsetBytes = off && Number.isFinite(off.bytes) ? off.bytes : 0
3121
+ const complete = offsetBytes >= fileSize
3122
+ sendJson(res, { ok: true, offsetBytes, fileSize, complete })
3123
+ } catch (e) {
3124
+ sendJson(res, { error: e.message }, 500)
3125
+ }
3126
+ return
3127
+ }
3128
+
3106
3129
  sendJson(res, { error: 'Not found' }, 404)
3107
3130
  } catch (e) {
3108
3131
  process.stderr.write(`[memory-service] ${req.url} error: ${e.stack || e.message}\n`)
@@ -22,7 +22,7 @@ import {
22
22
  } from 'node:fs';
23
23
  import { join, resolve } from 'node:path';
24
24
  import { tmpdir } from 'node:os';
25
- import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
25
+ import { updateJsonAtomicSync } from '../../../shared/atomic-file.mjs';
26
26
 
27
27
  // ── pg-process interface (Track A) ───────────────────────────────────────────
28
28
  // Dynamic import so this module loads even before Track A's file exists.
@@ -150,17 +150,17 @@ const _ACTIVE_FILE = join(_RUNTIME_ROOT, 'active-instance.json');
150
150
 
151
151
  function patchActiveInstance(fields) {
152
152
  try {
153
- let curRaw = {};
154
- try { curRaw = JSON.parse(readFileSync(_ACTIVE_FILE, 'utf8')); } catch {}
155
- // Drop stale fields (pid/startedAt) written by older server versions.
156
- const { pid: _legacyPid, startedAt: _legacyStartedAt, ...cur } = curRaw ?? {};
157
- // Omit null-valued fields (clean removal when pg is stopped).
158
- const merged = { ...cur, updatedAt: Date.now() };
159
- for (const [k, v] of Object.entries(fields)) {
160
- if (v == null) delete merged[k];
161
- else merged[k] = v;
162
- }
163
- writeJsonAtomicSync(_ACTIVE_FILE, merged, { compact: true, lock: true, fsyncDir: true });
153
+ updateJsonAtomicSync(_ACTIVE_FILE, (curRaw) => {
154
+ // Drop stale fields (pid/startedAt) written by older server versions.
155
+ const { pid: _legacyPid, startedAt: _legacyStartedAt, ...cur } = curRaw ?? {};
156
+ // Omit null-valued fields (clean removal when pg is stopped).
157
+ const merged = { ...cur, updatedAt: Date.now() };
158
+ for (const [k, v] of Object.entries(fields)) {
159
+ if (v == null) delete merged[k];
160
+ else merged[k] = v;
161
+ }
162
+ return merged;
163
+ }, { compact: true, fsyncDir: true });
164
164
  } catch (e) {
165
165
  process.stderr.write(`[supervisor-pg] patchActiveInstance failed: ${e?.message}\n`);
166
166
  }
@@ -418,3 +418,19 @@ export function writeFileAtomicSync(filePath, data, opts = {}) {
418
418
  export function writeJsonAtomicSync(filePath, value, opts = {}) {
419
419
  return writeFileAtomicSync(filePath, JSON.stringify(value, null, opts.compact ? 0 : 2) + '\n', opts);
420
420
  }
421
+
422
+ export function updateJsonAtomicSync(filePath, mutator, opts = {}) {
423
+ const { lock: _lock, ...writeOpts } = opts;
424
+ return withFileLockSync(`${filePath}.lock`, () => {
425
+ let cur = null;
426
+ try {
427
+ cur = JSON.parse(readFileSync(filePath, 'utf8'));
428
+ } catch {
429
+ cur = null;
430
+ }
431
+ const next = mutator(cur);
432
+ if (next === undefined) return cur;
433
+ writeJsonAtomicSync(filePath, next, { ...writeOpts, lock: false });
434
+ return next;
435
+ }, opts);
436
+ }
@@ -192,7 +192,6 @@ const RUNNING_STALL_MS = 10 * 60 * 1000;
192
192
  // unrelated statuslines during spawn/claim races.
193
193
  function isSessionRunning(s, { hbMap, now, ownerSessionId, clientHostPid, ownerHostPid, includeClosedGrace = true }) {
194
194
  if (s.owner !== 'bridge') return false;
195
- if (!matchesOwnerSession(s, ownerSessionId)) return false;
196
195
  const isSharedBackground = s.role && SHARED_BACKGROUND_ROLES.has(s.role);
197
196
  // Scoping unit differs by role kind (invariant, not a fallback):
198
197
  // - Maintenance/background roles (cycle1/2/3-agent, scheduler-task,
@@ -210,6 +209,7 @@ function isSessionRunning(s, { hbMap, now, ownerSessionId, clientHostPid, ownerH
210
209
  // per-terminal clientHostPid and stay strictly isolated to the
211
210
  // terminal that spawned them.
212
211
  if (isSharedBackground) {
212
+ if (!matchesOwnerSession(s, ownerSessionId)) return false;
213
213
  if (ownerHostPid && clientHostPid && clientHostPid !== ownerHostPid) return false;
214
214
  } else if (!matchesClientHostPid(s, clientHostPid)) {
215
215
  return false;
@@ -236,10 +236,10 @@ function isSessionRunning(s, { hbMap, now, ownerSessionId, clientHostPid, ownerH
236
236
  // sessions/ entirely, so they fall out of allSessions and vanish from L2 with
237
237
  // no extra logic here. We surface the surviving idle workers (greyed) so the
238
238
  // user can see a worker is parked-but-present, distinct from RUNNING.
239
- // Maintenance/background roles are excluded — only owner-scoped user workers.
239
+ // Maintenance/background roles are excluded — user workers scoped by clientHostPid only
240
+ // (ownerSessionId is volatile across daemon restarts; clientHostPid is per-terminal SSOT).
240
241
  function isIdleWorkerSession(s, { ownerSessionId, clientHostPid }) {
241
242
  if (s.owner !== 'bridge') return false;
242
- if (!matchesOwnerSession(s, ownerSessionId)) return false;
243
243
  if (!matchesClientHostPid(s, clientHostPid)) return false;
244
244
  if (s.closed === true) return false; // closed → handled by closed-grace / lastCompleted
245
245
  if (s.role && SHARED_BACKGROUND_ROLES.has(s.role)) return false; // maintenance, not a user worker