ac-framework 1.9.8 → 2.0.0

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.
@@ -156,7 +156,8 @@ async function setupPersistentMemory() {
156
156
  async function setupCollaborativeSystem() {
157
157
  const hasOpenCode = hasCommand('opencode');
158
158
  const hasTmux = hasCommand('tmux');
159
- const alreadyReady = hasOpenCode && hasTmux;
159
+ const hasZellij = hasCommand('zellij');
160
+ const alreadyReady = hasOpenCode && (hasZellij || hasTmux);
160
161
 
161
162
  console.log();
162
163
  await animatedSeparator(60);
@@ -168,10 +169,10 @@ async function setupCollaborativeSystem() {
168
169
  console.log(
169
170
  chalk.hex('#636E72')(
170
171
  ` ${COLLAB_SYSTEM_NAME} launches a real-time collaborative agent war-room with\n` +
171
- ' 4 coordinated roles (planner, critic, coder, reviewer) in tmux panes.\n\n' +
172
+ ' 4 coordinated roles (planner, critic, coder, reviewer) in multiplexer panes.\n\n' +
172
173
  ' Each round is turn-based with shared incremental context, so every\n' +
173
174
  ' contribution from one agent is fed to the next, not isolated fan-out.\n\n' +
174
- ` Dependencies: ${chalk.hex('#DFE6E9')('OpenCode')} + ${chalk.hex('#DFE6E9')('tmux')}`
175
+ ` Dependencies: ${chalk.hex('#DFE6E9')('OpenCode')} + ${chalk.hex('#DFE6E9')('zellij')} (${chalk.hex('#DFE6E9')('tmux')} fallback)`
175
176
  )
176
177
  );
177
178
  console.log();
@@ -223,7 +224,8 @@ async function setupCollaborativeSystem() {
223
224
 
224
225
  if (alreadyReady) {
225
226
  console.log();
226
- console.log(chalk.hex('#00B894')(' ◆ OpenCode and tmux are already available.'));
227
+ const mux = hasZellij ? 'zellij' : 'tmux';
228
+ console.log(chalk.hex('#00B894')(` ◆ OpenCode and ${mux} are already available.`));
227
229
  await installCollabMcpConnections();
228
230
  console.log(chalk.hex('#636E72')(' Run `acfm agents start --task "..."` to launch collaboration.'));
229
231
  console.log();
@@ -234,11 +236,20 @@ async function setupCollaborativeSystem() {
234
236
  console.log(chalk.hex('#B2BEC3')(` Installing ${COLLAB_SYSTEM_NAME} dependencies...`));
235
237
  console.log();
236
238
 
237
- const result = ensureCollabDependencies();
239
+ const result = await ensureCollabDependencies({
240
+ installZellij: true,
241
+ installTmux: true,
242
+ preferManagedZellij: true,
243
+ });
238
244
 
239
245
  const oColor = result.opencode.success ? chalk.hex('#00B894') : chalk.hex('#D63031');
246
+ const zColor = result.zellij.success ? chalk.hex('#00B894') : chalk.hex('#D63031');
240
247
  const tColor = result.tmux.success ? chalk.hex('#00B894') : chalk.hex('#D63031');
241
248
  console.log(oColor(` ◆ OpenCode: ${result.opencode.message}`));
249
+ console.log(zColor(` ◆ zellij: ${result.zellij.message}`));
250
+ if (result.zellij.binaryPath) {
251
+ console.log(chalk.hex('#636E72')(` ${result.zellij.binaryPath}`));
252
+ }
242
253
  console.log(tColor(` ◆ tmux: ${result.tmux.message}`));
243
254
  console.log();
244
255
 
@@ -17,7 +17,13 @@ import { COLLAB_ROLES } from '../agents/constants.js';
17
17
  import { buildEffectiveRoleModels, sanitizeRoleModels } from '../agents/model-selection.js';
18
18
  import { runWorkerIteration } from '../agents/orchestrator.js';
19
19
  import { getSessionDir } from '../agents/state-store.js';
20
- import { spawnTmuxSession, tmuxSessionExists } from '../agents/runtime.js';
20
+ import {
21
+ spawnTmuxSession,
22
+ spawnZellijSession,
23
+ tmuxSessionExists,
24
+ zellijSessionExists,
25
+ resolveMultiplexer,
26
+ } from '../agents/runtime.js';
21
27
  import {
22
28
  addUserMessage,
23
29
  createSession,
@@ -28,7 +34,8 @@ import {
28
34
  setCurrentSession,
29
35
  stopSession,
30
36
  } from '../agents/state-store.js';
31
- import { hasCommand, resolveCommandPath } from '../services/dependency-installer.js';
37
+ import { hasCommand, resolveCommandPath, resolveManagedZellijPath } from '../services/dependency-installer.js';
38
+ import { loadAgentsConfig } from '../agents/config-store.js';
32
39
 
33
40
  const __dirname = dirname(fileURLToPath(import.meta.url));
34
41
  const runnerPath = resolve(__dirname, '../../bin/acfm.js');
@@ -67,6 +74,21 @@ function launchAutopilot(sessionId) {
67
74
  child.unref();
68
75
  }
69
76
 
77
+ function resolveConfiguredZellijPath(config) {
78
+ const strategy = config?.agents?.zellij?.strategy || 'auto';
79
+ if (strategy === 'system') {
80
+ return resolveCommandPath('zellij');
81
+ }
82
+ const managed = resolveManagedZellijPath(config);
83
+ if (managed) return managed;
84
+ return resolveCommandPath('zellij');
85
+ }
86
+
87
+ async function muxExists(multiplexer, sessionName, zellijPath = null) {
88
+ if (multiplexer === 'zellij') return zellijSessionExists(sessionName, zellijPath);
89
+ return tmuxSessionExists(sessionName);
90
+ }
91
+
70
92
  class MCPCollabServer {
71
93
  constructor() {
72
94
  this.server = new McpServer({
@@ -92,7 +114,7 @@ class MCPCollabServer {
92
114
  reviewer: z.string().optional(),
93
115
  }).partial().optional().describe('Optional per-role models (provider/model)'),
94
116
  cwd: z.string().optional().describe('Working directory for agents'),
95
- spawnWorkers: z.boolean().default(true).describe('Create tmux workers and panes'),
117
+ spawnWorkers: z.boolean().default(true).describe('Create multiplexer workers and panes'),
96
118
  runPolicy: z.object({
97
119
  timeoutPerRoleMs: z.number().int().positive().optional(),
98
120
  retryOnTimeout: z.number().int().min(0).optional(),
@@ -107,8 +129,12 @@ class MCPCollabServer {
107
129
  throw new Error('OpenCode binary not found in PATH. Run: acfm agents setup');
108
130
  }
109
131
 
110
- if (spawnWorkers && !hasCommand('tmux')) {
111
- throw new Error('tmux is not installed. Run: acfm agents setup');
132
+ const config = await loadAgentsConfig();
133
+ const configuredMux = config.agents.multiplexer || 'auto';
134
+ const zellijPath = resolveConfiguredZellijPath(config);
135
+ const multiplexer = resolveMultiplexer(configuredMux, hasCommand('tmux'), Boolean(zellijPath));
136
+ if (spawnWorkers && !multiplexer) {
137
+ throw new Error('No multiplexer found (zellij/tmux). Run: acfm agents setup');
112
138
  }
113
139
 
114
140
  const state = await createSession(task, {
@@ -119,18 +145,31 @@ class MCPCollabServer {
119
145
  workingDirectory,
120
146
  opencodeBin,
121
147
  runPolicy,
148
+ multiplexer: multiplexer || configuredMux,
122
149
  });
123
150
  let updated = state;
124
151
  if (spawnWorkers) {
125
- const tmuxSessionName = `acfm-synapse-${state.sessionId.slice(0, 8)}`;
152
+ const sessionName = `acfm-synapse-${state.sessionId.slice(0, 8)}`;
126
153
  const sessionDir = getSessionDir(state.sessionId);
127
- await spawnTmuxSession({ sessionName: tmuxSessionName, sessionDir, sessionId: state.sessionId });
128
- updated = await saveSessionState({ ...state, tmuxSessionName });
154
+ if (multiplexer === 'zellij') {
155
+ await spawnZellijSession({ sessionName, sessionDir, sessionId: state.sessionId, binaryPath: zellijPath });
156
+ } else {
157
+ await spawnTmuxSession({ sessionName, sessionDir, sessionId: state.sessionId });
158
+ }
159
+ updated = await saveSessionState({
160
+ ...state,
161
+ multiplexer,
162
+ multiplexerSessionName: sessionName,
163
+ tmuxSessionName: multiplexer === 'tmux' ? sessionName : null,
164
+ });
129
165
  }
130
166
  await setCurrentSession(state.sessionId);
131
167
 
132
- const tmuxSessionName = updated.tmuxSessionName || null;
133
- const attachCommand = tmuxSessionName ? `tmux attach -t ${tmuxSessionName}` : null;
168
+ const mux = updated.multiplexer || null;
169
+ const muxSessionName = updated.multiplexerSessionName || updated.tmuxSessionName || null;
170
+ const attachCommand = muxSessionName
171
+ ? (mux === 'zellij' ? `zellij attach ${muxSessionName}` : `tmux attach -t ${muxSessionName}`)
172
+ : null;
134
173
  return {
135
174
  content: [{
136
175
  type: 'text',
@@ -142,7 +181,8 @@ class MCPCollabServer {
142
181
  roleModels: updated.roleModels || {},
143
182
  effectiveRoleModels: buildEffectiveRoleModels(updated, updated.model || null),
144
183
  run: summarizeRun(updated),
145
- tmuxSessionName,
184
+ multiplexer: mux,
185
+ multiplexerSessionName: muxSessionName,
146
186
  attachCommand,
147
187
  }, null, 2),
148
188
  }],
@@ -169,7 +209,7 @@ class MCPCollabServer {
169
209
  throw new Error(`Session is ${state.status}. Resume/start before invoking.`);
170
210
  }
171
211
 
172
- if (!state.tmuxSessionName) {
212
+ if (!state.multiplexerSessionName && !state.tmuxSessionName) {
173
213
  launchAutopilot(state.sessionId);
174
214
  }
175
215
 
@@ -193,8 +233,13 @@ class MCPCollabServer {
193
233
  status: state.status,
194
234
  run: summarizeRun(state),
195
235
  latestEvent: latestRunEvent(state),
196
- tmuxSessionName: state.tmuxSessionName || null,
197
- attachCommand: state.tmuxSessionName ? `tmux attach -t ${state.tmuxSessionName}` : null,
236
+ multiplexer: state.multiplexer || null,
237
+ multiplexerSessionName: state.multiplexerSessionName || state.tmuxSessionName || null,
238
+ attachCommand: state.multiplexerSessionName
239
+ ? (state.multiplexer === 'zellij'
240
+ ? `zellij attach ${state.multiplexerSessionName}`
241
+ : `tmux attach -t ${state.multiplexerSessionName}`)
242
+ : (state.tmuxSessionName ? `tmux attach -t ${state.tmuxSessionName}` : null),
198
243
  }, null, 2),
199
244
  }],
200
245
  };
@@ -408,35 +453,50 @@ class MCPCollabServer {
408
453
 
409
454
  this.server.tool(
410
455
  'collab_resume_session',
411
- 'Resume session and recreate tmux workers if needed',
456
+ 'Resume session and recreate workers if needed',
412
457
  {
413
458
  sessionId: z.string().optional().describe('Session ID (defaults to current session)'),
414
- recreateWorkers: z.boolean().default(true).describe('Recreate tmux session when missing'),
459
+ recreateWorkers: z.boolean().default(true).describe('Recreate multiplexer session when missing'),
415
460
  },
416
461
  async ({ sessionId, recreateWorkers }) => {
417
462
  try {
418
463
  const id = sessionId || await loadCurrentSessionId();
419
464
  if (!id) throw new Error('No active session found');
420
465
  let state = await loadSessionState(id);
466
+ const config = await loadAgentsConfig();
467
+ const zellijPath = resolveConfiguredZellijPath(config);
421
468
 
422
- const tmuxSessionName = state.tmuxSessionName || `acfm-synapse-${state.sessionId.slice(0, 8)}`;
423
- const tmuxExists = hasCommand('tmux') ? await tmuxSessionExists(tmuxSessionName) : false;
469
+ const multiplexer = state.multiplexer || resolveMultiplexer('auto', hasCommand('tmux'), Boolean(zellijPath));
470
+ if (!multiplexer) {
471
+ throw new Error('No multiplexer found (zellij/tmux). Run: acfm agents setup');
472
+ }
473
+ const sessionName = state.multiplexerSessionName || state.tmuxSessionName || `acfm-synapse-${state.sessionId.slice(0, 8)}`;
474
+ const sessionExists = await muxExists(multiplexer, sessionName, zellijPath);
424
475
 
425
- if (!tmuxExists && recreateWorkers) {
426
- if (!hasCommand('tmux')) {
427
- throw new Error('tmux is not installed. Run: acfm agents setup');
428
- }
476
+ if (!sessionExists && recreateWorkers) {
429
477
  const sessionDir = getSessionDir(state.sessionId);
430
- await spawnTmuxSession({ sessionName: tmuxSessionName, sessionDir, sessionId: state.sessionId });
478
+ if (multiplexer === 'zellij') {
479
+ if (!zellijPath) throw new Error('zellij is not installed. Run: acfm agents setup');
480
+ await spawnZellijSession({ sessionName, sessionDir, sessionId: state.sessionId, binaryPath: zellijPath });
481
+ } else {
482
+ if (!hasCommand('tmux')) throw new Error('tmux is not installed. Run: acfm agents setup');
483
+ await spawnTmuxSession({ sessionName, sessionDir, sessionId: state.sessionId });
484
+ }
431
485
  }
432
486
 
433
487
  state = await saveSessionState({
434
488
  ...state,
435
489
  status: 'running',
436
- tmuxSessionName,
490
+ multiplexer,
491
+ multiplexerSessionName: sessionName,
492
+ tmuxSessionName: multiplexer === 'tmux' ? sessionName : state.tmuxSessionName || null,
437
493
  });
438
494
  await setCurrentSession(state.sessionId);
439
495
 
496
+ const attachCommand = multiplexer === 'zellij'
497
+ ? `zellij attach ${sessionName}`
498
+ : `tmux attach -t ${sessionName}`;
499
+
440
500
  return {
441
501
  content: [{
442
502
  type: 'text',
@@ -444,8 +504,10 @@ class MCPCollabServer {
444
504
  success: true,
445
505
  sessionId: state.sessionId,
446
506
  status: state.status,
447
- tmuxSessionName,
448
- recreatedWorkers: !tmuxExists && recreateWorkers,
507
+ multiplexer,
508
+ multiplexerSessionName: sessionName,
509
+ recreatedWorkers: !sessionExists && recreateWorkers,
510
+ attachCommand,
449
511
  }, null, 2),
450
512
  }],
451
513
  };
@@ -1,7 +1,9 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
+ import { chmod, mkdir, rm, writeFile } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
- import { platform } from 'node:os';
5
+ import { arch, homedir, platform } from 'node:os';
6
+ import { createHash } from 'node:crypto';
5
7
 
6
8
  function preferredOpenCodePath() {
7
9
  const home = process.env.HOME;
@@ -17,6 +19,68 @@ function run(command, args, options = {}) {
17
19
  });
18
20
  }
19
21
 
22
+ function runInstallCommand(command) {
23
+ if (platform() === 'win32') {
24
+ return run('cmd.exe', ['/c', command], { stdio: 'inherit' });
25
+ }
26
+ return run('bash', ['-lc', command], { stdio: 'inherit' });
27
+ }
28
+
29
+ async function fetchJson(url) {
30
+ const response = await fetch(url, {
31
+ headers: {
32
+ Accept: 'application/vnd.github+json',
33
+ 'User-Agent': 'ac-framework',
34
+ },
35
+ });
36
+ if (!response.ok) {
37
+ throw new Error(`Request failed (${response.status}) while fetching ${url}`);
38
+ }
39
+ return response.json();
40
+ }
41
+
42
+ function sha256HexFromBuffer(buffer) {
43
+ const hash = createHash('sha256');
44
+ hash.update(buffer);
45
+ return hash.digest('hex');
46
+ }
47
+
48
+ function managedToolsRoot() {
49
+ return join(homedir(), '.acfm', 'tools', 'zellij');
50
+ }
51
+
52
+ function platformAssetPrefix() {
53
+ const p = platform();
54
+ const a = arch();
55
+ if (p === 'linux' && a === 'x64') return 'zellij-x86_64-unknown-linux-musl';
56
+ if (p === 'linux' && a === 'arm64') return 'zellij-aarch64-unknown-linux-musl';
57
+ if (p === 'darwin' && a === 'x64') return 'zellij-x86_64-apple-darwin';
58
+ if (p === 'darwin' && a === 'arm64') return 'zellij-aarch64-apple-darwin';
59
+ if (p === 'win32' && a === 'x64') return 'zellij-x86_64-pc-windows-msvc';
60
+ return null;
61
+ }
62
+
63
+ function managedZellijBinaryPath(version) {
64
+ const fileName = platform() === 'win32' ? 'zellij.exe' : 'zellij';
65
+ return join(managedToolsRoot(), version, fileName);
66
+ }
67
+
68
+ function extractTarball(tarPath, outputDir) {
69
+ return run('tar', ['-xzf', tarPath, '-C', outputDir]);
70
+ }
71
+
72
+ function findReleaseAsset(release, suffix) {
73
+ return (release.assets || []).find((asset) => asset.name === suffix) || null;
74
+ }
75
+
76
+ export function resolveManagedZellijPath(config = null) {
77
+ const fromEnv = process.env.ACFM_ZELLIJ_BIN;
78
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
79
+ const configured = config?.agents?.zellij?.binaryPath;
80
+ if (configured && existsSync(configured)) return configured;
81
+ return null;
82
+ }
83
+
20
84
  export function hasCommand(command) {
21
85
  return Boolean(resolveCommandPath(command));
22
86
  }
@@ -78,6 +142,29 @@ function resolveTmuxInstallCommand() {
78
142
  return null;
79
143
  }
80
144
 
145
+ function resolveZellijInstallCommand() {
146
+ if (platform() === 'darwin') {
147
+ if (hasCommand('brew')) return 'brew install zellij';
148
+ return null;
149
+ }
150
+
151
+ if (platform() === 'linux') {
152
+ if (hasCommand('apt-get')) return 'sudo apt-get update && sudo apt-get install -y zellij';
153
+ if (hasCommand('dnf')) return 'sudo dnf install -y zellij';
154
+ if (hasCommand('yum')) return 'sudo yum install -y zellij';
155
+ if (hasCommand('pacman')) return 'sudo pacman -S --noconfirm zellij';
156
+ if (hasCommand('zypper')) return 'sudo zypper --non-interactive install zellij';
157
+ }
158
+
159
+ if (platform() === 'win32') {
160
+ if (hasCommand('winget')) return 'winget install --id zellij-org.zellij -e';
161
+ if (hasCommand('choco')) return 'choco install zellij -y';
162
+ if (hasCommand('scoop')) return 'scoop install zellij';
163
+ }
164
+
165
+ return null;
166
+ }
167
+
81
168
  export function installTmux() {
82
169
  if (hasCommand('tmux')) {
83
170
  return { success: true, installed: false, message: 'tmux already installed' };
@@ -92,7 +179,7 @@ export function installTmux() {
92
179
  };
93
180
  }
94
181
 
95
- const result = run('bash', ['-lc', installCommand], { stdio: 'inherit' });
182
+ const result = runInstallCommand(installCommand);
96
183
  if (result.status !== 0) {
97
184
  return { success: false, installed: false, message: 'tmux installation command failed' };
98
185
  }
@@ -106,12 +193,167 @@ export function installTmux() {
106
193
  };
107
194
  }
108
195
 
109
- export function ensureCollabDependencies() {
196
+ export function installZellij() {
197
+ if (hasCommand('zellij')) {
198
+ return { success: true, installed: false, message: 'zellij already installed' };
199
+ }
200
+
201
+ const installCommand = resolveZellijInstallCommand();
202
+ if (!installCommand) {
203
+ return {
204
+ success: false,
205
+ installed: false,
206
+ message: 'No supported package manager detected for automatic zellij installation',
207
+ };
208
+ }
209
+
210
+ const result = runInstallCommand(installCommand);
211
+ if (result.status !== 0) {
212
+ return { success: false, installed: false, message: 'zellij installation command failed' };
213
+ }
214
+
215
+ return {
216
+ success: hasCommand('zellij'),
217
+ installed: true,
218
+ message: hasCommand('zellij')
219
+ ? 'zellij installed successfully'
220
+ : 'zellij installer finished but binary is not available in PATH yet',
221
+ };
222
+ }
223
+
224
+ export async function installManagedZellijLatest() {
225
+ const existingSystem = resolveCommandPath('zellij');
226
+ if (existingSystem) {
227
+ return {
228
+ success: true,
229
+ installed: false,
230
+ version: null,
231
+ binaryPath: existingSystem,
232
+ message: 'zellij already installed in system PATH',
233
+ source: 'system',
234
+ };
235
+ }
236
+
237
+ const prefix = platformAssetPrefix();
238
+ if (!prefix) {
239
+ return {
240
+ success: false,
241
+ installed: false,
242
+ version: null,
243
+ binaryPath: null,
244
+ message: `Unsupported OS/arch for managed zellij install: ${platform()}/${arch()}`,
245
+ source: 'managed',
246
+ };
247
+ }
248
+
249
+ try {
250
+ const release = await fetchJson('https://api.github.com/repos/zellij-org/zellij/releases/latest');
251
+ const version = String(release.tag_name || '').trim() || 'latest';
252
+
253
+ if (platform() === 'win32') {
254
+ const zipAsset = findReleaseAsset(release, `${prefix}.zip`);
255
+ if (!zipAsset) {
256
+ throw new Error(`No matching Windows asset found for ${prefix}`);
257
+ }
258
+ return {
259
+ success: false,
260
+ installed: false,
261
+ version,
262
+ binaryPath: null,
263
+ message: 'Managed Windows zellij install is not implemented yet; use winget/choco/scoop.',
264
+ source: 'managed',
265
+ };
266
+ }
267
+
268
+ const tarAsset = findReleaseAsset(release, `${prefix}.tar.gz`);
269
+ if (!tarAsset?.browser_download_url) {
270
+ throw new Error(`No matching zellij asset found for ${prefix}`);
271
+ }
272
+
273
+ const targetDir = join(managedToolsRoot(), version);
274
+ const binaryPath = managedZellijBinaryPath(version);
275
+ if (existsSync(binaryPath)) {
276
+ return {
277
+ success: true,
278
+ installed: false,
279
+ version,
280
+ binaryPath,
281
+ message: `Managed zellij already installed (${version})`,
282
+ source: 'managed',
283
+ };
284
+ }
285
+
286
+ await mkdir(targetDir, { recursive: true });
287
+ const tmpTarPath = join(targetDir, `${prefix}.tar.gz.download`);
288
+ const response = await fetch(tarAsset.browser_download_url, {
289
+ headers: { 'User-Agent': 'ac-framework' },
290
+ });
291
+ if (!response.ok) {
292
+ throw new Error(`Failed downloading ${tarAsset.name} (${response.status})`);
293
+ }
294
+ const raw = Buffer.from(await response.arrayBuffer());
295
+
296
+ const expectedDigest = String(tarAsset.digest || '').replace(/^sha256:/, '');
297
+ if (expectedDigest) {
298
+ const actualDigest = sha256HexFromBuffer(raw);
299
+ if (actualDigest !== expectedDigest) {
300
+ throw new Error(`Digest mismatch for ${tarAsset.name}`);
301
+ }
302
+ }
303
+
304
+ await writeFile(tmpTarPath, raw);
305
+ const extracted = extractTarball(tmpTarPath, targetDir);
306
+ await rm(tmpTarPath, { force: true });
307
+ if (extracted.status !== 0) {
308
+ throw new Error('Failed extracting zellij tarball');
309
+ }
310
+ if (!existsSync(binaryPath)) {
311
+ throw new Error(`zellij binary not found after extraction at ${binaryPath}`);
312
+ }
313
+ await chmod(binaryPath, 0o755);
314
+
315
+ const versionProbe = run(binaryPath, ['--version']);
316
+ if (versionProbe.status !== 0) {
317
+ throw new Error('Installed zellij binary failed version check');
318
+ }
319
+
320
+ return {
321
+ success: true,
322
+ installed: true,
323
+ version,
324
+ binaryPath,
325
+ message: `Managed zellij installed (${version})`,
326
+ source: 'managed',
327
+ };
328
+ } catch (error) {
329
+ return {
330
+ success: false,
331
+ installed: false,
332
+ version: null,
333
+ binaryPath: null,
334
+ message: `Managed zellij install failed: ${error.message}`,
335
+ source: 'managed',
336
+ };
337
+ }
338
+ }
339
+
340
+ export async function ensureCollabDependencies(options = {}) {
341
+ const installTmuxEnabled = options.installTmux ?? true;
342
+ const installZellijEnabled = options.installZellij ?? true;
343
+ const preferManagedZellij = options.preferManagedZellij ?? false;
110
344
  const opencode = installOpenCode();
111
- const tmux = installTmux();
345
+ const tmux = installTmuxEnabled
346
+ ? installTmux()
347
+ : { success: hasCommand('tmux'), installed: false, message: hasCommand('tmux') ? 'tmux already installed' : 'tmux installation skipped' };
348
+ const zellij = installZellijEnabled
349
+ ? (preferManagedZellij ? await installManagedZellijLatest() : installZellij())
350
+ : { success: hasCommand('zellij'), installed: false, message: hasCommand('zellij') ? 'zellij already installed' : 'zellij installation skipped' };
351
+
352
+ const hasMultiplexer = tmux.success || zellij.success;
112
353
  return {
113
354
  opencode,
114
355
  tmux,
115
- success: opencode.success && tmux.success,
356
+ zellij,
357
+ success: opencode.success && hasMultiplexer,
116
358
  };
117
359
  }