ac-framework 1.9.5 → 1.9.6

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.
Files changed (81) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +13 -1
  3. package/framework/mobile_development/.agent/workflows/ac.md +57 -4
  4. package/framework/mobile_development/.amazonq/prompts/ac.md +57 -4
  5. package/framework/mobile_development/.antigravity/workflows/ac.md +57 -4
  6. package/framework/mobile_development/.augment/commands/ac.md +57 -4
  7. package/framework/mobile_development/.claude/commands/opsx/ac.md +57 -4
  8. package/framework/mobile_development/.cline/commands/opsx/ac.md +57 -4
  9. package/framework/mobile_development/.clinerules/workflows/ac.md +57 -4
  10. package/framework/mobile_development/.codebuddy/commands/opsx/ac.md +57 -4
  11. package/framework/mobile_development/.continue/prompts/ac.md +57 -4
  12. package/framework/mobile_development/.cospec/openspec/commands/ac.md +57 -4
  13. package/framework/mobile_development/.crush/commands/opsx/ac.md +57 -4
  14. package/framework/mobile_development/.cursor/commands/ac.md +57 -4
  15. package/framework/mobile_development/.factory/commands/ac.md +57 -4
  16. package/framework/mobile_development/.gemini/commands/opsx/ac.md +57 -4
  17. package/framework/mobile_development/.github/prompts/ac.md +57 -4
  18. package/framework/mobile_development/.iflow/commands/ac.md +57 -4
  19. package/framework/mobile_development/.kilocode/workflows/ac.md +57 -4
  20. package/framework/mobile_development/.kimi/workflows/ac.md +57 -4
  21. package/framework/mobile_development/.opencode/command/ac.md +57 -4
  22. package/framework/mobile_development/.qoder/commands/opsx/ac.md +57 -4
  23. package/framework/mobile_development/.qwen/commands/ac.md +57 -4
  24. package/framework/mobile_development/.roo/commands/ac.md +57 -4
  25. package/framework/mobile_development/.windsurf/workflows/ac.md +57 -4
  26. package/framework/new_project/.agent/workflows/ac.md +39 -0
  27. package/framework/new_project/.amazonq/prompts/ac.md +39 -0
  28. package/framework/new_project/.antigravity/workflows/ac.md +39 -0
  29. package/framework/new_project/.augment/commands/ac.md +39 -0
  30. package/framework/new_project/.claude/commands/opsx/ac.md +39 -0
  31. package/framework/new_project/.cline/commands/opsx/ac.md +39 -0
  32. package/framework/new_project/.clinerules/workflows/ac.md +39 -0
  33. package/framework/new_project/.codebuddy/commands/opsx/ac.md +39 -0
  34. package/framework/new_project/.continue/prompts/ac.md +39 -0
  35. package/framework/new_project/.cospec/openspec/commands/ac.md +39 -0
  36. package/framework/new_project/.crush/commands/opsx/ac.md +39 -0
  37. package/framework/new_project/.cursor/commands/ac.md +39 -0
  38. package/framework/new_project/.factory/commands/ac.md +39 -0
  39. package/framework/new_project/.gemini/commands/opsx/ac.md +39 -0
  40. package/framework/new_project/.github/prompts/ac.md +39 -0
  41. package/framework/new_project/.iflow/commands/ac.md +39 -0
  42. package/framework/new_project/.kilocode/workflows/ac.md +39 -0
  43. package/framework/new_project/.kimi/workflows/ac.md +39 -0
  44. package/framework/new_project/.opencode/command/ac.md +16 -4
  45. package/framework/new_project/.qoder/commands/opsx/ac.md +39 -0
  46. package/framework/new_project/.qwen/commands/ac.md +39 -0
  47. package/framework/new_project/.roo/commands/ac.md +39 -0
  48. package/framework/new_project/.windsurf/workflows/ac.md +39 -0
  49. package/framework/web_development/.agent/workflows/ac.md +39 -0
  50. package/framework/web_development/.amazonq/prompts/ac.md +39 -0
  51. package/framework/web_development/.antigravity/workflows/ac.md +39 -0
  52. package/framework/web_development/.augment/commands/ac.md +39 -0
  53. package/framework/web_development/.claude/commands/opsx/ac.md +39 -0
  54. package/framework/web_development/.cline/commands/opsx/ac.md +39 -0
  55. package/framework/web_development/.clinerules/workflows/ac.md +39 -0
  56. package/framework/web_development/.codebuddy/commands/opsx/ac.md +39 -0
  57. package/framework/web_development/.continue/prompts/ac.md +39 -0
  58. package/framework/web_development/.cospec/openspec/commands/ac.md +39 -0
  59. package/framework/web_development/.crush/commands/opsx/ac.md +39 -0
  60. package/framework/web_development/.cursor/commands/ac.md +39 -0
  61. package/framework/web_development/.factory/commands/ac.md +39 -0
  62. package/framework/web_development/.gemini/commands/opsx/ac.md +39 -0
  63. package/framework/web_development/.github/prompts/ac.md +39 -0
  64. package/framework/web_development/.iflow/commands/ac.md +39 -0
  65. package/framework/web_development/.kilocode/workflows/ac.md +39 -0
  66. package/framework/web_development/.kimi/workflows/ac.md +39 -0
  67. package/framework/web_development/.opencode/command/ac.md +16 -4
  68. package/framework/web_development/.qoder/commands/opsx/ac.md +39 -0
  69. package/framework/web_development/.qwen/commands/ac.md +39 -0
  70. package/framework/web_development/.roo/commands/ac.md +39 -0
  71. package/framework/web_development/.windsurf/workflows/ac.md +39 -0
  72. package/package.json +1 -1
  73. package/src/agents/config-store.js +48 -0
  74. package/src/agents/model-selection.js +38 -0
  75. package/src/agents/opencode-client.js +3 -2
  76. package/src/agents/orchestrator.js +10 -3
  77. package/src/agents/runtime.js +80 -0
  78. package/src/agents/state-store.js +3 -0
  79. package/src/commands/agents.js +230 -83
  80. package/src/mcp/collab-server.js +105 -4
  81. package/src/services/dependency-installer.js +20 -1
