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.
- package/CHANGELOG.md +18 -0
- package/README.md +8 -4
- package/hooks/session-start.cjs +73 -2
- package/package.json +1 -1
- 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 +180 -9
- package/setup/launch.mjs +0 -0
- package/setup/locate-claude.mjs +38 -0
- package/setup/mixdog-cli.mjs +6 -42
- package/setup/setup-server.mjs +50 -2
- package/setup/setup.html +26 -12
- package/setup/tui.mjs +606 -0
- package/setup/wizard.mjs +117 -128
- 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
|
@@ -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
|
+
}
|
|
@@ -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 —
|
|
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
|