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,7 +1,8 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
+ import inquirer from 'inquirer';
3
4
  import { existsSync } from 'node:fs';
4
- import { mkdir, writeFile } from 'node:fs/promises';
5
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
6
  import { readFileSync } from 'node:fs';
6
7
  import { dirname, resolve } from 'node:path';
7
8
  import {
@@ -12,7 +13,7 @@ import {
12
13
  DEFAULT_SYNAPSE_MODEL,
13
14
  SESSION_ROOT_DIR,
14
15
  } from '../agents/constants.js';
15
- import { runOpenCodePrompt } from '../agents/opencode-client.js';
16
+ import { listOpenCodeModels, runOpenCodePrompt } from '../agents/opencode-client.js';
16
17
  import { runWorkerIteration } from '../agents/orchestrator.js';
17
18
  import {
18
19
  addUserMessage,
@@ -25,8 +26,18 @@ import {
25
26
  saveSessionState,
26
27
  setCurrentSession,
27
28
  stopSession,
29
+ writeMeetingSummary,
28
30
  } from '../agents/state-store.js';
29
- 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';
30
41
  import { getAgentsConfigPath, loadAgentsConfig, updateAgentsConfig } from '../agents/config-store.js';
31
42
  import {
32
43
  buildEffectiveRoleModels,
@@ -59,18 +70,53 @@ async function ensureSessionId(required = true) {
59
70
  function printStartSummary(state) {
60
71
  console.log(chalk.green(`✓ ${COLLAB_SYSTEM_NAME} session started`));
61
72
  console.log(chalk.dim(` Session: ${state.sessionId}`));
62
- 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}`));
63
77
  console.log(chalk.dim(` Task: ${state.task}`));
64
78
  console.log(chalk.dim(` Roles: ${state.roles.join(', ')}`));
65
79
  console.log();
66
80
  console.log(chalk.cyan('Attach with:'));
67
- 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
+ }
68
86
  console.log(chalk.white(' acfm agents live'));
69
87
  console.log();
70
88
  console.log(chalk.cyan('Interact with:'));
71
89
  console.log(chalk.white(' acfm agents send "your message"'));
72
90
  }
73
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
+
74
120
  function toMarkdownTranscript(state, transcript) {
75
121
  const displayedRound = Math.min(state.round, state.maxRounds);
76
122
  const lines = [
@@ -127,6 +173,39 @@ function printModelConfig(state) {
127
173
  }
128
174
  }
129
175
 
176
+ function groupModelsByProvider(models) {
177
+ const grouped = new Map();
178
+ for (const model of models) {
179
+ const [provider, ...rest] = model.split('/');
180
+ if (!provider || rest.length === 0) continue;
181
+ const modelName = rest.join('/');
182
+ if (!grouped.has(provider)) grouped.set(provider, []);
183
+ grouped.get(provider).push(modelName);
184
+ }
185
+ for (const [provider, modelNames] of grouped.entries()) {
186
+ grouped.set(provider, [...new Set(modelNames)].sort((a, b) => a.localeCompare(b)));
187
+ }
188
+ return grouped;
189
+ }
190
+
191
+ function runSummary(state) {
192
+ const run = state.run || {};
193
+ const events = Array.isArray(run.events) ? run.events.length : 0;
194
+ return {
195
+ status: run.status || 'idle',
196
+ runId: run.runId || null,
197
+ currentRole: run.currentRole || null,
198
+ lastError: run.lastError || null,
199
+ events,
200
+ };
201
+ }
202
+
203
+ async function readSessionArtifact(sessionId, filename) {
204
+ const path = resolve(getSessionDir(sessionId), filename);
205
+ if (!existsSync(path)) return null;
206
+ return readFile(path, 'utf8');
207
+ }
208
+
130
209
  async function preflightModel({ opencodeBin, model, cwd }) {
131
210
  const selected = normalizeModelId(model) || DEFAULT_SYNAPSE_MODEL;
132
211
  try {
@@ -147,12 +226,51 @@ export function agentsCommand() {
147
226
  const agents = new Command('agents')
148
227
  .description(`${COLLAB_SYSTEM_NAME} — collaborative multi-agent system powered by OpenCode`);
149
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
+
150
242
  agents
151
243
  .command('setup')
152
- .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)
153
246
  .option('--json', 'Output as JSON')
154
247
  .action(async (opts) => {
155
- 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
+ });
156
274
  let collabMcp = null;
157
275
 
158
276
  if (result.success) {
@@ -165,7 +283,9 @@ export function agentsCommand() {
165
283
  if (!opts.json) {
166
284
  const oLabel = result.opencode.success ? chalk.green('ok') : chalk.red('failed');
167
285
  const tLabel = result.tmux.success ? chalk.green('ok') : chalk.red('failed');
286
+ const zLabel = result.zellij.success ? chalk.green('ok') : chalk.red('failed');
168
287
  console.log(`OpenCode: ${oLabel} - ${result.opencode.message}`);
288
+ console.log(`zellij: ${zLabel} - ${result.zellij.message}`);
169
289
  console.log(`tmux: ${tLabel} - ${result.tmux.message}`);
170
290
  if (collabMcp) {
171
291
  console.log(`Collab MCP: ${chalk.green('ok')} - installed ${collabMcp.success}/${collabMcp.installed} on detected assistants`);
@@ -252,10 +372,10 @@ export function agentsCommand() {
252
372
  }
253
373
  console.log(chalk.bold('SynapseGrid Sessions'));
254
374
  for (const item of sessions) {
255
- console.log(
375
+ console.log(
256
376
  `${chalk.cyan(item.sessionId.slice(0, 8))} ${item.status.padEnd(10)} ` +
257
377
  `round ${String(item.round).padStart(2)}/${String(item.maxRounds).padEnd(2)} ` +
258
- `${item.tmuxSessionName || '-'} ${item.task}`
378
+ `${item.multiplexer || 'auto'}:${item.multiplexerSessionName || item.tmuxSessionName || '-'} ${item.task}`
259
379
  );
260
380
  }
261
381
  }
@@ -268,15 +388,17 @@ export function agentsCommand() {
268
388
 
269
389
  agents
270
390
  .command('attach')
271
- .description('Attach terminal to active SynapseGrid tmux session')
391
+ .description('Attach terminal to active SynapseGrid multiplexer session')
272
392
  .action(async () => {
273
393
  try {
274
394
  const sessionId = await ensureSessionId(true);
275
395
  const state = await loadSessionState(sessionId);
276
- if (!state.tmuxSessionName) {
277
- 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');
278
400
  }
279
- await runTmux('tmux', ['attach', '-t', state.tmuxSessionName], { stdio: 'inherit' });
401
+ await attachToMux(multiplexer, muxSessionName, false);
280
402
  } catch (error) {
281
403
  console.error(chalk.red(`Error: ${error.message}`));
282
404
  process.exit(1);
@@ -285,23 +407,22 @@ export function agentsCommand() {
285
407
 
286
408
  agents
287
409
  .command('live')
288
- .description('Attach to live tmux collaboration view (all agent panes)')
410
+ .description('Attach to live collaboration view (all agent panes)')
289
411
  .option('--readonly', 'Attach in read-only mode', false)
290
412
  .action(async (opts) => {
291
413
  try {
292
414
  const sessionId = await ensureSessionId(true);
293
415
  const state = await loadSessionState(sessionId);
294
- if (!state.tmuxSessionName) {
295
- 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');
296
420
  }
297
- const tmuxExists = await tmuxSessionExists(state.tmuxSessionName);
298
- if (!tmuxExists) {
299
- 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`);
300
424
  }
301
- const args = ['attach'];
302
- if (opts.readonly) args.push('-r');
303
- args.push('-t', state.tmuxSessionName);
304
- await runTmux('tmux', args, { stdio: 'inherit' });
425
+ await attachToMux(multiplexer, muxSessionName, Boolean(opts.readonly));
305
426
  } catch (error) {
306
427
  console.error(chalk.red(`Error: ${error.message}`));
307
428
  process.exit(1);
@@ -310,48 +431,60 @@ export function agentsCommand() {
310
431
 
311
432
  agents
312
433
  .command('resume')
313
- .description('Resume a previous session and optionally recreate tmux workers')
434
+ .description('Resume a previous session and optionally recreate multiplexer workers')
314
435
  .option('--session <id>', 'Session ID to resume (defaults to current)')
315
- .option('--no-recreate', 'Do not recreate tmux session/workers when missing')
316
- .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')
317
438
  .option('--json', 'Output as JSON')
318
439
  .action(async (opts) => {
319
440
  try {
320
441
  const sessionId = opts.session || await ensureSessionId(true);
321
442
  let state = await loadSessionState(sessionId);
443
+ const multiplexer = state.multiplexer || 'tmux';
322
444
 
323
- const tmuxSessionName = state.tmuxSessionName || `acfm-synapse-${state.sessionId.slice(0, 8)}`;
324
- 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
+ }
325
451
 
326
- if (!tmuxExists && opts.recreate) {
327
- if (!hasCommand('tmux')) {
328
- throw new Error('tmux is not installed. Run: acfm agents setup');
329
- }
452
+ const muxSessionName = sessionMuxName(state);
453
+ const muxExists = await sessionExistsForMux(multiplexer, muxSessionName);
454
+
455
+ if (!muxExists && opts.recreate) {
330
456
  const sessionDir = getSessionDir(state.sessionId);
331
- 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
+ }
332
462
  }
333
463
 
334
464
  state = await saveSessionState({
335
465
  ...state,
336
466
  status: 'running',
337
- tmuxSessionName,
467
+ multiplexer,
468
+ multiplexerSessionName: muxSessionName,
469
+ tmuxSessionName: multiplexer === 'tmux' ? muxSessionName : (state.tmuxSessionName || null),
338
470
  });
339
471
  await setCurrentSession(state.sessionId);
340
472
 
341
473
  output({
342
474
  sessionId: state.sessionId,
343
475
  status: state.status,
344
- tmuxSessionName,
345
- recreatedTmux: !tmuxExists && Boolean(opts.recreate),
476
+ multiplexer,
477
+ multiplexerSessionName: muxSessionName,
478
+ recreatedSession: !muxExists && Boolean(opts.recreate),
346
479
  }, opts.json);
347
480
 
348
481
  if (!opts.json) {
349
482
  console.log(chalk.green(`✓ Resumed session ${state.sessionId}`));
350
- console.log(chalk.dim(` tmux: ${tmuxSessionName}`));
483
+ console.log(chalk.dim(` ${multiplexer}: ${muxSessionName}`));
351
484
  }
352
485
 
353
486
  if (opts.attach) {
354
- await runTmux('tmux', ['attach', '-t', tmuxSessionName], { stdio: 'inherit' });
487
+ await attachToMux(multiplexer, muxSessionName, false);
355
488
  }
356
489
  } catch (error) {
357
490
  output({ error: error.message }, opts.json);
@@ -419,6 +552,123 @@ export function agentsCommand() {
419
552
  .command('model')
420
553
  .description('Manage default SynapseGrid model configuration');
421
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
+
626
+ model
627
+ .command('list')
628
+ .description('List available OpenCode models grouped by provider')
629
+ .option('--refresh', 'Refresh model cache from providers', false)
630
+ .option('--json', 'Output as JSON')
631
+ .action(async (opts) => {
632
+ try {
633
+ const opencodeBin = resolveCommandPath('opencode');
634
+ if (!opencodeBin) {
635
+ throw new Error('OpenCode binary not found. Run: acfm agents setup');
636
+ }
637
+
638
+ const models = await listOpenCodeModels({
639
+ binaryPath: opencodeBin,
640
+ refresh: Boolean(opts.refresh),
641
+ });
642
+ const grouped = groupModelsByProvider(models);
643
+ const providers = [...grouped.keys()].sort((a, b) => a.localeCompare(b));
644
+
645
+ const payload = {
646
+ count: models.length,
647
+ providers: providers.map((provider) => ({
648
+ provider,
649
+ models: grouped.get(provider).map((name) => `${provider}/${name}`),
650
+ })),
651
+ };
652
+
653
+ output(payload, opts.json);
654
+ if (!opts.json) {
655
+ console.log(chalk.bold('Available OpenCode models'));
656
+ console.log(chalk.dim(`Total: ${models.length}`));
657
+ for (const provider of providers) {
658
+ const providerModels = grouped.get(provider) || [];
659
+ console.log(chalk.cyan(`\n${provider}`));
660
+ for (const modelName of providerModels) {
661
+ console.log(chalk.dim(`- ${provider}/${modelName}`));
662
+ }
663
+ }
664
+ }
665
+ } catch (error) {
666
+ output({ error: error.message }, opts.json);
667
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
668
+ process.exit(1);
669
+ }
670
+ });
671
+
422
672
  model
423
673
  .command('get')
424
674
  .description('Show configured default global/per-role models')
@@ -430,6 +680,7 @@ export function agentsCommand() {
430
680
  configPath: getAgentsConfigPath(),
431
681
  defaultModel: config.agents.defaultModel,
432
682
  defaultRoleModels: config.agents.defaultRoleModels,
683
+ multiplexer: config.agents.multiplexer,
433
684
  };
434
685
  output(payload, opts.json);
435
686
  if (!opts.json) {
@@ -439,6 +690,116 @@ export function agentsCommand() {
439
690
  for (const role of COLLAB_ROLES) {
440
691
  console.log(chalk.dim(`- ${role}: ${payload.defaultRoleModels[role] || '(none)'}`));
441
692
  }
693
+ console.log(chalk.dim(`Multiplexer: ${payload.multiplexer || 'auto'}`));
694
+ }
695
+ } catch (error) {
696
+ output({ error: error.message }, opts.json);
697
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
698
+ process.exit(1);
699
+ }
700
+ });
701
+
702
+ model
703
+ .command('choose')
704
+ .description('Interactively choose a default model by provider and role')
705
+ .option('--refresh', 'Refresh model cache from providers', false)
706
+ .option('--json', 'Output as JSON')
707
+ .action(async (opts) => {
708
+ try {
709
+ const opencodeBin = resolveCommandPath('opencode');
710
+ if (!opencodeBin) {
711
+ throw new Error('OpenCode binary not found. Run: acfm agents setup');
712
+ }
713
+
714
+ const models = await listOpenCodeModels({
715
+ binaryPath: opencodeBin,
716
+ refresh: Boolean(opts.refresh),
717
+ });
718
+ if (models.length === 0) {
719
+ throw new Error('No models returned by OpenCode. Run: opencode auth list, opencode models --refresh');
720
+ }
721
+
722
+ const grouped = groupModelsByProvider(models);
723
+ const providerChoices = [...grouped.keys()]
724
+ .sort((a, b) => a.localeCompare(b))
725
+ .map((provider) => ({
726
+ name: `${provider} (${(grouped.get(provider) || []).length})`,
727
+ value: provider,
728
+ }));
729
+
730
+ const { provider } = await inquirer.prompt([
731
+ {
732
+ type: 'list',
733
+ name: 'provider',
734
+ message: 'Select model provider',
735
+ choices: providerChoices,
736
+ },
737
+ ]);
738
+
739
+ const selectedProviderModels = grouped.get(provider) || [];
740
+ const { modelName } = await inquirer.prompt([
741
+ {
742
+ type: 'list',
743
+ name: 'modelName',
744
+ message: `Select model from ${provider}`,
745
+ pageSize: 20,
746
+ choices: selectedProviderModels.map((name) => ({ name, value: name })),
747
+ },
748
+ ]);
749
+
750
+ const roleChoices = [
751
+ { name: 'Global fallback (all roles)', value: 'all' },
752
+ ...COLLAB_ROLES.map((role) => ({ name: `Role: ${role}`, value: role })),
753
+ ];
754
+ const { role } = await inquirer.prompt([
755
+ {
756
+ type: 'list',
757
+ name: 'role',
758
+ message: 'Apply model to',
759
+ choices: roleChoices,
760
+ },
761
+ ]);
762
+
763
+ const modelId = `${provider}/${modelName}`;
764
+ const updated = await updateAgentsConfig((current) => {
765
+ const next = {
766
+ agents: {
767
+ defaultModel: current.agents.defaultModel,
768
+ defaultRoleModels: { ...current.agents.defaultRoleModels },
769
+ multiplexer: current.agents.multiplexer || 'auto',
770
+ },
771
+ };
772
+
773
+ if (role === 'all') {
774
+ next.agents.defaultModel = modelId;
775
+ } else {
776
+ next.agents.defaultRoleModels = {
777
+ ...next.agents.defaultRoleModels,
778
+ [role]: modelId,
779
+ };
780
+ }
781
+
782
+ return next;
783
+ });
784
+
785
+ const payload = {
786
+ success: true,
787
+ selected: {
788
+ role,
789
+ provider,
790
+ model: modelId,
791
+ },
792
+ configPath: getAgentsConfigPath(),
793
+ defaultModel: updated.agents.defaultModel,
794
+ defaultRoleModels: updated.agents.defaultRoleModels,
795
+ };
796
+
797
+ output(payload, opts.json);
798
+ if (!opts.json) {
799
+ console.log(chalk.green('✓ SynapseGrid model selected and saved'));
800
+ console.log(chalk.dim(` Target: ${role === 'all' ? 'global fallback' : `role ${role}`}`));
801
+ console.log(chalk.dim(` Model: ${modelId}`));
802
+ console.log(chalk.dim(` Config: ${payload.configPath}`));
442
803
  }
443
804
  } catch (error) {
444
805
  output({ error: error.message }, opts.json);
@@ -466,6 +827,7 @@ export function agentsCommand() {
466
827
  agents: {
467
828
  defaultModel: current.agents.defaultModel,
468
829
  defaultRoleModels: { ...current.agents.defaultRoleModels },
830
+ multiplexer: current.agents.multiplexer || 'auto',
469
831
  },
470
832
  };
471
833
  if (role === 'all') {
@@ -514,6 +876,7 @@ export function agentsCommand() {
514
876
  agents: {
515
877
  defaultModel: current.agents.defaultModel,
516
878
  defaultRoleModels: { ...current.agents.defaultRoleModels },
879
+ multiplexer: current.agents.multiplexer || 'auto',
517
880
  },
518
881
  };
519
882
  if (role === 'all') {
@@ -545,6 +908,83 @@ export function agentsCommand() {
545
908
  }
546
909
  });
547
910
 
911
+ agents
912
+ .command('transcript')
913
+ .description('Show collaborative transcript (optionally filtered by role)')
914
+ .option('--session <id>', 'Session ID (defaults to current)')
915
+ .option('--role <role>', 'Role filter (planner|critic|coder|reviewer|all)', 'all')
916
+ .option('--limit <n>', 'Max messages to display', '40')
917
+ .option('--json', 'Output as JSON')
918
+ .action(async (opts) => {
919
+ try {
920
+ const sessionId = opts.session || await ensureSessionId(true);
921
+ const role = String(opts.role || 'all');
922
+ const limit = Number.parseInt(opts.limit, 10);
923
+ if (!Number.isInteger(limit) || limit <= 0) {
924
+ throw new Error('--limit must be a positive integer');
925
+ }
926
+ if (role !== 'all' && !COLLAB_ROLES.includes(role)) {
927
+ throw new Error('--role must be planner|critic|coder|reviewer|all');
928
+ }
929
+
930
+ const transcript = await loadTranscript(sessionId);
931
+ const filtered = transcript
932
+ .filter((msg) => role === 'all' || msg.from === role)
933
+ .slice(-limit);
934
+
935
+ output({ sessionId, count: filtered.length, transcript: filtered }, opts.json);
936
+ if (!opts.json) {
937
+ console.log(chalk.bold(`SynapseGrid transcript (${filtered.length})`));
938
+ for (const msg of filtered) {
939
+ console.log(chalk.cyan(`\n[${msg.from}] ${msg.timestamp || ''}`));
940
+ console.log(String(msg.content || '').trim() || chalk.dim('(empty)'));
941
+ }
942
+ }
943
+ } catch (error) {
944
+ output({ error: error.message }, opts.json);
945
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
946
+ process.exit(1);
947
+ }
948
+ });
949
+
950
+ agents
951
+ .command('summary')
952
+ .description('Show meeting summary generated from collaborative run')
953
+ .option('--session <id>', 'Session ID (defaults to current)')
954
+ .option('--json', 'Output as JSON')
955
+ .action(async (opts) => {
956
+ try {
957
+ const sessionId = opts.session || await ensureSessionId(true);
958
+ const state = await loadSessionState(sessionId);
959
+ const summaryFile = await readSessionArtifact(sessionId, 'meeting-summary.md');
960
+ const meetingLogFile = await readSessionArtifact(sessionId, 'meeting-log.md');
961
+ const payload = {
962
+ sessionId,
963
+ status: state.status,
964
+ finalSummary: state.run?.finalSummary || null,
965
+ sharedContext: state.run?.sharedContext || null,
966
+ summaryFile,
967
+ meetingLogFile,
968
+ };
969
+
970
+ output(payload, opts.json);
971
+ if (!opts.json) {
972
+ console.log(chalk.bold('SynapseGrid meeting summary'));
973
+ if (summaryFile) {
974
+ process.stdout.write(summaryFile.endsWith('\n') ? summaryFile : `${summaryFile}\n`);
975
+ } else if (payload.finalSummary) {
976
+ process.stdout.write(`${payload.finalSummary}\n`);
977
+ } else {
978
+ console.log(chalk.dim('No summary generated yet.'));
979
+ }
980
+ }
981
+ } catch (error) {
982
+ output({ error: error.message }, opts.json);
983
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
984
+ process.exit(1);
985
+ }
986
+ });
987
+
548
988
  agents
549
989
  .command('export')
550
990
  .description('Export collaborative transcript')
@@ -562,9 +1002,11 @@ export function agentsCommand() {
562
1002
  throw new Error('--format must be md or json');
563
1003
  }
564
1004
 
1005
+ const meetingSummary = await readSessionArtifact(sessionId, 'meeting-summary.md');
1006
+ const meetingLog = await readSessionArtifact(sessionId, 'meeting-log.md');
565
1007
  const payload = format === 'json'
566
- ? JSON.stringify({ state, transcript }, null, 2) + '\n'
567
- : toMarkdownTranscript(state, transcript) + '\n';
1008
+ ? JSON.stringify({ state, transcript, meetingSummary, meetingLog }, null, 2) + '\n'
1009
+ : `${toMarkdownTranscript(state, transcript)}\n\n## Meeting Summary\n\n${meetingSummary || state.run?.finalSummary || 'No summary generated yet.'}\n\n## Meeting Log\n\n${meetingLog || 'No meeting log generated yet.'}\n`;
568
1010
 
569
1011
  if (opts.out) {
570
1012
  const outputPath = resolve(opts.out);
@@ -598,17 +1040,15 @@ export function agentsCommand() {
598
1040
  .option('--model-critic <id>', 'Model for critic role (provider/model)')
599
1041
  .option('--model-coder <id>', 'Model for coder role (provider/model)')
600
1042
  .option('--model-reviewer <id>', 'Model for reviewer role (provider/model)')
1043
+ .option('--mux <name>', 'Multiplexer backend: auto|zellij|tmux')
601
1044
  .option('--cwd <path>', 'Working directory for agents', process.cwd())
602
- .option('--attach', 'Attach tmux immediately after start', false)
1045
+ .option('--attach', 'Attach multiplexer immediately after start', false)
603
1046
  .option('--json', 'Output as JSON')
604
1047
  .action(async (opts) => {
605
1048
  try {
606
1049
  if (!hasCommand('opencode')) {
607
1050
  throw new Error('OpenCode is not installed. Run: acfm agents setup');
608
1051
  }
609
- if (!hasCommand('tmux')) {
610
- throw new Error('tmux is not installed. Run: acfm agents setup');
611
- }
612
1052
  const opencodeBin = resolveCommandPath('opencode');
613
1053
  if (!opencodeBin) {
614
1054
  throw new Error('OpenCode binary not found. Run: acfm agents setup');
@@ -621,6 +1061,11 @@ export function agentsCommand() {
621
1061
  }
622
1062
 
623
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
+ }
624
1069
  const cliModel = assertValidModelIdOrNull('--model', opts.model || null);
625
1070
  const cliRoleModels = parseRoleModelOptions(opts);
626
1071
  for (const [role, model] of Object.entries(cliRoleModels)) {
@@ -655,30 +1100,47 @@ export function agentsCommand() {
655
1100
  maxRounds,
656
1101
  model: globalModel,
657
1102
  roleModels,
1103
+ multiplexer: selectedMux,
658
1104
  workingDirectory: resolve(opts.cwd),
659
1105
  opencodeBin,
660
1106
  });
661
- const tmuxSessionName = `acfm-synapse-${state.sessionId.slice(0, 8)}`;
1107
+ const muxSessionName = `acfm-synapse-${state.sessionId.slice(0, 8)}`;
662
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
+
663
1124
  const updated = await saveSessionState({
664
1125
  ...state,
665
- tmuxSessionName,
1126
+ multiplexer: selectedMux,
1127
+ multiplexerSessionName: muxSessionName,
1128
+ tmuxSessionName: selectedMux === 'tmux' ? muxSessionName : null,
666
1129
  });
667
1130
 
668
- await spawnTmuxSession({
669
- sessionName: tmuxSessionName,
670
- sessionDir,
671
- sessionId: state.sessionId,
672
- });
673
-
674
- 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);
675
1137
  if (!opts.json) {
676
1138
  printStartSummary(updated);
677
1139
  printModelConfig(updated);
678
1140
  }
679
1141
 
680
1142
  if (opts.attach) {
681
- await runTmux('tmux', ['attach', '-t', tmuxSessionName], { stdio: 'inherit' });
1143
+ await attachToMux(selectedMux, muxSessionName, false);
682
1144
  }
683
1145
  } catch (error) {
684
1146
  output({ error: error.message }, opts.json);
@@ -723,15 +1185,22 @@ export function agentsCommand() {
723
1185
  console.log(chalk.dim(`Round: ${Math.min(state.round, state.maxRounds)}/${state.maxRounds}`));
724
1186
  console.log(chalk.dim(`Active agent: ${state.activeAgent || 'none'}`));
725
1187
  console.log(chalk.dim(`Messages: ${state.messages.length}`));
1188
+ const summary = runSummary(state);
1189
+ console.log(chalk.dim(`Run: ${summary.status}${summary.currentRole ? ` (role=${summary.currentRole})` : ''}, events=${summary.events}`));
1190
+ if (summary.lastError?.message) {
1191
+ console.log(chalk.dim(`Run error: ${summary.lastError.message}`));
1192
+ }
726
1193
  console.log(chalk.dim(`Global model: ${state.model || '(opencode default)'}`));
1194
+ console.log(chalk.dim(`Multiplexer: ${state.multiplexer || 'auto'} (${sessionMuxName(state)})`));
727
1195
  for (const role of COLLAB_ROLES) {
728
1196
  const configured = state.roleModels?.[role] || '-';
729
1197
  const effective = effectiveRoleModels[role] || '(opencode default)';
730
1198
  console.log(chalk.dim(` ${role.padEnd(8)} configured=${configured} effective=${effective}`));
731
1199
  }
732
- if (state.tmuxSessionName) {
733
- console.log(chalk.dim(`tmux: ${state.tmuxSessionName}`));
734
- }
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'}`));
735
1204
  }
736
1205
  } catch (error) {
737
1206
  output({ error: error.message }, opts.json);
@@ -748,16 +1217,68 @@ export function agentsCommand() {
748
1217
  try {
749
1218
  const sessionId = await ensureSessionId(true);
750
1219
  let state = await loadSessionState(sessionId);
1220
+ const meetingSummaryPath = resolve(getSessionDir(state.sessionId), 'meeting-summary.md');
751
1221
  state = await stopSession(state, 'stopped');
752
- if (state.tmuxSessionName && hasCommand('tmux')) {
1222
+ if (state.run && state.run.status === 'running') {
1223
+ state = await saveSessionState({
1224
+ ...state,
1225
+ run: {
1226
+ ...state.run,
1227
+ status: 'cancelled',
1228
+ finishedAt: new Date().toISOString(),
1229
+ currentRole: null,
1230
+ lastError: {
1231
+ code: 'RUN_CANCELLED',
1232
+ message: 'Run cancelled by user',
1233
+ },
1234
+ },
1235
+ });
1236
+ }
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')) {
1266
+ try {
1267
+ await runZellij(['delete-session', muxSessionName]);
1268
+ } catch {
1269
+ // ignore if already closed
1270
+ }
1271
+ }
1272
+ if (multiplexer === 'tmux' && muxSessionName && hasCommand('tmux')) {
753
1273
  try {
754
- await runTmux('tmux', ['kill-session', '-t', state.tmuxSessionName]);
1274
+ await runTmux('tmux', ['kill-session', '-t', muxSessionName]);
755
1275
  } catch {
756
1276
  // ignore if already closed
757
1277
  }
758
1278
  }
759
1279
  output({ sessionId: state.sessionId, status: state.status }, opts.json);
760
1280
  if (!opts.json) console.log(chalk.green('✓ Collaborative session stopped'));
1281
+
761
1282
  } catch (error) {
762
1283
  output({ error: error.message }, opts.json);
763
1284
  if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
@@ -765,6 +1286,33 @@ export function agentsCommand() {
765
1286
  }
766
1287
  });
767
1288
 
1289
+ agents
1290
+ .command('autopilot')
1291
+ .description('Internal headless collaborative driver (non-tmux)')
1292
+ .requiredOption('--session <id>', 'Session id')
1293
+ .option('--poll-ms <n>', 'Polling interval in ms', '900')
1294
+ .action(async (opts) => {
1295
+ const pollMs = Number.parseInt(opts.pollMs, 10);
1296
+ while (true) {
1297
+ try {
1298
+ const state = await loadSessionState(opts.session);
1299
+ if (state.status !== 'running') process.exit(0);
1300
+
1301
+ for (const role of state.roles || COLLAB_ROLES) {
1302
+ await runWorkerIteration(opts.session, role, {
1303
+ cwd: state.workingDirectory || process.cwd(),
1304
+ model: state.model || null,
1305
+ opencodeBin: state.opencodeBin || resolveCommandPath('opencode') || undefined,
1306
+ timeoutMs: state.run?.policy?.timeoutPerRoleMs || 180000,
1307
+ });
1308
+ }
1309
+ } catch (error) {
1310
+ console.error(`[autopilot] ${error.message}`);
1311
+ }
1312
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, Number.isInteger(pollMs) ? pollMs : 900));
1313
+ }
1314
+ });
1315
+
768
1316
  agents
769
1317
  .command('worker')
770
1318
  .description('Internal worker loop for a single collaborative role')
@@ -828,11 +1376,17 @@ export function agentsCommand() {
828
1376
  try {
829
1377
  const opencodeBin = resolveCommandPath('opencode');
830
1378
  const tmuxInstalled = hasCommand('tmux');
1379
+ const zellijInstalled = hasCommand('zellij');
831
1380
  const cfg = await loadAgentsConfig();
832
1381
  const defaultModel = cfg.agents.defaultModel || DEFAULT_SYNAPSE_MODEL;
1382
+ const configuredMux = validateMultiplexer(cfg.agents.multiplexer || 'auto');
1383
+ const resolvedMux = resolveMultiplexer(configuredMux, tmuxInstalled, zellijInstalled);
833
1384
  const result = {
834
1385
  opencodeBin,
835
1386
  tmuxInstalled,
1387
+ zellijInstalled,
1388
+ configuredMultiplexer: configuredMux,
1389
+ resolvedMultiplexer: resolvedMux,
836
1390
  defaultModel,
837
1391
  defaultRoleModels: cfg.agents.defaultRoleModels,
838
1392
  preflight: null,
@@ -852,12 +1406,14 @@ export function agentsCommand() {
852
1406
  if (!opts.json) {
853
1407
  console.log(chalk.bold('SynapseGrid doctor'));
854
1408
  console.log(chalk.dim(`opencode: ${opencodeBin || 'not found'}`));
1409
+ console.log(chalk.dim(`zellij: ${zellijInstalled ? 'installed' : 'not installed'}`));
855
1410
  console.log(chalk.dim(`tmux: ${tmuxInstalled ? 'installed' : 'not installed'}`));
1411
+ console.log(chalk.dim(`multiplexer: configured=${configuredMux} resolved=${resolvedMux || 'none'}`));
856
1412
  console.log(chalk.dim(`default model: ${defaultModel}`));
857
1413
  console.log(chalk.dim(`preflight: ${result.preflight?.ok ? 'ok' : `failed - ${result.preflight?.error || 'unknown error'}`}`));
858
1414
  }
859
1415
 
860
- if (!result.preflight?.ok) process.exit(1);
1416
+ if (!result.preflight?.ok || !result.resolvedMultiplexer) process.exit(1);
861
1417
  } catch (error) {
862
1418
  output({ error: error.message }, opts.json);
863
1419
  if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));