ac-framework 2.1.0 → 2.3.0

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
@@ -142,6 +142,7 @@ Each role runs in turn against a shared, accumulating context so outputs from on
142
142
  |---|---|
143
143
  | `acfm agents setup` | Install optional dependencies (`opencode` and `zellij`/`tmux`) |
144
144
  | `acfm agents doctor` | Validate OpenCode/multiplexer/model preflight before start |
145
+ | `acfm agents doctor --verbose` | Include zellij capability probe details for strategy diagnostics |
145
146
  | `acfm agents install-mcps` | Install SynapseGrid MCP server for detected assistants |
146
147
  | `acfm agents uninstall-mcps` | Remove SynapseGrid MCP server from assistants |
147
148
  | `acfm agents start --task "..." --model-coder provider/model` | Start session with optional per-role models |
@@ -190,6 +191,7 @@ When driving SynapseGrid from another agent via MCP, prefer asynchronous run too
190
191
  - Default SynapseGrid model fallback is `opencode/mimo-v2-pro-free`.
191
192
  - Run `acfm agents doctor` when panes look idle to confirm model/provider preflight health.
192
193
  - When zellij is managed by AC Framework, its binary path is saved in `~/.acfm/config.json` and executed directly by SynapseGrid.
194
+ - `acfm agents start --json` now includes startup strategy diagnostics for zellij (`attach_with_layout`, fallbacks, and per-strategy errors).
193
195
 
194
196
  Each collaborative session now keeps human-readable artifacts under `~/.acfm/synapsegrid/<sessionId>/`:
195
197
  - `transcript.jsonl`: full chronological message stream
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ac-framework",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Agentic Coding Framework - Multi-assistant configuration system with OpenSpec workflows",
5
5
  "main": "src/index.js",
