ac-framework 1.9.6 → 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.
package/README.md CHANGED
@@ -141,6 +141,7 @@ Each role runs in turn against a shared, accumulating context so outputs from on
141
141
  | Command | Description |
142
142
  |---|---|
143
143
  | `acfm agents setup` | Install optional dependencies (`opencode` and `tmux`) |
144
+ | `acfm agents doctor` | Validate OpenCode/tmux/model preflight before start |
144
145
  | `acfm agents install-mcps` | Install SynapseGrid MCP server for detected assistants |
145
146
  | `acfm agents uninstall-mcps` | Remove SynapseGrid MCP server from assistants |
146
147
  | `acfm agents start --task "..." --model-coder provider/model` | Start session with optional per-role models |
@@ -164,6 +165,8 @@ Each role runs in turn against a shared, accumulating context so outputs from on
164
165
  - Inspect worker errors quickly with `acfm agents logs --role all --lines 120`.
165
166
  - MCP starts can now create tmux workers directly; if your assistant used headless steps before, start a new session and ensure worker spawning is enabled.
166
167
  - Configure role models directly at start (for example `--model-planner`, `--model-coder`) or persist defaults via `acfm agents model set`.
168
+ - Default SynapseGrid model fallback is `opencode/minimax-m2.5-free`.
169
+ - Run `acfm agents doctor` when panes look idle to confirm model/provider preflight health.
167
170
 
168
171
  ### Spec Workflow
169
172
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ac-framework",
3
- "version": "1.9.6",
3
+ "version": "1.9.7",
4
4
  "description": "Agentic Coding Framework - Multi-assistant configuration system with OpenSpec workflows",
5
5
  "main": "src/index.js",
