mixdog 0.7.5 → 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.
Files changed (35) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +18 -0
  4. package/hooks/hooks.json +6 -6
  5. package/hooks/session-start.cjs +73 -2
  6. package/hooks/shim-launcher.cjs +51 -0
  7. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  8. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  9. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  10. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  11. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  12. package/package.json +2 -2
  13. package/scripts/bootstrap.mjs +5 -59
  14. package/scripts/ensure-deps.mjs +259 -0
  15. package/scripts/resolve-bun.mjs +60 -0
  16. package/scripts/run-mcp.mjs +13 -168
  17. package/setup/install.mjs +220 -22
  18. package/setup/launch.mjs +0 -0
  19. package/setup/locate-claude.mjs +38 -0
  20. package/setup/mixdog-cli.mjs +95 -0
  21. package/setup/setup-server.mjs +50 -2
  22. package/setup/setup.html +26 -12
  23. package/setup/tui.mjs +606 -0
  24. package/setup/wizard.mjs +220 -151
  25. package/src/agent/bridge-stall-watchdog.mjs +2 -2
  26. package/src/agent/index.mjs +3 -3
  27. package/src/agent/orchestrator/providers/anthropic-oauth.mjs +139 -0
  28. package/src/agent/orchestrator/providers/openai-oauth.mjs +96 -0
  29. package/src/agent/orchestrator/session/manager.mjs +5 -3
  30. package/src/agent/orchestrator/session/store.mjs +9 -1
  31. package/src/channels/lib/runtime-paths.mjs +112 -74
  32. package/src/memory/index.mjs +30 -7
  33. package/src/memory/lib/pg/supervisor.mjs +12 -12
  34. package/src/shared/atomic-file.mjs +16 -0
  35. package/src/status/aggregator.mjs +3 -3
