ac-framework 1.9.8 → 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.
package/README.md CHANGED
@@ -6,7 +6,7 @@ It combines three layers in one CLI:
6
6
  - template-based assistant configurations for multiple IDEs and AI CLIs
7
7
  - a built-in spec-driven workflow inspired by OpenSpec / spec-driven development
8
8
  - a persistent local memory system with MCP integration for supported assistants
9
- - an optional collaborative multi-agent runtime powered by OpenCode + tmux
9
+ - an optional collaborative multi-agent runtime powered by OpenCode + zellij (tmux fallback)
10
10
 
11
11
  ## Why AC Framework
12
12
 
@@ -26,7 +26,7 @@ The goal is simple: help AI write better code, with more context, more disciplin
26
26
  - `Spec-driven workflow` - use `acfm spec` to initialize, create, validate, continue, and archive structured changes.
27
27
  - `Persistent memory` - store architectural decisions, bugfixes, refactors, conventions, and context in a local SQLite memory database.
28
28
  - `MCP integration` - connect the memory system to supported assistants through MCP so they can recall and save context directly.
29
- - `Collaborative agents (optional)` - enable SynapseGrid to run planner/critic/coder/reviewer in coordinated tmux panes with shared context.
29
+ - `Collaborative agents (optional)` - enable SynapseGrid to run planner/critic/coder/reviewer in coordinated zellij panes (tmux fallback) with shared context.
30
30
  - `GitHub sync` - use `acfm init --latest` or `acfm update` to pull the latest framework content from GitHub.
31
31
  - `Legacy compatibility` - `.acfm/` is the new default, but existing `openspec/` directories still work.
32
32
 
@@ -57,7 +57,7 @@ The CLI now guides you through:
57
57
  2. choose one or more assistants from that template
