metame-cli 1.5.8 → 1.5.10
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/index.js +19 -2
- package/package.json +1 -1
- package/scripts/daemon-bridges.js +36 -44
- package/scripts/daemon-checkpoints.js +38 -24
- package/scripts/daemon-claude-engine.js +238 -58
- package/scripts/daemon-command-router.js +6 -125
- package/scripts/daemon-command-session-route.js +7 -1
- package/scripts/daemon-engine-runtime.js +8 -1
- package/scripts/daemon-exec-commands.js +36 -25
- package/scripts/daemon-message-pipeline.js +268 -0
- package/scripts/daemon-ops-commands.js +12 -10
- package/scripts/daemon-reactive-lifecycle.js +421 -0
- package/scripts/daemon-session-store.js +24 -24
- package/scripts/daemon-task-scheduler.js +90 -112
- package/scripts/daemon-utils.js +55 -0
- package/scripts/daemon-warm-pool.js +162 -0
- package/scripts/daemon-worktrees.js +129 -0
- package/scripts/daemon.js +31 -3
- package/scripts/docs/orphan-files-review.md +72 -0
- package/scripts/hooks/intent-auto-rules.js +50 -0
- package/scripts/verify-reactive-claude-md.js +101 -0
- package/scripts/daemon.yaml +0 -356
|
@@ -180,7 +180,6 @@ function createTaskScheduler(deps) {
|
|
|
180
180
|
CLAUDE_BIN,
|
|
181
181
|
spawn: _spawn,
|
|
182
182
|
execSync,
|
|
183
|
-
execFileSync,
|
|
184
183
|
parseInterval,
|
|
185
184
|
loadState,
|
|
186
185
|
saveState,
|
|
@@ -201,6 +200,60 @@ function createTaskScheduler(deps) {
|
|
|
201
200
|
|
|
202
201
|
// Max characters from precondition context to inject into prompts (prevents token bombs)
|
|
203
202
|
const MAX_PRECONDITION_CHARS = 4000;
|
|
203
|
+
// Cap stdout buffer to prevent memory growth in long-running tasks (output_preview uses 200 chars anyway)
|
|
204
|
+
const MAX_STDOUT_BYTES = 1024 * 1024;
|
|
205
|
+
|
|
206
|
+
// Shared primitive: spawn a single claude -p invocation with silence watchdog.
|
|
207
|
+
// Resolves to { ok, output, error } — never rejects.
|
|
208
|
+
// label is used only for log messages (e.g. "Task foo" or "Workflow bar step 2").
|
|
209
|
+
function spawnClaude(args, prompt, timeoutMs, cwdPath, label) {
|
|
210
|
+
const env = { ...process.env, ...getDaemonProviderEnv(), CLAUDECODE: undefined, METAME_INTERNAL_PROMPT: '1' };
|
|
211
|
+
return new Promise((resolve) => {
|
|
212
|
+
const child = spawn(CLAUDE_BIN, args, {
|
|
213
|
+
cwd: cwdPath || undefined,
|
|
214
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
215
|
+
detached: process.platform !== 'win32',
|
|
216
|
+
env,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
let stdout = '';
|
|
220
|
+
let stderr = '';
|
|
221
|
+
let timedOut = false;
|
|
222
|
+
let lastActivity = Date.now();
|
|
223
|
+
let sigkillTimer = null;
|
|
224
|
+
|
|
225
|
+
const watchdog = setInterval(() => {
|
|
226
|
+
if (Date.now() - lastActivity >= timeoutMs) {
|
|
227
|
+
clearInterval(watchdog);
|
|
228
|
+
timedOut = true;
|
|
229
|
+
log('WARN', `${label} silent for ${timeoutMs / 1000}s — killing`);
|
|
230
|
+
try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); }
|
|
231
|
+
sigkillTimer = setTimeout(() => {
|
|
232
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { } }
|
|
233
|
+
}, 5000);
|
|
234
|
+
}
|
|
235
|
+
}, Math.min(timeoutMs, 30000));
|
|
236
|
+
|
|
237
|
+
child.stdin.write(prompt);
|
|
238
|
+
child.stdin.end();
|
|
239
|
+
child.stdout.on('data', (d) => { lastActivity = Date.now(); if (stdout.length < MAX_STDOUT_BYTES) stdout += d.toString(); });
|
|
240
|
+
child.stderr.on('data', (d) => { lastActivity = Date.now(); stderr += d.toString(); });
|
|
241
|
+
|
|
242
|
+
child.on('close', (code) => {
|
|
243
|
+
clearInterval(watchdog);
|
|
244
|
+
if (sigkillTimer) clearTimeout(sigkillTimer);
|
|
245
|
+
const output = stdout.trim();
|
|
246
|
+
if (timedOut) return resolve({ ok: false, error: 'silent_timeout', output });
|
|
247
|
+
if (code !== 0) return resolve({ ok: false, error: (stderr || `Exit code ${code}`).slice(0, 200), output: '' });
|
|
248
|
+
resolve({ ok: true, output, stderr });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
child.on('error', (err) => {
|
|
252
|
+
clearInterval(watchdog);
|
|
253
|
+
resolve({ ok: false, error: err.message, output: '' });
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
}
|
|
204
257
|
|
|
205
258
|
// On Windows, resolve .cmd → actual Node.js entry to avoid cmd.exe flash
|
|
206
259
|
function _resolveNodeEntry(cmdPath) {
|
|
@@ -476,110 +529,46 @@ function createTaskScheduler(deps) {
|
|
|
476
529
|
log('INFO', `Executing task: ${task.name} (model: ${model}${mcpConfig ? ', mcp: ' + path.basename(mcpConfig) : ''})`);
|
|
477
530
|
}
|
|
478
531
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
...process.env,
|
|
486
|
-
...getDaemonProviderEnv(),
|
|
487
|
-
CLAUDECODE: undefined,
|
|
488
|
-
METAME_INTERNAL_PROMPT: '1',
|
|
489
|
-
};
|
|
490
|
-
|
|
491
|
-
return new Promise((resolve) => {
|
|
492
|
-
const child = spawn(CLAUDE_BIN, asyncArgs, {
|
|
493
|
-
cwd: cwd || undefined,
|
|
494
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
495
|
-
detached: process.platform !== 'win32', // process groups are POSIX-only
|
|
496
|
-
env: asyncEnv,
|
|
497
|
-
});
|
|
532
|
+
const timeoutMs = resolveTimeoutMs(task.timeout, 1800);
|
|
533
|
+
return spawnClaude(claudeArgs, fullPrompt, timeoutMs, cwd, `Task ${task.name}`).then((result) => {
|
|
534
|
+
const { output } = result;
|
|
535
|
+
const prevSid = state.tasks[task.name]?.session_id;
|
|
536
|
+
const prevCreatedAt = state.tasks[task.name]?.session_created_at;
|
|
537
|
+
const sessionFields = { ...(prevSid && { session_id: prevSid }), ...(prevCreatedAt && { session_created_at: prevCreatedAt }) };
|
|
498
538
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
const timer = setTimeout(() => {
|
|
504
|
-
timedOut = true;
|
|
505
|
-
log('WARN', `Task ${task.name} timeout (${timeoutMs / 1000}s) — killing process group`);
|
|
506
|
-
try { process.kill(-child.pid, 'SIGTERM'); } catch { child.kill('SIGTERM'); }
|
|
507
|
-
setTimeout(() => {
|
|
508
|
-
try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch { } }
|
|
509
|
-
}, 5000);
|
|
510
|
-
}, timeoutMs);
|
|
511
|
-
|
|
512
|
-
child.stdin.write(fullPrompt);
|
|
513
|
-
child.stdin.end();
|
|
514
|
-
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
515
|
-
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
516
|
-
|
|
517
|
-
child.on('close', (code) => {
|
|
518
|
-
clearTimeout(timer);
|
|
519
|
-
const output = stdout.trim();
|
|
520
|
-
if (timedOut) {
|
|
521
|
-
const prevSid = state.tasks[task.name]?.session_id;
|
|
522
|
-
const prevCreatedAt = state.tasks[task.name]?.session_created_at;
|
|
523
|
-
state.tasks[task.name] = {
|
|
524
|
-
last_run: new Date().toISOString(),
|
|
525
|
-
status: 'timeout',
|
|
526
|
-
error: 'Task exceeded timeout',
|
|
527
|
-
...(prevSid && { session_id: prevSid }),
|
|
528
|
-
...(prevCreatedAt && { session_created_at: prevCreatedAt }),
|
|
529
|
-
};
|
|
539
|
+
if (!result.ok) {
|
|
540
|
+
if (result.error === 'silent_timeout') {
|
|
541
|
+
state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'error', error: `Task silent for ${timeoutMs / 1000}s`, ...sessionFields };
|
|
530
542
|
saveState(state);
|
|
531
|
-
return
|
|
543
|
+
return { success: false, error: 'silent_timeout', output: output || '' };
|
|
532
544
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'session_reset', error: 'Session expired' };
|
|
539
|
-
saveState(state);
|
|
540
|
-
return resolve({ success: false, error: 'session_expired', output: '' });
|
|
541
|
-
}
|
|
542
|
-
log('ERROR', `Task ${task.name} failed (exit ${code}): ${errMsg}`);
|
|
543
|
-
const prevSid = state.tasks[task.name]?.session_id;
|
|
544
|
-
const prevCreatedAt = state.tasks[task.name]?.session_created_at;
|
|
545
|
-
state.tasks[task.name] = {
|
|
546
|
-
last_run: new Date().toISOString(),
|
|
547
|
-
status: 'error',
|
|
548
|
-
error: errMsg,
|
|
549
|
-
...(prevSid && { session_id: prevSid }),
|
|
550
|
-
...(prevCreatedAt && { session_created_at: prevCreatedAt }),
|
|
551
|
-
};
|
|
545
|
+
const errMsg = result.error;
|
|
546
|
+
// Persistent session expired: reset so next run creates a new one
|
|
547
|
+
if (task.persistent_session && (errMsg.includes('not found') || errMsg.includes('No session'))) {
|
|
548
|
+
log('WARN', `Persistent session for ${task.name} expired, will create new on next run`);
|
|
549
|
+
state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'session_reset', error: 'Session expired' };
|
|
552
550
|
saveState(state);
|
|
553
|
-
return
|
|
551
|
+
return { success: false, error: 'session_expired', output: '' };
|
|
554
552
|
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
const prevSessionId = state.tasks[task.name]?.session_id;
|
|
558
|
-
const prevCreatedAt = state.tasks[task.name]?.session_created_at;
|
|
559
|
-
state.tasks[task.name] = {
|
|
560
|
-
last_run: new Date().toISOString(),
|
|
561
|
-
status: 'success',
|
|
562
|
-
output_preview: output.slice(0, 200),
|
|
563
|
-
...(prevSessionId && { session_id: prevSessionId }),
|
|
564
|
-
...(prevCreatedAt && { session_created_at: prevCreatedAt }),
|
|
565
|
-
};
|
|
553
|
+
log('ERROR', `Task ${task.name} failed: ${errMsg}`);
|
|
554
|
+
state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'error', error: errMsg, ...sessionFields };
|
|
566
555
|
saveState(state);
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
resolve({ success: true, output, tokens: estimatedTokens });
|
|
570
|
-
});
|
|
556
|
+
return { success: false, error: errMsg, output: '' };
|
|
557
|
+
}
|
|
571
558
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
559
|
+
const estimatedTokens = Math.ceil((fullPrompt.length + output.length) / 4);
|
|
560
|
+
recordTokens(state, estimatedTokens, { category: classifyTaskUsage(task) });
|
|
561
|
+
state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'success', output_preview: output.slice(0, 200), ...sessionFields };
|
|
562
|
+
saveState(state);
|
|
563
|
+
maybeSaveTaskMemory(task, output, estimatedTokens, prevSid || '');
|
|
564
|
+
log('INFO', `Task ${task.name} completed (est. ${estimatedTokens} tokens)`);
|
|
565
|
+
return { success: true, output, tokens: estimatedTokens };
|
|
577
566
|
});
|
|
578
567
|
}
|
|
579
568
|
|
|
580
569
|
// parseInterval — imported from ./utils
|
|
581
570
|
|
|
582
|
-
function executeWorkflow(task, config, precheck) {
|
|
571
|
+
async function executeWorkflow(task, config, precheck) {
|
|
583
572
|
const state = loadState();
|
|
584
573
|
if (!checkBudget(config, state)) {
|
|
585
574
|
log('WARN', `Budget exceeded, skipping workflow: ${task.name}`);
|
|
@@ -622,30 +611,19 @@ function createTaskScheduler(deps) {
|
|
|
622
611
|
args.push(i === 0 ? '--session-id' : '--resume', sessionId);
|
|
623
612
|
|
|
624
613
|
log('INFO', `Workflow ${task.name} step ${i + 1}/${steps.length}: ${step.skill || 'prompt'}`);
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
timeout: resolveTimeoutMs(step.timeout, 300),
|
|
630
|
-
maxBuffer: 5 * 1024 * 1024,
|
|
631
|
-
cwd,
|
|
632
|
-
...(process.platform === 'win32' ? { windowsHide: true } : {}),
|
|
633
|
-
env: {
|
|
634
|
-
...process.env,
|
|
635
|
-
...getDaemonProviderEnv(),
|
|
636
|
-
CLAUDECODE: undefined,
|
|
637
|
-
METAME_INTERNAL_PROMPT: '1',
|
|
638
|
-
},
|
|
639
|
-
...(process.platform === 'win32' ? { shell: process.env.COMSPEC || true, windowsHide: true } : {}),
|
|
640
|
-
}).trim();
|
|
614
|
+
// Steps share a session and must run sequentially
|
|
615
|
+
const stepResult = await spawnClaude(args, prompt, resolveTimeoutMs(step.timeout, 1800), cwd, `Workflow ${task.name} step ${i + 1}`);
|
|
616
|
+
if (stepResult.ok) {
|
|
617
|
+
const output = stepResult.output;
|
|
641
618
|
const tk = Math.ceil((prompt.length + output.length) / 4);
|
|
642
619
|
totalTokens += tk;
|
|
643
620
|
outputs.push({ step: i + 1, skill: step.skill || null, output: output.slice(0, 500), tokens: tk });
|
|
644
621
|
log('INFO', `Workflow ${task.name} step ${i + 1} done (${tk} tokens)`);
|
|
645
622
|
if (!checkBudget(config, loopState)) { log('WARN', 'Budget exceeded mid-workflow'); break; }
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
|
|
623
|
+
} else {
|
|
624
|
+
const errMsg = stepResult.error.slice(0, 200);
|
|
625
|
+
log('ERROR', `Workflow ${task.name} step ${i + 1} failed: ${errMsg}`);
|
|
626
|
+
outputs.push({ step: i + 1, skill: step.skill || null, error: errMsg });
|
|
649
627
|
if (!step.optional) {
|
|
650
628
|
recordTokens(loopState, totalTokens, { category: classifyTaskUsage(task) });
|
|
651
629
|
state.tasks[task.name] = { last_run: new Date().toISOString(), status: 'error', error: `Step ${i + 1} failed`, steps_completed: i, steps_total: steps.length };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* daemon-utils.js
|
|
5
|
+
*
|
|
6
|
+
* Shared normalization helpers used across daemon modules.
|
|
7
|
+
* Single source of truth — no other module should redefine these.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function normalizeEngineName(name, defaultEngine = 'claude') {
|
|
11
|
+
const n = String(name || '').trim().toLowerCase();
|
|
12
|
+
return n === 'codex' ? 'codex' : (typeof defaultEngine === 'function' ? defaultEngine() : defaultEngine);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeCodexSandboxMode(value, fallback = null) {
|
|
16
|
+
const text = String(value || '').trim().toLowerCase();
|
|
17
|
+
if (!text) return fallback;
|
|
18
|
+
if (text === 'read-only' || text === 'readonly') return 'read-only';
|
|
19
|
+
if (text === 'workspace-write' || text === 'workspace') return 'workspace-write';
|
|
20
|
+
if (
|
|
21
|
+
text === 'danger-full-access'
|
|
22
|
+
|| text === 'dangerous'
|
|
23
|
+
|| text === 'full-access'
|
|
24
|
+
|| text === 'full'
|
|
25
|
+
|| text === 'bypass'
|
|
26
|
+
|| text === 'writable'
|
|
27
|
+
) return 'danger-full-access';
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeCodexApprovalPolicy(value, fallback = null) {
|
|
32
|
+
const text = String(value || '').trim().toLowerCase();
|
|
33
|
+
if (!text) return fallback;
|
|
34
|
+
if (text === 'never' || text === 'no' || text === 'none') return 'never';
|
|
35
|
+
if (text === 'on-failure' || text === 'on_failure' || text === 'failure') return 'on-failure';
|
|
36
|
+
if (text === 'on-request' || text === 'on_request' || text === 'request') return 'on-request';
|
|
37
|
+
if (text === 'untrusted') return 'untrusted';
|
|
38
|
+
return fallback;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mergeAgentMaps(cfg) {
|
|
42
|
+
return {
|
|
43
|
+
...(cfg.telegram ? cfg.telegram.chat_agent_map : {}),
|
|
44
|
+
...(cfg.feishu ? cfg.feishu.chat_agent_map : {}),
|
|
45
|
+
...(cfg.imessage ? cfg.imessage.chat_agent_map : {}),
|
|
46
|
+
...(cfg.siri_bridge ? cfg.siri_bridge.chat_agent_map : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = {
|
|
51
|
+
normalizeEngineName,
|
|
52
|
+
normalizeCodexSandboxMode,
|
|
53
|
+
normalizeCodexApprovalPolicy,
|
|
54
|
+
mergeAgentMaps,
|
|
55
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* daemon-warm-pool.js
|
|
5
|
+
*
|
|
6
|
+
* Persistent Claude CLI process pool for eliminating cold-start latency.
|
|
7
|
+
*
|
|
8
|
+
* Problem: Each `claude -p --print` spawns a new process → ~11s cold start (CLI init + API first-token).
|
|
9
|
+
* Solution: After a turn completes, keep the process alive. Next message writes to stdin → ~3s response.
|
|
10
|
+
*
|
|
11
|
+
* Architecture:
|
|
12
|
+
* - Pool keyed by sessionChatId (not raw chatId — same session key used by daemon-session-store)
|
|
13
|
+
* - Each warm entry holds a live child process spawned with `--input-format stream-json`
|
|
14
|
+
* - `acquireWarm(key)` → returns child process (removes from pool to prevent double-use)
|
|
15
|
+
* - `storeWarm(key, child, meta)` → parks process in pool with idle timeout
|
|
16
|
+
* - Idle timeout kills unused warm processes (default: 5 minutes)
|
|
17
|
+
* - Process death auto-cleans pool entry
|
|
18
|
+
*
|
|
19
|
+
* Only for Claude engine. Codex does not support `--input-format stream-json`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function createWarmPool(deps) {
|
|
23
|
+
const { log } = deps;
|
|
24
|
+
|
|
25
|
+
// Pool: sessionKey -> { child, sessionId, cwd, idleTimer }
|
|
26
|
+
const pool = new Map();
|
|
27
|
+
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Acquire a warm process for the given session key.
|
|
31
|
+
* Returns { child, sessionId, cwd } or null.
|
|
32
|
+
* The entry is REMOVED from the pool (caller owns the process now).
|
|
33
|
+
*/
|
|
34
|
+
function acquireWarm(sessionKey) {
|
|
35
|
+
const entry = pool.get(sessionKey);
|
|
36
|
+
if (!entry) return null;
|
|
37
|
+
|
|
38
|
+
// Check if process is still alive
|
|
39
|
+
if (entry.child.killed || entry.child.exitCode !== null) {
|
|
40
|
+
_cleanup(sessionKey);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Remove from pool — caller now owns this process
|
|
45
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
46
|
+
pool.delete(sessionKey);
|
|
47
|
+
log('INFO', `[WarmPool] Acquired warm process pid=${entry.child.pid} for ${sessionKey}`);
|
|
48
|
+
return { child: entry.child, sessionId: entry.sessionId, cwd: entry.cwd };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Park a process in the pool for future reuse.
|
|
53
|
+
* The process must have been spawned with --input-format stream-json.
|
|
54
|
+
* Previous entry for the same key is killed.
|
|
55
|
+
*/
|
|
56
|
+
function storeWarm(sessionKey, child, meta = {}) {
|
|
57
|
+
// Kill existing warm process for this key (if any)
|
|
58
|
+
const existing = pool.get(sessionKey);
|
|
59
|
+
if (existing) {
|
|
60
|
+
_killEntry(existing);
|
|
61
|
+
pool.delete(sessionKey);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Don't store dead processes
|
|
65
|
+
if (child.killed || child.exitCode !== null) {
|
|
66
|
+
log('INFO', `[WarmPool] Not storing dead process for ${sessionKey}`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Set idle timeout
|
|
71
|
+
const idleTimer = setTimeout(() => {
|
|
72
|
+
const e = pool.get(sessionKey);
|
|
73
|
+
if (e && e.child === child) {
|
|
74
|
+
log('INFO', `[WarmPool] Idle timeout, killing warm process pid=${child.pid} for ${sessionKey}`);
|
|
75
|
+
_killEntry(e);
|
|
76
|
+
pool.delete(sessionKey);
|
|
77
|
+
}
|
|
78
|
+
}, IDLE_TIMEOUT_MS);
|
|
79
|
+
if (typeof idleTimer.unref === 'function') idleTimer.unref();
|
|
80
|
+
|
|
81
|
+
// Auto-cleanup on unexpected death
|
|
82
|
+
const onExit = () => {
|
|
83
|
+
const e = pool.get(sessionKey);
|
|
84
|
+
if (e && e.child === child) {
|
|
85
|
+
if (e.idleTimer) clearTimeout(e.idleTimer);
|
|
86
|
+
pool.delete(sessionKey);
|
|
87
|
+
log('INFO', `[WarmPool] Process died unexpectedly for ${sessionKey}`);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
child.once('close', onExit);
|
|
91
|
+
child.once('error', onExit);
|
|
92
|
+
|
|
93
|
+
pool.set(sessionKey, {
|
|
94
|
+
child,
|
|
95
|
+
sessionId: meta.sessionId || '',
|
|
96
|
+
cwd: meta.cwd || '',
|
|
97
|
+
idleTimer,
|
|
98
|
+
});
|
|
99
|
+
log('INFO', `[WarmPool] Stored warm process pid=${child.pid} for ${sessionKey} (pool size: ${pool.size})`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Kill and remove a specific warm process.
|
|
104
|
+
*/
|
|
105
|
+
function releaseWarm(sessionKey) {
|
|
106
|
+
const entry = pool.get(sessionKey);
|
|
107
|
+
if (!entry) return;
|
|
108
|
+
_killEntry(entry);
|
|
109
|
+
pool.delete(sessionKey);
|
|
110
|
+
log('INFO', `[WarmPool] Released ${sessionKey}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Kill all warm processes (used during daemon shutdown).
|
|
115
|
+
*/
|
|
116
|
+
function releaseAll() {
|
|
117
|
+
for (const [_key, entry] of pool) {
|
|
118
|
+
_killEntry(entry);
|
|
119
|
+
}
|
|
120
|
+
const count = pool.size;
|
|
121
|
+
pool.clear();
|
|
122
|
+
if (count > 0) log('INFO', `[WarmPool] Released all (${count} processes)`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function _killEntry(entry) {
|
|
126
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
127
|
+
try {
|
|
128
|
+
process.kill(-entry.child.pid, 'SIGTERM');
|
|
129
|
+
} catch {
|
|
130
|
+
try { entry.child.kill('SIGTERM'); } catch { /* */ }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _cleanup(sessionKey) {
|
|
135
|
+
const entry = pool.get(sessionKey);
|
|
136
|
+
if (entry && entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
137
|
+
pool.delete(sessionKey);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build the stream-json user message for stdin.
|
|
142
|
+
*/
|
|
143
|
+
function buildStreamMessage(prompt, sessionId) {
|
|
144
|
+
return JSON.stringify({
|
|
145
|
+
type: 'user',
|
|
146
|
+
message: { role: 'user', content: prompt },
|
|
147
|
+
session_id: sessionId || 'default',
|
|
148
|
+
parent_tool_use_id: null,
|
|
149
|
+
}) + '\n';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
acquireWarm,
|
|
154
|
+
storeWarm,
|
|
155
|
+
releaseWarm,
|
|
156
|
+
releaseAll,
|
|
157
|
+
buildStreamMessage,
|
|
158
|
+
_pool: pool,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { createWarmPool };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* daemon-worktrees.js — Isolated worktree per actor
|
|
5
|
+
*
|
|
6
|
+
* Every entity that runs Claude (real chatId, virtual _agent_*, _scope_*, _bound_*)
|
|
7
|
+
* gets its own git worktree branching from the project repo.
|
|
8
|
+
* This eliminates cross-contamination of checkpoints and working-tree state
|
|
9
|
+
* between parallel agents — with a single, unified code path.
|
|
10
|
+
*
|
|
11
|
+
* Directory layout:
|
|
12
|
+
* ~/.metame/worktrees/<proj-basename>/<actor-key>/
|
|
13
|
+
*
|
|
14
|
+
* Branch convention:
|
|
15
|
+
* agent/<actor-key> (created from parent HEAD if new)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function createWorktreeUtils(deps) {
|
|
19
|
+
const { fs, path, log, HOME } = deps;
|
|
20
|
+
const { execFileSync } = require('child_process');
|
|
21
|
+
|
|
22
|
+
const WIN_HIDE = process.platform === 'win32' ? { windowsHide: true } : {};
|
|
23
|
+
const WORKTREES_BASE = path.join(HOME, '.metame', 'worktrees');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Derive a stable, filesystem-safe actor key from chatId.
|
|
27
|
+
* This is the ONLY place in the codebase that distinguishes virtual vs real.
|
|
28
|
+
*/
|
|
29
|
+
function resolveWorktreeKey(chatId) {
|
|
30
|
+
const s = String(chatId || '');
|
|
31
|
+
if (s.startsWith('_agent_')) return s.slice(7);
|
|
32
|
+
if (s.startsWith('_scope_')) return s.slice(7).split('__')[0];
|
|
33
|
+
if (s.startsWith('_bound_')) return `bound_${s.slice(7)}`;
|
|
34
|
+
return `chat_${s.replace(/[^a-zA-Z0-9_\-]/g, '_')}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _sanitizeKey(key) {
|
|
38
|
+
return String(key).replace(/[^a-zA-Z0-9_\-]/g, '_').slice(0, 60);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Walk up directories to find the git root containing .git */
|
|
42
|
+
function _findGitRoot(dir) {
|
|
43
|
+
let cur = path.resolve(dir);
|
|
44
|
+
const root = path.parse(cur).root;
|
|
45
|
+
while (cur !== root) {
|
|
46
|
+
if (fs.existsSync(path.join(cur, '.git'))) return cur;
|
|
47
|
+
cur = path.dirname(cur);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get or create an isolated worktree for the given actor.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} parentCwd - Project's main working directory (git repo root or subdir)
|
|
56
|
+
* @param {string} worktreeKey - Actor identifier (from resolveWorktreeKey or member.key)
|
|
57
|
+
* @returns {string|null} Path to the worktree, or null on unrecoverable failure
|
|
58
|
+
*/
|
|
59
|
+
function getOrCreateWorktree(parentCwd, worktreeKey) {
|
|
60
|
+
if (!parentCwd || !worktreeKey) return null;
|
|
61
|
+
const safeKey = _sanitizeKey(worktreeKey);
|
|
62
|
+
if (!safeKey) return null;
|
|
63
|
+
|
|
64
|
+
const projBasename = path.basename(path.resolve(parentCwd));
|
|
65
|
+
const worktreePath = path.join(WORKTREES_BASE, projBasename, safeKey);
|
|
66
|
+
|
|
67
|
+
// Fast path: already exists (worktree or legacy plain repo)
|
|
68
|
+
if (fs.existsSync(worktreePath)) {
|
|
69
|
+
return worktreePath;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const gitRoot = _findGitRoot(parentCwd);
|
|
73
|
+
|
|
74
|
+
// Ensure parent directory for the worktree
|
|
75
|
+
try {
|
|
76
|
+
fs.mkdirSync(path.join(WORKTREES_BASE, projBasename), { recursive: true });
|
|
77
|
+
} catch (e) {
|
|
78
|
+
log('WARN', `[worktrees] mkdir failed: ${e.message}`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!gitRoot) {
|
|
83
|
+
// Not a git repo — plain mkdir + git init (fallback)
|
|
84
|
+
try {
|
|
85
|
+
fs.mkdirSync(worktreePath, { recursive: true });
|
|
86
|
+
execFileSync('git', ['init', '-q'], { cwd: worktreePath, stdio: 'ignore', timeout: 5000, ...WIN_HIDE });
|
|
87
|
+
log('INFO', `[worktrees] plain dir (no parent git): ${worktreePath}`);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
log('WARN', `[worktrees] fallback git init failed: ${e.message}`);
|
|
90
|
+
}
|
|
91
|
+
return fs.existsSync(worktreePath) ? worktreePath : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Git repo — create a proper linked worktree
|
|
95
|
+
const branchName = `agent/${safeKey}`;
|
|
96
|
+
try {
|
|
97
|
+
// Does the branch already exist? (e.g. worktree was removed but branch remains)
|
|
98
|
+
let branchExists = false;
|
|
99
|
+
try {
|
|
100
|
+
execFileSync('git', ['rev-parse', '--verify', branchName],
|
|
101
|
+
{ cwd: gitRoot, stdio: 'ignore', timeout: 3000, ...WIN_HIDE });
|
|
102
|
+
branchExists = true;
|
|
103
|
+
} catch { /* branch is new */ }
|
|
104
|
+
|
|
105
|
+
const addArgs = branchExists
|
|
106
|
+
? ['worktree', 'add', worktreePath, branchName]
|
|
107
|
+
: ['worktree', 'add', '-b', branchName, worktreePath];
|
|
108
|
+
|
|
109
|
+
execFileSync('git', addArgs,
|
|
110
|
+
{ cwd: gitRoot, stdio: 'ignore', timeout: 15000, ...WIN_HIDE });
|
|
111
|
+
|
|
112
|
+
log('INFO', `[worktrees] created: ${worktreePath} → branch ${branchName}`);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
log('WARN', `[worktrees] git worktree add failed (${e.message}); falling back to plain dir`);
|
|
115
|
+
try {
|
|
116
|
+
if (!fs.existsSync(worktreePath)) {
|
|
117
|
+
fs.mkdirSync(worktreePath, { recursive: true });
|
|
118
|
+
execFileSync('git', ['init', '-q'], { cwd: worktreePath, stdio: 'ignore', timeout: 5000, ...WIN_HIDE });
|
|
119
|
+
}
|
|
120
|
+
} catch { /* best-effort */ }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return fs.existsSync(worktreePath) ? worktreePath : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { resolveWorktreeKey, getOrCreateWorktree };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { createWorktreeUtils };
|