@@ -1,11 +1,9 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { spawn } from 'node:child_process';
4
3
  import { existsSync } from 'node:fs';
5
4
  import { mkdir, writeFile } from 'node:fs/promises';
6
5
  import { readFileSync } from 'node:fs';
7
6
  import { dirname, resolve } from 'node:path';
8
- import { fileURLToPath } from 'node:url';
9
7
  import {
10
8
  COLLAB_ROLES,
11
9
  COLLAB_SYSTEM_NAME,
@@ -26,7 +24,15 @@ import {
26
24
  setCurrentSession,
27
25
  stopSession,
28
26
  } from '../agents/state-store.js';
29
- import { ensureCollabDependencies, hasCommand } from '../services/dependency-installer.js';
27
+ import { roleLogPath, runTmux, spawnTmuxSession, tmuxSessionExists } from '../agents/runtime.js';
28
+ import { getAgentsConfigPath, loadAgentsConfig, updateAgentsConfig } from '../agents/config-store.js';
29
+ import {
30
+ buildEffectiveRoleModels,
31
+ isValidModelId,
32
+ normalizeModelId,
33
+ sanitizeRoleModels,
34
+ } from '../agents/model-selection.js';
35
+ import { ensureCollabDependencies, hasCommand, resolveCommandPath } from '../services/dependency-installer.js';
30
36
 
31
37
  function output(data, json) {
32
38
  if (json) {
@@ -34,79 +40,12 @@ function output(data, json) {
34
40
  }
35
41
  }
36
42
 
37
- function roleLogPath(sessionDir, role) {
38
- return resolve(sessionDir, `${role}.log`);
39
- }
40
-
41
43
  function tailLines(text, maxLines) {
42
44
  const lines = text.split('\n');
43
45
  const sliced = lines.slice(Math.max(lines.length - maxLines, 0));
44
46
  return sliced.join('\n').trimEnd();
45
47
  }
46
48
 
47
- const __dirname = dirname(fileURLToPath(import.meta.url));
48
- const runnerPath = resolve(__dirname, '../../bin/acfm.js');
49
-
50
- function runTmux(command, args, options = {}) {
51
- return new Promise((resolvePromise, rejectPromise) => {
52
- const child = spawn(command, args, {
53
- cwd: options.cwd || process.cwd(),
54
- stdio: options.stdio || 'pipe',
55
- env: process.env,
56
- });
57
-
58
- let stderr = '';
59
- let stdout = '';
60
- if (child.stderr) {
61
- child.stderr.on('data', (chunk) => {
62
- stderr += chunk.toString();
63
- });
64
- }
65
- if (child.stdout) {
66
- child.stdout.on('data', (chunk) => {
67
- stdout += chunk.toString();
68
- });
69
- }
70
-
71
- child.on('error', rejectPromise);
72
- child.on('close', (code) => {
73
- if (code === 0) {
74
- resolvePromise({ stdout, stderr });
75
- return;
76
- }
77
- rejectPromise(new Error(stderr.trim() || `${command} exited with code ${code}`));
78
- });
79
- });
80
- }
81
-
82
- async function spawnTmuxSession({ sessionName, sessionDir, sessionId }) {
83
- const role0 = COLLAB_ROLES[0];
84
- await runTmux('tmux', [
85
- 'new-session',
86
- '-d',
87
- '-s',
88
- sessionName,
89
- '-n',
90
- role0,
91
- `bash -lc 'node "${runnerPath}" agents worker --session ${sessionId} --role ${role0} >> "${roleLogPath(sessionDir, role0)}" 2>&1'`,
92
- ]);
93
-
94
- for (let idx = 1; idx < COLLAB_ROLES.length; idx += 1) {
95
- const role = COLLAB_ROLES[idx];
96
- await runTmux('tmux', [
97
- 'split-window',
98
- '-t',
99
- sessionName,
100
- '-v',
101
- `bash -lc 'node "${runnerPath}" agents worker --session ${sessionId} --role ${role} >> "${roleLogPath(sessionDir, role)}" 2>&1'`,
102
- ]);
103
- }
104
-
105
- await runTmux('tmux', ['select-layout', '-t', sessionName, 'tiled']);
106
- await runTmux('tmux', ['set-option', '-t', sessionName, 'pane-border-status', 'top']);
107
- await runTmux('tmux', ['set-option', '-t', sessionName, 'pane-border-format', '#{pane_index}:#{pane_title}']);
108
- }
109
-
110
49
  async function ensureSessionId(required = true) {
111
50
  const sessionId = await loadCurrentSessionId();
112
51
  if (!sessionId && required) {
@@ -124,18 +63,20 @@ function printStartSummary(state) {
124
63
  console.log();
125
64
  console.log(chalk.cyan('Attach with:'));
126
65
  console.log(chalk.white(` tmux attach -t ${state.tmuxSessionName}`));
66
+ console.log(chalk.white(' acfm agents live'));
127
67
  console.log();
128
68
  console.log(chalk.cyan('Interact with:'));
129
69
  console.log(chalk.white(' acfm agents send "your message"'));
130
70
  }
131
71
 
132
72
  function toMarkdownTranscript(state, transcript) {
73
+ const displayedRound = Math.min(state.round, state.maxRounds);
133
74
  const lines = [
134
75
  `# SynapseGrid Session ${state.sessionId}`,
135
76
  '',
136
77
  `- Task: ${state.task}`,
137
78
  `- Status: ${state.status}`,
138
- `- Rounds: ${state.round}/${state.maxRounds}`,
79
+ `- Rounds: ${displayedRound}/${state.maxRounds}`,
139
80
  `- Roles: ${state.roles.join(', ')}`,
140
81
  `- Created: ${state.createdAt}`,
141
82
  `- Updated: ${state.updatedAt}`,
@@ -155,6 +96,35 @@ function toMarkdownTranscript(state, transcript) {
155
96
  return lines.join('\n');
156
97
  }
157
98
 
99
+ function parseRoleModelOptions(opts) {
100
+ return sanitizeRoleModels({
101
+ planner: opts.modelPlanner,
102
+ critic: opts.modelCritic,
103
+ coder: opts.modelCoder,
104
+ reviewer: opts.modelReviewer,
105
+ });
106
+ }
107
+
108
+ function assertValidModelIdOrNull(label, value) {
109
+ const normalized = normalizeModelId(value);
110
+ if (!normalized) return null;
111
+ if (!isValidModelId(normalized)) {
112
+ throw new Error(`${label} must be in provider/model format`);
113
+ }
114
+ return normalized;
115
+ }
116
+
117
+ function printModelConfig(state) {
118
+ const effectiveRoleModels = buildEffectiveRoleModels(state, state.model || null);
119
+ console.log(chalk.bold('\nModel configuration'));
120
+ console.log(chalk.dim(` Global fallback: ${state.model || '(opencode default)'}`));
121
+ for (const role of COLLAB_ROLES) {
122
+ const configured = state.roleModels?.[role] || '-';
123
+ const effective = effectiveRoleModels[role] || '(opencode default)';
124
+ console.log(chalk.dim(` ${role.padEnd(8)} configured=${configured} effective=${effective}`));
125
+ }
126
+ }
127
+
158
128
  export function agentsCommand() {
159
129
  const agents = new Command('agents')
160
130
  .description(`${COLLAB_SYSTEM_NAME} — collaborative multi-agent system powered by OpenCode`);
@@ -295,6 +265,27 @@ export function agentsCommand() {
295
265
  }
296
266
  });
297
267
 
268
+ agents
269
+ .command('live')
270
+ .description('Attach to live tmux collaboration view (all agent panes)')
271
+ .option('--readonly', 'Attach in read-only mode', false)
272
+ .action(async (opts) => {
273
+ try {
274
+ const sessionId = await ensureSessionId(true);
275
+ const state = await loadSessionState(sessionId);
276
+ if (!state.tmuxSessionName) {
277
+ throw new Error('No tmux session registered for active collaborative session');
278
+ }
279
+ const args = ['attach'];
280
+ if (opts.readonly) args.push('-r');
281
+ args.push('-t', state.tmuxSessionName);
282
+ await runTmux('tmux', args, { stdio: 'inherit' });
283
+ } catch (error) {
284
+ console.error(chalk.red(`Error: ${error.message}`));
285
+ process.exit(1);
286
+ }
287
+ });
288
+
298
289
  agents
299
290
  .command('resume')
300
291
  .description('Resume a previous session and optionally recreate tmux workers')
@@ -308,15 +299,7 @@ export function agentsCommand() {
308
299
  let state = await loadSessionState(sessionId);
309
300
 
310
301
  const tmuxSessionName = state.tmuxSessionName || `acfm-synapse-${state.sessionId.slice(0, 8)}`;
311
- let tmuxExists = false;
312
- if (hasCommand('tmux')) {
313
- try {
314
- await runTmux('tmux', ['has-session', '-t', tmuxSessionName]);
315
- tmuxExists = true;
316
- } catch {
317
- tmuxExists = false;
318
- }
319
- }
302
+ const tmuxExists = hasCommand('tmux') ? await tmuxSessionExists(tmuxSessionName) : false;
320
303
 
321
304
  if (!tmuxExists && opts.recreate) {
322
305
  if (!hasCommand('tmux')) {
@@ -410,6 +393,136 @@ export function agentsCommand() {
410
393
  }
411
394
  });
412
395
 
396
+ const model = agents
397
+ .command('model')
398
+ .description('Manage default SynapseGrid model configuration');
399
+
400
+ model
401
+ .command('get')
402
+ .description('Show configured default global/per-role models')
403
+ .option('--json', 'Output as JSON')
404
+ .action(async (opts) => {
405
+ try {
406
+ const config = await loadAgentsConfig();
407
+ const payload = {
408
+ configPath: getAgentsConfigPath(),
409
+ defaultModel: config.agents.defaultModel,
410
+ defaultRoleModels: config.agents.defaultRoleModels,
411
+ };
412
+ output(payload, opts.json);
413
+ if (!opts.json) {
414
+ console.log(chalk.bold('SynapseGrid default models'));
415
+ console.log(chalk.dim(`Config: ${payload.configPath}`));
416
+ console.log(chalk.dim(`Global fallback: ${payload.defaultModel || '(none)'}`));
417
+ for (const role of COLLAB_ROLES) {
418
+ console.log(chalk.dim(`- ${role}: ${payload.defaultRoleModels[role] || '(none)'}`));
419
+ }
420
+ }
421
+ } catch (error) {
422
+ output({ error: error.message }, opts.json);
423
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
424
+ process.exit(1);
425
+ }
426
+ });
427
+
428
+ model
429
+ .command('set <modelId>')
430
+ .description('Set default model globally or for a specific role')
431
+ .option('--role <role>', 'planner|critic|coder|reviewer|all', 'all')
432
+ .option('--json', 'Output as JSON')
433
+ .action(async (modelId, opts) => {
434
+ try {
435
+ const role = String(opts.role || 'all');
436
+ const normalized = assertValidModelIdOrNull('model', modelId);
437
+ if (!normalized) throw new Error('model must be provided');
438
+ if (role !== 'all' && !COLLAB_ROLES.includes(role)) {
439
+ throw new Error('--role must be planner|critic|coder|reviewer|all');
440
+ }
441
+
442
+ const updated = await updateAgentsConfig((current) => {
443
+ const next = {
444
+ agents: {
445
+ defaultModel: current.agents.defaultModel,
446
+ defaultRoleModels: { ...current.agents.defaultRoleModels },
447
+ },
448
+ };
449
+ if (role === 'all') {
450
+ next.agents.defaultModel = normalized;
451
+ } else {
452
+ next.agents.defaultRoleModels = {
453
+ ...next.agents.defaultRoleModels,
454
+ [role]: normalized,
455
+ };
456
+ }
457
+ return next;
458
+ });
459
+
460
+ const payload = {
461
+ success: true,
462
+ configPath: getAgentsConfigPath(),
463
+ defaultModel: updated.agents.defaultModel,
464
+ defaultRoleModels: updated.agents.defaultRoleModels,
465
+ };
466
+ output(payload, opts.json);
467
+ if (!opts.json) {
468
+ console.log(chalk.green('✓ SynapseGrid model configuration updated'));
469
+ console.log(chalk.dim(` Config: ${payload.configPath}`));
470
+ }
471
+ } catch (error) {
472
+ output({ error: error.message }, opts.json);
473
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
474
+ process.exit(1);
475
+ }
476
+ });
477
+
478
+ model
479
+ .command('clear')
480
+ .description('Clear default model globally or for a specific role')
481
+ .option('--role <role>', 'planner|critic|coder|reviewer|all', 'all')
482
+ .option('--json', 'Output as JSON')
483
+ .action(async (opts) => {
484
+ try {
485
+ const role = String(opts.role || 'all');
486
+ if (role !== 'all' && !COLLAB_ROLES.includes(role)) {
487
+ throw new Error('--role must be planner|critic|coder|reviewer|all');
488
+ }
489
+
490
+ const updated = await updateAgentsConfig((current) => {
491
+ const next = {
492
+ agents: {
493
+ defaultModel: current.agents.defaultModel,
494
+ defaultRoleModels: { ...current.agents.defaultRoleModels },
495
+ },
496
+ };
497
+ if (role === 'all') {
498
+ next.agents.defaultModel = null;
499
+ next.agents.defaultRoleModels = {};
500
+ } else {
501
+ const currentRoles = { ...next.agents.defaultRoleModels };
502
+ delete currentRoles[role];
503
+ next.agents.defaultRoleModels = currentRoles;
504
+ }
505
+ return next;
506
+ });
507
+
508
+ const payload = {
509
+ success: true,
510
+ configPath: getAgentsConfigPath(),
511
+ defaultModel: updated.agents.defaultModel,
512
+ defaultRoleModels: updated.agents.defaultRoleModels,
513
+ };
514
+ output(payload, opts.json);
515
+ if (!opts.json) {
516
+ console.log(chalk.green('✓ SynapseGrid model configuration cleared'));
517
+ console.log(chalk.dim(` Config: ${payload.configPath}`));
518
+ }
519
+ } catch (error) {
520
+ output({ error: error.message }, opts.json);
521
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
522
+ process.exit(1);
523
+ }
524
+ });
525
+
413
526
  agents
414
527
  .command('export')
415
528
  .description('Export collaborative transcript')
@@ -459,6 +572,10 @@ export function agentsCommand() {
459
572
  .requiredOption('--task <text>', 'Initial task from user')
460
573
  .option('--rounds <n>', 'Maximum collaboration rounds', String(DEFAULT_MAX_ROUNDS))
461
574
  .option('--model <id>', 'Model to use (provider/model)')
575
+ .option('--model-planner <id>', 'Model for planner role (provider/model)')
576
+ .option('--model-critic <id>', 'Model for critic role (provider/model)')
577
+ .option('--model-coder <id>', 'Model for coder role (provider/model)')
578
+ .option('--model-reviewer <id>', 'Model for reviewer role (provider/model)')
462
579
  .option('--cwd <path>', 'Working directory for agents', process.cwd())
463
580
  .option('--attach', 'Attach tmux immediately after start', false)
464
581
  .option('--json', 'Output as JSON')
@@ -470,6 +587,10 @@ export function agentsCommand() {
470
587
  if (!hasCommand('tmux')) {
471
588
  throw new Error('tmux is not installed. Run: acfm agents setup');
472
589
  }
590
+ const opencodeBin = resolveCommandPath('opencode');
591
+ if (!opencodeBin) {
592
+ throw new Error('OpenCode binary not found. Run: acfm agents setup');
593
+ }
473
594
 
474
595
  await mkdir(SESSION_ROOT_DIR, { recursive: true });
475
596
  const maxRounds = Number.parseInt(opts.rounds, 10);
@@ -477,11 +598,28 @@ export function agentsCommand() {
477
598
  throw new Error('--rounds must be a positive integer');
478
599
  }
479
600
 
601
+ const config = await loadAgentsConfig();
602
+ const cliModel = assertValidModelIdOrNull('--model', opts.model || null);
603
+ const cliRoleModels = parseRoleModelOptions(opts);
604
+ for (const [role, model] of Object.entries(cliRoleModels)) {
605
+ if (!isValidModelId(model)) {
606
+ throw new Error(`--model-${role} must be in provider/model format`);
607
+ }
608
+ }
609
+ const defaultRoleModels = sanitizeRoleModels(config.agents.defaultRoleModels);
610
+ const roleModels = {
611
+ ...defaultRoleModels,
612
+ ...cliRoleModels,
613
+ };
614
+ const globalModel = cliModel || config.agents.defaultModel || null;
615
+
480
616
  const state = await createSession(opts.task, {
481
617
  roles: COLLAB_ROLES,
482
618
  maxRounds,
483
- model: opts.model || null,
619
+ model: globalModel,
620
+ roleModels,
484
621
  workingDirectory: resolve(opts.cwd),
622
+ opencodeBin,
485
623
  });
486
624
  const tmuxSessionName = `acfm-synapse-${state.sessionId.slice(0, 8)}`;
487
625
  const sessionDir = getSessionDir(state.sessionId);
@@ -499,6 +637,7 @@ export function agentsCommand() {
499
637
  output({ sessionId: updated.sessionId, tmuxSessionName, status: updated.status }, opts.json);
500
638
  if (!opts.json) {
501
639
  printStartSummary(updated);
640
+ printModelConfig(updated);
502
641
  }
503
642
 
504
643
  if (opts.attach) {
@@ -538,14 +677,21 @@ export function agentsCommand() {
538
677
  try {
539
678
  const sessionId = await ensureSessionId(true);
540
679
  const state = await loadSessionState(sessionId);
541
- output(state, opts.json);
680
+ const effectiveRoleModels = buildEffectiveRoleModels(state, state.model || null);
681
+ output({ ...state, effectiveRoleModels }, opts.json);
542
682
  if (!opts.json) {
543
683
  console.log(chalk.bold(`${COLLAB_SYSTEM_NAME} Status`));
544
684
  console.log(chalk.dim(`Session: ${state.sessionId}`));
545
685
  console.log(chalk.dim(`Status: ${state.status}`));
546
- console.log(chalk.dim(`Round: ${state.round}/${state.maxRounds}`));
686
+ console.log(chalk.dim(`Round: ${Math.min(state.round, state.maxRounds)}/${state.maxRounds}`));
547
687
  console.log(chalk.dim(`Active agent: ${state.activeAgent || 'none'}`));
548
688
  console.log(chalk.dim(`Messages: ${state.messages.length}`));
689
+ console.log(chalk.dim(`Global model: ${state.model || '(opencode default)'}`));
690
+ for (const role of COLLAB_ROLES) {
691
+ const configured = state.roleModels?.[role] || '-';
692
+ const effective = effectiveRoleModels[role] || '(opencode default)';
693
+ console.log(chalk.dim(` ${role.padEnd(8)} configured=${configured} effective=${effective}`));
694
+ }
549
695
  if (state.tmuxSessionName) {
550
696
  console.log(chalk.dim(`tmux: ${state.tmuxSessionName}`));
551
697
  }
@@ -616,6 +762,7 @@ export function agentsCommand() {
616
762
  const nextState = await runWorkerIteration(opts.session, role, {
617
763
  cwd: state.workingDirectory || process.cwd(),
618
764
  model: state.model || null,
765
+ opencodeBin: state.opencodeBin || resolveCommandPath('opencode') || undefined,
619
766
  });
620
767
  const latest = nextState.messages[nextState.messages.length - 1];
621
768
  if (latest?.from === role) {
@@ -9,7 +9,10 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
9
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
10
  import { z } from 'zod';
11
11
  import { COLLAB_ROLES } from '../agents/constants.js';
12
+ import { buildEffectiveRoleModels, sanitizeRoleModels } from '../agents/model-selection.js';
12
13
  import { runWorkerIteration } from '../agents/orchestrator.js';
14
+ import { getSessionDir } from '../agents/state-store.js';
15
+ import { spawnTmuxSession, tmuxSessionExists } from '../agents/runtime.js';
13
16
  import {
14
17
  addUserMessage,
15
18
  createSession,
@@ -20,6 +23,7 @@ import {
20
23
  setCurrentSession,
21
24
  stopSession,
22
25
  } from '../agents/state-store.js';
26
+ import { hasCommand, resolveCommandPath } from '../services/dependency-installer.js';
23
27
 
24
28
  class MCPCollabServer {
25
29
  constructor() {
@@ -39,20 +43,59 @@ class MCPCollabServer {
39
43
  task: z.string().describe('Initial collaborative task'),
40
44
  maxRounds: z.number().int().positive().default(3).describe('Maximum collaboration rounds'),
41
45
  model: z.string().optional().describe('Model id (provider/model) for opencode run'),
46
+ roleModels: z.object({
47
+ planner: z.string().optional(),
48
+ critic: z.string().optional(),
49
+ coder: z.string().optional(),
50
+ reviewer: z.string().optional(),
51
+ }).partial().optional().describe('Optional per-role models (provider/model)'),
52
+ cwd: z.string().optional().describe('Working directory for agents'),
53
+ spawnWorkers: z.boolean().default(true).describe('Create tmux workers and panes'),
42
54
  },
43
- async ({ task, maxRounds, model }) => {
55
+ async ({ task, maxRounds, model, roleModels, cwd, spawnWorkers }) => {
44
56
  try {
57
+ const workingDirectory = cwd || process.cwd();
58
+ const opencodeBin = resolveCommandPath('opencode');
59
+ if (!opencodeBin) {
60
+ throw new Error('OpenCode binary not found in PATH. Run: acfm agents setup');
61
+ }
62
+
63
+ if (spawnWorkers && !hasCommand('tmux')) {
64
+ throw new Error('tmux is not installed. Run: acfm agents setup');
65
+ }
66
+
45
67
  const state = await createSession(task, {
46
68
  roles: COLLAB_ROLES,
47
69
  maxRounds,
48
70
  model: model || null,
49
- workingDirectory: process.cwd(),
71
+ roleModels: sanitizeRoleModels(roleModels),
72
+ workingDirectory,
73
+ opencodeBin,
50
74
  });
75
+ let updated = state;
76
+ if (spawnWorkers) {
77
+ const tmuxSessionName = `acfm-synapse-${state.sessionId.slice(0, 8)}`;
78
+ const sessionDir = getSessionDir(state.sessionId);
79
+ await spawnTmuxSession({ sessionName: tmuxSessionName, sessionDir, sessionId: state.sessionId });
80
+ updated = await saveSessionState({ ...state, tmuxSessionName });
81
+ }
51
82
  await setCurrentSession(state.sessionId);
83
+
84
+ const tmuxSessionName = updated.tmuxSessionName || null;
85
+ const attachCommand = tmuxSessionName ? `tmux attach -t ${tmuxSessionName}` : null;
52
86
  return {
53
87
  content: [{
54
88
  type: 'text',
55
- text: JSON.stringify({ success: true, sessionId: state.sessionId, status: state.status }, null, 2),
89
+ text: JSON.stringify({
90
+ success: true,
91
+ sessionId: updated.sessionId,
92
+ status: updated.status,
93
+ model: updated.model || null,
94
+ roleModels: updated.roleModels || {},
95
+ effectiveRoleModels: buildEffectiveRoleModels(updated, updated.model || null),
96
+ tmuxSessionName,
97
+ attachCommand,
98
+ }, null, 2),
56
99
  }],
57
100
  };
58
101
  } catch (error) {
@@ -102,9 +145,11 @@ class MCPCollabServer {
102
145
  if (!id) {
103
146
  throw new Error('No active session found');
104
147
  }
148
+ const loaded = await loadSessionState(id);
105
149
 
106
150
  const state = await runWorkerIteration(id, role, {
107
151
  cwd: process.cwd(),
152
+ opencodeBin: loaded.opencodeBin || resolveCommandPath('opencode') || undefined,
108
153
  });
109
154
 
110
155
  return {
@@ -116,6 +161,9 @@ class MCPCollabServer {
116
161
  status: state.status,
117
162
  round: state.round,
118
163
  activeAgent: state.activeAgent,
164
+ model: state.model || null,
165
+ roleModels: state.roleModels || {},
166
+ effectiveRoleModels: buildEffectiveRoleModels(state, state.model || null),
119
167
  messageCount: state.messages.length,
120
168
  }, null, 2),
121
169
  }],
@@ -126,6 +174,55 @@ class MCPCollabServer {
126
174
  }
127
175
  );
128
176
 
177
+ this.server.tool(
178
+ 'collab_resume_session',
179
+ 'Resume session and recreate tmux workers if needed',
180
+ {
181
+ sessionId: z.string().optional().describe('Session ID (defaults to current session)'),
182
+ recreateWorkers: z.boolean().default(true).describe('Recreate tmux session when missing'),
183
+ },
184
+ async ({ sessionId, recreateWorkers }) => {
185
+ try {
186
+ const id = sessionId || await loadCurrentSessionId();
187
+ if (!id) throw new Error('No active session found');
188
+ let state = await loadSessionState(id);
189
+
190
+ const tmuxSessionName = state.tmuxSessionName || `acfm-synapse-${state.sessionId.slice(0, 8)}`;
191
+ const tmuxExists = hasCommand('tmux') ? await tmuxSessionExists(tmuxSessionName) : false;
192
+
193
+ if (!tmuxExists && recreateWorkers) {
194
+ if (!hasCommand('tmux')) {
195
+ throw new Error('tmux is not installed. Run: acfm agents setup');
196
+ }
197
+ const sessionDir = getSessionDir(state.sessionId);
198
+ await spawnTmuxSession({ sessionName: tmuxSessionName, sessionDir, sessionId: state.sessionId });
199
+ }
200
+
201
+ state = await saveSessionState({
202
+ ...state,
203
+ status: 'running',
204
+ tmuxSessionName,
205
+ });
206
+ await setCurrentSession(state.sessionId);
207
+
208
+ return {
209
+ content: [{
210
+ type: 'text',
211
+ text: JSON.stringify({
212
+ success: true,
213
+ sessionId: state.sessionId,
214
+ status: state.status,
215
+ tmuxSessionName,
216
+ recreatedWorkers: !tmuxExists && recreateWorkers,
217
+ }, null, 2),
218
+ }],
219
+ };
220
+ } catch (error) {
221
+ return { content: [{ type: 'text', text: JSON.stringify({ error: error.message }) }], isError: true };
222
+ }
223
+ }
224
+ );
225
+
129
226
  this.server.tool(
130
227
  'collab_status',
131
228
  'Get current collaborative session state',
@@ -144,7 +241,11 @@ class MCPCollabServer {
144
241
  return {
145
242
  content: [{
146
243
  type: 'text',
147
- text: JSON.stringify({ state, transcript }, null, 2),
244
+ text: JSON.stringify({
245
+ state,
246
+ effectiveRoleModels: buildEffectiveRoleModels(state, state.model || null),
247
+ transcript,
248
+ }, null, 2),
148
249
  }],
149
250
  };
150
251
  } catch (error) {
@@ -1,6 +1,14 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
2
4
  import { platform } from 'node:os';
3
5
 
6
+ function preferredOpenCodePath() {
7
+ const home = process.env.HOME;
8
+ if (!home) return null;
9
+ return join(home, '.opencode', 'bin', 'opencode');
10
+ }
11
+
4
12
  function run(command, args, options = {}) {
5
13
  return spawnSync(command, args, {
6
14
  stdio: options.stdio || 'pipe',
@@ -10,9 +18,20 @@ function run(command, args, options = {}) {
10
18
  }
11
19
 
12
20
  export function hasCommand(command) {
21
+ return Boolean(resolveCommandPath(command));
22
+ }
23
+
24
+ export function resolveCommandPath(command) {
25
+ const preferredPath = command === 'opencode' ? preferredOpenCodePath() : null;
26
+ if (preferredPath && existsSync(preferredPath)) {
27
+ return preferredPath;
28
+ }
13
29
  const locator = platform() === 'win32' ? 'where' : 'which';
14
30
  const result = run(locator, [command]);
15
- return result.status === 0;
31
+ if (result.status !== 0) return null;
32
+ const out = String(result.stdout || '').trim();
33
+ if (!out) return null;
34
+ return out.split('\n').map((line) => line.trim()).filter(Boolean)[0] || null;
16
35
  }
17
36
 
18
37
  export function installOpenCode() {