ac-framework 1.9.9 → 2.0.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
@@ -147,6 +147,7 @@ Each role runs in turn against a shared, accumulating context so outputs from on
147
147
  | `acfm agents start --task "..." --model-coder provider/model` | Start session with optional per-role models |
148
148
  | `acfm agents start --task "..." --mux zellij` | Start session forcing zellij backend (`auto`/`tmux` also supported) |
149
149
  | `acfm agents runtime get` | Show configured multiplexer backend (`auto`, `zellij`, `tmux`) |
150
+ | `acfm agents runtime install-zellij` | Download latest zellij release into `~/.acfm/tools/zellij` |
150
151
  | `acfm agents runtime set zellij` | Persist preferred multiplexer backend |
151
152
  | `acfm agents resume` | Resume a previous session and recreate workers if needed |
152
153
  | `acfm agents list` | List recent SynapseGrid sessions |
@@ -155,6 +156,7 @@ Each role runs in turn against a shared, accumulating context so outputs from on
155
156
  | `acfm agents logs` | Show recent worker logs (all roles or one role) |
156
157
  | `acfm agents transcript --role all --limit 40` | Show captured cross-agent transcript |
157
158
  | `acfm agents summary` | Show generated collaboration meeting summary |
159
+ | `acfm agents artifacts` | Show artifact paths/existence for current session |
158
160
  | `acfm agents export --format md --out file.md` | Export transcript in Markdown or JSON |
159
161
  | `acfm agents send "..."` | Send a new user message into the active session |
160
162
  | `acfm agents status` | Show current collaborative session state |
@@ -187,12 +189,15 @@ When driving SynapseGrid from another agent via MCP, prefer asynchronous run too
187
189
  - Configure role models directly at start (for example `--model-planner`, `--model-coder`) or persist defaults via `acfm agents model choose` / `acfm agents model set`.
188
190
  - Default SynapseGrid model fallback is `opencode/mimo-v2-pro-free`.
189
191
  - Run `acfm agents doctor` when panes look idle to confirm model/provider preflight health.
192
+ - When zellij is managed by AC Framework, its binary path is saved in `~/.acfm/config.json` and executed directly by SynapseGrid.
190
193
 
191
194
  Each collaborative session now keeps human-readable artifacts under `~/.acfm/synapsegrid/<sessionId>/`:
192
195
  - `transcript.jsonl`: full chronological message stream
193
196
  - `turns/*.json`: one file per round/role turn with captured output metadata
194
197
  - `meeting-log.md`: incremental meeting notes generated per turn
195
198
  - `meeting-summary.md`: final consolidated summary (roles, decisions, open issues, risks, action items)
199
+ - `turns/raw/*.ndjson`: raw OpenCode event stream captured per role/round
200
+ - `turns/raw/*.stderr.log`: stderr capture per role/round
196
201
 
197
202
  ### Spec Workflow
198
203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ac-framework",
3
- "version": "1.9.9",
3
+ "version": "2.0.0",
4
4
  "description": "Agentic Coding Framework - Multi-assistant configuration system with OpenSpec workflows",
5
5
  "main": "src/index.js",
6
6
  "exports": {
@@ -12,11 +12,24 @@ function normalizeConfig(raw) {
12
12
  const agents = raw?.agents && typeof raw.agents === 'object' ? raw.agents : {};
13
13
  const configuredMultiplexer = typeof agents.multiplexer === 'string' ? agents.multiplexer.trim().toLowerCase() : '';
14
14
  const multiplexer = ['auto', 'zellij', 'tmux'].includes(configuredMultiplexer) ? configuredMultiplexer : 'auto';
15
+ const zellij = agents?.zellij && typeof agents.zellij === 'object' ? agents.zellij : {};
16
+ const zellijStrategy = typeof zellij.strategy === 'string' ? zellij.strategy.trim().toLowerCase() : 'auto';
17
+ const strategy = ['auto', 'managed', 'system'].includes(zellijStrategy) ? zellijStrategy : 'auto';
18
+ const binaryPath = typeof zellij.binaryPath === 'string' && zellij.binaryPath.trim() ? zellij.binaryPath.trim() : null;
19
+ const version = typeof zellij.version === 'string' && zellij.version.trim() ? zellij.version.trim() : null;
20
+ const source = typeof zellij.source === 'string' && zellij.source.trim() ? zellij.source.trim() : null;
21
+
15
22
  return {
16
23
  agents: {
17
24
  defaultModel: normalizeModelId(agents.defaultModel) || DEFAULT_SYNAPSE_MODEL,
18
25
  defaultRoleModels: sanitizeRoleModels(agents.defaultRoleModels),
19
26
  multiplexer,
27
+ zellij: {
28
+ strategy,
29
+ binaryPath,
30
+ version,
31
+ source,
32
+ },
20
33
  },
21
34
  };
22
35
  }
@@ -11,10 +11,12 @@ import {
11
11
  } from './run-state.js';
12
12
  import {
13
13
  addAgentMessage,
14
+ appendTurnRawCapture,
14
15
  appendMeetingTurn,
15
16
  loadSessionState,
16
17
  saveSessionState,
17
18
  stopSession,
19
+ updateSessionDiagnostics,
18
20
  writeMeetingSummary,
19
21
  withSessionLock,
20
22
  } from './state-store.js';
@@ -33,6 +35,21 @@ async function finalizeSessionArtifacts(state) {
33
35
  });
34
36
  }
35
37
 