6
6
  "exports": {
@@ -47,8 +47,80 @@ function sleep(ms) {
47
47
  return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
48
48
  }
49
49
 
50
+ function stripAnsi(text) {
51
+ return String(text || '').replace(/\x1B\[[0-9;]*m/g, '');
52
+ }
53
+
54
+ async function commandSupports(command, args, pattern, runner) {
55
+ try {
56
+ const result = await runner(command, args);
57
+ return pattern.test(`${result.stdout || ''}\n${result.stderr || ''}`);
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ export async function probeZellijCapabilities(binaryPath, options = {}) {
64
+ const runner = options.runCommandImpl || runCommand;
65
+ const command = binaryPath || process.env.ACFM_ZELLIJ_BIN || 'zellij';
66
+ const capabilities = {
67
+ binary: command,
68
+ version: null,
69
+ attachCreateBackground: false,
70
+ actionNewTabLayout: false,
71
+ actionNewPane: false,
72
+ listPanesJson: false,
73
+ setupCheck: false,
74
+ };
75
+
76
+ try {
77
+ const versionResult = await runner(command, ['--version']);
78
+ capabilities.version = stripAnsi(versionResult.stdout || versionResult.stderr || '').trim() || null;
79
+ } catch {
80
+ capabilities.version = null;
81
+ }
82
+
83
+ capabilities.attachCreateBackground = await commandSupports(
84
+ command,
85
+ ['attach', '--help'],
86
+ /--create-background/,
87
+ runner,
88
+ );
89
+ capabilities.actionNewTabLayout = await commandSupports(
90
+ command,
91
+ ['action', 'new-tab', '--help'],
92
+ /--layout/,
93
+ runner,
94
+ );
95
+ capabilities.actionNewPane = await commandSupports(
96
+ command,
97
+ ['action', 'new-pane', '--help'],
98
+ /USAGE:/,
99
+ runner,
100
+ );
101
+ capabilities.listPanesJson = await commandSupports(
102
+ command,
103
+ ['action', 'list-panes', '--help'],
104
+ /--json/,
105
+ runner,
106
+ );
107
+ capabilities.setupCheck = await commandSupports(
108
+ command,
109
+ ['setup', '--help'],
110
+ /--check/,
111
+ runner,
112
+ );
113
+
114
+ return capabilities;
115
+ }
116
+
117
+ function workerShellCommand(sessionId, role, roleLog) {
118
+ return `node "${runnerPath}" agents worker --session ${sessionId} --role ${role} 2>&1 | tee -a "${roleLog}"`;
119
+ }
120
+
50
121
  function workerCommand(sessionId, role, roleLog) {
51
- return `bash -lc 'node "${runnerPath}" agents worker --session ${sessionId} --role ${role} 2>&1 | tee -a "${roleLog}"'`;
122
+ const shell = workerShellCommand(sessionId, role, roleLog);
123
+ return `bash -lc '${shell}'`;
52
124
  }
53
125
 
54
126
  export async function runTmux(command, args, options = {}) {
@@ -95,26 +167,26 @@ export async function tmuxSessionExists(sessionName) {
95
167
  }
96
168
 
97
169
  async function writeZellijLayout({ layoutPath, sessionId, sessionDir }) {
98
- const panes = COLLAB_ROLES.map((role) => {
170
+ const paneNode = (role) => {
99
171
  const roleLog = roleLogPath(sessionDir, role);
100
172
  const cmd = workerCommand(sessionId, role, roleLog).replace(/"/g, '\\"');
101
- return ` pane name="${role}" command="bash" args { "-lc" "${cmd}" }`;
102
- });
173
+ return [
174
+ ` pane name="${role}" command="bash" {`,
175
+ ` args "-lc" "${cmd}"`,
176
+ ' }',
177
+ ].join('\n');
178
+ };
103
179
 
104
180
  const content = [
105
181
  'layout {',
106
- ' default_tab_template {',
107
- ' tab name="SynapseGrid" {',
108
- ' pane split_direction="vertical" {',
109
- ' pane split_direction="horizontal" {',
110
- panes[0],
111
- panes[1],
112
- ' }',
113
- ' pane split_direction="horizontal" {',
114
- panes[2],
115
- panes[3],
116
- ' }',
117
- ' }',
182
+ ' pane split_direction="vertical" {',
183
+ ' pane split_direction="horizontal" {',
184
+ paneNode(COLLAB_ROLES[0]),
185
+ paneNode(COLLAB_ROLES[1]),
186
+ ' }',
187
+ ' pane split_direction="horizontal" {',
188
+ paneNode(COLLAB_ROLES[2]),
189
+ paneNode(COLLAB_ROLES[3]),
118
190
  ' }',
119
191
  ' }',
120
192
  '}',
@@ -132,36 +204,114 @@ export async function spawnZellijSession({
132
204
  waitForSessionMs = 10000,
133
205
  pollIntervalMs = 250,
134
206
  runCommandImpl,
135
- spawnImpl,
207
+ capabilities = null,
136
208
  }) {
137
209
  const layoutPath = resolve(sessionDir, 'synapsegrid-layout.kdl');
138
210
  await writeZellijLayout({ layoutPath, sessionId, sessionDir });
139
211
  const command = binaryPath || process.env.ACFM_ZELLIJ_BIN || 'zellij';
212
+ const runner = runCommandImpl || runCommand;
213
+ const caps = capabilities || await probeZellijCapabilities(binaryPath, { runCommandImpl: runner });
214
+ const strategyErrors = [];
140
215
 
141
- const spawnFn = spawnImpl || spawn;
142
- const child = spawnFn(command, ['--session', sessionName, '--layout', layoutPath], {
143
- cwd: sessionDir,
144
- env: process.env,
145
- detached: true,
146
- stdio: 'ignore',
147
- });
148
- if (typeof child.unref === 'function') {
149
- child.unref();
216
+ const existing = await zellijSessionExists(sessionName, binaryPath, { runCommandImpl: runner });
217
+ if (existing) {
218
+ return { layoutPath, strategy: 'already_exists', capabilities: caps, strategyErrors };
219
+ }
220
+
221
+ const strategies = [];
222
+ if (caps.attachCreateBackground) {
223
+ strategies.push({
224
+ name: 'attach_with_layout',
225
+ run: async () => {
226
+ await runner(command, ['--layout', layoutPath, 'attach', '--create-background', sessionName], {
227
+ cwd: sessionDir,
228
+ });
229
+ },
230
+ });
231
+ }
232
+
233
+ if (caps.attachCreateBackground && caps.actionNewTabLayout) {
234
+ strategies.push({
235
+ name: 'attach_then_newtab_layout',
236
+ run: async () => {
237
+ await runner(command, ['attach', '--create-background', sessionName], { cwd: sessionDir });
238
+ await runner(command, ['--session', sessionName, 'action', 'new-tab', '--name', 'SynapseGrid', '--layout', layoutPath], {
239
+ cwd: sessionDir,
240
+ });
241
+ },
242
+ });
243
+ }
244
+
245
+ if (caps.attachCreateBackground && caps.actionNewPane) {
246
+ strategies.push({
247
+ name: 'attach_then_action_panes',
248
+ run: async () => {
249
+ await runner(command, ['attach', '--create-background', sessionName], { cwd: sessionDir });
250
+ const role0 = COLLAB_ROLES[0];
251
+ const role0Log = roleLogPath(sessionDir, role0);
252
+ await runner(
253
+ command,
254
+ ['--session', sessionName, 'action', 'new-tab', '--name', 'SynapseGrid', '--', 'bash', '-lc', workerShellCommand(sessionId, role0, role0Log)],
255
+ { cwd: sessionDir },
256
+ );
257
+
258
+ const role1 = COLLAB_ROLES[1];
259
+ const role1Log = roleLogPath(sessionDir, role1);
260
+ await runner(
261
+ command,
262
+ ['--session', sessionName, 'action', 'new-pane', '--direction', 'right', '--', 'bash', '-lc', workerShellCommand(sessionId, role1, role1Log)],
263
+ { cwd: sessionDir },
264
+ );
265
+
266
+ const role2 = COLLAB_ROLES[2];
267
+ const role2Log = roleLogPath(sessionDir, role2);
268
+ await runner(
269
+ command,
270
+ ['--session', sessionName, 'action', 'new-pane', '--direction', 'down', '--', 'bash', '-lc', workerShellCommand(sessionId, role2, role2Log)],
271
+ { cwd: sessionDir },
272
+ );
273
+
274
+ const role3 = COLLAB_ROLES[3];
275
+ const role3Log = roleLogPath(sessionDir, role3);
276
+ await runner(
277
+ command,
278
+ ['--session', sessionName, 'action', 'new-pane', '--direction', 'right', '--', 'bash', '-lc', workerShellCommand(sessionId, role3, role3Log)],
279
+ { cwd: sessionDir },
280
+ );
281
+ },
282
+ });
283
+ }
284
+
285
+ let strategyUsed = null;
286
+ for (const strategy of strategies) {
287
+ try {
288
+ // eslint-disable-next-line no-await-in-loop
289
+ await strategy.run();
290
+ strategyUsed = strategy.name;
291
+ break;
292
+ } catch (error) {
293
+ strategyErrors.push({ strategy: strategy.name, error: error.message });
294
+ }
295
+ }
296
+
297
+ if (!strategyUsed) {
298
+ const details = strategyErrors.map((item) => `${item.strategy}: ${item.error}`).join(' | ');
299
+ throw new Error(`Unable to initialize zellij session using supported strategies. ${details || 'No compatible strategy available.'}`);
150
300
  }
151
301
 
152
302
  const startedAt = Date.now();
153
303
  while ((Date.now() - startedAt) < waitForSessionMs) {
154
304
  // eslint-disable-next-line no-await-in-loop
155
- const exists = await zellijSessionExists(sessionName, binaryPath, { runCommandImpl });
305
+ const exists = await zellijSessionExists(sessionName, binaryPath, { runCommandImpl: runner });
156
306
  if (exists) {
157
- return { layoutPath };
307
+ return { layoutPath, strategy: strategyUsed, capabilities: caps, strategyErrors };
158
308
  }
159
309
  // eslint-disable-next-line no-await-in-loop
160
310
  await sleep(pollIntervalMs);
161
311
  }
162
312
 
163
313
  throw new Error(
164
- `Timed out waiting for zellij session '${sessionName}' to start (binary: ${command}). ` +
314
+ `Timed out waiting for zellij session '${sessionName}' to start (binary: ${command}, strategy: ${strategyUsed || 'none'}). ` +
165
315
  'Try `acfm agents doctor` or fallback with `acfm agents start --mux tmux ...`'
166
316
  );
167
317
  }
@@ -171,8 +321,14 @@ export async function zellijSessionExists(sessionName, binaryPath, options = {})
171
321
  const runner = options.runCommandImpl || runCommand;
172
322
  const command = binaryPath || process.env.ACFM_ZELLIJ_BIN || 'zellij';
173
323
  const result = await runner(command, ['list-sessions']);
174
- const lines = result.stdout.split('\n').map((line) => line.trim()).filter(Boolean);
175
- return lines.some((line) => line === sessionName || line.startsWith(`${sessionName} `));
324
+ const lines = stripAnsi(result.stdout)
325
+ .split('\n')
326
+ .map((line) => line.trim())
327
+ .filter(Boolean);
328
+ return lines.some((line) => {
329
+ if (line === sessionName || line.startsWith(`${sessionName} `)) return true;
330
+ return line.includes(sessionName);
331
+ });
176
332
  } catch {
177
333
  return false;
178
334
  }
@@ -31,6 +31,7 @@ import {
31
31
  writeMeetingSummary,
32
32
  } from '../agents/state-store.js';
33
33
  import {
34
+ probeZellijCapabilities,
34
35
  roleLogPath,
35
36
  runTmux,
36
37
  runZellij,
@@ -153,6 +154,16 @@ async function attachToMux(multiplexer, sessionName, readonly = false, zellijPat
153
154
  await runTmux('tmux', args, { stdio: 'inherit' });
154
155
  }
155
156
 
157
+ async function readZellijCapabilities(config) {
158
+ const zellijPath = resolveConfiguredZellijPath(config);
159
+ if (!zellijPath) return null;
160
+ try {
161
+ return await probeZellijCapabilities(zellijPath);
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
156
167
  function toMarkdownTranscript(state, transcript) {
157
168
  const displayedRound = Math.min(state.round, state.maxRounds);
158
169
  const lines = [
@@ -1270,6 +1281,7 @@ Examples:
1270
1281
  const muxResolution = resolveMultiplexerWithPaths(config, configuredMux);
1271
1282
  let selectedMux = muxResolution.selected;
1272
1283
  let zellijPath = muxResolution.zellijPath;
1284
+ const tmuxPath = muxResolution.tmuxPath;
1273
1285
  if (!selectedMux) {
1274
1286
  if (configuredMux !== 'tmux' && shouldUseManagedZellij(config)) {
1275
1287
  const installResult = await installManagedZellijLatest();
@@ -1336,41 +1348,64 @@ Examples:
1336
1348
  const muxSessionName = `acfm-synapse-${state.sessionId.slice(0, 8)}`;
1337
1349
  const sessionDir = getSessionDir(state.sessionId);
1338
1350
 
1339
- if (selectedMux === 'zellij') {
1340
- await spawnZellijSession({
1341
- sessionName: muxSessionName,
1342
- sessionDir,
1343
- sessionId: state.sessionId,
1344
- binaryPath: zellijPath,
1345
- });
1346
- } else {
1351
+ let activeMux = selectedMux;
1352
+ let startupDiagnostics = null;
1353
+ try {
1354
+ if (selectedMux === 'zellij') {
1355
+ startupDiagnostics = await spawnZellijSession({
1356
+ sessionName: muxSessionName,
1357
+ sessionDir,
1358
+ sessionId: state.sessionId,
1359
+ binaryPath: zellijPath,
1360
+ });
1361
+ } else {
1362
+ await spawnTmuxSession({
1363
+ sessionName: muxSessionName,
1364
+ sessionDir,
1365
+ sessionId: state.sessionId,
1366
+ });
1367
+ }
1368
+ } catch (muxError) {
1369
+ const canFallbackToTmux = configuredMux === 'auto' && selectedMux === 'zellij' && Boolean(tmuxPath);
1370
+ if (!canFallbackToTmux) {
1371
+ throw muxError;
1372
+ }
1373
+
1374
+ if (!opts.json) {
1375
+ console.log(chalk.yellow(`zellij startup failed, falling back to tmux: ${muxError.message}`));
1376
+ }
1347
1377
  await spawnTmuxSession({
1348
1378
  sessionName: muxSessionName,
1349
1379
  sessionDir,
1350
1380
  sessionId: state.sessionId,
1351
1381
  });
1382
+ activeMux = 'tmux';
1352
1383
  }
1353
1384
 
1354
1385
  const updated = await saveSessionState({
1355
1386
  ...state,
1356
- multiplexer: selectedMux,
1387
+ multiplexer: activeMux,
1357
1388
  multiplexerSessionName: muxSessionName,
1358
- tmuxSessionName: selectedMux === 'tmux' ? muxSessionName : null,
1389
+ tmuxSessionName: activeMux === 'tmux' ? muxSessionName : null,
1359
1390
  });
1360
1391
 
1361
1392
  output({
1362
1393
  sessionId: updated.sessionId,
1363
- multiplexer: selectedMux,
1394
+ multiplexer: activeMux,
1364
1395
  multiplexerSessionName: muxSessionName,
1365
1396
  status: updated.status,
1397
+ startupDiagnostics,
1366
1398
  }, opts.json);
1367
1399
  if (!opts.json) {
1368
1400
  printStartSummary(updated);
1401
+ if (startupDiagnostics?.strategy) {
1402
+ console.log(chalk.dim(` zellij strategy: ${startupDiagnostics.strategy}`));
1403
+ }
1369
1404
  printModelConfig(updated);
1370
1405
  }
1371
1406
 
1372
1407
  if (opts.attach) {
1373
- await attachToMux(selectedMux, muxSessionName, false, zellijPath);
1408
+ await attachToMux(activeMux, muxSessionName, false, zellijPath);
1374
1409
  }
1375
1410
  } catch (error) {
1376
1411
  output({ error: error.message }, opts.json);
@@ -1503,7 +1538,15 @@ Examples:
1503
1538
  try {
1504
1539
  await runZellij(['delete-session', muxSessionName], { binaryPath: zellijPath });
1505
1540
  } catch {
1506
- // ignore if already closed
1541
+ try {
1542
+ await runZellij(['kill-session', muxSessionName], { binaryPath: zellijPath });
1543
+ } catch {
1544
+ try {
1545
+ await runZellij(['delete-session', '--force', muxSessionName], { binaryPath: zellijPath });
1546
+ } catch {
1547
+ // ignore if already closed
1548
+ }
1549
+ }
1507
1550
  }
1508
1551
  }
1509
1552
  if (multiplexer === 'tmux' && muxSessionName && hasCommand('tmux')) {
@@ -1608,6 +1651,7 @@ Examples:
1608
1651
  agents
1609
1652
  .command('doctor')
1610
1653
  .description('Run diagnostics for SynapseGrid/OpenCode runtime')
1654
+ .option('--verbose', 'Include backend capability details', false)
1611
1655
  .option('--json', 'Output as JSON')
1612
1656
  .action(async (opts) => {
1613
1657
  try {
@@ -1630,8 +1674,13 @@ Examples:
1630
1674
  defaultModel,
1631
1675
  defaultRoleModels: cfg.agents.defaultRoleModels,
1632
1676
  preflight: null,
1677
+ zellijCapabilities: null,
1633
1678
  };
1634
1679
 
1680
+ if (opts.verbose && zellijPath) {
1681
+ result.zellijCapabilities = await readZellijCapabilities(cfg);
1682
+ }
1683
+
1635
1684
  if (opencodeBin) {
1636
1685
  result.preflight = await preflightModel({
1637
1686
  opencodeBin,
@@ -18,6 +18,7 @@ import { buildEffectiveRoleModels, sanitizeRoleModels } from '../agents/model-se
18
18
  import { runWorkerIteration } from '../agents/orchestrator.js';
19
19
  import { getSessionDir } from '../agents/state-store.js';
20
20
  import {
21
+ probeZellijCapabilities,
21
22
  spawnTmuxSession,
22
23
  spawnZellijSession,
23
24
  tmuxSessionExists,
@@ -84,6 +85,16 @@ function resolveConfiguredZellijPath(config) {
84
85
  return resolveCommandPath('zellij');
85
86
  }
86
87
 
88
+ async function readZellijCapabilities(config) {
89
+ const zellijPath = resolveConfiguredZellijPath(config);
90
+ if (!zellijPath) return null;
91
+ try {
92
+ return await probeZellijCapabilities(zellijPath);
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
87
98
  async function muxExists(multiplexer, sessionName, zellijPath = null) {
88
99
  if (multiplexer === 'zellij') return zellijSessionExists(sessionName, zellijPath);
89
100
  return tmuxSessionExists(sessionName);
@@ -152,7 +163,13 @@ class MCPCollabServer {
152
163
  const sessionName = `acfm-synapse-${state.sessionId.slice(0, 8)}`;
153
164
  const sessionDir = getSessionDir(state.sessionId);
154
165
  if (multiplexer === 'zellij') {
155
- await spawnZellijSession({ sessionName, sessionDir, sessionId: state.sessionId, binaryPath: zellijPath });
166
+ await spawnZellijSession({
167
+ sessionName,
168
+ sessionDir,
169
+ sessionId: state.sessionId,
170
+ binaryPath: zellijPath,
171
+ capabilities: await readZellijCapabilities(config),
172
+ });
156
173
  } else {
157
174
  await spawnTmuxSession({ sessionName, sessionDir, sessionId: state.sessionId });
158
175
  }
@@ -477,7 +494,13 @@ class MCPCollabServer {
477
494
  const sessionDir = getSessionDir(state.sessionId);
478
495
  if (multiplexer === 'zellij') {
479
496
  if (!zellijPath) throw new Error('zellij is not installed. Run: acfm agents setup');
480
- await spawnZellijSession({ sessionName, sessionDir, sessionId: state.sessionId, binaryPath: zellijPath });
497
+ await spawnZellijSession({
498
+ sessionName,
499
+ sessionDir,
500
+ sessionId: state.sessionId,
501
+ binaryPath: zellijPath,
502
+ capabilities: await readZellijCapabilities(config),
503
+ });
481
504
  } else {
482
505
  if (!hasCommand('tmux')) throw new Error('tmux is not installed. Run: acfm agents setup');
483
506
  await spawnTmuxSession({ sessionName, sessionDir, sessionId: state.sessionId });