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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +18 -0
- package/README.md +18 -0
- package/hooks/hooks.json +6 -6
- package/hooks/session-start.cjs +73 -2
- package/hooks/shim-launcher.cjs +51 -0
- package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
- package/package.json +2 -2
- package/scripts/bootstrap.mjs +5 -59
- package/scripts/ensure-deps.mjs +259 -0
- package/scripts/resolve-bun.mjs +60 -0
- package/scripts/run-mcp.mjs +13 -168
- package/setup/install.mjs +220 -22
- package/setup/launch.mjs +0 -0
- package/setup/locate-claude.mjs +38 -0
- package/setup/mixdog-cli.mjs +95 -0
- package/setup/setup-server.mjs +50 -2
- package/setup/setup.html +26 -12
- package/setup/tui.mjs +606 -0
- package/setup/wizard.mjs +220 -151
- package/src/agent/bridge-stall-watchdog.mjs +2 -2
- package/src/agent/index.mjs +3 -3
- package/src/agent/orchestrator/providers/anthropic-oauth.mjs +139 -0
- package/src/agent/orchestrator/providers/openai-oauth.mjs +96 -0
- package/src/agent/orchestrator/session/manager.mjs +5 -3
- package/src/agent/orchestrator/session/store.mjs +9 -1
- package/src/channels/lib/runtime-paths.mjs +112 -74
- package/src/memory/index.mjs +30 -7
- package/src/memory/lib/pg/supervisor.mjs +12 -12
- package/src/shared/atomic-file.mjs +16 -0
- 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
|
-
//
|
|
1787
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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,
|
package/src/memory/index.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
+
}
|