38
+ async function maybeRecordNoTurnDiagnostic(state) {
39
+ const runState = ensureRunState(state);
40
+ const hasTurnEvents = Array.isArray(runState.events)
41
+ ? runState.events.some((event) => event?.type === 'role_succeeded' || event?.type === 'role_failed')
42
+ : false;
43
+ if (!hasTurnEvents) {
44
+ await updateSessionDiagnostics(state.sessionId, {
45
+ warning: 'Session ended before any role turn was captured',
46
+ runStatus: runState.status,
47
+ stateStatus: state.status,
48
+ runEventCount: Array.isArray(runState.events) ? runState.events.length : 0,
49
+ });
50
+ }
51
+ }
52
+
36
53
  function buildRuntimePrompt({ state, role }) {
37
54
  const roleContext = ROLE_SYSTEM_PROMPTS[role] || '';
38
55
  const collaborativePrompt = buildAgentPrompt({
@@ -162,6 +179,7 @@ export async function runTurn(sessionId, options = {}) {
162
179
  if (shouldStop(state)) {
163
180
  if (state.status === 'running') {
164
181
  state = await stopSession(state, 'completed');
182
+ await maybeRecordNoTurnDiagnostic(state);
165
183
  state = await finalizeSessionArtifacts(state);
166
184
  }
167
185
  return state;
@@ -200,6 +218,7 @@ export async function runTurn(sessionId, options = {}) {
200
218
 
201
219
  if (shouldStop(state)) {
202
220
  state = await stopSession(state, 'completed');
221
+ await maybeRecordNoTurnDiagnostic(state);
203
222
  state = await finalizeSessionArtifacts(state);
204
223
  }
205
224
 
@@ -267,6 +286,7 @@ export async function executeActiveTurn(sessionId, role, options = {}) {
267
286
 
268
287
  if (shouldStop(state)) {
269
288
  state = await stopSession(state, 'completed');
289
+ await maybeRecordNoTurnDiagnostic(state);
270
290
  state = await finalizeSessionArtifacts(state);
271
291
  }
272
292
 
@@ -317,6 +337,8 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
317
337
  const prompt = buildRuntimePrompt({ state, role });
318
338
  let content;
319
339
  let outputEvents = [];
340
+ let outputStdout = '';
341
+ let outputStderr = '';
320
342
  let effectiveModel = null;
321
343
  let failed = false;
322
344
  let errorMessage = '';
@@ -340,6 +362,8 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
340
362
  });
341
363
  content = output.text;
342
364
  outputEvents = output.events || [];
365
+ outputStdout = output.stdout || '';
366
+ outputStderr = output.stderr || '';
343
367
  } catch (error) {
344
368
  failed = true;
345
369
  errorMessage = error.message;
@@ -348,15 +372,26 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
348
372
 
349
373
  state = await addAgentMessage(state, role, content);
350
374
  if (failed) {
351
- await appendMeetingTurn(sessionId, createTurnRecord({
375
+ const failedTurn = createTurnRecord({
352
376
  round: state.round,
353
377
  role,
354
378
  model: effectiveModel,
355
379
  content,
356
380
  events: outputEvents,
357
- }));
381
+ });
382
+ await appendMeetingTurn(sessionId, failedTurn);
383
+ await appendTurnRawCapture(sessionId, failedTurn, {
384
+ stdout: outputStdout,
385
+ stderr: outputStderr,
386
+ events: outputEvents,
387
+ });
388
+ await updateSessionDiagnostics(sessionId, {
389
+ lastError: errorMessage,
390
+ lastFailedRole: role,
391
+ });
358
392
  state = await saveSessionState(applyRoleFailurePolicy(state, role, errorMessage));
359
393
  if (state.status === 'failed') {
394
+ await maybeRecordNoTurnDiagnostic(state);
360
395
  state = await finalizeSessionArtifacts(state);
361
396
  }
362
397
  return state;
@@ -370,6 +405,14 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
370
405
  events: outputEvents,
371
406
  });
372
407
  await appendMeetingTurn(sessionId, turnRecord);
408
+ await appendTurnRawCapture(sessionId, turnRecord, {
409
+ stdout: outputStdout,
410
+ stderr: outputStderr,
411
+ events: outputEvents,
412
+ });
413
+ await updateSessionDiagnostics(sessionId, {
414
+ lastError: null,
415
+ });
373
416
 
374
417
  const updatedShared = updateSharedContext(ensureRunState(state).sharedContext, turnRecord);
375
418
  const succeededRun = appendRunEvent({
@@ -397,6 +440,7 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
397
440
  ...state,
398
441
  run: finalRun,
399
442
  });
443
+ await maybeRecordNoTurnDiagnostic(state);
400
444
  state = await finalizeSessionArtifacts(state);
401
445
  }
402
446
 
@@ -120,16 +120,18 @@ async function writeZellijLayout({ layoutPath, sessionId, sessionDir }) {
120
120
  await writeFile(layoutPath, content, 'utf8');
121
121
  }
122
122
 
123
- export async function spawnZellijSession({ sessionName, sessionDir, sessionId }) {
123
+ export async function spawnZellijSession({ sessionName, sessionDir, sessionId, binaryPath }) {
124
124
  const layoutPath = resolve(sessionDir, 'synapsegrid-layout.kdl');
125
125
  await writeZellijLayout({ layoutPath, sessionId, sessionDir });
126
- await runCommand('zellij', ['--session', sessionName, '--layout', layoutPath, '--detach']);
126
+ const command = binaryPath || process.env.ACFM_ZELLIJ_BIN || 'zellij';
127
+ await runCommand(command, ['--session', sessionName, '--layout', layoutPath, '--detach']);
127
128
  return { layoutPath };
128
129
  }
129
130
 
130
- export async function zellijSessionExists(sessionName) {
131
+ export async function zellijSessionExists(sessionName, binaryPath) {
131
132
  try {
132
- const result = await runCommand('zellij', ['list-sessions']);
133
+ const command = binaryPath || process.env.ACFM_ZELLIJ_BIN || 'zellij';
134
+ const result = await runCommand(command, ['list-sessions']);
133
135
  const lines = result.stdout.split('\n').map((line) => line.trim()).filter(Boolean);
134
136
  return lines.some((line) => line === sessionName || line.startsWith(`${sessionName} `));
135
137
  } catch {
@@ -138,7 +140,8 @@ export async function zellijSessionExists(sessionName) {
138
140
  }
139
141
 
140
142
  export async function runZellij(args, options = {}) {
141
- return runCommand('zellij', args, options);
143
+ const command = options.binaryPath || process.env.ACFM_ZELLIJ_BIN || 'zellij';
144
+ return runCommand(command, args, options);
142
145
  }
143
146
 
144
147
  export function resolveMultiplexer(preferred = 'auto', hasTmuxCommand = false, hasZellijCommand = false) {
@@ -55,6 +55,28 @@ function getMeetingSummaryPath(sessionId) {
55
55
  return join(getSessionDir(sessionId), 'meeting-summary.md');
56
56
  }
57
57
 
58
+ function getTurnsRawDir(sessionId) {
59
+ return join(getTurnsDir(sessionId), 'raw');
60
+ }
61
+
62
+ function getDiagnosticsPath(sessionId) {
63
+ return join(getSessionDir(sessionId), 'diagnostics.json');
64
+ }
65
+
66
+ export function sessionArtifactPaths(sessionId) {
67
+ return {
68
+ sessionDir: getSessionDir(sessionId),
69
+ statePath: getSessionStatePath(sessionId),
70
+ transcriptPath: getTranscriptPath(sessionId),
71
+ turnsDir: getTurnsDir(sessionId),
72
+ turnsRawDir: getTurnsRawDir(sessionId),
73
+ meetingLogPath: getMeetingLogPath(sessionId),
74
+ meetingLogJsonlPath: getMeetingLogJsonlPath(sessionId),
75
+ meetingSummaryPath: getMeetingSummaryPath(sessionId),
76
+ diagnosticsPath: getDiagnosticsPath(sessionId),
77
+ };
78
+ }
79
+
58
80
  function initialState(task, options = {}) {
59
81
  const sessionId = randomUUID();
60
82
  const createdAt = new Date().toISOString();
@@ -94,13 +116,98 @@ export async function createSession(task, options = {}) {
94
116
  await ensureSessionRoot();
95
117
  const state = initialState(task, options);
96
118
  const sessionDir = getSessionDir(state.sessionId);
119
+ const turnsDir = getTurnsDir(state.sessionId);
120
+ const turnsRawDir = getTurnsRawDir(state.sessionId);
121
+ const meetingLogPath = getMeetingLogPath(state.sessionId);
122
+ const meetingLogJsonlPath = getMeetingLogJsonlPath(state.sessionId);
123
+ const meetingSummaryPath = getMeetingSummaryPath(state.sessionId);
124
+ const diagnosticsPath = getDiagnosticsPath(state.sessionId);
97
125
  await mkdir(sessionDir, { recursive: true });
126
+ await mkdir(turnsDir, { recursive: true });
127
+ await mkdir(turnsRawDir, { recursive: true });
98
128
  await writeFile(getSessionStatePath(state.sessionId), JSON.stringify(state, null, 2) + '\n', 'utf8');
129
+ await writeFile(meetingLogPath, [
130
+ '# SynapseGrid Meeting Log',
131
+ '',
132
+ `Session: ${state.sessionId}`,
133
+ `Created: ${state.createdAt}`,
134
+ '',
135
+ '_No turns captured yet._',
136
+ '',
137
+ ].join('\n'), 'utf8');
138
+ await writeFile(meetingLogJsonlPath, '', 'utf8');
139
+ await writeFile(meetingSummaryPath, [
140
+ '# SynapseGrid Meeting Summary',
141
+ '',
142
+ `Session: ${state.sessionId}`,
143
+ 'Status: running',
144
+ '',
145
+ 'Summary is pending until the first turn is captured.',
146
+ '',
147
+ ].join('\n'), 'utf8');
148
+ await writeFile(diagnosticsPath, JSON.stringify({
149
+ sessionId: state.sessionId,
150
+ createdAt: state.createdAt,
151
+ turnCount: 0,
152
+ lastTurnAt: null,
153
+ lastError: null,
154
+ }, null, 2) + '\n', 'utf8');
99
155
  await writeCurrentSession(state.sessionId, state.updatedAt);
100
156
  await appendTranscript(state.sessionId, state.messages[0]);
101
157
  return state;
102
158
  }
103
159
 
160
+ export async function ensureSessionArtifacts(sessionId, state = null) {
161
+ const paths = sessionArtifactPaths(sessionId);
162
+ await mkdir(paths.sessionDir, { recursive: true });
163
+ await mkdir(paths.turnsDir, { recursive: true });
164
+ await mkdir(paths.turnsRawDir, { recursive: true });
165
+
166
+ const currentState = state || (existsSync(paths.statePath)
167
+ ? JSON.parse(await readFile(paths.statePath, 'utf8'))
168
+ : null);
169
+
170
+ if (!existsSync(paths.meetingLogPath)) {
171
+ await writeFile(paths.meetingLogPath, [
172
+ '# SynapseGrid Meeting Log',
173
+ '',
174
+ `Session: ${sessionId}`,
175
+ `Created: ${currentState?.createdAt || new Date().toISOString()}`,
176
+ '',
177
+ '_No turns captured yet._',
178
+ '',
179
+ ].join('\n'), 'utf8');
180
+ }
181
+
182
+ if (!existsSync(paths.meetingLogJsonlPath)) {
183
+ await writeFile(paths.meetingLogJsonlPath, '', 'utf8');
184
+ }
185
+
186
+ if (!existsSync(paths.meetingSummaryPath)) {
187
+ await writeFile(paths.meetingSummaryPath, [
188
+ '# SynapseGrid Meeting Summary',
189
+ '',
190
+ `Session: ${sessionId}`,
191
+ `Status: ${currentState?.status || 'running'}`,
192
+ '',
193
+ 'Summary is pending until the first turn is captured.',
194
+ '',
195
+ ].join('\n'), 'utf8');
196
+ }
197
+
198
+ if (!existsSync(paths.diagnosticsPath)) {
199
+ await writeFile(paths.diagnosticsPath, JSON.stringify({
200
+ sessionId,
201
+ createdAt: currentState?.createdAt || new Date().toISOString(),
202
+ turnCount: 0,
203
+ lastTurnAt: null,
204
+ lastError: null,
205
+ }, null, 2) + '\n', 'utf8');
206
+ }
207
+
208
+ return paths;
209
+ }
210
+
104
211
  export async function appendTranscript(sessionId, message) {
105
212
  const transcriptPath = getTranscriptPath(sessionId);
106
213
  const line = JSON.stringify(message) + '\n';
@@ -112,18 +219,25 @@ export async function appendTranscript(sessionId, message) {
112
219
  }
113
220
 
114
221
  export async function appendMeetingTurn(sessionId, turnRecord) {
115
- const sessionDir = getSessionDir(sessionId);
116
- const turnsDir = getTurnsDir(sessionId);
222
+ const {
223
+ sessionDir,
224
+ turnsDir,
225
+ turnsRawDir,
226
+ meetingLogPath,
227
+ meetingLogJsonlPath,
228
+ diagnosticsPath,
229
+ } = sessionArtifactPaths(sessionId);
117
230
  await mkdir(sessionDir, { recursive: true });
118
231
  await mkdir(turnsDir, { recursive: true });
232
+ await mkdir(turnsRawDir, { recursive: true });
119
233
 
120
234
  const safeRole = String(turnRecord?.role || 'unknown').replace(/[^a-z0-9_-]/gi, '_');
121
235
  const safeRound = Number.isInteger(turnRecord?.round) ? turnRecord.round : 0;
122
236
  const turnFilePath = join(turnsDir, `${String(safeRound).padStart(3, '0')}-${safeRole}.json`);
123
237
  await writeFile(turnFilePath, JSON.stringify(turnRecord, null, 2) + '\n', 'utf8');
124
238
 
125
- const mdPath = getMeetingLogPath(sessionId);
126
- const jsonlPath = getMeetingLogJsonlPath(sessionId);
239
+ const mdPath = meetingLogPath;
240
+ const jsonlPath = meetingLogJsonlPath;
127
241
  const snippet = (turnRecord?.snippet || '').trim() || '(empty output)';
128
242
  const keyPoints = Array.isArray(turnRecord?.keyPoints) ? turnRecord.keyPoints : [];
129
243
 
@@ -149,6 +263,16 @@ export async function appendMeetingTurn(sessionId, turnRecord) {
149
263
  }
150
264
 
151
265
  await appendFile(jsonlPath, JSON.stringify(turnRecord) + '\n', 'utf8');
266
+
267
+ const diagnostics = existsSync(diagnosticsPath)
268
+ ? JSON.parse(await readFile(diagnosticsPath, 'utf8'))
269
+ : { sessionId, turnCount: 0 };
270
+ diagnostics.turnCount = Number.isInteger(diagnostics.turnCount) ? diagnostics.turnCount + 1 : 1;
271
+ diagnostics.lastTurnAt = turnRecord?.timestamp || new Date().toISOString();
272
+ diagnostics.lastRole = safeRole;
273
+ diagnostics.lastRound = safeRound;
274
+ await writeFile(diagnosticsPath, JSON.stringify(diagnostics, null, 2) + '\n', 'utf8');
275
+
152
276
  return {
153
277
  turnFilePath,
154
278
  meetingLogPath: mdPath,
@@ -156,6 +280,47 @@ export async function appendMeetingTurn(sessionId, turnRecord) {
156
280
  };
157
281
  }
158
282
 
283
+ export async function appendTurnRawCapture(sessionId, turnRecord, capture = {}) {
284
+ const { turnsRawDir } = sessionArtifactPaths(sessionId);
285
+ await mkdir(turnsRawDir, { recursive: true });
286
+
287
+ const safeRole = String(turnRecord?.role || 'unknown').replace(/[^a-z0-9_-]/gi, '_');
288
+ const safeRound = Number.isInteger(turnRecord?.round) ? turnRecord.round : 0;
289
+ const baseName = `${String(safeRound).padStart(3, '0')}-${safeRole}`;
290
+ const ndjsonPath = join(turnsRawDir, `${baseName}.ndjson`);
291
+ const stderrPath = join(turnsRawDir, `${baseName}.stderr.log`);
292
+ const metaPath = join(turnsRawDir, `${baseName}.meta.json`);
293
+
294
+ await writeFile(ndjsonPath, String(capture.stdout || ''), 'utf8');
295
+ await writeFile(stderrPath, String(capture.stderr || ''), 'utf8');
296
+ await writeFile(metaPath, JSON.stringify({
297
+ round: safeRound,
298
+ role: safeRole,
299
+ model: turnRecord?.model || null,
300
+ capturedAt: new Date().toISOString(),
301
+ stdoutBytes: Buffer.byteLength(String(capture.stdout || ''), 'utf8'),
302
+ stderrBytes: Buffer.byteLength(String(capture.stderr || ''), 'utf8'),
303
+ eventCount: Array.isArray(capture.events) ? capture.events.length : 0,
304
+ }, null, 2) + '\n', 'utf8');
305
+
306
+ return { ndjsonPath, stderrPath, metaPath };
307
+ }
308
+
309
+ export async function updateSessionDiagnostics(sessionId, patch = {}) {
310
+ const { diagnosticsPath, sessionDir } = sessionArtifactPaths(sessionId);
311
+ await mkdir(sessionDir, { recursive: true });
312
+ const current = existsSync(diagnosticsPath)
313
+ ? JSON.parse(await readFile(diagnosticsPath, 'utf8'))
314
+ : { sessionId, turnCount: 0, lastTurnAt: null };
315
+ const next = {
316
+ ...current,
317
+ ...patch,
318
+ updatedAt: new Date().toISOString(),
319
+ };
320
+ await writeFile(diagnosticsPath, JSON.stringify(next, null, 2) + '\n', 'utf8');
321
+ return next;
322
+ }
323
+
159
324
  export async function writeMeetingSummary(sessionId, summaryMarkdown) {
160
325
  const outputPath = getMeetingSummaryPath(sessionId);
161
326
  await writeFile(outputPath, String(summaryMarkdown || '').trimEnd() + '\n', 'utf8');
@@ -18,6 +18,7 @@ import { runWorkerIteration } from '../agents/orchestrator.js';
18
18
  import {
19
19
  addUserMessage,
20
20
  createSession,
21
+ ensureSessionArtifacts,
21
22
  getSessionDir,
22
23
  loadCurrentSessionId,
23
24
  loadSessionState,
@@ -26,6 +27,7 @@ import {
26
27
  saveSessionState,
27
28
  setCurrentSession,
28
29
  stopSession,
30
+ sessionArtifactPaths,
29
31
  writeMeetingSummary,
30
32
  } from '../agents/state-store.js';
31
33
  import {
@@ -45,7 +47,13 @@ import {
45
47
  normalizeModelId,
46
48
  sanitizeRoleModels,
47
49
  } from '../agents/model-selection.js';
48
- import { ensureCollabDependencies, hasCommand, resolveCommandPath } from '../services/dependency-installer.js';
50
+ import {
51
+ ensureCollabDependencies,
52
+ hasCommand,
53
+ resolveCommandPath,
54
+ resolveManagedZellijPath,
55
+ installManagedZellijLatest,
56
+ } from '../services/dependency-installer.js';
49
57
 
50
58
  function output(data, json) {
51
59
  if (json) {
@@ -101,14 +109,42 @@ function sessionMuxName(state) {
101
109
  return state.multiplexerSessionName || state.tmuxSessionName || `acfm-synapse-${state.sessionId.slice(0, 8)}`;
102
110
  }
103
111
 
104
- async function sessionExistsForMux(multiplexer, sessionName) {
105
- if (multiplexer === 'zellij') return zellijSessionExists(sessionName);
112
+ async function sessionExistsForMux(multiplexer, sessionName, zellijPath = null) {
113
+ if (multiplexer === 'zellij') return zellijSessionExists(sessionName, zellijPath);
106
114
  return tmuxSessionExists(sessionName);
107
115
  }
108
116
 
109
- async function attachToMux(multiplexer, sessionName, readonly = false) {
117
+ function resolveConfiguredZellijPath(config) {
118
+ const strategy = config?.agents?.zellij?.strategy || 'auto';
119
+ if (strategy === 'system') {
120
+ return resolveCommandPath('zellij');
121
+ }
122
+ const managed = resolveManagedZellijPath(config);
123
+ if (managed) return managed;
124
+ return resolveCommandPath('zellij');
125
+ }
126
+
127
+ function shouldUseManagedZellij(config) {
128
+ const strategy = config?.agents?.zellij?.strategy || 'auto';
129
+ if (strategy === 'managed') return true;
130
+ if (strategy === 'system') return false;
131
+ return true;
132
+ }
133
+
134
+ function resolveMultiplexerWithPaths(config, requestedMux = 'auto') {
135
+ const zellijPath = resolveConfiguredZellijPath(config);
136
+ const tmuxPath = resolveCommandPath('tmux');
137
+ const selected = resolveMultiplexer(requestedMux, Boolean(tmuxPath), Boolean(zellijPath));
138
+ return {
139
+ selected,
140
+ zellijPath,
141
+ tmuxPath,
142
+ };
143
+ }
144
+
145
+ async function attachToMux(multiplexer, sessionName, readonly = false, zellijPath = null) {
110
146
  if (multiplexer === 'zellij') {
111
- await runZellij(['attach', sessionName], { stdio: 'inherit' });
147
+ await runZellij(['attach', sessionName], { stdio: 'inherit', binaryPath: zellijPath });
112
148
  return;
113
149
  }
114
150
  const args = ['attach'];
@@ -206,6 +242,18 @@ async function readSessionArtifact(sessionId, filename) {
206
242
  return readFile(path, 'utf8');
207
243
  }
208
244
 
245
+ async function collectArtifactStatus(sessionId) {
246
+ await ensureSessionArtifacts(sessionId);
247
+ const paths = sessionArtifactPaths(sessionId);
248
+ return {
249
+ sessionId,
250
+ checkedAt: new Date().toISOString(),
251
+ artifacts: Object.fromEntries(
252
+ Object.entries(paths).map(([key, value]) => [key, { path: value, exists: existsSync(value) }])
253
+ ),
254
+ };
255
+ }
256
+
209
257
  async function preflightModel({ opencodeBin, model, cwd }) {
210
258
  const selected = normalizeModelId(model) || DEFAULT_SYNAPSE_MODEL;
211
259
  try {
@@ -230,7 +278,9 @@ export function agentsCommand() {
230
278
  Examples:
231
279
  acfm agents start --task "Implement auth flow" --mux auto
232
280
  acfm agents setup
281
+ acfm agents artifacts
233
282
  acfm agents runtime get
283
+ acfm agents runtime install-zellij
234
284
  acfm agents runtime set zellij
235
285
  acfm agents model choose
236
286
  acfm agents model list
@@ -270,28 +320,50 @@ Examples:
270
320
  const result = ensureCollabDependencies({
271
321
  installZellij,
272
322
  installTmux,
323
+ preferManagedZellij: installZellij,
273
324
  });
325
+ const awaited = await result;
274
326
  let collabMcp = null;
275
327
 
276
- if (result.success) {
328
+ if (awaited.success) {
277
329
  const { detectAndInstallMCPs } = await import('../services/mcp-installer.js');
278
330
  collabMcp = detectAndInstallMCPs({ target: 'collab' });
279
331
  }
280
332
 
281
- const payload = { ...result, collabMcp };
333
+ const payload = { ...awaited, collabMcp };
282
334
  output(payload, opts.json);
283
335
  if (!opts.json) {
284
- const oLabel = result.opencode.success ? chalk.green('ok') : chalk.red('failed');
285
- const tLabel = result.tmux.success ? chalk.green('ok') : chalk.red('failed');
286
- const zLabel = result.zellij.success ? chalk.green('ok') : chalk.red('failed');
287
- console.log(`OpenCode: ${oLabel} - ${result.opencode.message}`);
288
- console.log(`zellij: ${zLabel} - ${result.zellij.message}`);
289
- console.log(`tmux: ${tLabel} - ${result.tmux.message}`);
336
+ const oLabel = awaited.opencode.success ? chalk.green('ok') : chalk.red('failed');
337
+ const tLabel = awaited.tmux.success ? chalk.green('ok') : chalk.red('failed');
338
+ const zLabel = awaited.zellij.success ? chalk.green('ok') : chalk.red('failed');
339
+ console.log(`OpenCode: ${oLabel} - ${awaited.opencode.message}`);
340
+ console.log(`zellij: ${zLabel} - ${awaited.zellij.message}`);
341
+ if (awaited.zellij.binaryPath) {
342
+ console.log(chalk.dim(` ${awaited.zellij.binaryPath}`));
343
+ }
344
+ console.log(`tmux: ${tLabel} - ${awaited.tmux.message}`);
290
345
  if (collabMcp) {
291
346
  console.log(`Collab MCP: ${chalk.green('ok')} - installed ${collabMcp.success}/${collabMcp.installed} on detected assistants`);
292
347
  }
293
348
  }
294
- if (!result.success) process.exit(1);
349
+
350
+ if (awaited.zellij.success && awaited.zellij.source === 'managed' && awaited.zellij.binaryPath) {
351
+ await updateAgentsConfig((current) => ({
352
+ agents: {
353
+ defaultModel: current.agents.defaultModel,
354
+ defaultRoleModels: { ...current.agents.defaultRoleModels },
355
+ multiplexer: current.agents.multiplexer || 'auto',
356
+ zellij: {
357
+ strategy: 'managed',
358
+ binaryPath: awaited.zellij.binaryPath,
359
+ version: awaited.zellij.version || null,
360
+ source: 'managed',
361
+ },
362
+ },
363
+ }));
364
+ }
365
+
366
+ if (!awaited.success) process.exit(1);
295
367
  });
296
368
 
297
369
  agents
@@ -393,12 +465,14 @@ Examples:
393
465
  try {
394
466
  const sessionId = await ensureSessionId(true);
395
467
  const state = await loadSessionState(sessionId);
468
+ const cfg = await loadAgentsConfig();
469
+ const zellijPath = resolveConfiguredZellijPath(cfg);
396
470
  const multiplexer = state.multiplexer || 'tmux';
397
471
  const muxSessionName = sessionMuxName(state);
398
472
  if (!muxSessionName) {
399
473
  throw new Error('No multiplexer session registered for active collaborative session');
400
474
  }
401
- await attachToMux(multiplexer, muxSessionName, false);
475
+ await attachToMux(multiplexer, muxSessionName, false, zellijPath);
402
476
  } catch (error) {
403
477
  console.error(chalk.red(`Error: ${error.message}`));
404
478
  process.exit(1);
@@ -413,16 +487,18 @@ Examples:
413
487
  try {
414
488
  const sessionId = await ensureSessionId(true);
415
489
  const state = await loadSessionState(sessionId);
490
+ const cfg = await loadAgentsConfig();
491
+ const zellijPath = resolveConfiguredZellijPath(cfg);
416
492
  const multiplexer = state.multiplexer || 'tmux';
417
493
  const muxSessionName = sessionMuxName(state);
418
494
  if (!muxSessionName) {
419
495
  throw new Error('No multiplexer session registered for active collaborative session');
420
496
  }
421
- const sessionExists = await sessionExistsForMux(multiplexer, muxSessionName);
497
+ const sessionExists = await sessionExistsForMux(multiplexer, muxSessionName, zellijPath);
422
498
  if (!sessionExists) {
423
499
  throw new Error(`${multiplexer} session ${muxSessionName} no longer exists. Run: acfm agents resume`);
424
500
  }
425
- await attachToMux(multiplexer, muxSessionName, Boolean(opts.readonly));
501
+ await attachToMux(multiplexer, muxSessionName, Boolean(opts.readonly), zellijPath);
426
502
  } catch (error) {
427
503
  console.error(chalk.red(`Error: ${error.message}`));
428
504
  process.exit(1);
@@ -441,21 +517,29 @@ Examples:
441
517
  const sessionId = opts.session || await ensureSessionId(true);
442
518
  let state = await loadSessionState(sessionId);
443
519
  const multiplexer = state.multiplexer || 'tmux';
520
+ const cfg = await loadAgentsConfig();
521
+ const zellijPath = resolveConfiguredZellijPath(cfg);
522
+ const tmuxPath = resolveCommandPath('tmux');
444
523
 
445
- if (multiplexer === 'zellij' && !hasCommand('zellij')) {
524
+ if (multiplexer === 'zellij' && !zellijPath) {
446
525
  throw new Error('zellij is not installed. Run: acfm agents setup');
447
526
  }
448
- if (multiplexer === 'tmux' && !hasCommand('tmux')) {
527
+ if (multiplexer === 'tmux' && !tmuxPath) {
449
528
  throw new Error('tmux is not installed. Run: acfm agents setup');
450
529
  }
451
530
 
452
531
  const muxSessionName = sessionMuxName(state);
453
- const muxExists = await sessionExistsForMux(multiplexer, muxSessionName);
532
+ const muxExists = await sessionExistsForMux(multiplexer, muxSessionName, zellijPath);
454
533
 
455
534
  if (!muxExists && opts.recreate) {
456
535
  const sessionDir = getSessionDir(state.sessionId);
457
536
  if (multiplexer === 'zellij') {
458
- await spawnZellijSession({ sessionName: muxSessionName, sessionDir, sessionId: state.sessionId });
537
+ await spawnZellijSession({
538
+ sessionName: muxSessionName,
539
+ sessionDir,
540
+ sessionId: state.sessionId,
541
+ binaryPath: zellijPath,
542
+ });
459
543
  } else {
460
544
  await spawnTmuxSession({ sessionName: muxSessionName, sessionDir, sessionId: state.sessionId });
461
545
  }
@@ -484,7 +568,7 @@ Examples:
484
568
  }
485
569
 
486
570
  if (opts.attach) {
487
- await attachToMux(multiplexer, muxSessionName, false);
571
+ await attachToMux(multiplexer, muxSessionName, false, zellijPath);
488
572
  }
489
573
  } catch (error) {
490
574
  output({ error: error.message }, opts.json);
@@ -564,15 +648,19 @@ Examples:
564
648
  try {
565
649
  const cfg = await loadAgentsConfig();
566
650
  const configured = validateMultiplexer(cfg.agents.multiplexer || 'auto');
567
- const resolved = resolveMultiplexer(configured, hasCommand('tmux'), hasCommand('zellij'));
651
+ const zellijPath = resolveConfiguredZellijPath(cfg);
652
+ const tmuxPath = resolveCommandPath('tmux');
653
+ const resolved = resolveMultiplexer(configured, Boolean(tmuxPath), Boolean(zellijPath));
568
654
  const payload = {
569
655
  configPath: getAgentsConfigPath(),
570
656
  multiplexer: configured,
571
657
  resolved,
572
658
  available: {
573
- zellij: hasCommand('zellij'),
574
- tmux: hasCommand('tmux'),
659
+ zellij: Boolean(zellijPath),
660
+ tmux: Boolean(tmuxPath),
575
661
  },
662
+ zellij: cfg.agents.zellij,
663
+ zellijPath,
576
664
  };
577
665
  output(payload, opts.json);
578
666
  if (!opts.json) {
@@ -581,6 +669,9 @@ Examples:
581
669
  console.log(chalk.dim(`Configured: ${configured}`));
582
670
  console.log(chalk.dim(`Resolved: ${resolved || 'none'}`));
583
671
  console.log(chalk.dim(`zellij=${payload.available.zellij} tmux=${payload.available.tmux}`));
672
+ if (payload.zellijPath) {
673
+ console.log(chalk.dim(`zellij path: ${payload.zellijPath}`));
674
+ }
584
675
  }
585
676
  } catch (error) {
586
677
  output({ error: error.message }, opts.json);
@@ -601,9 +692,14 @@ Examples:
601
692
  defaultModel: current.agents.defaultModel,
602
693
  defaultRoleModels: { ...current.agents.defaultRoleModels },
603
694
  multiplexer: selected,
695
+ zellij: {
696
+ ...(current.agents.zellij || { strategy: 'auto', binaryPath: null, version: null, source: null }),
697
+ },
604
698
  },
605
699
  }));
606
- const resolved = resolveMultiplexer(updated.agents.multiplexer, hasCommand('tmux'), hasCommand('zellij'));
700
+ const zellijPath = resolveConfiguredZellijPath(updated);
701
+ const tmuxPath = resolveCommandPath('tmux');
702
+ const resolved = resolveMultiplexer(updated.agents.multiplexer, Boolean(tmuxPath), Boolean(zellijPath));
607
703
  const payload = {
608
704
  success: true,
609
705
  configPath: getAgentsConfigPath(),
@@ -623,6 +719,46 @@ Examples:
623
719
  }
624
720
  });
625
721
 
722
+ runtime
723
+ .command('install-zellij')
724
+ .description('Install latest zellij release managed by AC Framework')
725
+ .option('--json', 'Output as JSON')
726
+ .action(async (opts) => {
727
+ try {
728
+ const result = await installManagedZellijLatest();
729
+ if (!result.success) {
730
+ output(result, opts.json);
731
+ if (!opts.json) console.error(chalk.red(`Error: ${result.message}`));
732
+ process.exit(1);
733
+ }
734
+
735
+ await updateAgentsConfig((current) => ({
736
+ agents: {
737
+ defaultModel: current.agents.defaultModel,
738
+ defaultRoleModels: { ...current.agents.defaultRoleModels },
739
+ multiplexer: current.agents.multiplexer || 'auto',
740
+ zellij: {
741
+ strategy: result.source === 'system' ? 'system' : 'managed',
742
+ binaryPath: result.binaryPath,
743
+ version: result.version || null,
744
+ source: result.source || 'managed',
745
+ },
746
+ },
747
+ }));
748
+
749
+ output(result, opts.json);
750
+ if (!opts.json) {
751
+ console.log(chalk.green('✓ Managed zellij ready'));
752
+ console.log(chalk.dim(` Version: ${result.version || 'unknown'}`));
753
+ console.log(chalk.dim(` Binary: ${result.binaryPath}`));
754
+ }
755
+ } catch (error) {
756
+ output({ error: error.message }, opts.json);
757
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
758
+ process.exit(1);
759
+ }
760
+ });
761
+
626
762
  model
627
763
  .command('list')
628
764
  .description('List available OpenCode models grouped by provider')
@@ -767,6 +903,9 @@ Examples:
767
903
  defaultModel: current.agents.defaultModel,
768
904
  defaultRoleModels: { ...current.agents.defaultRoleModels },
769
905
  multiplexer: current.agents.multiplexer || 'auto',
906
+ zellij: {
907
+ ...(current.agents.zellij || { strategy: 'auto', binaryPath: null, version: null, source: null }),
908
+ },
770
909
  },
771
910
  };
772
911
 
@@ -828,6 +967,9 @@ Examples:
828
967
  defaultModel: current.agents.defaultModel,
829
968
  defaultRoleModels: { ...current.agents.defaultRoleModels },
830
969
  multiplexer: current.agents.multiplexer || 'auto',
970
+ zellij: {
971
+ ...(current.agents.zellij || { strategy: 'auto', binaryPath: null, version: null, source: null }),
972
+ },
831
973
  },
832
974
  };
833
975
  if (role === 'all') {
@@ -877,6 +1019,9 @@ Examples:
877
1019
  defaultModel: current.agents.defaultModel,
878
1020
  defaultRoleModels: { ...current.agents.defaultRoleModels },
879
1021
  multiplexer: current.agents.multiplexer || 'auto',
1022
+ zellij: {
1023
+ ...(current.agents.zellij || { strategy: 'auto', binaryPath: null, version: null, source: null }),
1024
+ },
880
1025
  },
881
1026
  };
882
1027
  if (role === 'all') {
@@ -985,6 +1130,66 @@ Examples:
985
1130
  }
986
1131
  });
987
1132
 
1133
+ agents
1134
+ .command('artifacts')
1135
+ .description('Show SynapseGrid artifact paths and existence status')
1136
+ .option('--session <id>', 'Session ID (defaults to current)')
1137
+ .option('--watch', 'Continuously watch artifact status', false)
1138
+ .option('--interval <ms>', 'Polling interval in milliseconds for --watch', '1500')
1139
+ .option('--json', 'Output as JSON')
1140
+ .action(async (opts) => {
1141
+ try {
1142
+ const sessionId = opts.session || await ensureSessionId(true);
1143
+ const intervalMs = Number.parseInt(opts.interval, 10);
1144
+ if (!Number.isInteger(intervalMs) || intervalMs <= 0) {
1145
+ throw new Error('--interval must be a positive integer');
1146
+ }
1147
+
1148
+ const printSnapshot = async () => {
1149
+ const snapshot = await collectArtifactStatus(sessionId);
1150
+ if (opts.json) {
1151
+ process.stdout.write(JSON.stringify(snapshot) + '\n');
1152
+ return;
1153
+ }
1154
+
1155
+ console.log(chalk.bold('SynapseGrid artifacts'));
1156
+ console.log(chalk.dim(`Session: ${snapshot.sessionId}`));
1157
+ console.log(chalk.dim(`Checked: ${snapshot.checkedAt}`));
1158
+ for (const [key, meta] of Object.entries(snapshot.artifacts)) {
1159
+ console.log(chalk.dim(`${key}: ${meta.exists ? 'ok' : 'missing'} -> ${meta.path}`));
1160
+ }
1161
+ };
1162
+
1163
+ if (!opts.watch) {
1164
+ const snapshot = await collectArtifactStatus(sessionId);
1165
+ output(snapshot, opts.json);
1166
+ if (!opts.json) {
1167
+ console.log(chalk.bold('SynapseGrid artifacts'));
1168
+ for (const [key, meta] of Object.entries(snapshot.artifacts)) {
1169
+ console.log(chalk.dim(`${key}: ${meta.exists ? 'ok' : 'missing'} -> ${meta.path}`));
1170
+ }
1171
+ }
1172
+ return;
1173
+ }
1174
+
1175
+ if (!opts.json) {
1176
+ console.log(chalk.cyan('Watching artifacts (Ctrl+C to stop)\n'));
1177
+ }
1178
+
1179
+ while (true) {
1180
+ if (!opts.json) {
1181
+ process.stdout.write('\x1Bc');
1182
+ }
1183
+ await printSnapshot();
1184
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs));
1185
+ }
1186
+ } catch (error) {
1187
+ output({ error: error.message }, opts.json);
1188
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
1189
+ process.exit(1);
1190
+ }
1191
+ });
1192
+
988
1193
  agents
989
1194
  .command('export')
990
1195
  .description('Export collaborative transcript')
@@ -1062,7 +1267,31 @@ Examples:
1062
1267
 
1063
1268
  const config = await loadAgentsConfig();
1064
1269
  const configuredMux = validateMultiplexer(opts.mux || config.agents.multiplexer || 'auto');
1065
- const selectedMux = resolveMultiplexer(configuredMux, hasCommand('tmux'), hasCommand('zellij'));
1270
+ const muxResolution = resolveMultiplexerWithPaths(config, configuredMux);
1271
+ let selectedMux = muxResolution.selected;
1272
+ let zellijPath = muxResolution.zellijPath;
1273
+ if (!selectedMux) {
1274
+ if (configuredMux !== 'tmux' && shouldUseManagedZellij(config)) {
1275
+ const installResult = await installManagedZellijLatest();
1276
+ if (installResult.success && installResult.binaryPath) {
1277
+ await updateAgentsConfig((current) => ({
1278
+ agents: {
1279
+ defaultModel: current.agents.defaultModel,
1280
+ defaultRoleModels: { ...current.agents.defaultRoleModels },
1281
+ multiplexer: current.agents.multiplexer || 'auto',
1282
+ zellij: {
1283
+ strategy: 'managed',
1284
+ binaryPath: installResult.binaryPath,
1285
+ version: installResult.version || null,
1286
+ source: 'managed',
1287
+ },
1288
+ },
1289
+ }));
1290
+ zellijPath = installResult.binaryPath;
1291
+ selectedMux = resolveMultiplexer(configuredMux, Boolean(resolveCommandPath('tmux')), Boolean(zellijPath));
1292
+ }
1293
+ }
1294
+ }
1066
1295
  if (!selectedMux) {
1067
1296
  throw new Error('No multiplexer found. Install zellij or tmux with: acfm agents setup');
1068
1297
  }
@@ -1112,6 +1341,7 @@ Examples:
1112
1341
  sessionName: muxSessionName,
1113
1342
  sessionDir,
1114
1343
  sessionId: state.sessionId,
1344
+ binaryPath: zellijPath,
1115
1345
  });
1116
1346
  } else {
1117
1347
  await spawnTmuxSession({
@@ -1140,7 +1370,7 @@ Examples:
1140
1370
  }
1141
1371
 
1142
1372
  if (opts.attach) {
1143
- await attachToMux(selectedMux, muxSessionName, false);
1373
+ await attachToMux(selectedMux, muxSessionName, false, zellijPath);
1144
1374
  }
1145
1375
  } catch (error) {
1146
1376
  output({ error: error.message }, opts.json);
@@ -1176,6 +1406,7 @@ Examples:
1176
1406
  try {
1177
1407
  const sessionId = await ensureSessionId(true);
1178
1408
  const state = await loadSessionState(sessionId);
1409
+ await ensureSessionArtifacts(sessionId, state);
1179
1410
  const effectiveRoleModels = buildEffectiveRoleModels(state, state.model || null);
1180
1411
  output({ ...state, effectiveRoleModels }, opts.json);
1181
1412
  if (!opts.json) {
@@ -1199,8 +1430,12 @@ Examples:
1199
1430
  }
1200
1431
  const meetingLogPath = resolve(getSessionDir(state.sessionId), 'meeting-log.md');
1201
1432
  const meetingSummaryPath = resolve(getSessionDir(state.sessionId), 'meeting-summary.md');
1433
+ const turnsDirPath = resolve(getSessionDir(state.sessionId), 'turns');
1434
+ const rawDirPath = resolve(getSessionDir(state.sessionId), 'turns', 'raw');
1202
1435
  console.log(chalk.dim(`meeting-log: ${existsSync(meetingLogPath) ? meetingLogPath : 'not generated yet'}`));
1203
1436
  console.log(chalk.dim(`meeting-summary: ${existsSync(meetingSummaryPath) ? meetingSummaryPath : 'not generated yet'}`));
1437
+ console.log(chalk.dim(`turns: ${existsSync(turnsDirPath) ? turnsDirPath : 'not generated yet'}`));
1438
+ console.log(chalk.dim(`turns/raw: ${existsSync(rawDirPath) ? rawDirPath : 'not generated yet'}`));
1204
1439
  }
1205
1440
  } catch (error) {
1206
1441
  output({ error: error.message }, opts.json);
@@ -1262,9 +1497,11 @@ Examples:
1262
1497
 
1263
1498
  const multiplexer = state.multiplexer || 'tmux';
1264
1499
  const muxSessionName = sessionMuxName(state);
1265
- if (multiplexer === 'zellij' && muxSessionName && hasCommand('zellij')) {
1500
+ const cfg = await loadAgentsConfig();
1501
+ const zellijPath = resolveConfiguredZellijPath(cfg);
1502
+ if (multiplexer === 'zellij' && muxSessionName && zellijPath) {
1266
1503
  try {
1267
- await runZellij(['delete-session', muxSessionName]);
1504
+ await runZellij(['delete-session', muxSessionName], { binaryPath: zellijPath });
1268
1505
  } catch {
1269
1506
  // ignore if already closed
1270
1507
  }
@@ -1376,8 +1613,9 @@ Examples:
1376
1613
  try {
1377
1614
  const opencodeBin = resolveCommandPath('opencode');
1378
1615
  const tmuxInstalled = hasCommand('tmux');
1379
- const zellijInstalled = hasCommand('zellij');
1380
1616
  const cfg = await loadAgentsConfig();
1617
+ const zellijPath = resolveConfiguredZellijPath(cfg);
1618
+ const zellijInstalled = Boolean(zellijPath);
1381
1619
  const defaultModel = cfg.agents.defaultModel || DEFAULT_SYNAPSE_MODEL;
1382
1620
  const configuredMux = validateMultiplexer(cfg.agents.multiplexer || 'auto');
1383
1621
  const resolvedMux = resolveMultiplexer(configuredMux, tmuxInstalled, zellijInstalled);
@@ -1385,6 +1623,8 @@ Examples:
1385
1623
  opencodeBin,
1386
1624
  tmuxInstalled,
1387
1625
  zellijInstalled,
1626
+ zellijPath,
1627
+ zellijConfig: cfg.agents.zellij,
1388
1628
  configuredMultiplexer: configuredMux,
1389
1629
  resolvedMultiplexer: resolvedMux,
1390
1630
  defaultModel,
@@ -1407,6 +1647,7 @@ Examples:
1407
1647
  console.log(chalk.bold('SynapseGrid doctor'));
1408
1648
  console.log(chalk.dim(`opencode: ${opencodeBin || 'not found'}`));
1409
1649
  console.log(chalk.dim(`zellij: ${zellijInstalled ? 'installed' : 'not installed'}`));
1650
+ if (zellijPath) console.log(chalk.dim(`zellij path: ${zellijPath}`));
1410
1651
  console.log(chalk.dim(`tmux: ${tmuxInstalled ? 'installed' : 'not installed'}`));
1411
1652
  console.log(chalk.dim(`multiplexer: configured=${configuredMux} resolved=${resolvedMux || 'none'}`));
1412
1653
  console.log(chalk.dim(`default model: ${defaultModel}`));
@@ -236,13 +236,20 @@ async function setupCollaborativeSystem() {
236
236
  console.log(chalk.hex('#B2BEC3')(` Installing ${COLLAB_SYSTEM_NAME} dependencies...`));
237
237
  console.log();
238
238
 
239
- const result = ensureCollabDependencies({ installZellij: true, installTmux: true });
239
+ const result = await ensureCollabDependencies({
240
+ installZellij: true,
241
+ installTmux: true,
242
+ preferManagedZellij: true,
243
+ });
240
244
 
241
245
  const oColor = result.opencode.success ? chalk.hex('#00B894') : chalk.hex('#D63031');
242
246
  const zColor = result.zellij.success ? chalk.hex('#00B894') : chalk.hex('#D63031');
243
247
  const tColor = result.tmux.success ? chalk.hex('#00B894') : chalk.hex('#D63031');
244
248
  console.log(oColor(` ◆ OpenCode: ${result.opencode.message}`));
245
249
  console.log(zColor(` ◆ zellij: ${result.zellij.message}`));
250
+ if (result.zellij.binaryPath) {
251
+ console.log(chalk.hex('#636E72')(` ${result.zellij.binaryPath}`));
252
+ }
246
253
  console.log(tColor(` ◆ tmux: ${result.tmux.message}`));
247
254
  console.log();
248
255
 
@@ -34,7 +34,7 @@ import {
34
34
  setCurrentSession,
35
35
  stopSession,
36
36
  } from '../agents/state-store.js';
37
- import { hasCommand, resolveCommandPath } from '../services/dependency-installer.js';
37
+ import { hasCommand, resolveCommandPath, resolveManagedZellijPath } from '../services/dependency-installer.js';
38
38
  import { loadAgentsConfig } from '../agents/config-store.js';
39
39
 
40
40
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -74,8 +74,18 @@ function launchAutopilot(sessionId) {
74
74
  child.unref();
75
75
  }
76
76
 
77
- async function muxExists(multiplexer, sessionName) {
78
- if (multiplexer === 'zellij') return zellijSessionExists(sessionName);
77
+ function resolveConfiguredZellijPath(config) {
78
+ const strategy = config?.agents?.zellij?.strategy || 'auto';
79
+ if (strategy === 'system') {
80
+ return resolveCommandPath('zellij');
81
+ }
82
+ const managed = resolveManagedZellijPath(config);
83
+ if (managed) return managed;
84
+ return resolveCommandPath('zellij');
85
+ }
86
+
87
+ async function muxExists(multiplexer, sessionName, zellijPath = null) {
88
+ if (multiplexer === 'zellij') return zellijSessionExists(sessionName, zellijPath);
79
89
  return tmuxSessionExists(sessionName);
80
90
  }
81
91
 
@@ -121,7 +131,8 @@ class MCPCollabServer {
121
131
 
122
132
  const config = await loadAgentsConfig();
123
133
  const configuredMux = config.agents.multiplexer || 'auto';
124
- const multiplexer = resolveMultiplexer(configuredMux, hasCommand('tmux'), hasCommand('zellij'));
134
+ const zellijPath = resolveConfiguredZellijPath(config);
135
+ const multiplexer = resolveMultiplexer(configuredMux, hasCommand('tmux'), Boolean(zellijPath));
125
136
  if (spawnWorkers && !multiplexer) {
126
137
  throw new Error('No multiplexer found (zellij/tmux). Run: acfm agents setup');
127
138
  }
@@ -141,7 +152,7 @@ class MCPCollabServer {
141
152
  const sessionName = `acfm-synapse-${state.sessionId.slice(0, 8)}`;
142
153
  const sessionDir = getSessionDir(state.sessionId);
143
154
  if (multiplexer === 'zellij') {
144
- await spawnZellijSession({ sessionName, sessionDir, sessionId: state.sessionId });
155
+ await spawnZellijSession({ sessionName, sessionDir, sessionId: state.sessionId, binaryPath: zellijPath });
145
156
  } else {
146
157
  await spawnTmuxSession({ sessionName, sessionDir, sessionId: state.sessionId });
147
158
  }
@@ -452,19 +463,21 @@ class MCPCollabServer {
452
463
  const id = sessionId || await loadCurrentSessionId();
453
464
  if (!id) throw new Error('No active session found');
454
465
  let state = await loadSessionState(id);
466
+ const config = await loadAgentsConfig();
467
+ const zellijPath = resolveConfiguredZellijPath(config);
455
468
 
456
- const multiplexer = state.multiplexer || resolveMultiplexer('auto', hasCommand('tmux'), hasCommand('zellij'));
469
+ const multiplexer = state.multiplexer || resolveMultiplexer('auto', hasCommand('tmux'), Boolean(zellijPath));
457
470
  if (!multiplexer) {
458
471
  throw new Error('No multiplexer found (zellij/tmux). Run: acfm agents setup');
459
472
  }
460
473
  const sessionName = state.multiplexerSessionName || state.tmuxSessionName || `acfm-synapse-${state.sessionId.slice(0, 8)}`;
461
- const sessionExists = await muxExists(multiplexer, sessionName);
474
+ const sessionExists = await muxExists(multiplexer, sessionName, zellijPath);
462
475
 
463
476
  if (!sessionExists && recreateWorkers) {
464
477
  const sessionDir = getSessionDir(state.sessionId);
465
478
  if (multiplexer === 'zellij') {
466
- if (!hasCommand('zellij')) throw new Error('zellij is not installed. Run: acfm agents setup');
467
- await spawnZellijSession({ sessionName, sessionDir, sessionId: state.sessionId });
479
+ if (!zellijPath) throw new Error('zellij is not installed. Run: acfm agents setup');
480
+ await spawnZellijSession({ sessionName, sessionDir, sessionId: state.sessionId, binaryPath: zellijPath });
468
481
  } else {
469
482
  if (!hasCommand('tmux')) throw new Error('tmux is not installed. Run: acfm agents setup');
470
483
  await spawnTmuxSession({ sessionName, sessionDir, sessionId: state.sessionId });
@@ -1,7 +1,9 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
+ import { chmod, mkdir, rm, writeFile } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
- import { platform } from 'node:os';
5
+ import { arch, homedir, platform } from 'node:os';
6
+ import { createHash } from 'node:crypto';
5
7
 
6
8
  function preferredOpenCodePath() {
7
9
  const home = process.env.HOME;
@@ -24,6 +26,61 @@ function runInstallCommand(command) {
24
26
  return run('bash', ['-lc', command], { stdio: 'inherit' });
25
27
  }
26
28
 
29
+ async function fetchJson(url) {
30
+ const response = await fetch(url, {
31
+ headers: {
32
+ Accept: 'application/vnd.github+json',
33
+ 'User-Agent': 'ac-framework',
34
+ },
35
+ });
36
+ if (!response.ok) {
37
+ throw new Error(`Request failed (${response.status}) while fetching ${url}`);
38
+ }
39
+ return response.json();
40
+ }
41
+
42
+ function sha256HexFromBuffer(buffer) {
43
+ const hash = createHash('sha256');
44
+ hash.update(buffer);
45
+ return hash.digest('hex');
46
+ }
47
+
48
+ function managedToolsRoot() {
49
+ return join(homedir(), '.acfm', 'tools', 'zellij');
50
+ }
51
+
52
+ function platformAssetPrefix() {
53
+ const p = platform();
54
+ const a = arch();
55
+ if (p === 'linux' && a === 'x64') return 'zellij-x86_64-unknown-linux-musl';
56
+ if (p === 'linux' && a === 'arm64') return 'zellij-aarch64-unknown-linux-musl';
57
+ if (p === 'darwin' && a === 'x64') return 'zellij-x86_64-apple-darwin';
58
+ if (p === 'darwin' && a === 'arm64') return 'zellij-aarch64-apple-darwin';
59
+ if (p === 'win32' && a === 'x64') return 'zellij-x86_64-pc-windows-msvc';
60
+ return null;
61
+ }
62
+
63
+ function managedZellijBinaryPath(version) {
64
+ const fileName = platform() === 'win32' ? 'zellij.exe' : 'zellij';
65
+ return join(managedToolsRoot(), version, fileName);
66
+ }
67
+
68
+ function extractTarball(tarPath, outputDir) {
69
+ return run('tar', ['-xzf', tarPath, '-C', outputDir]);
70
+ }
71
+
72
+ function findReleaseAsset(release, suffix) {
73
+ return (release.assets || []).find((asset) => asset.name === suffix) || null;
74
+ }
75
+
76
+ export function resolveManagedZellijPath(config = null) {
77
+ const fromEnv = process.env.ACFM_ZELLIJ_BIN;
78
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
79
+ const configured = config?.agents?.zellij?.binaryPath;
80
+ if (configured && existsSync(configured)) return configured;
81
+ return null;
82
+ }
83
+
27
84
  export function hasCommand(command) {
28
85
  return Boolean(resolveCommandPath(command));
29
86
  }
@@ -164,15 +221,132 @@ export function installZellij() {
164
221
  };
165
222
  }
166
223
 
167
- export function ensureCollabDependencies(options = {}) {
224
+ export async function installManagedZellijLatest() {
225
+ const existingSystem = resolveCommandPath('zellij');
226
+ if (existingSystem) {
227
+ return {
228
+ success: true,
229
+ installed: false,
230
+ version: null,
231
+ binaryPath: existingSystem,
232
+ message: 'zellij already installed in system PATH',
233
+ source: 'system',
234
+ };
235
+ }
236
+
237
+ const prefix = platformAssetPrefix();
238
+ if (!prefix) {
239
+ return {
240
+ success: false,
241
+ installed: false,
242
+ version: null,
243
+ binaryPath: null,
244
+ message: `Unsupported OS/arch for managed zellij install: ${platform()}/${arch()}`,
245
+ source: 'managed',
246
+ };
247
+ }
248
+
249
+ try {
250
+ const release = await fetchJson('https://api.github.com/repos/zellij-org/zellij/releases/latest');
251
+ const version = String(release.tag_name || '').trim() || 'latest';
252
+
253
+ if (platform() === 'win32') {
254
+ const zipAsset = findReleaseAsset(release, `${prefix}.zip`);
255
+ if (!zipAsset) {
256
+ throw new Error(`No matching Windows asset found for ${prefix}`);
257
+ }
258
+ return {
259
+ success: false,
260
+ installed: false,
261
+ version,
262
+ binaryPath: null,
263
+ message: 'Managed Windows zellij install is not implemented yet; use winget/choco/scoop.',
264
+ source: 'managed',
265
+ };
266
+ }
267
+
268
+ const tarAsset = findReleaseAsset(release, `${prefix}.tar.gz`);
269
+ if (!tarAsset?.browser_download_url) {
270
+ throw new Error(`No matching zellij asset found for ${prefix}`);
271
+ }
272
+
273
+ const targetDir = join(managedToolsRoot(), version);
274
+ const binaryPath = managedZellijBinaryPath(version);
275
+ if (existsSync(binaryPath)) {
276
+ return {
277
+ success: true,
278
+ installed: false,
279
+ version,
280
+ binaryPath,
281
+ message: `Managed zellij already installed (${version})`,
282
+ source: 'managed',
283
+ };
284
+ }
285
+
286
+ await mkdir(targetDir, { recursive: true });
287
+ const tmpTarPath = join(targetDir, `${prefix}.tar.gz.download`);
288
+ const response = await fetch(tarAsset.browser_download_url, {
289
+ headers: { 'User-Agent': 'ac-framework' },
290
+ });
291
+ if (!response.ok) {
292
+ throw new Error(`Failed downloading ${tarAsset.name} (${response.status})`);
293
+ }
294
+ const raw = Buffer.from(await response.arrayBuffer());
295
+
296
+ const expectedDigest = String(tarAsset.digest || '').replace(/^sha256:/, '');
297
+ if (expectedDigest) {
298
+ const actualDigest = sha256HexFromBuffer(raw);
299
+ if (actualDigest !== expectedDigest) {
300
+ throw new Error(`Digest mismatch for ${tarAsset.name}`);
301
+ }
302
+ }
303
+
304
+ await writeFile(tmpTarPath, raw);
305
+ const extracted = extractTarball(tmpTarPath, targetDir);
306
+ await rm(tmpTarPath, { force: true });
307
+ if (extracted.status !== 0) {
308
+ throw new Error('Failed extracting zellij tarball');
309
+ }
310
+ if (!existsSync(binaryPath)) {
311
+ throw new Error(`zellij binary not found after extraction at ${binaryPath}`);
312
+ }
313
+ await chmod(binaryPath, 0o755);
314
+
315
+ const versionProbe = run(binaryPath, ['--version']);
316
+ if (versionProbe.status !== 0) {
317
+ throw new Error('Installed zellij binary failed version check');
318
+ }
319
+
320
+ return {
321
+ success: true,
322
+ installed: true,
323
+ version,
324
+ binaryPath,
325
+ message: `Managed zellij installed (${version})`,
326
+ source: 'managed',
327
+ };
328
+ } catch (error) {
329
+ return {
330
+ success: false,
331
+ installed: false,
332
+ version: null,
333
+ binaryPath: null,
334
+ message: `Managed zellij install failed: ${error.message}`,
335
+ source: 'managed',
336
+ };
337
+ }
338
+ }
339
+
340
+ export async function ensureCollabDependencies(options = {}) {
168
341
  const installTmuxEnabled = options.installTmux ?? true;
169
342
  const installZellijEnabled = options.installZellij ?? true;
343
+ const preferManagedZellij = options.preferManagedZellij ?? false;
170
344
  const opencode = installOpenCode();
171
345
  const tmux = installTmuxEnabled
172
346
  ? installTmux()
173
347
  : { success: hasCommand('tmux'), installed: false, message: hasCommand('tmux') ? 'tmux already installed' : 'tmux installation skipped' };
174
348
  const zellij = installZellijEnabled
175
- ? installZellij()
349
+ ? (preferManagedZellij ? await installManagedZellijLatest() : installZellij())
176
350
  : { success: hasCommand('zellij'), installed: false, message: hasCommand('zellij') ? 'zellij already installed' : 'zellij installation skipped' };
177
351
 
178
352
  const hasMultiplexer = tmux.success || zellij.success;