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.
@@ -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
- // Use spawnClaudeAsync (non-blocking spawn with process-group kill) instead of
480
- // execFileSync (sync, blocks event loop, can't kill sub-agents).
481
- // executeTask now returns a Promise — callers must handle it with .then() or await.
482
- const timeoutMs = resolveTimeoutMs(task.timeout, 120);
483
- const asyncArgs = [...claudeArgs];
484
- const asyncEnv = {
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
- let stdout = '';
500
- let stderr = '';
501
- let timedOut = false;
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 resolve({ success: false, error: 'timeout', output: '' });
543
+ return { success: false, error: 'silent_timeout', output: output || '' };
532
544
  }
533
- if (code !== 0) {
534
- const errMsg = (stderr || `Exit code ${code}`).slice(0, 200);
535
- // Persistent session expired: reset so next run creates a new one
536
- if (task.persistent_session && (errMsg.includes('not found') || errMsg.includes('No session'))) {
537
- log('WARN', `Persistent session for ${task.name} expired, will create new on next run`);
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 resolve({ success: false, error: errMsg, output: '' });
551
+ return { success: false, error: 'session_expired', output: '' };
554
552
  }
555
- const estimatedTokens = Math.ceil((fullPrompt.length + output.length) / 4);
556
- recordTokens(state, estimatedTokens, { category: classifyTaskUsage(task) });
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
- maybeSaveTaskMemory(task, output, estimatedTokens, prevSessionId || '');
568
- log('INFO', `Task ${task.name} completed (est. ${estimatedTokens} tokens)`);
569
- resolve({ success: true, output, tokens: estimatedTokens });
570
- });
556
+ return { success: false, error: errMsg, output: '' };
557
+ }
571
558
 
572
- child.on('error', (err) => {
573
- clearTimeout(timer);
574
- log('ERROR', `Task ${task.name} spawn error: ${err.message}`);
575
- resolve({ success: false, error: err.message, output: '' });
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
- try {
626
- const output = execFileSync(CLAUDE_BIN, args, {
627
- input: prompt,
628
- encoding: 'utf8',
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
- } catch (e) {
647
- log('ERROR', `Workflow ${task.name} step ${i + 1} failed: ${e.message.slice(0, 200)}`);
648
- outputs.push({ step: i + 1, skill: step.skill || null, error: e.message.slice(0, 200) });
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 };