ac-framework 1.9.7 → 1.9.9

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.
@@ -1,15 +1,38 @@
1
1
  import { buildAgentPrompt, ROLE_SYSTEM_PROMPTS } from './role-prompts.js';
2
- import { runOpenCodePrompt } from './opencode-client.js';
2
+ import { runOpenCodePromptDetailed } from './opencode-client.js';
3
3
  import { nextRole, shouldStop } from './scheduler.js';
4
4
  import { resolveRoleModel } from './model-selection.js';
5
+ import { buildMeetingSummary, createTurnRecord, updateSharedContext } from './collab-summary.js';
6
+ import {
7
+ appendRunEvent,
8
+ extractFinalSummary,
9
+ incrementRoleRetry,
10
+ roleRetryCount,
11
+ } from './run-state.js';
5
12
  import {
6
13
  addAgentMessage,
14
+ appendMeetingTurn,
7
15
  loadSessionState,
8
16
  saveSessionState,
9
17
  stopSession,
18
+ writeMeetingSummary,
10
19
  withSessionLock,
11
20
  } from './state-store.js';
12
21
 
22
+ async function finalizeSessionArtifacts(state) {
23
+ const runState = ensureRunState(state);
24
+ const summaryMd = buildMeetingSummary(state.messages, runState, runState.sharedContext);
25
+ await writeMeetingSummary(state.sessionId, summaryMd);
26
+ const completedRun = {
27
+ ...runState,
28
+ finalSummary: extractFinalSummary(state.messages, runState),
29
+ };
30
+ return saveSessionState({
31
+ ...state,
32
+ run: completedRun,
33
+ });
34
+ }
35
+
13
36
  function buildRuntimePrompt({ state, role }) {
14
37
  const roleContext = ROLE_SYSTEM_PROMPTS[role] || '';
15
38
  const collaborativePrompt = buildAgentPrompt({
@@ -17,17 +40,129 @@ function buildRuntimePrompt({ state, role }) {
17
40
  task: state.task,
18
41
  round: state.round,
19
42
  messages: state.messages,
43
+ sharedContext: state.run?.sharedContext || null,
20
44
  });
21
45
 
22
46
  return [roleContext, '', collaborativePrompt].join('\n');
23
47
  }
24
48
 
49
+ function ensureRunState(state) {
50
+ if (state.run && typeof state.run === 'object') {
51
+ return {
52
+ ...state.run,
53
+ sharedContext: state.run.sharedContext && typeof state.run.sharedContext === 'object'
54
+ ? {
55
+ decisions: Array.isArray(state.run.sharedContext.decisions) ? state.run.sharedContext.decisions : [],
56
+ openIssues: Array.isArray(state.run.sharedContext.openIssues) ? state.run.sharedContext.openIssues : [],
57
+ risks: Array.isArray(state.run.sharedContext.risks) ? state.run.sharedContext.risks : [],
58
+ actionItems: Array.isArray(state.run.sharedContext.actionItems) ? state.run.sharedContext.actionItems : [],
59
+ notes: Array.isArray(state.run.sharedContext.notes) ? state.run.sharedContext.notes : [],
60
+ }
61
+ : {
62
+ decisions: [],
63
+ openIssues: [],
64
+ risks: [],
65
+ actionItems: [],
66
+ notes: [],
67
+ },
68
+ };
69
+ }
70
+ return {
71
+ runId: null,
72
+ status: 'idle',
73
+ startedAt: null,
74
+ finishedAt: null,
75
+ currentRole: null,
76
+ retriesUsed: {},
77
+ round: state.round || 1,
78
+ events: [],
79
+ finalSummary: null,
80
+ sharedContext: {
81
+ decisions: [],
82
+ openIssues: [],
83
+ risks: [],
84
+ actionItems: [],
85
+ notes: [],
86
+ },
87
+ lastError: null,
88
+ policy: {
89
+ timeoutPerRoleMs: 180000,
90
+ retryOnTimeout: 1,
91
+ fallbackOnFailure: 'abort',
92
+ maxRounds: state.maxRounds,
93
+ },
94
+ };
95
+ }
96
+
97
+ function applyRoleFailurePolicy(state, role, errorMessage) {
98
+ let run = ensureRunState(state);
99
+ const policy = run.policy || {};
100
+ const currentRetries = roleRetryCount(run, role);
101
+ const canRetry = currentRetries < (policy.retryOnTimeout ?? 0);
102
+
103
+ run = appendRunEvent(run, 'role_failed', {
104
+ role,
105
+ retry: canRetry,
106
+ error: errorMessage,
107
+ });
108
+
109
+ if (canRetry) {
110
+ run = incrementRoleRetry(run, role);
111
+ return {
112
+ ...state,
113
+ run: {
114
+ ...run,
115
+ currentRole: role,
116
+ status: 'running',
117
+ lastError: null,
118
+ },
119
+ activeAgent: null,
120
+ // retry same role by rewinding index
121
+ nextRoleIndex: state.roles.indexOf(role),
122
+ };
123
+ }
124
+
125
+ const fallback = policy.fallbackOnFailure || 'abort';
126
+ if (fallback === 'skip') {
127
+ const skipped = appendRunEvent(run, 'role_skipped', { role, error: errorMessage });
128
+ return {
129
+ ...state,
130
+ run: {
131
+ ...skipped,
132
+ currentRole: null,
133
+ status: 'running',
134
+ lastError: null,
135
+ },
136
+ activeAgent: null,
137
+ };
138
+ }
139
+
140
+ const failed = appendRunEvent(run, 'run_failed', { role, error: errorMessage });
141
+ return {
142
+ ...state,
143
+ status: 'failed',
144
+ activeAgent: null,
145
+ run: {
146
+ ...failed,
147
+ status: 'failed',
148
+ currentRole: null,
149
+ finishedAt: new Date().toISOString(),
150
+ lastError: {
151
+ code: 'ROLE_FAILURE',
152
+ message: errorMessage,
153
+ role,
154
+ },
155
+ },
156
+ };
157
+ }
158
+
25
159
  export async function runTurn(sessionId, options = {}) {
26
160
  return withSessionLock(sessionId, async () => {
27
161
  let state = await loadSessionState(sessionId);
28
162
  if (shouldStop(state)) {
29
163
  if (state.status === 'running') {
30
164
  state = await stopSession(state, 'completed');
165
+ state = await finalizeSessionArtifacts(state);
31
166
  }
32
167
  return state;
33
168
  }
@@ -44,7 +179,7 @@ export async function runTurn(sessionId, options = {}) {
44
179
  let content;
45
180
  try {
46
181
  const effectiveModel = resolveRoleModel(state, scheduled.role, options.model);
47
- content = await runOpenCodePrompt({
182
+ const output = await runOpenCodePromptDetailed({
48
183
  prompt,
49
184
  cwd: options.cwd || process.cwd(),
50
185
  model: effectiveModel,
@@ -52,6 +187,7 @@ export async function runTurn(sessionId, options = {}) {
52
187
  binaryPath: options.opencodeBin,
53
188
  timeoutMs: options.timeoutMs,
54
189
  });
190
+ content = output.text;
55
191
  } catch (error) {
56
192
  content = `Agent failed: ${error.message}`;
57
193
  }
@@ -64,6 +200,7 @@ export async function runTurn(sessionId, options = {}) {
64
200
 
65
201
  if (shouldStop(state)) {
66
202
  state = await stopSession(state, 'completed');
203
+ state = await finalizeSessionArtifacts(state);
67
204
  }
68
205
 
69
206
  return state;
@@ -109,7 +246,7 @@ export async function executeActiveTurn(sessionId, role, options = {}) {
109
246
  let content;
110
247
  try {
111
248
  const effectiveModel = resolveRoleModel(state, role, options.model);
112
- content = await runOpenCodePrompt({
249
+ const output = await runOpenCodePromptDetailed({
113
250
  prompt,
114
251
  cwd: options.cwd || process.cwd(),
115
252
  model: effectiveModel,
@@ -117,6 +254,7 @@ export async function executeActiveTurn(sessionId, role, options = {}) {
117
254
  binaryPath: options.opencodeBin,
118
255
  timeoutMs: options.timeoutMs,
119
256
  });
257
+ content = output.text;
120
258
  } catch (error) {
121
259
  content = `Agent failed: ${error.message}`;
122
260
  }
@@ -129,6 +267,7 @@ export async function executeActiveTurn(sessionId, role, options = {}) {
129
267
 
130
268
  if (shouldStop(state)) {
131
269
  state = await stopSession(state, 'completed');
270
+ state = await finalizeSessionArtifacts(state);
132
271
  }
133
272
 
134
273
  return state;
@@ -141,13 +280,33 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
141
280
  if (state.status !== 'running') return state;
142
281
  if (!state.roles.includes(role)) return state;
143
282
 
283
+ const run = ensureRunState(state);
284
+ if (run.status === 'cancelled' || run.status === 'failed' || run.status === 'completed') {
285
+ return state;
286
+ }
287
+
144
288
  if (!state.activeAgent) {
145
289
  const scheduled = nextRole(state);
290
+ const startedRun = run.status === 'idle'
291
+ ? appendRunEvent({
292
+ ...run,
293
+ status: 'running',
294
+ startedAt: new Date().toISOString(),
295
+ currentRole: scheduled.role,
296
+ round: scheduled.round,
297
+ }, 'run_started', { round: scheduled.round })
298
+ : appendRunEvent({
299
+ ...run,
300
+ currentRole: scheduled.role,
301
+ round: scheduled.round,
302
+ }, 'role_scheduled', { role: scheduled.role, round: scheduled.round });
303
+
146
304
  state = await saveSessionState({
147
305
  ...state,
148
306
  activeAgent: scheduled.role,
149
307
  nextRoleIndex: scheduled.nextRoleIndex,
150
308
  round: scheduled.round,
309
+ run: startedRun,
151
310
  });
152
311
  }
153
312
 
@@ -157,28 +316,88 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
157
316
 
158
317
  const prompt = buildRuntimePrompt({ state, role });
159
318
  let content;
319
+ let outputEvents = [];
320
+ let effectiveModel = null;
321
+ let failed = false;
322
+ let errorMessage = '';
160
323
  try {
161
- const effectiveModel = resolveRoleModel(state, role, options.model);
162
- content = await runOpenCodePrompt({
324
+ effectiveModel = resolveRoleModel(state, role, options.model);
325
+ state = await saveSessionState({
326
+ ...state,
327
+ run: appendRunEvent({
328
+ ...ensureRunState(state),
329
+ currentRole: role,
330
+ status: 'running',
331
+ }, 'role_started', { role, model: effectiveModel }),
332
+ });
333
+ const output = await runOpenCodePromptDetailed({
163
334
  prompt,
164
335
  cwd: options.cwd || process.cwd(),
165
336
  model: effectiveModel,
166
337
  agent: options.agent,
167
338
  binaryPath: options.opencodeBin,
168
- timeoutMs: options.timeoutMs,
339
+ timeoutMs: options.timeoutMs || ensureRunState(state).policy?.timeoutPerRoleMs || 180000,
169
340
  });
341
+ content = output.text;
342
+ outputEvents = output.events || [];
170
343
  } catch (error) {
344
+ failed = true;
345
+ errorMessage = error.message;
171
346
  content = `Agent failed: ${error.message}`;
172
347
  }
173
348
 
174
349
  state = await addAgentMessage(state, role, content);
350
+ if (failed) {
351
+ await appendMeetingTurn(sessionId, createTurnRecord({
352
+ round: state.round,
353
+ role,
354
+ model: effectiveModel,
355
+ content,
356
+ events: outputEvents,
357
+ }));
358
+ state = await saveSessionState(applyRoleFailurePolicy(state, role, errorMessage));
359
+ if (state.status === 'failed') {
360
+ state = await finalizeSessionArtifacts(state);
361
+ }
362
+ return state;
363
+ }
364
+
365
+ const turnRecord = createTurnRecord({
366
+ round: state.round,
367
+ role,
368
+ model: effectiveModel,
369
+ content,
370
+ events: outputEvents,
371
+ });
372
+ await appendMeetingTurn(sessionId, turnRecord);
373
+
374
+ const updatedShared = updateSharedContext(ensureRunState(state).sharedContext, turnRecord);
375
+ const succeededRun = appendRunEvent({
376
+ ...ensureRunState(state),
377
+ currentRole: null,
378
+ lastError: null,
379
+ sharedContext: updatedShared,
380
+ }, 'role_succeeded', { role, chars: content.length, events: outputEvents.length });
381
+
175
382
  state = await saveSessionState({
176
383
  ...state,
177
384
  activeAgent: null,
385
+ run: succeededRun,
178
386
  });
179
387
 
180
388
  if (shouldStop(state)) {
181
389
  state = await stopSession(state, 'completed');
390
+ const finalRun = appendRunEvent({
391
+ ...ensureRunState(state),
392
+ status: 'completed',
393
+ finishedAt: new Date().toISOString(),
394
+ finalSummary: extractFinalSummary(state.messages, ensureRunState(state)),
395
+ }, 'run_completed', { round: state.round });
396
+ state = await saveSessionState({
397
+ ...state,
398
+ run: finalRun,
399
+ });
400
+ state = await finalizeSessionArtifacts(state);
182
401
  }
183
402
 
184
403
  return state;
@@ -25,11 +25,41 @@ export const ROLE_SYSTEM_PROMPTS = {
25
25
  ].join(' '),
26
26
  };
27
27
 
28
- export function buildAgentPrompt({ role, task, round, messages, maxMessages = 24 }) {
28
+ function formatSharedContext(sharedContext) {
29
+ if (!sharedContext || typeof sharedContext !== 'object') {
30
+ return 'No shared summary yet.';
31
+ }
32
+
33
+ const sections = [
34
+ ['Decisions', sharedContext.decisions],
35
+ ['Open issues', sharedContext.openIssues],
36
+ ['Risks', sharedContext.risks],
37
+ ['Action items', sharedContext.actionItems],
38
+ ['Notes', sharedContext.notes],
39
+ ];
40
+
41
+ const lines = [];
42
+ for (const [name, list] of sections) {
43
+ const items = Array.isArray(list) ? list.slice(-6) : [];
44
+ lines.push(`${name}:`);
45
+ if (items.length === 0) {
46
+ lines.push('- (none)');
47
+ } else {
48
+ for (const item of items) {
49
+ lines.push(`- ${item}`);
50
+ }
51
+ }
52
+ }
53
+
54
+ return lines.join('\n');
55
+ }
56
+
57
+ export function buildAgentPrompt({ role, task, round, messages, sharedContext = null, maxMessages = 18 }) {
29
58
  const recent = messages.slice(-maxMessages);
30
59
  const transcript = recent.length
31
60
  ? recent.map((msg, idx) => `${idx + 1}. [${msg.from}] ${msg.content}`).join('\n')
32
61
  : 'No previous messages.';
62
+ const shared = formatSharedContext(sharedContext);
33
63
 
34
64
  return [
35
65
  `ROLE: ${role}`,
@@ -37,6 +67,9 @@ export function buildAgentPrompt({ role, task, round, messages, maxMessages = 24
37
67
  '',
38
68
  `TASK: ${task}`,
39
69
  '',
70
+ 'SHARED CONTEXT SUMMARY:',
71
+ shared,
72
+ '',
40
73
  'TEAM TRANSCRIPT (latest first-order history):',
41
74
  transcript,
42
75
  '',
@@ -0,0 +1,113 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ DEFAULT_ROLE_RETRIES,
4
+ DEFAULT_ROLE_TIMEOUT_MS,
5
+ DEFAULT_MAX_ROUNDS,
6
+ } from './constants.js';
7
+
8
+ export function normalizeRunPolicy(policy = {}, maxRounds = DEFAULT_MAX_ROUNDS) {
9
+ const timeoutPerRoleMs = Number.isInteger(policy.timeoutPerRoleMs) && policy.timeoutPerRoleMs > 0
10
+ ? policy.timeoutPerRoleMs
11
+ : DEFAULT_ROLE_TIMEOUT_MS;
12
+ const retryOnTimeout = Number.isInteger(policy.retryOnTimeout) && policy.retryOnTimeout >= 0
13
+ ? policy.retryOnTimeout
14
+ : DEFAULT_ROLE_RETRIES;
15
+ const fallbackOnFailure = ['retry', 'skip', 'abort'].includes(policy.fallbackOnFailure)
16
+ ? policy.fallbackOnFailure
17
+ : 'abort';
18
+ const rounds = Number.isInteger(maxRounds) && maxRounds > 0 ? maxRounds : DEFAULT_MAX_ROUNDS;
19
+
20
+ return {
21
+ timeoutPerRoleMs,
22
+ retryOnTimeout,
23
+ fallbackOnFailure,
24
+ maxRounds: rounds,
25
+ };
26
+ }
27
+
28
+ export function createRunState(policy = {}, maxRounds = DEFAULT_MAX_ROUNDS) {
29
+ return {
30
+ runId: randomUUID(),
31
+ status: 'idle',
32
+ startedAt: null,
33
+ finishedAt: null,
34
+ currentRole: null,
35
+ retriesUsed: {},
36
+ round: 1,
37
+ events: [],
38
+ finalSummary: null,
39
+ sharedContext: {
40
+ decisions: [],
41
+ openIssues: [],
42
+ risks: [],
43
+ actionItems: [],
44
+ notes: [],
45
+ },
46
+ lastError: null,
47
+ policy: normalizeRunPolicy(policy, maxRounds),
48
+ };
49
+ }
50
+
51
+ export function appendRunEvent(run, type, details = {}) {
52
+ const event = {
53
+ id: randomUUID(),
54
+ type,
55
+ timestamp: new Date().toISOString(),
56
+ ...details,
57
+ };
58
+ const events = [...(run.events || []), event];
59
+ return {
60
+ ...run,
61
+ events,
62
+ };
63
+ }
64
+
65
+ export function roleRetryCount(run, role) {
66
+ return Number(run?.retriesUsed?.[role] || 0);
67
+ }
68
+
69
+ export function incrementRoleRetry(run, role) {
70
+ return {
71
+ ...run,
72
+ retriesUsed: {
73
+ ...(run.retriesUsed || {}),
74
+ [role]: roleRetryCount(run, role) + 1,
75
+ },
76
+ };
77
+ }
78
+
79
+ export function extractFinalSummary(messages = [], run = null) {
80
+ const agentMessages = messages.filter((msg) => msg?.from && msg.from !== 'user');
81
+ if (agentMessages.length === 0) return '';
82
+ const orderedRoles = ['planner', 'critic', 'coder', 'reviewer'];
83
+ const lastByRole = new Map();
84
+ for (const msg of agentMessages) lastByRole.set(msg.from, msg.content || '');
85
+
86
+ const sections = ['# SynapseGrid Final Summary', ''];
87
+ sections.push('## Per-role last contributions');
88
+ for (const role of orderedRoles) {
89
+ const content = String(lastByRole.get(role) || '').trim();
90
+ sections.push(`- ${role}: ${content ? content.slice(0, 500) : '(none)'}`);
91
+ }
92
+
93
+ const shared = run?.sharedContext;
94
+ if (shared && typeof shared === 'object') {
95
+ const writeList = (title, items) => {
96
+ sections.push('');
97
+ sections.push(`## ${title}`);
98
+ const list = Array.isArray(items) ? items.slice(-10) : [];
99
+ if (list.length === 0) {
100
+ sections.push('- (none)');
101
+ } else {
102
+ for (const item of list) sections.push(`- ${item}`);
103
+ }
104
+ };
105
+
106
+ writeList('Decisions', shared.decisions);
107
+ writeList('Open issues', shared.openIssues);
108
+ writeList('Risks', shared.risks);
109
+ writeList('Action items', shared.actionItems);
110
+ }
111
+
112
+ return sections.join('\n').trim();
113
+ }
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { writeFile } from 'node:fs/promises';
4
5
  import { COLLAB_ROLES } from './constants.js';
5
6
 
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -10,7 +11,7 @@ export function roleLogPath(sessionDir, role) {
10
11
  return resolve(sessionDir, `${role}.log`);
11
12
  }
12
13
 
13
- export function runTmux(command, args, options = {}) {
14
+ function runCommand(command, args, options = {}) {
14
15
  return new Promise((resolvePromise, rejectPromise) => {
15
16
  const child = spawn(command, args, {
16
17
  cwd: options.cwd || process.cwd(),
@@ -42,6 +43,14 @@ export function runTmux(command, args, options = {}) {
42
43
  });
43
44
  }
44
45
 
46
+ function workerCommand(sessionId, role, roleLog) {
47
+ return `bash -lc 'node "${runnerPath}" agents worker --session ${sessionId} --role ${role} 2>&1 | tee -a "${roleLog}"'`;
48
+ }
49
+
50
+ export async function runTmux(command, args, options = {}) {
51
+ return runCommand(command, args, options);
52
+ }
53
+
45
54
  export async function spawnTmuxSession({ sessionName, sessionDir, sessionId }) {
46
55
  const role0 = COLLAB_ROLES[0];
47
56
  const role0Log = roleLogPath(sessionDir, role0);
@@ -52,7 +61,7 @@ export async function spawnTmuxSession({ sessionName, sessionDir, sessionId }) {
52
61
  sessionName,
53
62
  '-n',
54
63
  role0,
55
- `bash -lc 'node "${runnerPath}" agents worker --session ${sessionId} --role ${role0} 2>&1 | tee -a "${role0Log}"'`,
64
+ workerCommand(sessionId, role0, role0Log),
56
65
  ]);
57
66
 
58
67
  for (let idx = 1; idx < COLLAB_ROLES.length; idx += 1) {
@@ -63,7 +72,7 @@ export async function spawnTmuxSession({ sessionName, sessionDir, sessionId }) {
63
72
  '-t',
64
73
  sessionName,
65
74
  '-v',
66
- `bash -lc 'node "${runnerPath}" agents worker --session ${sessionId} --role ${role} 2>&1 | tee -a "${roleLog}"'`,
75
+ workerCommand(sessionId, role, roleLog),
67
76
  ]);
68
77
  }
69
78
 
@@ -80,3 +89,66 @@ export async function tmuxSessionExists(sessionName) {
80
89
  return false;
81
90
  }
82
91
  }
92
+
93
+ async function writeZellijLayout({ layoutPath, sessionId, sessionDir }) {
94
+ const panes = COLLAB_ROLES.map((role) => {
95
+ const roleLog = roleLogPath(sessionDir, role);
96
+ const cmd = workerCommand(sessionId, role, roleLog).replace(/"/g, '\\"');
97
+ return ` pane name="${role}" command="bash" args { "-lc" "${cmd}" }`;
98
+ });
99
+
100
+ const content = [
101
+ 'layout {',
102
+ ' default_tab_template {',
103
+ ' tab name="SynapseGrid" {',
104
+ ' pane split_direction="vertical" {',
105
+ ' pane split_direction="horizontal" {',
106
+ panes[0],
107
+ panes[1],
108
+ ' }',
109
+ ' pane split_direction="horizontal" {',
110
+ panes[2],
111
+ panes[3],
112
+ ' }',
113
+ ' }',
114
+ ' }',
115
+ ' }',
116
+ '}',
117
+ '',
118
+ ].join('\n');
119
+
120
+ await writeFile(layoutPath, content, 'utf8');
121
+ }
122
+
123
+ export async function spawnZellijSession({ sessionName, sessionDir, sessionId }) {
124
+ const layoutPath = resolve(sessionDir, 'synapsegrid-layout.kdl');
125
+ await writeZellijLayout({ layoutPath, sessionId, sessionDir });
126
+ await runCommand('zellij', ['--session', sessionName, '--layout', layoutPath, '--detach']);
127
+ return { layoutPath };
128
+ }
129
+
130
+ export async function zellijSessionExists(sessionName) {
131
+ try {
132
+ const result = await runCommand('zellij', ['list-sessions']);
133
+ const lines = result.stdout.split('\n').map((line) => line.trim()).filter(Boolean);
134
+ return lines.some((line) => line === sessionName || line.startsWith(`${sessionName} `));
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ export async function runZellij(args, options = {}) {
141
+ return runCommand('zellij', args, options);
142
+ }
143
+
144
+ export function resolveMultiplexer(preferred = 'auto', hasTmuxCommand = false, hasZellijCommand = false) {
145
+ if (preferred === 'tmux') {
146
+ return hasTmuxCommand ? 'tmux' : null;
147
+ }
148
+ if (preferred === 'zellij') {
149
+ return hasZellijCommand ? 'zellij' : null;
150
+ }
151
+ if (hasZellijCommand) return 'zellij';
152
+ if (hasTmuxCommand) return 'tmux';
153
+ return null;
154
+ }