58
58
  3. install the matching root instruction files like `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, or `copilot-instructions.md`
59
59
  4. optionally initialize NexusVault persistent memory and MCP connections
60
- 5. optionally enable SynapseGrid collaborative agents (auto-installs OpenCode + tmux)
60
+ 5. optionally enable SynapseGrid collaborative agents (auto-installs OpenCode + zellij/tmux)
61
61
 
62
62
  If enabled, `acfm init` also auto-installs the optional SynapseGrid MCP server into detected assistants.
63
63
 
@@ -130,7 +130,7 @@ Some assistants include bundled companions automatically:
130
130
 
131
131
  ### Collaborative Agents (Optional)
132
132
 
133
- SynapseGrid is an optional collaborative runtime that coordinates 4 OpenCode-backed roles in tmux panes:
133
+ SynapseGrid is an optional collaborative runtime that coordinates 4 OpenCode-backed roles in multiplexer panes (zellij preferred, tmux fallback):
134
134
  - planner
135
135
  - critic
136
136
  - coder
@@ -140,15 +140,18 @@ Each role runs in turn against a shared, accumulating context so outputs from on
140
140
 
141
141
  | Command | Description |
142
142
  |---|---|
143
- | `acfm agents setup` | Install optional dependencies (`opencode` and `tmux`) |
144
- | `acfm agents doctor` | Validate OpenCode/tmux/model preflight before start |
143
+ | `acfm agents setup` | Install optional dependencies (`opencode` and `zellij`/`tmux`) |
144
+ | `acfm agents doctor` | Validate OpenCode/multiplexer/model preflight before start |
145
145
  | `acfm agents install-mcps` | Install SynapseGrid MCP server for detected assistants |
146
146
  | `acfm agents uninstall-mcps` | Remove SynapseGrid MCP server from assistants |
147
147
  | `acfm agents start --task "..." --model-coder provider/model` | Start session with optional per-role models |
148
+ | `acfm agents start --task "..." --mux zellij` | Start session forcing zellij backend (`auto`/`tmux` also supported) |
149
+ | `acfm agents runtime get` | Show configured multiplexer backend (`auto`, `zellij`, `tmux`) |
150
+ | `acfm agents runtime set zellij` | Persist preferred multiplexer backend |
148
151
  | `acfm agents resume` | Resume a previous session and recreate workers if needed |
149
152
  | `acfm agents list` | List recent SynapseGrid sessions |
150
- | `acfm agents attach` | Attach directly to the SynapseGrid tmux session |
151
- | `acfm agents live` | Attach to full live tmux view (all agents) |
153
+ | `acfm agents attach` | Attach directly to the active SynapseGrid multiplexer session |
154
+ | `acfm agents live` | Attach to full live multiplexer view (all agents) |
152
155
  | `acfm agents logs` | Show recent worker logs (all roles or one role) |
153
156
  | `acfm agents transcript --role all --limit 40` | Show captured cross-agent transcript |
154
157
  | `acfm agents summary` | Show generated collaboration meeting summary |
@@ -166,7 +169,7 @@ Each role runs in turn against a shared, accumulating context so outputs from on
166
169
 
167
170
  When driving SynapseGrid from another agent via MCP, prefer asynchronous run tools over role-by-role stepping:
168
171
 
169
- - `collab_start_session` to initialize session and optional tmux workers
172
+ - `collab_start_session` to initialize session and optional zellij/tmux workers
170
173
  - `collab_invoke_team` to launch full 4-role collaboration run
171
174
  - `collab_wait_run` to wait for completion/failure with bounded timeout
172
175
  - `collab_get_result` to fetch final consolidated output and run diagnostics
@@ -180,7 +183,7 @@ When driving SynapseGrid from another agent via MCP, prefer asynchronous run too
180
183
  - Attach to worker panes with `acfm agents live` (or `acfm agents attach`) to see real-time role discussion.
181
184
  - Inspect worker errors quickly with `acfm agents logs --role all --lines 120`.
182
185
  - Inspect collaborative discussion with `acfm agents transcript` and `acfm agents summary`.
183
- - MCP starts can now create tmux workers directly; if your assistant used headless steps before, start a new session and ensure worker spawning is enabled.
186
+ - MCP starts can now create zellij/tmux workers directly; if your assistant used headless steps before, start a new session and ensure worker spawning is enabled.
184
187
  - Configure role models directly at start (for example `--model-planner`, `--model-coder`) or persist defaults via `acfm agents model choose` / `acfm agents model set`.
185
188
  - Default SynapseGrid model fallback is `opencode/mimo-v2-pro-free`.
186
189
  - Run `acfm agents doctor` when panes look idle to confirm model/provider preflight health.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ac-framework",
3
- "version": "1.9.8",
3
+ "version": "1.9.9",
4
4
  "description": "Agentic Coding Framework - Multi-assistant configuration system with OpenSpec workflows",
5
5
  "main": "src/index.js",
6
6
  "exports": {
@@ -10,10 +10,13 @@ const CONFIG_PATH = join(ACFM_DIR, 'config.json');
10
10
 
11
11
  function normalizeConfig(raw) {
12
12
  const agents = raw?.agents && typeof raw.agents === 'object' ? raw.agents : {};
13
+ const configuredMultiplexer = typeof agents.multiplexer === 'string' ? agents.multiplexer.trim().toLowerCase() : '';
14
+ const multiplexer = ['auto', 'zellij', 'tmux'].includes(configuredMultiplexer) ? configuredMultiplexer : 'auto';
13
15
  return {
14
16
  agents: {
15
17
  defaultModel: normalizeModelId(agents.defaultModel) || DEFAULT_SYNAPSE_MODEL,
16
18
  defaultRoleModels: sanitizeRoleModels(agents.defaultRoleModels),
19
+ multiplexer,
17
20
  },
18
21
  };
19
22
  }
@@ -19,6 +19,20 @@ import {
19
19
  withSessionLock,
20
20
  } from './state-store.js';
21
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
+
22
36
  function buildRuntimePrompt({ state, role }) {
23
37
  const roleContext = ROLE_SYSTEM_PROMPTS[role] || '';
24
38
  const collaborativePrompt = buildAgentPrompt({
@@ -63,6 +77,13 @@ function ensureRunState(state) {
63
77
  round: state.round || 1,
64
78
  events: [],
65
79
  finalSummary: null,
80
+ sharedContext: {
81
+ decisions: [],
82
+ openIssues: [],
83
+ risks: [],
84
+ actionItems: [],
85
+ notes: [],
86
+ },
66
87
  lastError: null,
67
88
  policy: {
68
89
  timeoutPerRoleMs: 180000,
@@ -141,6 +162,7 @@ export async function runTurn(sessionId, options = {}) {
141
162
  if (shouldStop(state)) {
142
163
  if (state.status === 'running') {
143
164
  state = await stopSession(state, 'completed');
165
+ state = await finalizeSessionArtifacts(state);
144
166
  }
145
167
  return state;
146
168
  }
@@ -178,6 +200,7 @@ export async function runTurn(sessionId, options = {}) {
178
200
 
179
201
  if (shouldStop(state)) {
180
202
  state = await stopSession(state, 'completed');
203
+ state = await finalizeSessionArtifacts(state);
181
204
  }
182
205
 
183
206
  return state;
@@ -244,6 +267,7 @@ export async function executeActiveTurn(sessionId, role, options = {}) {
244
267
 
245
268
  if (shouldStop(state)) {
246
269
  state = await stopSession(state, 'completed');
270
+ state = await finalizeSessionArtifacts(state);
247
271
  }
248
272
 
249
273
  return state;
@@ -332,6 +356,9 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
332
356
  events: outputEvents,
333
357
  }));
334
358
  state = await saveSessionState(applyRoleFailurePolicy(state, role, errorMessage));
359
+ if (state.status === 'failed') {
360
+ state = await finalizeSessionArtifacts(state);
361
+ }
335
362
  return state;
336
363
  }
337
364
 
@@ -360,8 +387,6 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
360
387
 
361
388
  if (shouldStop(state)) {
362
389
  state = await stopSession(state, 'completed');
363
- const summaryMd = buildMeetingSummary(state.messages, ensureRunState(state), ensureRunState(state).sharedContext);
364
- await writeMeetingSummary(sessionId, summaryMd);
365
390
  const finalRun = appendRunEvent({
366
391
  ...ensureRunState(state),
367
392
  status: 'completed',
@@ -372,6 +397,7 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
372
397
  ...state,
373
398
  run: finalRun,
374
399
  });
400
+ state = await finalizeSessionArtifacts(state);
375
401
  }
376
402
 
377
403
  return state;
@@ -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
+ }
@@ -73,6 +73,8 @@ function initialState(task, options = {}) {
73
73
  model: options.model || null,
74
74
  roleModels: sanitizeRoleModels(options.roleModels),
75
75
  opencodeBin: options.opencodeBin || null,
76
+ multiplexer: options.multiplexer || 'auto',
77
+ multiplexerSessionName: options.multiplexerSessionName || null,
76
78
  tmuxSessionName: options.tmuxSessionName || null,
77
79
  run: createRunState(options.runPolicy, Number.isInteger(options.maxRounds) ? options.maxRounds : DEFAULT_MAX_ROUNDS),
78
80
  messages: [
@@ -208,6 +210,8 @@ export async function listSessions(limit = 20) {
208
210
  round: state.round,
209
211
  maxRounds: state.maxRounds,
210
212
  tmuxSessionName: state.tmuxSessionName || null,
213
+ multiplexer: state.multiplexer || 'auto',
214
+ multiplexerSessionName: state.multiplexerSessionName || null,
211
215
  createdAt: state.createdAt,
212
216
  updatedAt: state.updatedAt,
213
217
  mtime: stateStats.mtimeMs,
@@ -26,8 +26,18 @@ import {
26
26
  saveSessionState,
27
27
  setCurrentSession,
28
28
  stopSession,
29
+ writeMeetingSummary,
29
30
  } from '../agents/state-store.js';
30
- import { roleLogPath, runTmux, spawnTmuxSession, tmuxSessionExists } from '../agents/runtime.js';
31
+ import {
32
+ roleLogPath,
33
+ runTmux,
34
+ runZellij,
35
+ spawnTmuxSession,
36
+ spawnZellijSession,
37
+ tmuxSessionExists,
38
+ zellijSessionExists,
39
+ resolveMultiplexer,
40
+ } from '../agents/runtime.js';
31
41
  import { getAgentsConfigPath, loadAgentsConfig, updateAgentsConfig } from '../agents/config-store.js';
32
42
  import {
33
43
  buildEffectiveRoleModels,
@@ -60,18 +70,53 @@ async function ensureSessionId(required = true) {
60
70
  function printStartSummary(state) {
61
71
  console.log(chalk.green(`✓ ${COLLAB_SYSTEM_NAME} session started`));
62
72
  console.log(chalk.dim(` Session: ${state.sessionId}`));
63
- console.log(chalk.dim(` tmux: ${state.tmuxSessionName}`));
73
+ const multiplexer = state.multiplexer || 'auto';
74
+ const muxSessionName = state.multiplexerSessionName || state.tmuxSessionName || '-';
75
+ console.log(chalk.dim(` Multiplexer: ${multiplexer}`));
76
+ console.log(chalk.dim(` Session name: ${muxSessionName}`));
64
77
  console.log(chalk.dim(` Task: ${state.task}`));
65
78
  console.log(chalk.dim(` Roles: ${state.roles.join(', ')}`));
66
79
  console.log();
67
80
  console.log(chalk.cyan('Attach with:'));
68
- console.log(chalk.white(` tmux attach -t ${state.tmuxSessionName}`));
81
+ if (multiplexer === 'zellij') {
82
+ console.log(chalk.white(` zellij attach ${muxSessionName}`));
83
+ } else {
84
+ console.log(chalk.white(` tmux attach -t ${muxSessionName}`));
85
+ }
69
86
  console.log(chalk.white(' acfm agents live'));
70
87
  console.log();
71
88
  console.log(chalk.cyan('Interact with:'));
72
89
  console.log(chalk.white(' acfm agents send "your message"'));
73
90
  }
74
91
 
92
+ function validateMultiplexer(value) {
93
+ const normalized = String(value || '').trim().toLowerCase();
94
+ if (!['auto', 'zellij', 'tmux'].includes(normalized)) {
95
+ throw new Error('--mux must be one of: auto|zellij|tmux');
96
+ }
97
+ return normalized;
98
+ }
99
+
100
+ function sessionMuxName(state) {
101
+ return state.multiplexerSessionName || state.tmuxSessionName || `acfm-synapse-${state.sessionId.slice(0, 8)}`;
102
+ }
103
+
104
+ async function sessionExistsForMux(multiplexer, sessionName) {
105
+ if (multiplexer === 'zellij') return zellijSessionExists(sessionName);
106
+ return tmuxSessionExists(sessionName);
107
+ }
108
+
109
+ async function attachToMux(multiplexer, sessionName, readonly = false) {
110
+ if (multiplexer === 'zellij') {
111
+ await runZellij(['attach', sessionName], { stdio: 'inherit' });
112
+ return;
113
+ }
114
+ const args = ['attach'];
115
+ if (readonly) args.push('-r');
116
+ args.push('-t', sessionName);
117
+ await runTmux('tmux', args, { stdio: 'inherit' });
118
+ }
119
+
75
120
  function toMarkdownTranscript(state, transcript) {
76
121
  const displayedRound = Math.min(state.round, state.maxRounds);
77
122
  const lines = [
@@ -181,12 +226,51 @@ export function agentsCommand() {
181
226
  const agents = new Command('agents')
182
227
  .description(`${COLLAB_SYSTEM_NAME} — collaborative multi-agent system powered by OpenCode`);
183
228
 
229
+ agents.addHelpText('after', `
230
+ Examples:
231
+ acfm agents start --task "Implement auth flow" --mux auto
232
+ acfm agents setup
233
+ acfm agents runtime get
234
+ acfm agents runtime set zellij
235
+ acfm agents model choose
236
+ acfm agents model list
237
+ acfm agents transcript --role all --limit 40
238
+ acfm agents summary
239
+ acfm agents export --format md --out ./session.md
240
+ `);
241
+
184
242
  agents
185
243
  .command('setup')
186
- .description('Install optional collaboration dependencies (OpenCode + tmux)')
244
+ .description('Install optional collaboration dependencies (OpenCode + zellij/tmux)')
245
+ .option('--yes', 'Install dependencies without interactive confirmation', false)
187
246
  .option('--json', 'Output as JSON')
188
247
  .action(async (opts) => {
189
- const result = ensureCollabDependencies();
248
+ let installZellij = true;
249
+ let installTmux = true;
250
+
251
+ if (!opts.yes && !opts.json) {
252
+ const answers = await inquirer.prompt([
253
+ {
254
+ type: 'confirm',
255
+ name: 'installZellij',
256
+ message: 'Install zellij (recommended, multiplatform backend)?',
257
+ default: true,
258
+ },
259
+ {
260
+ type: 'confirm',
261
+ name: 'installTmux',
262
+ message: 'Install tmux as fallback backend?',
263
+ default: true,
264
+ },
265
+ ]);
266
+ installZellij = Boolean(answers.installZellij);
267
+ installTmux = Boolean(answers.installTmux);
268
+ }
269
+
270
+ const result = ensureCollabDependencies({
271
+ installZellij,
272
+ installTmux,
273
+ });
190
274
  let collabMcp = null;
191
275
 
192
276
  if (result.success) {
@@ -199,7 +283,9 @@ export function agentsCommand() {
199
283
  if (!opts.json) {
200
284
  const oLabel = result.opencode.success ? chalk.green('ok') : chalk.red('failed');
201
285
  const tLabel = result.tmux.success ? chalk.green('ok') : chalk.red('failed');
286
+ const zLabel = result.zellij.success ? chalk.green('ok') : chalk.red('failed');
202
287
  console.log(`OpenCode: ${oLabel} - ${result.opencode.message}`);
288
+ console.log(`zellij: ${zLabel} - ${result.zellij.message}`);
203
289
  console.log(`tmux: ${tLabel} - ${result.tmux.message}`);
204
290
  if (collabMcp) {
205
291
  console.log(`Collab MCP: ${chalk.green('ok')} - installed ${collabMcp.success}/${collabMcp.installed} on detected assistants`);
@@ -286,10 +372,10 @@ export function agentsCommand() {
286
372
  }
287
373
  console.log(chalk.bold('SynapseGrid Sessions'));
288
374
  for (const item of sessions) {
289
- console.log(
375
+ console.log(
290
376
  `${chalk.cyan(item.sessionId.slice(0, 8))} ${item.status.padEnd(10)} ` +
291
377
  `round ${String(item.round).padStart(2)}/${String(item.maxRounds).padEnd(2)} ` +
292
- `${item.tmuxSessionName || '-'} ${item.task}`
378
+ `${item.multiplexer || 'auto'}:${item.multiplexerSessionName || item.tmuxSessionName || '-'} ${item.task}`
293
379
  );
294
380
  }
295
381
  }
@@ -302,15 +388,17 @@ export function agentsCommand() {
302
388
 
303
389
  agents
304
390
  .command('attach')
305
- .description('Attach terminal to active SynapseGrid tmux session')
391
+ .description('Attach terminal to active SynapseGrid multiplexer session')
306
392
  .action(async () => {
307
393
  try {
308
394
  const sessionId = await ensureSessionId(true);
309
395
  const state = await loadSessionState(sessionId);
310
- if (!state.tmuxSessionName) {
311
- throw new Error('No tmux session registered for active collaborative session');
396
+ const multiplexer = state.multiplexer || 'tmux';
397
+ const muxSessionName = sessionMuxName(state);
398
+ if (!muxSessionName) {
399
+ throw new Error('No multiplexer session registered for active collaborative session');
312
400
  }
313
- await runTmux('tmux', ['attach', '-t', state.tmuxSessionName], { stdio: 'inherit' });
401
+ await attachToMux(multiplexer, muxSessionName, false);
314
402
  } catch (error) {
315
403
  console.error(chalk.red(`Error: ${error.message}`));
316
404
  process.exit(1);
@@ -319,23 +407,22 @@ export function agentsCommand() {
319
407
 
320
408
  agents
321
409
  .command('live')
322
- .description('Attach to live tmux collaboration view (all agent panes)')
410
+ .description('Attach to live collaboration view (all agent panes)')
323
411
  .option('--readonly', 'Attach in read-only mode', false)
324
412
  .action(async (opts) => {
325
413
  try {
326
414
  const sessionId = await ensureSessionId(true);
327
415
  const state = await loadSessionState(sessionId);
328
- if (!state.tmuxSessionName) {
329
- throw new Error('No tmux session registered for active collaborative session');
416
+ const multiplexer = state.multiplexer || 'tmux';
417
+ const muxSessionName = sessionMuxName(state);
418
+ if (!muxSessionName) {
419
+ throw new Error('No multiplexer session registered for active collaborative session');
330
420
  }
331
- const tmuxExists = await tmuxSessionExists(state.tmuxSessionName);
332
- if (!tmuxExists) {
333
- throw new Error(`tmux session ${state.tmuxSessionName} no longer exists. Run: acfm agents resume`);
421
+ const sessionExists = await sessionExistsForMux(multiplexer, muxSessionName);
422
+ if (!sessionExists) {
423
+ throw new Error(`${multiplexer} session ${muxSessionName} no longer exists. Run: acfm agents resume`);
334
424
  }
335
- const args = ['attach'];
336
- if (opts.readonly) args.push('-r');
337
- args.push('-t', state.tmuxSessionName);
338
- await runTmux('tmux', args, { stdio: 'inherit' });
425
+ await attachToMux(multiplexer, muxSessionName, Boolean(opts.readonly));
339
426
  } catch (error) {
340
427
  console.error(chalk.red(`Error: ${error.message}`));
341
428
  process.exit(1);
@@ -344,48 +431,60 @@ export function agentsCommand() {
344
431
 
345
432
  agents
346
433
  .command('resume')
347
- .description('Resume a previous session and optionally recreate tmux workers')
434
+ .description('Resume a previous session and optionally recreate multiplexer workers')
348
435
  .option('--session <id>', 'Session ID to resume (defaults to current)')
349
- .option('--no-recreate', 'Do not recreate tmux session/workers when missing')
350
- .option('--no-attach', 'Do not attach tmux after resume')
436
+ .option('--no-recreate', 'Do not recreate multiplexer session/workers when missing')
437
+ .option('--no-attach', 'Do not attach multiplexer after resume')
351
438
  .option('--json', 'Output as JSON')
352
439
  .action(async (opts) => {
353
440
  try {
354
441
  const sessionId = opts.session || await ensureSessionId(true);
355
442
  let state = await loadSessionState(sessionId);
443
+ const multiplexer = state.multiplexer || 'tmux';
356
444
 
357
- const tmuxSessionName = state.tmuxSessionName || `acfm-synapse-${state.sessionId.slice(0, 8)}`;
358
- const tmuxExists = hasCommand('tmux') ? await tmuxSessionExists(tmuxSessionName) : false;
445
+ if (multiplexer === 'zellij' && !hasCommand('zellij')) {
446
+ throw new Error('zellij is not installed. Run: acfm agents setup');
447
+ }
448
+ if (multiplexer === 'tmux' && !hasCommand('tmux')) {
449
+ throw new Error('tmux is not installed. Run: acfm agents setup');
450
+ }
359
451
 
360
- if (!tmuxExists && opts.recreate) {
361
- if (!hasCommand('tmux')) {
362
- throw new Error('tmux is not installed. Run: acfm agents setup');
363
- }
452
+ const muxSessionName = sessionMuxName(state);
453
+ const muxExists = await sessionExistsForMux(multiplexer, muxSessionName);
454
+
455
+ if (!muxExists && opts.recreate) {
364
456
  const sessionDir = getSessionDir(state.sessionId);
365
- await spawnTmuxSession({ sessionName: tmuxSessionName, sessionDir, sessionId: state.sessionId });
457
+ if (multiplexer === 'zellij') {
458
+ await spawnZellijSession({ sessionName: muxSessionName, sessionDir, sessionId: state.sessionId });
459
+ } else {
460
+ await spawnTmuxSession({ sessionName: muxSessionName, sessionDir, sessionId: state.sessionId });
461
+ }
366
462
  }
367
463
 
368
464
  state = await saveSessionState({
369
465
  ...state,
370
466
  status: 'running',
371
- tmuxSessionName,
467
+ multiplexer,
468
+ multiplexerSessionName: muxSessionName,
469
+ tmuxSessionName: multiplexer === 'tmux' ? muxSessionName : (state.tmuxSessionName || null),
372
470
  });
373
471
  await setCurrentSession(state.sessionId);
374
472
 
375
473
  output({
376
474
  sessionId: state.sessionId,
377
475
  status: state.status,
378
- tmuxSessionName,
379
- recreatedTmux: !tmuxExists && Boolean(opts.recreate),
476
+ multiplexer,
477
+ multiplexerSessionName: muxSessionName,
478
+ recreatedSession: !muxExists && Boolean(opts.recreate),
380
479
  }, opts.json);
381
480
 
382
481
  if (!opts.json) {
383
482
  console.log(chalk.green(`✓ Resumed session ${state.sessionId}`));
384
- console.log(chalk.dim(` tmux: ${tmuxSessionName}`));
483
+ console.log(chalk.dim(` ${multiplexer}: ${muxSessionName}`));
385
484
  }
386
485
 
387
486
  if (opts.attach) {
388
- await runTmux('tmux', ['attach', '-t', tmuxSessionName], { stdio: 'inherit' });
487
+ await attachToMux(multiplexer, muxSessionName, false);
389
488
  }
390
489
  } catch (error) {
391
490
  output({ error: error.message }, opts.json);
@@ -453,6 +552,77 @@ export function agentsCommand() {
453
552
  .command('model')
454
553
  .description('Manage default SynapseGrid model configuration');
455
554
 
555
+ const runtime = agents
556
+ .command('runtime')
557
+ .description('Manage SynapseGrid runtime backend settings');
558
+
559
+ runtime
560
+ .command('get')
561
+ .description('Show configured multiplexer backend')
562
+ .option('--json', 'Output as JSON')
563
+ .action(async (opts) => {
564
+ try {
565
+ const cfg = await loadAgentsConfig();
566
+ const configured = validateMultiplexer(cfg.agents.multiplexer || 'auto');
567
+ const resolved = resolveMultiplexer(configured, hasCommand('tmux'), hasCommand('zellij'));
568
+ const payload = {
569
+ configPath: getAgentsConfigPath(),
570
+ multiplexer: configured,
571
+ resolved,
572
+ available: {
573
+ zellij: hasCommand('zellij'),
574
+ tmux: hasCommand('tmux'),
575
+ },
576
+ };
577
+ output(payload, opts.json);
578
+ if (!opts.json) {
579
+ console.log(chalk.bold('SynapseGrid runtime backend'));
580
+ console.log(chalk.dim(`Config: ${payload.configPath}`));
581
+ console.log(chalk.dim(`Configured: ${configured}`));
582
+ console.log(chalk.dim(`Resolved: ${resolved || 'none'}`));
583
+ console.log(chalk.dim(`zellij=${payload.available.zellij} tmux=${payload.available.tmux}`));
584
+ }
585
+ } catch (error) {
586
+ output({ error: error.message }, opts.json);
587
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
588
+ process.exit(1);
589
+ }
590
+ });
591
+
592
+ runtime
593
+ .command('set <mux>')
594
+ .description('Set multiplexer backend: auto|zellij|tmux')
595
+ .option('--json', 'Output as JSON')
596
+ .action(async (mux, opts) => {
597
+ try {
598
+ const selected = validateMultiplexer(mux);
599
+ const updated = await updateAgentsConfig((current) => ({
600
+ agents: {
601
+ defaultModel: current.agents.defaultModel,
602
+ defaultRoleModels: { ...current.agents.defaultRoleModels },
603
+ multiplexer: selected,
604
+ },
605
+ }));
606
+ const resolved = resolveMultiplexer(updated.agents.multiplexer, hasCommand('tmux'), hasCommand('zellij'));
607
+ const payload = {
608
+ success: true,
609
+ configPath: getAgentsConfigPath(),
610
+ multiplexer: updated.agents.multiplexer,
611
+ resolved,
612
+ };
613
+ output(payload, opts.json);
614
+ if (!opts.json) {
615
+ console.log(chalk.green('✓ SynapseGrid runtime backend updated'));
616
+ console.log(chalk.dim(` Configured: ${payload.multiplexer}`));
617
+ console.log(chalk.dim(` Resolved: ${payload.resolved || 'none'}`));
618
+ }
619
+ } catch (error) {
620
+ output({ error: error.message }, opts.json);
621
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
622
+ process.exit(1);
623
+ }
624
+ });
625
+
456
626
  model
457
627
  .command('list')
458
628
  .description('List available OpenCode models grouped by provider')
@@ -510,6 +680,7 @@ export function agentsCommand() {
510
680
  configPath: getAgentsConfigPath(),
511
681
  defaultModel: config.agents.defaultModel,
512
682
  defaultRoleModels: config.agents.defaultRoleModels,
683
+ multiplexer: config.agents.multiplexer,
513
684
  };
514
685
  output(payload, opts.json);
515
686
  if (!opts.json) {
@@ -519,6 +690,7 @@ export function agentsCommand() {
519
690
  for (const role of COLLAB_ROLES) {
520
691
  console.log(chalk.dim(`- ${role}: ${payload.defaultRoleModels[role] || '(none)'}`));
521
692
  }
693
+ console.log(chalk.dim(`Multiplexer: ${payload.multiplexer || 'auto'}`));
522
694
  }
523
695
  } catch (error) {
524
696
  output({ error: error.message }, opts.json);
@@ -594,6 +766,7 @@ export function agentsCommand() {
594
766
  agents: {
595
767
  defaultModel: current.agents.defaultModel,
596
768
  defaultRoleModels: { ...current.agents.defaultRoleModels },
769
+ multiplexer: current.agents.multiplexer || 'auto',
597
770
  },
598
771
  };
599
772
 
@@ -654,6 +827,7 @@ export function agentsCommand() {
654
827
  agents: {
655
828
  defaultModel: current.agents.defaultModel,
656
829
  defaultRoleModels: { ...current.agents.defaultRoleModels },
830
+ multiplexer: current.agents.multiplexer || 'auto',
657
831
  },
658
832
  };
659
833
  if (role === 'all') {
@@ -702,6 +876,7 @@ export function agentsCommand() {
702
876
  agents: {
703
877
  defaultModel: current.agents.defaultModel,
704
878
  defaultRoleModels: { ...current.agents.defaultRoleModels },
879
+ multiplexer: current.agents.multiplexer || 'auto',
705
880
  },
706
881
  };
707
882
  if (role === 'all') {
@@ -865,17 +1040,15 @@ export function agentsCommand() {
865
1040
  .option('--model-critic <id>', 'Model for critic role (provider/model)')
866
1041
  .option('--model-coder <id>', 'Model for coder role (provider/model)')
867
1042
  .option('--model-reviewer <id>', 'Model for reviewer role (provider/model)')
1043
+ .option('--mux <name>', 'Multiplexer backend: auto|zellij|tmux')
868
1044
  .option('--cwd <path>', 'Working directory for agents', process.cwd())
869
- .option('--attach', 'Attach tmux immediately after start', false)
1045
+ .option('--attach', 'Attach multiplexer immediately after start', false)
870
1046
  .option('--json', 'Output as JSON')
871
1047
  .action(async (opts) => {
872
1048
  try {
873
1049
  if (!hasCommand('opencode')) {
874
1050
  throw new Error('OpenCode is not installed. Run: acfm agents setup');
875
1051
  }
876
- if (!hasCommand('tmux')) {
877
- throw new Error('tmux is not installed. Run: acfm agents setup');
878
- }
879
1052
  const opencodeBin = resolveCommandPath('opencode');
880
1053
  if (!opencodeBin) {
881
1054
  throw new Error('OpenCode binary not found. Run: acfm agents setup');
@@ -888,6 +1061,11 @@ export function agentsCommand() {
888
1061
  }
889
1062
 
890
1063
  const config = await loadAgentsConfig();
1064
+ const configuredMux = validateMultiplexer(opts.mux || config.agents.multiplexer || 'auto');
1065
+ const selectedMux = resolveMultiplexer(configuredMux, hasCommand('tmux'), hasCommand('zellij'));
1066
+ if (!selectedMux) {
1067
+ throw new Error('No multiplexer found. Install zellij or tmux with: acfm agents setup');
1068
+ }
891
1069
  const cliModel = assertValidModelIdOrNull('--model', opts.model || null);
892
1070
  const cliRoleModels = parseRoleModelOptions(opts);
893
1071
  for (const [role, model] of Object.entries(cliRoleModels)) {
@@ -922,30 +1100,47 @@ export function agentsCommand() {
922
1100
  maxRounds,
923
1101
  model: globalModel,
924
1102
  roleModels,
1103
+ multiplexer: selectedMux,
925
1104
  workingDirectory: resolve(opts.cwd),
926
1105
  opencodeBin,
927
1106
  });
928
- const tmuxSessionName = `acfm-synapse-${state.sessionId.slice(0, 8)}`;
1107
+ const muxSessionName = `acfm-synapse-${state.sessionId.slice(0, 8)}`;
929
1108
  const sessionDir = getSessionDir(state.sessionId);
1109
+
1110
+ if (selectedMux === 'zellij') {
1111
+ await spawnZellijSession({
1112
+ sessionName: muxSessionName,
1113
+ sessionDir,
1114
+ sessionId: state.sessionId,
1115
+ });
1116
+ } else {
1117
+ await spawnTmuxSession({
1118
+ sessionName: muxSessionName,
1119
+ sessionDir,
1120
+ sessionId: state.sessionId,
1121
+ });
1122
+ }
1123
+
930
1124
  const updated = await saveSessionState({
931
1125
  ...state,
932
- tmuxSessionName,
933
- });
934
-
935
- await spawnTmuxSession({
936
- sessionName: tmuxSessionName,
937
- sessionDir,
938
- sessionId: state.sessionId,
1126
+ multiplexer: selectedMux,
1127
+ multiplexerSessionName: muxSessionName,
1128
+ tmuxSessionName: selectedMux === 'tmux' ? muxSessionName : null,
939
1129
  });
940
1130
 
941
- output({ sessionId: updated.sessionId, tmuxSessionName, status: updated.status }, opts.json);
1131
+ output({
1132
+ sessionId: updated.sessionId,
1133
+ multiplexer: selectedMux,
1134
+ multiplexerSessionName: muxSessionName,
1135
+ status: updated.status,
1136
+ }, opts.json);
942
1137
  if (!opts.json) {
943
1138
  printStartSummary(updated);
944
1139
  printModelConfig(updated);
945
1140
  }
946
1141
 
947
1142
  if (opts.attach) {
948
- await runTmux('tmux', ['attach', '-t', tmuxSessionName], { stdio: 'inherit' });
1143
+ await attachToMux(selectedMux, muxSessionName, false);
949
1144
  }
950
1145
  } catch (error) {
951
1146
  output({ error: error.message }, opts.json);
@@ -996,14 +1191,16 @@ export function agentsCommand() {
996
1191
  console.log(chalk.dim(`Run error: ${summary.lastError.message}`));
997
1192
  }
998
1193
  console.log(chalk.dim(`Global model: ${state.model || '(opencode default)'}`));
1194
+ console.log(chalk.dim(`Multiplexer: ${state.multiplexer || 'auto'} (${sessionMuxName(state)})`));
999
1195
  for (const role of COLLAB_ROLES) {
1000
1196
  const configured = state.roleModels?.[role] || '-';
1001
1197
  const effective = effectiveRoleModels[role] || '(opencode default)';
1002
1198
  console.log(chalk.dim(` ${role.padEnd(8)} configured=${configured} effective=${effective}`));
1003
1199
  }
1004
- if (state.tmuxSessionName) {
1005
- console.log(chalk.dim(`tmux: ${state.tmuxSessionName}`));
1006
- }
1200
+ const meetingLogPath = resolve(getSessionDir(state.sessionId), 'meeting-log.md');
1201
+ const meetingSummaryPath = resolve(getSessionDir(state.sessionId), 'meeting-summary.md');
1202
+ console.log(chalk.dim(`meeting-log: ${existsSync(meetingLogPath) ? meetingLogPath : 'not generated yet'}`));
1203
+ console.log(chalk.dim(`meeting-summary: ${existsSync(meetingSummaryPath) ? meetingSummaryPath : 'not generated yet'}`));
1007
1204
  }
1008
1205
  } catch (error) {
1009
1206
  output({ error: error.message }, opts.json);
@@ -1020,6 +1217,7 @@ export function agentsCommand() {
1020
1217
  try {
1021
1218
  const sessionId = await ensureSessionId(true);
1022
1219
  let state = await loadSessionState(sessionId);
1220
+ const meetingSummaryPath = resolve(getSessionDir(state.sessionId), 'meeting-summary.md');
1023
1221
  state = await stopSession(state, 'stopped');
1024
1222
  if (state.run && state.run.status === 'running') {
1025
1223
  state = await saveSessionState({
@@ -1036,15 +1234,51 @@ export function agentsCommand() {
1036
1234
  },
1037
1235
  });
1038
1236
  }
1039
- if (state.tmuxSessionName && hasCommand('tmux')) {
1237
+
1238
+ if (!existsSync(meetingSummaryPath)) {
1239
+ const fallbackSummary = [
1240
+ '# SynapseGrid Meeting Summary',
1241
+ '',
1242
+ `Session: ${state.sessionId}`,
1243
+ `Status: ${state.status}`,
1244
+ '',
1245
+ 'This summary was auto-generated at stop time because the run did not complete normally.',
1246
+ '',
1247
+ '## Last message',
1248
+ state.messages?.[state.messages.length - 1]?.content || '(none)',
1249
+ '',
1250
+ ].join('\n');
1251
+ await writeMeetingSummary(state.sessionId, fallbackSummary);
1252
+ if (state.run && !state.run.finalSummary) {
1253
+ state = await saveSessionState({
1254
+ ...state,
1255
+ run: {
1256
+ ...state.run,
1257
+ finalSummary: fallbackSummary,
1258
+ },
1259
+ });
1260
+ }
1261
+ }
1262
+
1263
+ const multiplexer = state.multiplexer || 'tmux';
1264
+ const muxSessionName = sessionMuxName(state);
1265
+ if (multiplexer === 'zellij' && muxSessionName && hasCommand('zellij')) {
1040
1266
  try {
1041
- await runTmux('tmux', ['kill-session', '-t', state.tmuxSessionName]);
1267
+ await runZellij(['delete-session', muxSessionName]);
1268
+ } catch {
1269
+ // ignore if already closed
1270
+ }
1271
+ }
1272
+ if (multiplexer === 'tmux' && muxSessionName && hasCommand('tmux')) {
1273
+ try {
1274
+ await runTmux('tmux', ['kill-session', '-t', muxSessionName]);
1042
1275
  } catch {
1043
1276
  // ignore if already closed
1044
1277
  }
1045
1278
  }
1046
1279
  output({ sessionId: state.sessionId, status: state.status }, opts.json);
1047
1280
  if (!opts.json) console.log(chalk.green('✓ Collaborative session stopped'));
1281
+
1048
1282
  } catch (error) {
1049
1283
  output({ error: error.message }, opts.json);
1050
1284
  if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
@@ -1142,11 +1376,17 @@ export function agentsCommand() {
1142
1376
  try {
1143
1377
  const opencodeBin = resolveCommandPath('opencode');
1144
1378
  const tmuxInstalled = hasCommand('tmux');
1379
+ const zellijInstalled = hasCommand('zellij');
1145
1380
  const cfg = await loadAgentsConfig();
1146
1381
  const defaultModel = cfg.agents.defaultModel || DEFAULT_SYNAPSE_MODEL;
1382
+ const configuredMux = validateMultiplexer(cfg.agents.multiplexer || 'auto');
1383
+ const resolvedMux = resolveMultiplexer(configuredMux, tmuxInstalled, zellijInstalled);
1147
1384
  const result = {
1148
1385
  opencodeBin,
1149
1386
  tmuxInstalled,
1387
+ zellijInstalled,
1388
+ configuredMultiplexer: configuredMux,
1389
+ resolvedMultiplexer: resolvedMux,
1150
1390
  defaultModel,
1151
1391
  defaultRoleModels: cfg.agents.defaultRoleModels,
1152
1392
  preflight: null,
@@ -1166,12 +1406,14 @@ export function agentsCommand() {
1166
1406
  if (!opts.json) {
1167
1407
  console.log(chalk.bold('SynapseGrid doctor'));
1168
1408
  console.log(chalk.dim(`opencode: ${opencodeBin || 'not found'}`));
1409
+ console.log(chalk.dim(`zellij: ${zellijInstalled ? 'installed' : 'not installed'}`));
1169
1410
  console.log(chalk.dim(`tmux: ${tmuxInstalled ? 'installed' : 'not installed'}`));
1411
+ console.log(chalk.dim(`multiplexer: configured=${configuredMux} resolved=${resolvedMux || 'none'}`));
1170
1412
  console.log(chalk.dim(`default model: ${defaultModel}`));
1171
1413
  console.log(chalk.dim(`preflight: ${result.preflight?.ok ? 'ok' : `failed - ${result.preflight?.error || 'unknown error'}`}`));
1172
1414
  }
1173
1415
 
1174
- if (!result.preflight?.ok) process.exit(1);
1416
+ if (!result.preflight?.ok || !result.resolvedMultiplexer) process.exit(1);
1175
1417
  } catch (error) {
1176
1418
  output({ error: error.message }, opts.json);
1177
1419
  if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
@@ -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,13 @@ 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 = ensureCollabDependencies({ installZellij: true, installTmux: true });
238
240
 
239
241
  const oColor = result.opencode.success ? chalk.hex('#00B894') : chalk.hex('#D63031');
242
+ const zColor = result.zellij.success ? chalk.hex('#00B894') : chalk.hex('#D63031');
240
243
  const tColor = result.tmux.success ? chalk.hex('#00B894') : chalk.hex('#D63031');
241
244
  console.log(oColor(` ◆ OpenCode: ${result.opencode.message}`));
245
+ console.log(zColor(` ◆ zellij: ${result.zellij.message}`));
242
246
  console.log(tColor(` ◆ tmux: ${result.tmux.message}`));
243
247
  console.log();
244
248
 
@@ -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,
@@ -29,6 +35,7 @@ import {
29
35
  stopSession,
30
36
  } from '../agents/state-store.js';
31
37
  import { hasCommand, resolveCommandPath } 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,11 @@ function launchAutopilot(sessionId) {
67
74
  child.unref();
68
75
  }
69
76
 
77
+ async function muxExists(multiplexer, sessionName) {
78
+ if (multiplexer === 'zellij') return zellijSessionExists(sessionName);
79
+ return tmuxSessionExists(sessionName);
80
+ }
81
+
70
82
  class MCPCollabServer {
71
83
  constructor() {
72
84
  this.server = new McpServer({
@@ -92,7 +104,7 @@ class MCPCollabServer {
92
104
  reviewer: z.string().optional(),
93
105
  }).partial().optional().describe('Optional per-role models (provider/model)'),
94
106
  cwd: z.string().optional().describe('Working directory for agents'),
95
- spawnWorkers: z.boolean().default(true).describe('Create tmux workers and panes'),
107
+ spawnWorkers: z.boolean().default(true).describe('Create multiplexer workers and panes'),
96
108
  runPolicy: z.object({
97
109
  timeoutPerRoleMs: z.number().int().positive().optional(),
98
110
  retryOnTimeout: z.number().int().min(0).optional(),
@@ -107,8 +119,11 @@ class MCPCollabServer {
107
119
  throw new Error('OpenCode binary not found in PATH. Run: acfm agents setup');
108
120
  }
109
121
 
110
- if (spawnWorkers && !hasCommand('tmux')) {
111
- throw new Error('tmux is not installed. Run: acfm agents setup');
122
+ const config = await loadAgentsConfig();
123
+ const configuredMux = config.agents.multiplexer || 'auto';
124
+ const multiplexer = resolveMultiplexer(configuredMux, hasCommand('tmux'), hasCommand('zellij'));
125
+ if (spawnWorkers && !multiplexer) {
126
+ throw new Error('No multiplexer found (zellij/tmux). Run: acfm agents setup');
112
127
  }
113
128
 
114
129
  const state = await createSession(task, {
@@ -119,18 +134,31 @@ class MCPCollabServer {
119
134
  workingDirectory,
120
135
  opencodeBin,
121
136
  runPolicy,
137
+ multiplexer: multiplexer || configuredMux,
122
138
  });
123
139
  let updated = state;
124
140
  if (spawnWorkers) {
125
- const tmuxSessionName = `acfm-synapse-${state.sessionId.slice(0, 8)}`;
141
+ const sessionName = `acfm-synapse-${state.sessionId.slice(0, 8)}`;
126
142
  const sessionDir = getSessionDir(state.sessionId);
127
- await spawnTmuxSession({ sessionName: tmuxSessionName, sessionDir, sessionId: state.sessionId });
128
- updated = await saveSessionState({ ...state, tmuxSessionName });
143
+ if (multiplexer === 'zellij') {
144
+ await spawnZellijSession({ sessionName, sessionDir, sessionId: state.sessionId });
145
+ } else {
146
+ await spawnTmuxSession({ sessionName, sessionDir, sessionId: state.sessionId });
147
+ }
148
+ updated = await saveSessionState({
149
+ ...state,
150
+ multiplexer,
151
+ multiplexerSessionName: sessionName,
152
+ tmuxSessionName: multiplexer === 'tmux' ? sessionName : null,
153
+ });
129
154
  }
130
155
  await setCurrentSession(state.sessionId);
131
156
 
132
- const tmuxSessionName = updated.tmuxSessionName || null;
133
- const attachCommand = tmuxSessionName ? `tmux attach -t ${tmuxSessionName}` : null;
157
+ const mux = updated.multiplexer || null;
158
+ const muxSessionName = updated.multiplexerSessionName || updated.tmuxSessionName || null;
159
+ const attachCommand = muxSessionName
160
+ ? (mux === 'zellij' ? `zellij attach ${muxSessionName}` : `tmux attach -t ${muxSessionName}`)
161
+ : null;
134
162
  return {
135
163
  content: [{
136
164
  type: 'text',
@@ -142,7 +170,8 @@ class MCPCollabServer {
142
170
  roleModels: updated.roleModels || {},
143
171
  effectiveRoleModels: buildEffectiveRoleModels(updated, updated.model || null),
144
172
  run: summarizeRun(updated),
145
- tmuxSessionName,
173
+ multiplexer: mux,
174
+ multiplexerSessionName: muxSessionName,
146
175
  attachCommand,
147
176
  }, null, 2),
148
177
  }],
@@ -169,7 +198,7 @@ class MCPCollabServer {
169
198
  throw new Error(`Session is ${state.status}. Resume/start before invoking.`);
170
199
  }
171
200
 
172
- if (!state.tmuxSessionName) {
201
+ if (!state.multiplexerSessionName && !state.tmuxSessionName) {
173
202
  launchAutopilot(state.sessionId);
174
203
  }
175
204
 
@@ -193,8 +222,13 @@ class MCPCollabServer {
193
222
  status: state.status,
194
223
  run: summarizeRun(state),
195
224
  latestEvent: latestRunEvent(state),
196
- tmuxSessionName: state.tmuxSessionName || null,
197
- attachCommand: state.tmuxSessionName ? `tmux attach -t ${state.tmuxSessionName}` : null,
225
+ multiplexer: state.multiplexer || null,
226
+ multiplexerSessionName: state.multiplexerSessionName || state.tmuxSessionName || null,
227
+ attachCommand: state.multiplexerSessionName
228
+ ? (state.multiplexer === 'zellij'
229
+ ? `zellij attach ${state.multiplexerSessionName}`
230
+ : `tmux attach -t ${state.multiplexerSessionName}`)
231
+ : (state.tmuxSessionName ? `tmux attach -t ${state.tmuxSessionName}` : null),
198
232
  }, null, 2),
199
233
  }],
200
234
  };
@@ -408,10 +442,10 @@ class MCPCollabServer {
408
442
 
409
443
  this.server.tool(
410
444
  'collab_resume_session',
411
- 'Resume session and recreate tmux workers if needed',
445
+ 'Resume session and recreate workers if needed',
412
446
  {
413
447
  sessionId: z.string().optional().describe('Session ID (defaults to current session)'),
414
- recreateWorkers: z.boolean().default(true).describe('Recreate tmux session when missing'),
448
+ recreateWorkers: z.boolean().default(true).describe('Recreate multiplexer session when missing'),
415
449
  },
416
450
  async ({ sessionId, recreateWorkers }) => {
417
451
  try {
@@ -419,24 +453,37 @@ class MCPCollabServer {
419
453
  if (!id) throw new Error('No active session found');
420
454
  let state = await loadSessionState(id);
421
455
 
422
- const tmuxSessionName = state.tmuxSessionName || `acfm-synapse-${state.sessionId.slice(0, 8)}`;
423
- const tmuxExists = hasCommand('tmux') ? await tmuxSessionExists(tmuxSessionName) : false;
456
+ const multiplexer = state.multiplexer || resolveMultiplexer('auto', hasCommand('tmux'), hasCommand('zellij'));
457
+ if (!multiplexer) {
458
+ throw new Error('No multiplexer found (zellij/tmux). Run: acfm agents setup');
459
+ }
460
+ const sessionName = state.multiplexerSessionName || state.tmuxSessionName || `acfm-synapse-${state.sessionId.slice(0, 8)}`;
461
+ const sessionExists = await muxExists(multiplexer, sessionName);
424
462
 
425
- if (!tmuxExists && recreateWorkers) {
426
- if (!hasCommand('tmux')) {
427
- throw new Error('tmux is not installed. Run: acfm agents setup');
428
- }
463
+ if (!sessionExists && recreateWorkers) {
429
464
  const sessionDir = getSessionDir(state.sessionId);
430
- await spawnTmuxSession({ sessionName: tmuxSessionName, sessionDir, sessionId: state.sessionId });
465
+ if (multiplexer === 'zellij') {
466
+ if (!hasCommand('zellij')) throw new Error('zellij is not installed. Run: acfm agents setup');
467
+ await spawnZellijSession({ sessionName, sessionDir, sessionId: state.sessionId });
468
+ } else {
469
+ if (!hasCommand('tmux')) throw new Error('tmux is not installed. Run: acfm agents setup');
470
+ await spawnTmuxSession({ sessionName, sessionDir, sessionId: state.sessionId });
471
+ }
431
472
  }
432
473
 
433
474
  state = await saveSessionState({
434
475
  ...state,
435
476
  status: 'running',
436
- tmuxSessionName,
477
+ multiplexer,
478
+ multiplexerSessionName: sessionName,
479
+ tmuxSessionName: multiplexer === 'tmux' ? sessionName : state.tmuxSessionName || null,
437
480
  });
438
481
  await setCurrentSession(state.sessionId);
439
482
 
483
+ const attachCommand = multiplexer === 'zellij'
484
+ ? `zellij attach ${sessionName}`
485
+ : `tmux attach -t ${sessionName}`;
486
+
440
487
  return {
441
488
  content: [{
442
489
  type: 'text',
@@ -444,8 +491,10 @@ class MCPCollabServer {
444
491
  success: true,
445
492
  sessionId: state.sessionId,
446
493
  status: state.status,
447
- tmuxSessionName,
448
- recreatedWorkers: !tmuxExists && recreateWorkers,
494
+ multiplexer,
495
+ multiplexerSessionName: sessionName,
496
+ recreatedWorkers: !sessionExists && recreateWorkers,
497
+ attachCommand,
449
498
  }, null, 2),
450
499
  }],
451
500
  };
@@ -17,6 +17,13 @@ function run(command, args, options = {}) {
17
17
  });
18
18
  }
19
19
 
20
+ function runInstallCommand(command) {
21
+ if (platform() === 'win32') {
22
+ return run('cmd.exe', ['/c', command], { stdio: 'inherit' });
23
+ }
24
+ return run('bash', ['-lc', command], { stdio: 'inherit' });
25
+ }
26
+
20
27
  export function hasCommand(command) {
21
28
  return Boolean(resolveCommandPath(command));
22
29
  }
@@ -78,6 +85,29 @@ function resolveTmuxInstallCommand() {
78
85
  return null;
79
86
  }
80
87
 
88
+ function resolveZellijInstallCommand() {
89
+ if (platform() === 'darwin') {
90
+ if (hasCommand('brew')) return 'brew install zellij';
91
+ return null;
92
+ }
93
+
94
+ if (platform() === 'linux') {
95
+ if (hasCommand('apt-get')) return 'sudo apt-get update && sudo apt-get install -y zellij';
96
+ if (hasCommand('dnf')) return 'sudo dnf install -y zellij';
97
+ if (hasCommand('yum')) return 'sudo yum install -y zellij';
98
+ if (hasCommand('pacman')) return 'sudo pacman -S --noconfirm zellij';
99
+ if (hasCommand('zypper')) return 'sudo zypper --non-interactive install zellij';
100
+ }
101
+
102
+ if (platform() === 'win32') {
103
+ if (hasCommand('winget')) return 'winget install --id zellij-org.zellij -e';
104
+ if (hasCommand('choco')) return 'choco install zellij -y';
105
+ if (hasCommand('scoop')) return 'scoop install zellij';
106
+ }
107
+
108
+ return null;
109
+ }
110
+
81
111
  export function installTmux() {
82
112
  if (hasCommand('tmux')) {
83
113
  return { success: true, installed: false, message: 'tmux already installed' };
@@ -92,7 +122,7 @@ export function installTmux() {
92
122
  };
93
123
  }
94
124
 
95
- const result = run('bash', ['-lc', installCommand], { stdio: 'inherit' });
125
+ const result = runInstallCommand(installCommand);
96
126
  if (result.status !== 0) {
97
127
  return { success: false, installed: false, message: 'tmux installation command failed' };
98
128
  }
@@ -106,12 +136,50 @@ export function installTmux() {
106
136
  };
107
137
  }
108
138
 
109
- export function ensureCollabDependencies() {
139
+ export function installZellij() {
140
+ if (hasCommand('zellij')) {
141
+ return { success: true, installed: false, message: 'zellij already installed' };
142
+ }
143
+
144
+ const installCommand = resolveZellijInstallCommand();
145
+ if (!installCommand) {
146
+ return {
147
+ success: false,
148
+ installed: false,
149
+ message: 'No supported package manager detected for automatic zellij installation',
150
+ };
151
+ }
152
+
153
+ const result = runInstallCommand(installCommand);
154
+ if (result.status !== 0) {
155
+ return { success: false, installed: false, message: 'zellij installation command failed' };
156
+ }
157
+
158
+ return {
159
+ success: hasCommand('zellij'),
160
+ installed: true,
161
+ message: hasCommand('zellij')
162
+ ? 'zellij installed successfully'
163
+ : 'zellij installer finished but binary is not available in PATH yet',
164
+ };
165
+ }
166
+
167
+ export function ensureCollabDependencies(options = {}) {
168
+ const installTmuxEnabled = options.installTmux ?? true;
169
+ const installZellijEnabled = options.installZellij ?? true;
110
170
  const opencode = installOpenCode();
111
- const tmux = installTmux();
171
+ const tmux = installTmuxEnabled
172
+ ? installTmux()
173
+ : { success: hasCommand('tmux'), installed: false, message: hasCommand('tmux') ? 'tmux already installed' : 'tmux installation skipped' };
174
+ const zellij = installZellijEnabled
175
+ ? installZellij()
176
+ : { success: hasCommand('zellij'), installed: false, message: hasCommand('zellij') ? 'zellij already installed' : 'zellij installation skipped' };
177
+
178
+ const hasMultiplexer = tmux.success || zellij.success;
112
179
  return {
113
180
  opencode,
114
181
  tmux,
115
- success: opencode.success && tmux.success,
182
+ zellij,
183
+ success: opencode.success && hasMultiplexer,
116
184
  };
117
185
  }