@@ -8,6 +8,8 @@
8
8
  import { readFileSync, existsSync, statSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { homedir } from 'os';
11
+ import { createServer } from 'http';
12
+ import { randomBytes, createHash } from 'crypto';
11
13
  import {
12
14
  traceBridgeFetch,
13
15
  traceBridgeSse,
@@ -208,6 +210,22 @@ const ANTHROPIC_VERSION = '2023-06-01';
208
210
  const DEFAULT_CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json');
209
211
  const CLAUDE_CODE_CLIENT_ID = process.env.ANTHROPIC_OAUTH_CLIENT_ID || '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
210
212
  const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
213
+ const CLAUDE_AI_AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize';
214
+ const ALL_OAUTH_SCOPES = [
215
+ 'org:create_api_key',
216
+ 'user:profile',
217
+ 'user:inference',
218
+ 'user:sessions:claude_code',
219
+ 'user:mcp_servers',
220
+ 'user:file_upload',
221
+ ];
222
+ const OAUTH_LOGIN_SCOPE = ALL_OAUTH_SCOPES.join(' ');
223
+ const OAUTH_CALLBACK_HOST = 'localhost';
224
+ const OAUTH_CALLBACK_PORT = 54545;
225
+ const OAUTH_CALLBACK_PATH = '/callback';
226
+ const OAUTH_REDIRECT_URI = `http://${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`;
227
+ const OAUTH_LOGIN_TIMEOUT_MS = 5 * 60_000;
228
+ const OAUTH_TOKEN_TIMEOUT_MS = 30_000;
211
229
 
212
230
  // Anthropic OAuth contract for first-party Claude Code clients.
213
231
  // Opus/Sonnet requests are gated on a specific system-prompt prefix.
@@ -1739,6 +1757,127 @@ export class AnthropicOAuthProvider {
1739
1757
  }
1740
1758
  }
1741
1759
 
1760
+ // --- Login flow (PKCE loopback, export for setup UI / CLI) ---
1761
+
1762
+ function _oauthGeneratePKCE() {
1763
+ const verifier = randomBytes(32).toString('base64url');
1764
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
1765
+ return { verifier, challenge };
1766
+ }
1767
+
1768
+ function _oauthCredentialsWritePath() {
1769
+ for (const p of credentialCandidates()) {
1770
+ if (existsSync(p)) return p;
1771
+ }
1772
+ return DEFAULT_CREDENTIALS_PATH;
1773
+ }
1774
+
1775
+ function _oauthParseScopeField(scope) {
1776
+ if (Array.isArray(scope)) return scope;
1777
+ return String(scope || '').split(' ').filter(Boolean);
1778
+ }
1779
+
1780
+ export async function loginOAuth() {
1781
+ const pkce = _oauthGeneratePKCE();
1782
+ const state = randomBytes(32).toString('base64url');
1783
+ const url = new URL(CLAUDE_AI_AUTHORIZE_URL);
1784
+ url.searchParams.set('code', 'true');
1785
+ url.searchParams.set('client_id', CLAUDE_CODE_CLIENT_ID);
1786
+ url.searchParams.set('response_type', 'code');
1787
+ url.searchParams.set('redirect_uri', OAUTH_REDIRECT_URI);
1788
+ url.searchParams.set('scope', OAUTH_LOGIN_SCOPE);
1789
+ url.searchParams.set('code_challenge', pkce.challenge);
1790
+ url.searchParams.set('code_challenge_method', 'S256');
1791
+ url.searchParams.set('state', state);
1792
+ process.stderr.write(`\n[anthropic-oauth] Open this URL to log in with Claude:\n${url.toString()}\n\n`);
1793
+ try {
1794
+ const { exec } = await import('child_process');
1795
+ const opener = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
1796
+ exec(`${opener} "${url.toString()}"`, { windowsHide: true });
1797
+ } catch { /* user opens manually */ }
1798
+
1799
+ return new Promise((resolve) => {
1800
+ const timeout = setTimeout(() => { server.close(); resolve(null); }, OAUTH_LOGIN_TIMEOUT_MS);
1801
+ const server = createServer(async (req, res) => {
1802
+ const u = new URL(req.url || '/', `http://${OAUTH_CALLBACK_HOST}:${OAUTH_CALLBACK_PORT}`);
1803
+ if (u.pathname !== OAUTH_CALLBACK_PATH) {
1804
+ res.writeHead(404);
1805
+ res.end();
1806
+ return;
1807
+ }
1808
+ const code = u.searchParams.get('code');
1809
+ if (!code || u.searchParams.get('state') !== state) {
1810
+ res.writeHead(400);
1811
+ res.end('Invalid');
1812
+ clearTimeout(timeout);
1813
+ server.close();
1814
+ resolve(null);
1815
+ return;
1816
+ }
1817
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1818
+ res.end('<html><body><h2>Claude login successful! You can close this tab.</h2></body></html>');
1819
+ clearTimeout(timeout);
1820
+ server.close();
1821
+ try {
1822
+ const tokenRes = await fetch(TOKEN_URL, {
1823
+ method: 'POST',
1824
+ headers: {
1825
+ 'Content-Type': 'application/json',
1826
+ 'anthropic-dangerous-direct-browser-access': 'true',
1827
+ 'user-agent': `claude-cli/${resolveCliVersion()} (external, sdk-cli)`,
1828
+ },
1829
+ body: JSON.stringify({
1830
+ grant_type: 'authorization_code',
1831
+ code,
1832
+ redirect_uri: OAUTH_REDIRECT_URI,
1833
+ client_id: CLAUDE_CODE_CLIENT_ID,
1834
+ code_verifier: pkce.verifier,
1835
+ state,
1836
+ }),
1837
+ redirect: 'error',
1838
+ signal: AbortSignal.timeout(OAUTH_TOKEN_TIMEOUT_MS),
1839
+ dispatcher: getLlmDispatcher(),
1840
+ });
1841
+ if (!tokenRes.ok) { resolve(null); return; }
1842
+ const json = await tokenRes.json();
1843
+ const accessToken = json?.access_token || json?.accessToken;
1844
+ const refreshToken = json?.refresh_token || json?.refreshToken;
1845
+ if (!accessToken || !refreshToken) { resolve(null); return; }
1846
+ const expiresAt = _normalizeExpiresAt(json?.expires_at ?? json?.expiresAt)
1847
+ || (typeof json?.expires_in === 'number' ? Date.now() + json.expires_in * 1000 : 0);
1848
+ const scopes = _oauthParseScopeField(json?.scope);
1849
+ const credPath = _oauthCredentialsWritePath();
1850
+ let raw = {};
1851
+ if (existsSync(credPath)) {
1852
+ raw = JSON.parse(readFileSync(credPath, 'utf-8'));
1853
+ }
1854
+ const existingOauth = raw.claudeAiOauth || {};
1855
+ raw.claudeAiOauth = {
1856
+ ...existingOauth,
1857
+ accessToken,
1858
+ refreshToken,
1859
+ expiresAt,
1860
+ scopes,
1861
+ subscriptionType: existingOauth.subscriptionType ?? null,
1862
+ };
1863
+ _saveCredentialsFile(credPath, raw);
1864
+ resolve({
1865
+ path: credPath,
1866
+ accessToken,
1867
+ refreshToken,
1868
+ expiresAt,
1869
+ scopes,
1870
+ subscriptionType: raw.claudeAiOauth.subscriptionType,
1871
+ });
1872
+ } catch {
1873
+ resolve(null);
1874
+ }
1875
+ });
1876
+ server.listen(OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_HOST);
1877
+ server.on('error', () => { clearTimeout(timeout); resolve(null); });
1878
+ });
1879
+ }
1880
+
1742
1881
  // Additive exports for test harnesses.
1743
1882
  // Lets the SSE parser be exercised in isolation against a synthetic
1744
1883
  // ReadableStream without needing a live OAuth session.
@@ -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
+ }