6
6
  "exports": {
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { DEFAULT_SYNAPSE_MODEL } from './constants.js';
5
6
  import { sanitizeRoleModels, normalizeModelId } from './model-selection.js';
6
7
 
7
8
  const ACFM_DIR = join(homedir(), '.acfm');
@@ -11,7 +12,7 @@ function normalizeConfig(raw) {
11
12
  const agents = raw?.agents && typeof raw.agents === 'object' ? raw.agents : {};
12
13
  return {
13
14
  agents: {
14
- defaultModel: normalizeModelId(agents.defaultModel) || null,
15
+ defaultModel: normalizeModelId(agents.defaultModel) || DEFAULT_SYNAPSE_MODEL,
15
16
  defaultRoleModels: sanitizeRoleModels(agents.defaultRoleModels),
16
17
  },
17
18
  };
@@ -4,5 +4,6 @@ import { join } from 'node:path';
4
4
  export const COLLAB_SYSTEM_NAME = 'SynapseGrid';
5
5
  export const COLLAB_ROLES = ['planner', 'critic', 'coder', 'reviewer'];
6
6
  export const DEFAULT_MAX_ROUNDS = 3;
7
+ export const DEFAULT_SYNAPSE_MODEL = 'opencode/minimax-m2.5-free';
7
8
  export const SESSION_ROOT_DIR = join(homedir(), '.acfm', 'synapsegrid');
8
9
  export const CURRENT_SESSION_FILE = join(SESSION_ROOT_DIR, 'current-session.json');
@@ -1,9 +1,29 @@
1
- import { execFile } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
-
4
- const execFileAsync = promisify(execFile);
1
+ import { spawn } from 'node:child_process';
5
2
 
6
3
  function parseOpenCodeRunOutput(stdout) {
4
+ const ndjsonLines = stdout
5
+ .split('\n')
6
+ .map((line) => line.trim())
7
+ .filter(Boolean);
8
+
9
+ if (ndjsonLines.length > 0) {
10
+ const textChunks = [];
11
+ for (const line of ndjsonLines) {
12
+ try {
13
+ const event = JSON.parse(line);
14
+ const text = event?.part?.text;
15
+ if (typeof text === 'string' && text.trim()) {
16
+ textChunks.push(text.trim());
17
+ }
18
+ } catch {
19
+ // ignore malformed lines and continue
20
+ }
21
+ }
22
+ if (textChunks.length > 0) {
23
+ return textChunks.join('\n\n');
24
+ }
25
+ }
26
+
7
27
  try {
8
28
  const parsed = JSON.parse(stdout);
9
29
  if (Array.isArray(parsed)) {
@@ -33,10 +53,48 @@ export async function runOpenCodePrompt({ prompt, cwd, model, agent, timeoutMs =
33
53
  }
34
54
  args.push('--', prompt);
35
55
 
36
- const { stdout, stderr } = await execFileAsync(binary, args, {
37
- cwd,
38
- timeout: timeoutMs,
39
- maxBuffer: 10 * 1024 * 1024,
56
+ const { stdout, stderr } = await new Promise((resolvePromise, rejectPromise) => {
57
+ const child = spawn(binary, args, {
58
+ cwd,
59
+ env: process.env,
60
+ stdio: ['ignore', 'pipe', 'pipe'],
61
+ });
62
+
63
+ let out = '';
64
+ let err = '';
65
+ let timedOut = false;
66
+
67
+ const timer = setTimeout(() => {
68
+ timedOut = true;
69
+ child.kill('SIGTERM');
70
+ setTimeout(() => child.kill('SIGKILL'), 1500).unref();
71
+ }, timeoutMs);
72
+
73
+ child.stdout.on('data', (chunk) => {
74
+ out += chunk.toString();
75
+ });
76
+ child.stderr.on('data', (chunk) => {
77
+ err += chunk.toString();
78
+ });
79
+
80
+ child.on('error', (error) => {
81
+ clearTimeout(timer);
82
+ rejectPromise(error);
83
+ });
84
+
85
+ child.on('close', (code, signal) => {
86
+ clearTimeout(timer);
87
+ if (timedOut) {
88
+ rejectPromise(new Error(`opencode timed out after ${timeoutMs}ms`));
89
+ return;
90
+ }
91
+ if (code !== 0) {
92
+ const details = [err.trim(), out.trim()].filter(Boolean).join(' | ');
93
+ rejectPromise(new Error(details || `opencode exited with code ${code}${signal ? ` (${signal})` : ''}`));
94
+ return;
95
+ }
96
+ resolvePromise({ stdout: out, stderr: err });
97
+ });
40
98
  });
41
99
 
42
100
  const parsed = parseOpenCodeRunOutput(stdout);
@@ -44,6 +44,7 @@ export function runTmux(command, args, options = {}) {
44
44
 
45
45
  export async function spawnTmuxSession({ sessionName, sessionDir, sessionId }) {
46
46
  const role0 = COLLAB_ROLES[0];
47
+ const role0Log = roleLogPath(sessionDir, role0);
47
48
  await runTmux('tmux', [
48
49
  'new-session',
49
50
  '-d',
@@ -51,17 +52,18 @@ export async function spawnTmuxSession({ sessionName, sessionDir, sessionId }) {
51
52
  sessionName,
52
53
  '-n',
53
54
  role0,
54
- `bash -lc 'node "${runnerPath}" agents worker --session ${sessionId} --role ${role0} >> "${roleLogPath(sessionDir, role0)}" 2>&1'`,
55
+ `bash -lc 'node "${runnerPath}" agents worker --session ${sessionId} --role ${role0} 2>&1 | tee -a "${role0Log}"'`,
55
56
  ]);
56
57
 
57
58
  for (let idx = 1; idx < COLLAB_ROLES.length; idx += 1) {
58
59
  const role = COLLAB_ROLES[idx];
60
+ const roleLog = roleLogPath(sessionDir, role);
59
61
  await runTmux('tmux', [
60
62
  'split-window',
61
63
  '-t',
62
64
  sessionName,
63
65
  '-v',
64
- `bash -lc 'node "${runnerPath}" agents worker --session ${sessionId} --role ${role} >> "${roleLogPath(sessionDir, role)}" 2>&1'`,
66
+ `bash -lc 'node "${runnerPath}" agents worker --session ${sessionId} --role ${role} 2>&1 | tee -a "${roleLog}"'`,
65
67
  ]);
66
68
  }
67
69
 
@@ -109,7 +109,6 @@ export async function saveSessionState(state) {
109
109
  updatedAt: new Date().toISOString(),
110
110
  };
111
111
  await writeFile(getSessionStatePath(updated.sessionId), JSON.stringify(updated, null, 2) + '\n', 'utf8');
112
- await writeCurrentSession(updated.sessionId, updated.updatedAt);
113
112
  return updated;
114
113
  }
115
114
 
@@ -9,8 +9,10 @@ import {
9
9
  COLLAB_SYSTEM_NAME,
10
10
  CURRENT_SESSION_FILE,
11
11
  DEFAULT_MAX_ROUNDS,
12
+ DEFAULT_SYNAPSE_MODEL,
12
13
  SESSION_ROOT_DIR,
13
14
  } from '../agents/constants.js';
15
+ import { runOpenCodePrompt } from '../agents/opencode-client.js';
14
16
  import { runWorkerIteration } from '../agents/orchestrator.js';
15
17
  import {
16
18
  addUserMessage,
@@ -125,6 +127,22 @@ function printModelConfig(state) {
125
127
  }
126
128
  }
127
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
+
128
146
  export function agentsCommand() {
129
147
  const agents = new Command('agents')
130
148
  .description(`${COLLAB_SYSTEM_NAME} — collaborative multi-agent system powered by OpenCode`);
@@ -276,6 +294,10 @@ export function agentsCommand() {
276
294
  if (!state.tmuxSessionName) {
277
295
  throw new Error('No tmux session registered for active collaborative session');
278
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
+ }
279
301
  const args = ['attach'];
280
302
  if (opts.readonly) args.push('-r');
281
303
  args.push('-t', state.tmuxSessionName);
@@ -495,7 +517,7 @@ export function agentsCommand() {
495
517
  },
496
518
  };
497
519
  if (role === 'all') {
498
- next.agents.defaultModel = null;
520
+ next.agents.defaultModel = DEFAULT_SYNAPSE_MODEL;
499
521
  next.agents.defaultRoleModels = {};
500
522
  } else {
501
523
  const currentRoles = { ...next.agents.defaultRoleModels };
@@ -611,7 +633,22 @@ export function agentsCommand() {
611
633
  ...defaultRoleModels,
612
634
  ...cliRoleModels,
613
635
  };
614
- const globalModel = cliModel || config.agents.defaultModel || null;
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
+ }
615
652
 
616
653
  const state = await createSession(opts.task, {
617
654
  roles: COLLAB_ROLES,
@@ -749,6 +786,7 @@ export function agentsCommand() {
749
786
 
750
787
  while (true) {
751
788
  try {
789
+ console.log(`[${role}] polling session ${opts.session}`);
752
790
  const state = await loadSessionState(opts.session);
753
791
  if (!state.roles.includes(role)) {
754
792
  console.log(`[${role}] role not configured in session. exiting.`);
@@ -759,14 +797,20 @@ export function agentsCommand() {
759
797
  process.exit(0);
760
798
  }
761
799
 
800
+ if (state.activeAgent === role) {
801
+ console.log(`[${role}] executing turn with model=${state.roleModels?.[role] || state.model || DEFAULT_SYNAPSE_MODEL}`);
802
+ }
762
803
  const nextState = await runWorkerIteration(opts.session, role, {
763
804
  cwd: state.workingDirectory || process.cwd(),
764
805
  model: state.model || null,
765
806
  opencodeBin: state.opencodeBin || resolveCommandPath('opencode') || undefined,
807
+ timeoutMs: 180000,
766
808
  });
767
809
  const latest = nextState.messages[nextState.messages.length - 1];
768
810
  if (latest?.from === role) {
769
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}`);
770
814
  }
771
815
  } catch (error) {
772
816
  console.error(`[${role}] loop error: ${error.message}`);
@@ -776,5 +820,50 @@ export function agentsCommand() {
776
820
  }
777
821
  });
778
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
+
779
868
  return agents;
780
869
  }