ac-framework 1.9.5 → 1.9.7

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 (82) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +16 -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 +49 -0
  74. package/src/agents/constants.js +1 -0
  75. package/src/agents/model-selection.js +38 -0
  76. package/src/agents/opencode-client.js +68 -9
  77. package/src/agents/orchestrator.js +10 -3
  78. package/src/agents/runtime.js +82 -0
  79. package/src/agents/state-store.js +3 -1
  80. package/src/commands/agents.js +319 -83
  81. package/src/mcp/collab-server.js +105 -4
  82. package/src/services/dependency-installer.js +20 -1
@@ -0,0 +1,82 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { COLLAB_ROLES } from './constants.js';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const runnerPath = resolve(__dirname, '../../bin/acfm.js');
8
+
9
+ export function roleLogPath(sessionDir, role) {
10
+ return resolve(sessionDir, `${role}.log`);
11
+ }
12
+
13
+ export function runTmux(command, args, options = {}) {
14
+ return new Promise((resolvePromise, rejectPromise) => {
15
+ const child = spawn(command, args, {
16
+ cwd: options.cwd || process.cwd(),
17
+ stdio: options.stdio || 'pipe',
18
+ env: process.env,
19
+ });
20
+
21
+ let stderr = '';
22
+ let stdout = '';
23
+ if (child.stderr) {
24
+ child.stderr.on('data', (chunk) => {
25
+ stderr += chunk.toString();
26
+ });
27
+ }
28
+ if (child.stdout) {
29
+ child.stdout.on('data', (chunk) => {
30
+ stdout += chunk.toString();
31
+ });
32
+ }
33
+
34
+ child.on('error', rejectPromise);
35
+ child.on('close', (code) => {
36
+ if (code === 0) {
37
+ resolvePromise({ stdout, stderr });
38
+ return;
39
+ }
40
+ rejectPromise(new Error(stderr.trim() || `${command} exited with code ${code}`));
41
+ });
42
+ });
43
+ }
44
+
45
+ export async function spawnTmuxSession({ sessionName, sessionDir, sessionId }) {
46
+ const role0 = COLLAB_ROLES[0];
47
+ const role0Log = roleLogPath(sessionDir, role0);
48
+ await runTmux('tmux', [
49
+ 'new-session',
50
+ '-d',
51
+ '-s',
52
+ sessionName,
53
+ '-n',
54
+ role0,
55
+ `bash -lc 'node "${runnerPath}" agents worker --session ${sessionId} --role ${role0} 2>&1 | tee -a "${role0Log}"'`,
56
+ ]);
57
+
58
+ for (let idx = 1; idx < COLLAB_ROLES.length; idx += 1) {
59
+ const role = COLLAB_ROLES[idx];
60
+ const roleLog = roleLogPath(sessionDir, role);
61
+ await runTmux('tmux', [
62
+ 'split-window',
63
+ '-t',
64
+ sessionName,
65
+ '-v',
66
+ `bash -lc 'node "${runnerPath}" agents worker --session ${sessionId} --role ${role} 2>&1 | tee -a "${roleLog}"'`,
67
+ ]);
68
+ }
69
+
70
+ await runTmux('tmux', ['select-layout', '-t', sessionName, 'tiled']);
71
+ await runTmux('tmux', ['set-option', '-t', sessionName, 'pane-border-status', 'top']);
72
+ await runTmux('tmux', ['set-option', '-t', sessionName, 'pane-border-format', '#{pane_index}:#{pane_title}']);
73
+ }
74
+
75
+ export async function tmuxSessionExists(sessionName) {
76
+ try {
77
+ await runTmux('tmux', ['has-session', '-t', sessionName]);
78
+ return true;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
@@ -8,6 +8,7 @@ import {
8
8
  SESSION_ROOT_DIR,
9
9
  CURRENT_SESSION_FILE,
10
10
  } from './constants.js';
11
+ import { sanitizeRoleModels } from './model-selection.js';
11
12
 
12
13
  function sleep(ms) {
13
14
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -53,6 +54,8 @@ function initialState(task, options = {}) {
53
54
  roles: options.roles?.length ? options.roles : COLLAB_ROLES,
54
55
  workingDirectory: options.workingDirectory || process.cwd(),
55
56
  model: options.model || null,
57
+ roleModels: sanitizeRoleModels(options.roleModels),
58
+ opencodeBin: options.opencodeBin || null,
56
59
  tmuxSessionName: options.tmuxSessionName || null,
57
60
  messages: [
58
61
  {
@@ -106,7 +109,6 @@ export async function saveSessionState(state) {
106
109
  updatedAt: new Date().toISOString(),
107
110
  };
108
111
  await writeFile(getSessionStatePath(updated.sessionId), JSON.stringify(updated, null, 2) + '\n', 'utf8');
109
- await writeCurrentSession(updated.sessionId, updated.updatedAt);
110
112
  return updated;
111
113
  }
112
114
 
@@ -1,18 +1,18 @@
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,
12
10
  CURRENT_SESSION_FILE,
13
11
  DEFAULT_MAX_ROUNDS,
12
+ DEFAULT_SYNAPSE_MODEL,
14
13
  SESSION_ROOT_DIR,
15
14
  } from '../agents/constants.js';
15
+ import { runOpenCodePrompt } from '../agents/opencode-client.js';
16
16
  import { runWorkerIteration } from '../agents/orchestrator.js';
17
17
  import {
18
18
  addUserMessage,
@@ -26,7 +26,15 @@ import {
26
26
  setCurrentSession,
27
27
  stopSession,
28
28
  } from '../agents/state-store.js';
29
- import { ensureCollabDependencies, hasCommand } from '../services/dependency-installer.js';
29
+ import { roleLogPath, runTmux, spawnTmuxSession, tmuxSessionExists } from '../agents/runtime.js';
30
+ import { getAgentsConfigPath, loadAgentsConfig, updateAgentsConfig } from '../agents/config-store.js';
31
+ import {
32
+ buildEffectiveRoleModels,
33
+ isValidModelId,
34
+ normalizeModelId,
35
+ sanitizeRoleModels,
36
+ } from '../agents/model-selection.js';
37
+ import { ensureCollabDependencies, hasCommand, resolveCommandPath } from '../services/dependency-installer.js';
30
38
 
31
39
  function output(data, json) {
32
40
  if (json) {
@@ -34,79 +42,12 @@ function output(data, json) {
34
42
  }
35
43
  }
36
44
 
37
- function roleLogPath(sessionDir, role) {
38
- return resolve(sessionDir, `${role}.log`);
39
- }
40
-
41
45
  function tailLines(text, maxLines) {
42
46
  const lines = text.split('\n');
43
47
  const sliced = lines.slice(Math.max(lines.length - maxLines, 0));
44
48
  return sliced.join('\n').trimEnd();
45
49
  }
46
50
 
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
51
  async function ensureSessionId(required = true) {
111
52
  const sessionId = await loadCurrentSessionId();
112
53
  if (!sessionId && required) {
@@ -124,18 +65,20 @@ function printStartSummary(state) {
124
65
  console.log();
125
66
  console.log(chalk.cyan('Attach with:'));
126
67
  console.log(chalk.white(` tmux attach -t ${state.tmuxSessionName}`));
68
+ console.log(chalk.white(' acfm agents live'));
127
69
  console.log();
128
70
  console.log(chalk.cyan('Interact with:'));
129
71
  console.log(chalk.white(' acfm agents send "your message"'));
130
72
  }
131
73
 
132
74
  function toMarkdownTranscript(state, transcript) {
75
+ const displayedRound = Math.min(state.round, state.maxRounds);
133
76
  const lines = [
134
77
  `# SynapseGrid Session ${state.sessionId}`,
135
78
  '',
136
79
  `- Task: ${state.task}`,
137
80
  `- Status: ${state.status}`,
138
- `- Rounds: ${state.round}/${state.maxRounds}`,
81
+ `- Rounds: ${displayedRound}/${state.maxRounds}`,
139
82
  `- Roles: ${state.roles.join(', ')}`,
140
83
  `- Created: ${state.createdAt}`,
141
84
  `- Updated: ${state.updatedAt}`,
@@ -155,6 +98,51 @@ function toMarkdownTranscript(state, transcript) {
155
98
  return lines.join('\n');
156
99
  }
157
100
 
101
+ function parseRoleModelOptions(opts) {
102
+ return sanitizeRoleModels({
103
+ planner: opts.modelPlanner,
104
+ critic: opts.modelCritic,
105
+ coder: opts.modelCoder,
106
+ reviewer: opts.modelReviewer,
107
+ });
108
+ }
109
+
110
+ function assertValidModelIdOrNull(label, value) {
111
+ const normalized = normalizeModelId(value);
112
+ if (!normalized) return null;
113
+ if (!isValidModelId(normalized)) {
114
+ throw new Error(`${label} must be in provider/model format`);
115
+ }
116
+ return normalized;
117
+ }
118
+
119
+ function printModelConfig(state) {
120
+ const effectiveRoleModels = buildEffectiveRoleModels(state, state.model || null);
121
+ console.log(chalk.bold('\nModel configuration'));
122
+ console.log(chalk.dim(` Global fallback: ${state.model || '(opencode default)'}`));
123
+ for (const role of COLLAB_ROLES) {
124
+ const configured = state.roleModels?.[role] || '-';
125
+ const effective = effectiveRoleModels[role] || '(opencode default)';
126
+ console.log(chalk.dim(` ${role.padEnd(8)} configured=${configured} effective=${effective}`));
127
+ }
128
+ }
129
+
130
+ async function preflightModel({ opencodeBin, model, cwd }) {
131
+ const selected = normalizeModelId(model) || DEFAULT_SYNAPSE_MODEL;
132
+ try {
133
+ await runOpenCodePrompt({
134
+ prompt: 'Reply with exactly: OK',
135
+ cwd,
136
+ model: selected,
137
+ binaryPath: opencodeBin,
138
+ timeoutMs: 45000,
139
+ });
140
+ return { ok: true, model: selected };
141
+ } catch (error) {
142
+ return { ok: false, model: selected, error: error.message };
143
+ }
144
+ }
145
+
158
146
  export function agentsCommand() {
159
147
  const agents = new Command('agents')
160
148
  .description(`${COLLAB_SYSTEM_NAME} — collaborative multi-agent system powered by OpenCode`);
@@ -295,6 +283,31 @@ export function agentsCommand() {
295
283
  }
296
284
  });
297
285
 
286
+ agents
287
+ .command('live')
288
+ .description('Attach to live tmux collaboration view (all agent panes)')
289
+ .option('--readonly', 'Attach in read-only mode', false)
290
+ .action(async (opts) => {
291
+ try {
292
+ const sessionId = await ensureSessionId(true);
293
+ const state = await loadSessionState(sessionId);
294
+ if (!state.tmuxSessionName) {
295
+ throw new Error('No tmux session registered for active collaborative session');
296
+ }
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`);
300
+ }
301
+ const args = ['attach'];
302
+ if (opts.readonly) args.push('-r');
303
+ args.push('-t', state.tmuxSessionName);
304
+ await runTmux('tmux', args, { stdio: 'inherit' });
305
+ } catch (error) {
306
+ console.error(chalk.red(`Error: ${error.message}`));
307
+ process.exit(1);
308
+ }
309
+ });
310
+
298
311
  agents
299
312
  .command('resume')
300
313
  .description('Resume a previous session and optionally recreate tmux workers')
@@ -308,15 +321,7 @@ export function agentsCommand() {
308
321
  let state = await loadSessionState(sessionId);
309
322
 
310
323
  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
- }
324
+ const tmuxExists = hasCommand('tmux') ? await tmuxSessionExists(tmuxSessionName) : false;
320
325
 
321
326
  if (!tmuxExists && opts.recreate) {
322
327
  if (!hasCommand('tmux')) {
@@ -410,6 +415,136 @@ export function agentsCommand() {
410
415
  }
411
416
  });
412
417
 
418
+ const model = agents
419
+ .command('model')
420
+ .description('Manage default SynapseGrid model configuration');
421
+
422
+ model
423
+ .command('get')
424
+ .description('Show configured default global/per-role models')
425
+ .option('--json', 'Output as JSON')
426
+ .action(async (opts) => {
427
+ try {
428
+ const config = await loadAgentsConfig();
429
+ const payload = {
430
+ configPath: getAgentsConfigPath(),
431
+ defaultModel: config.agents.defaultModel,
432
+ defaultRoleModels: config.agents.defaultRoleModels,
433
+ };
434
+ output(payload, opts.json);
435
+ if (!opts.json) {
436
+ console.log(chalk.bold('SynapseGrid default models'));
437
+ console.log(chalk.dim(`Config: ${payload.configPath}`));
438
+ console.log(chalk.dim(`Global fallback: ${payload.defaultModel || '(none)'}`));
439
+ for (const role of COLLAB_ROLES) {
440
+ console.log(chalk.dim(`- ${role}: ${payload.defaultRoleModels[role] || '(none)'}`));
441
+ }
442
+ }
443
+ } catch (error) {
444
+ output({ error: error.message }, opts.json);
445
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
446
+ process.exit(1);
447
+ }
448
+ });
449
+
450
+ model
451
+ .command('set <modelId>')
452
+ .description('Set default model globally or for a specific role')
453
+ .option('--role <role>', 'planner|critic|coder|reviewer|all', 'all')
454
+ .option('--json', 'Output as JSON')
455
+ .action(async (modelId, opts) => {
456
+ try {
457
+ const role = String(opts.role || 'all');
458
+ const normalized = assertValidModelIdOrNull('model', modelId);
459
+ if (!normalized) throw new Error('model must be provided');
460
+ if (role !== 'all' && !COLLAB_ROLES.includes(role)) {
461
+ throw new Error('--role must be planner|critic|coder|reviewer|all');
462
+ }
463
+
464
+ const updated = await updateAgentsConfig((current) => {
465
+ const next = {
466
+ agents: {
467
+ defaultModel: current.agents.defaultModel,
468
+ defaultRoleModels: { ...current.agents.defaultRoleModels },
469
+ },
470
+ };
471
+ if (role === 'all') {
472
+ next.agents.defaultModel = normalized;
473
+ } else {
474
+ next.agents.defaultRoleModels = {
475
+ ...next.agents.defaultRoleModels,
476
+ [role]: normalized,
477
+ };
478
+ }
479
+ return next;
480
+ });
481
+
482
+ const payload = {
483
+ success: true,
484
+ configPath: getAgentsConfigPath(),
485
+ defaultModel: updated.agents.defaultModel,
486
+ defaultRoleModels: updated.agents.defaultRoleModels,
487
+ };
488
+ output(payload, opts.json);
489
+ if (!opts.json) {
490
+ console.log(chalk.green('✓ SynapseGrid model configuration updated'));
491
+ console.log(chalk.dim(` Config: ${payload.configPath}`));
492
+ }
493
+ } catch (error) {
494
+ output({ error: error.message }, opts.json);
495
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
496
+ process.exit(1);
497
+ }
498
+ });
499
+
500
+ model
501
+ .command('clear')
502
+ .description('Clear default model globally or for a specific role')
503
+ .option('--role <role>', 'planner|critic|coder|reviewer|all', 'all')
504
+ .option('--json', 'Output as JSON')
505
+ .action(async (opts) => {
506
+ try {
507
+ const role = String(opts.role || 'all');
508
+ if (role !== 'all' && !COLLAB_ROLES.includes(role)) {
509
+ throw new Error('--role must be planner|critic|coder|reviewer|all');
510
+ }
511
+
512
+ const updated = await updateAgentsConfig((current) => {
513
+ const next = {
514
+ agents: {
515
+ defaultModel: current.agents.defaultModel,
516
+ defaultRoleModels: { ...current.agents.defaultRoleModels },
517
+ },
518
+ };
519
+ if (role === 'all') {
520
+ next.agents.defaultModel = DEFAULT_SYNAPSE_MODEL;
521
+ next.agents.defaultRoleModels = {};
522
+ } else {
523
+ const currentRoles = { ...next.agents.defaultRoleModels };
524
+ delete currentRoles[role];
525
+ next.agents.defaultRoleModels = currentRoles;
526
+ }
527
+ return next;
528
+ });
529
+
530
+ const payload = {
531
+ success: true,
532
+ configPath: getAgentsConfigPath(),
533
+ defaultModel: updated.agents.defaultModel,
534
+ defaultRoleModels: updated.agents.defaultRoleModels,
535
+ };
536
+ output(payload, opts.json);
537
+ if (!opts.json) {
538
+ console.log(chalk.green('✓ SynapseGrid model configuration cleared'));
539
+ console.log(chalk.dim(` Config: ${payload.configPath}`));
540
+ }
541
+ } catch (error) {
542
+ output({ error: error.message }, opts.json);
543
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
544
+ process.exit(1);
545
+ }
546
+ });
547
+
413
548
  agents
414
549
  .command('export')
415
550
  .description('Export collaborative transcript')
@@ -459,6 +594,10 @@ export function agentsCommand() {
459
594
  .requiredOption('--task <text>', 'Initial task from user')
460
595
  .option('--rounds <n>', 'Maximum collaboration rounds', String(DEFAULT_MAX_ROUNDS))
461
596
  .option('--model <id>', 'Model to use (provider/model)')
597
+ .option('--model-planner <id>', 'Model for planner role (provider/model)')
598
+ .option('--model-critic <id>', 'Model for critic role (provider/model)')
599
+ .option('--model-coder <id>', 'Model for coder role (provider/model)')
600
+ .option('--model-reviewer <id>', 'Model for reviewer role (provider/model)')
462
601
  .option('--cwd <path>', 'Working directory for agents', process.cwd())
463
602
  .option('--attach', 'Attach tmux immediately after start', false)
464
603
  .option('--json', 'Output as JSON')
@@ -470,6 +609,10 @@ export function agentsCommand() {
470
609
  if (!hasCommand('tmux')) {
471
610
  throw new Error('tmux is not installed. Run: acfm agents setup');
472
611
  }
612
+ const opencodeBin = resolveCommandPath('opencode');
613
+ if (!opencodeBin) {
614
+ throw new Error('OpenCode binary not found. Run: acfm agents setup');
615
+ }
473
616
 
474
617
  await mkdir(SESSION_ROOT_DIR, { recursive: true });
475
618
  const maxRounds = Number.parseInt(opts.rounds, 10);
@@ -477,11 +620,43 @@ export function agentsCommand() {
477
620
  throw new Error('--rounds must be a positive integer');
478
621
  }
479
622
 
623
+ const config = await loadAgentsConfig();
624
+ const cliModel = assertValidModelIdOrNull('--model', opts.model || null);
625
+ const cliRoleModels = parseRoleModelOptions(opts);
626
+ for (const [role, model] of Object.entries(cliRoleModels)) {
627
+ if (!isValidModelId(model)) {
628
+ throw new Error(`--model-${role} must be in provider/model format`);
629
+ }
630
+ }
631
+ const defaultRoleModels = sanitizeRoleModels(config.agents.defaultRoleModels);
632
+ const roleModels = {
633
+ ...defaultRoleModels,
634
+ ...cliRoleModels,
635
+ };
636
+ const globalModel = cliModel || config.agents.defaultModel || DEFAULT_SYNAPSE_MODEL;
637
+
638
+ const modelsToCheck = new Set([globalModel, ...Object.values(roleModels)]);
639
+ for (const modelToCheck of modelsToCheck) {
640
+ const preflight = await preflightModel({
641
+ opencodeBin,
642
+ model: modelToCheck,
643
+ cwd: resolve(opts.cwd),
644
+ });
645
+ if (!preflight.ok) {
646
+ throw new Error(
647
+ `Model preflight failed for ${preflight.model}: ${preflight.error}. ` +
648
+ 'Check OpenCode auth/providers with: opencode auth list, opencode models'
649
+ );
650
+ }
651
+ }
652
+
480
653
  const state = await createSession(opts.task, {
481
654
  roles: COLLAB_ROLES,
482
655
  maxRounds,
483
- model: opts.model || null,
656
+ model: globalModel,
657
+ roleModels,
484
658
  workingDirectory: resolve(opts.cwd),
659
+ opencodeBin,
485
660
  });
486
661
  const tmuxSessionName = `acfm-synapse-${state.sessionId.slice(0, 8)}`;
487
662
  const sessionDir = getSessionDir(state.sessionId);
@@ -499,6 +674,7 @@ export function agentsCommand() {
499
674
  output({ sessionId: updated.sessionId, tmuxSessionName, status: updated.status }, opts.json);
500
675
  if (!opts.json) {
501
676
  printStartSummary(updated);
677
+ printModelConfig(updated);
502
678
  }
503
679
 
504
680
  if (opts.attach) {
@@ -538,14 +714,21 @@ export function agentsCommand() {
538
714
  try {
539
715
  const sessionId = await ensureSessionId(true);
540
716
  const state = await loadSessionState(sessionId);
541
- output(state, opts.json);
717
+ const effectiveRoleModels = buildEffectiveRoleModels(state, state.model || null);
718
+ output({ ...state, effectiveRoleModels }, opts.json);
542
719
  if (!opts.json) {
543
720
  console.log(chalk.bold(`${COLLAB_SYSTEM_NAME} Status`));
544
721
  console.log(chalk.dim(`Session: ${state.sessionId}`));
545
722
  console.log(chalk.dim(`Status: ${state.status}`));
546
- console.log(chalk.dim(`Round: ${state.round}/${state.maxRounds}`));
723
+ console.log(chalk.dim(`Round: ${Math.min(state.round, state.maxRounds)}/${state.maxRounds}`));
547
724
  console.log(chalk.dim(`Active agent: ${state.activeAgent || 'none'}`));
548
725
  console.log(chalk.dim(`Messages: ${state.messages.length}`));
726
+ console.log(chalk.dim(`Global model: ${state.model || '(opencode default)'}`));
727
+ for (const role of COLLAB_ROLES) {
728
+ const configured = state.roleModels?.[role] || '-';
729
+ const effective = effectiveRoleModels[role] || '(opencode default)';
730
+ console.log(chalk.dim(` ${role.padEnd(8)} configured=${configured} effective=${effective}`));
731
+ }
549
732
  if (state.tmuxSessionName) {
550
733
  console.log(chalk.dim(`tmux: ${state.tmuxSessionName}`));
551
734
  }
@@ -603,6 +786,7 @@ export function agentsCommand() {
603
786
 
604
787
  while (true) {
605
788
  try {
789
+ console.log(`[${role}] polling session ${opts.session}`);
606
790
  const state = await loadSessionState(opts.session);
607
791
  if (!state.roles.includes(role)) {
608
792
  console.log(`[${role}] role not configured in session. exiting.`);
@@ -613,13 +797,20 @@ export function agentsCommand() {
613
797
  process.exit(0);
614
798
  }
615
799
 
800
+ if (state.activeAgent === role) {
801
+ console.log(`[${role}] executing turn with model=${state.roleModels?.[role] || state.model || DEFAULT_SYNAPSE_MODEL}`);
802
+ }
616
803
  const nextState = await runWorkerIteration(opts.session, role, {
617
804
  cwd: state.workingDirectory || process.cwd(),
618
805
  model: state.model || null,
806
+ opencodeBin: state.opencodeBin || resolveCommandPath('opencode') || undefined,
807
+ timeoutMs: 180000,
619
808
  });
620
809
  const latest = nextState.messages[nextState.messages.length - 1];
621
810
  if (latest?.from === role) {
622
811
  console.log(`[${role}] message emitted (${latest.content.length} chars)`);
812
+ } else if (nextState.activeAgent && nextState.activeAgent !== role) {
813
+ console.log(`[${role}] waiting for active role=${nextState.activeAgent}`);
623
814
  }
624
815
  } catch (error) {
625
816
  console.error(`[${role}] loop error: ${error.message}`);
@@ -629,5 +820,50 @@ export function agentsCommand() {
629
820
  }
630
821
  });
631
822
 
823
+ agents
824
+ .command('doctor')
825
+ .description('Run diagnostics for SynapseGrid/OpenCode runtime')
826
+ .option('--json', 'Output as JSON')
827
+ .action(async (opts) => {
828
+ try {
829
+ const opencodeBin = resolveCommandPath('opencode');
830
+ const tmuxInstalled = hasCommand('tmux');
831
+ const cfg = await loadAgentsConfig();
832
+ const defaultModel = cfg.agents.defaultModel || DEFAULT_SYNAPSE_MODEL;
833
+ const result = {
834
+ opencodeBin,
835
+ tmuxInstalled,
836
+ defaultModel,
837
+ defaultRoleModels: cfg.agents.defaultRoleModels,
838
+ preflight: null,
839
+ };
840
+
841
+ if (opencodeBin) {
842
+ result.preflight = await preflightModel({
843
+ opencodeBin,
844
+ model: defaultModel,
845
+ cwd: process.cwd(),
846
+ });
847
+ } else {
848
+ result.preflight = { ok: false, error: 'OpenCode binary not found' };
849
+ }
850
+
851
+ output(result, opts.json);
852
+ if (!opts.json) {
853
+ console.log(chalk.bold('SynapseGrid doctor'));
854
+ console.log(chalk.dim(`opencode: ${opencodeBin || 'not found'}`));
855
+ console.log(chalk.dim(`tmux: ${tmuxInstalled ? 'installed' : 'not installed'}`));
856
+ console.log(chalk.dim(`default model: ${defaultModel}`));
857
+ console.log(chalk.dim(`preflight: ${result.preflight?.ok ? 'ok' : `failed - ${result.preflight?.error || 'unknown error'}`}`));
858
+ }
859
+
860
+ if (!result.preflight?.ok) process.exit(1);
861
+ } catch (error) {
862
+ output({ error: error.message }, opts.json);
863
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
864
+ process.exit(1);
865
+ }
866
+ });
867
+
632
868
  return agents;
633
869
  }