ac-framework 1.9.8 → 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
@@ -6,7 +6,7 @@ It combines three layers in one CLI:
6
6
  - template-based assistant configurations for multiple IDEs and AI CLIs
7
7
  - a built-in spec-driven workflow inspired by OpenSpec / spec-driven development
8
8
  - a persistent local memory system with MCP integration for supported assistants
9
- - an optional collaborative multi-agent runtime powered by OpenCode + tmux
9
+ - an optional collaborative multi-agent runtime powered by OpenCode + zellij (tmux fallback)
10
10
 
11
11
  ## Why AC Framework
12
12
 
@@ -26,7 +26,7 @@ The goal is simple: help AI write better code, with more context, more disciplin
26
26
  - `Spec-driven workflow` - use `acfm spec` to initialize, create, validate, continue, and archive structured changes.
27
27
  - `Persistent memory` - store architectural decisions, bugfixes, refactors, conventions, and context in a local SQLite memory database.
28
28
  - `MCP integration` - connect the memory system to supported assistants through MCP so they can recall and save context directly.
29
- - `Collaborative agents (optional)` - enable SynapseGrid to run planner/critic/coder/reviewer in coordinated tmux panes with shared context.
29
+ - `Collaborative agents (optional)` - enable SynapseGrid to run planner/critic/coder/reviewer in coordinated zellij panes (tmux fallback) with shared context.
30
30
  - `GitHub sync` - use `acfm init --latest` or `acfm update` to pull the latest framework content from GitHub.
31
31
  - `Legacy compatibility` - `.acfm/` is the new default, but existing `openspec/` directories still work.
32
32
 
@@ -57,7 +57,7 @@ The CLI now guides you through:
57
57
  2. choose one or more assistants from that template
58
58
  3. install the matching root instruction files like `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, or `copilot-instructions.md`
59
59
  4. optionally initialize NexusVault persistent memory and MCP connections
60
- 5. optionally enable SynapseGrid collaborative agents (auto-installs OpenCode + tmux)
60
+ 5. optionally enable SynapseGrid collaborative agents (auto-installs OpenCode + zellij/tmux)
61
61
 
62
62
  If enabled, `acfm init` also auto-installs the optional SynapseGrid MCP server into detected assistants.
63
63
 
@@ -130,7 +130,7 @@ Some assistants include bundled companions automatically:
130
130
 
131
131
  ### Collaborative Agents (Optional)
132
132
 
133
- SynapseGrid is an optional collaborative runtime that coordinates 4 OpenCode-backed roles in tmux panes:
133
+ SynapseGrid is an optional collaborative runtime that coordinates 4 OpenCode-backed roles in multiplexer panes (zellij preferred, tmux fallback):
134
134
  - planner
135
135
  - critic
136
136
  - coder
@@ -140,18 +140,23 @@ Each role runs in turn against a shared, accumulating context so outputs from on
140
140
 
141
141
  | Command | Description |
142
142
  |---|---|
143
- | `acfm agents setup` | Install optional dependencies (`opencode` and `tmux`) |
144
- | `acfm agents doctor` | Validate OpenCode/tmux/model preflight before start |
143
+ | `acfm agents setup` | Install optional dependencies (`opencode` and `zellij`/`tmux`) |
144
+ | `acfm agents doctor` | Validate OpenCode/multiplexer/model preflight before start |
145
145
  | `acfm agents install-mcps` | Install SynapseGrid MCP server for detected assistants |
146
146
  | `acfm agents uninstall-mcps` | Remove SynapseGrid MCP server from assistants |
147
147
  | `acfm agents start --task "..." --model-coder provider/model` | Start session with optional per-role models |
148
+ | `acfm agents start --task "..." --mux zellij` | Start session forcing zellij backend (`auto`/`tmux` also supported) |
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` |
151
+ | `acfm agents runtime set zellij` | Persist preferred multiplexer backend |
148
152
  | `acfm agents resume` | Resume a previous session and recreate workers if needed |
149
153
  | `acfm agents list` | List recent SynapseGrid sessions |
150
- | `acfm agents attach` | Attach directly to the SynapseGrid tmux session |
151
- | `acfm agents live` | Attach to full live tmux view (all agents) |
154
+ | `acfm agents attach` | Attach directly to the active SynapseGrid multiplexer session |
155
+ | `acfm agents live` | Attach to full live multiplexer view (all agents) |
152
156
  | `acfm agents logs` | Show recent worker logs (all roles or one role) |
153
157
  | `acfm agents transcript --role all --limit 40` | Show captured cross-agent transcript |
154
158
  | `acfm agents summary` | Show generated collaboration meeting summary |
159
+ | `acfm agents artifacts` | Show artifact paths/existence for current session |
155
160
  | `acfm agents export --format md --out file.md` | Export transcript in Markdown or JSON |
156
161
  | `acfm agents send "..."` | Send a new user message into the active session |
157
162
  | `acfm agents status` | Show current collaborative session state |
@@ -166,7 +171,7 @@ Each role runs in turn against a shared, accumulating context so outputs from on
166
171
 
167
172
  When driving SynapseGrid from another agent via MCP, prefer asynchronous run tools over role-by-role stepping:
168
173
 
169
- - `collab_start_session` to initialize session and optional tmux workers
174
+ - `collab_start_session` to initialize session and optional zellij/tmux workers
170
175
  - `collab_invoke_team` to launch full 4-role collaboration run
171
176
  - `collab_wait_run` to wait for completion/failure with bounded timeout
172
177
  - `collab_get_result` to fetch final consolidated output and run diagnostics
@@ -180,16 +185,19 @@ When driving SynapseGrid from another agent via MCP, prefer asynchronous run too
180
185
  - Attach to worker panes with `acfm agents live` (or `acfm agents attach`) to see real-time role discussion.
181
186
  - Inspect worker errors quickly with `acfm agents logs --role all --lines 120`.
182
187
  - Inspect collaborative discussion with `acfm agents transcript` and `acfm agents summary`.
183
- - 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.
188
+ - MCP starts can now create zellij/tmux workers directly; if your assistant used headless steps before, start a new session and ensure worker spawning is enabled.
184
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`.
185
190
  - Default SynapseGrid model fallback is `opencode/mimo-v2-pro-free`.
186
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.
187
193
 
188
194
  Each collaborative session now keeps human-readable artifacts under `~/.acfm/synapsegrid/<sessionId>/`:
189
195
  - `transcript.jsonl`: full chronological message stream
190
196
  - `turns/*.json`: one file per round/role turn with captured output metadata
191
197
  - `meeting-log.md`: incremental meeting notes generated per turn
192
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
193
201
 
194
202
  ### Spec Workflow
195
203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ac-framework",
3
- "version": "1.9.8",
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": {
@@ -10,10 +10,26 @@ const CONFIG_PATH = join(ACFM_DIR, 'config.json');
10
10
 
11
11
  function normalizeConfig(raw) {
12
12
  const agents = raw?.agents && typeof raw.agents === 'object' ? raw.agents : {};
13
+ const configuredMultiplexer = typeof agents.multiplexer === 'string' ? agents.multiplexer.trim().toLowerCase() : '';
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
+
13
22
  return {
14
23
  agents: {
15
24
  defaultModel: normalizeModelId(agents.defaultModel) || DEFAULT_SYNAPSE_MODEL,
16
25
  defaultRoleModels: sanitizeRoleModels(agents.defaultRoleModels),
26
+ multiplexer,
27
+ zellij: {
28
+ strategy,
29
+ binaryPath,
30
+ version,
31
+ source,
32
+ },
17
33
  },
18
34
  };
19
35
  }
@@ -11,14 +11,45 @@ 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';
21
23
 
24
+ async function finalizeSessionArtifacts(state) {
25
+ const runState = ensureRunState(state);
26
+ const summaryMd = buildMeetingSummary(state.messages, runState, runState.sharedContext);
27
+ await writeMeetingSummary(state.sessionId, summaryMd);
28
+ const completedRun = {
29
+ ...runState,
30
+ finalSummary: extractFinalSummary(state.messages, runState),
31
+ };
32
+ return saveSessionState({
33
+ ...state,
34
+ run: completedRun,
35
+ });
36
+ }
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
+
22
53
  function buildRuntimePrompt({ state, role }) {
23
54
  const roleContext = ROLE_SYSTEM_PROMPTS[role] || '';
24
55
  const collaborativePrompt = buildAgentPrompt({
@@ -63,6 +94,13 @@ function ensureRunState(state) {
63
94
  round: state.round || 1,
64
95
  events: [],
65
96
  finalSummary: null,
97
+ sharedContext: {
98
+ decisions: [],
99
+ openIssues: [],
100
+ risks: [],
101
+ actionItems: [],
102
+ notes: [],
103
+ },
66
104
  lastError: null,
67
105
  policy: {
68
106
  timeoutPerRoleMs: 180000,
@@ -141,6 +179,8 @@ export async function runTurn(sessionId, options = {}) {
141
179
  if (shouldStop(state)) {
142
180
  if (state.status === 'running') {
143
181
  state = await stopSession(state, 'completed');
182
+ await maybeRecordNoTurnDiagnostic(state);
183
+ state = await finalizeSessionArtifacts(state);
144
184
  }
145
185
  return state;
146
186
  }
@@ -178,6 +218,8 @@ export async function runTurn(sessionId, options = {}) {
178
218
 
179
219
  if (shouldStop(state)) {
180
220
  state = await stopSession(state, 'completed');
221
+ await maybeRecordNoTurnDiagnostic(state);
222
+ state = await finalizeSessionArtifacts(state);
181
223
  }
182
224
 
183
225
  return state;
@@ -244,6 +286,8 @@ export async function executeActiveTurn(sessionId, role, options = {}) {
244
286
 
245
287
  if (shouldStop(state)) {
246
288
  state = await stopSession(state, 'completed');
289
+ await maybeRecordNoTurnDiagnostic(state);
290
+ state = await finalizeSessionArtifacts(state);
247
291
  }
248
292
 
249
293
  return state;
@@ -293,6 +337,8 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
293
337
  const prompt = buildRuntimePrompt({ state, role });
294
338
  let content;
295
339
  let outputEvents = [];
340
+ let outputStdout = '';
341
+ let outputStderr = '';
296
342
  let effectiveModel = null;
297
343
  let failed = false;
298
344
  let errorMessage = '';
@@ -316,6 +362,8 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
316
362
  });
317
363
  content = output.text;
318
364
  outputEvents = output.events || [];
365
+ outputStdout = output.stdout || '';
366
+ outputStderr = output.stderr || '';
319
367
  } catch (error) {
320
368
  failed = true;
321
369
  errorMessage = error.message;
@@ -324,14 +372,28 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
324
372
 
325
373
  state = await addAgentMessage(state, role, content);
326
374
  if (failed) {
327
- await appendMeetingTurn(sessionId, createTurnRecord({
375
+ const failedTurn = createTurnRecord({
328
376
  round: state.round,
329
377
  role,
330
378
  model: effectiveModel,
331
379
  content,
332
380
  events: outputEvents,
333
- }));
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
+ });
334
392
  state = await saveSessionState(applyRoleFailurePolicy(state, role, errorMessage));
393
+ if (state.status === 'failed') {
394
+ await maybeRecordNoTurnDiagnostic(state);
395
+ state = await finalizeSessionArtifacts(state);
396
+ }
335
397
  return state;
336
398
  }
337
399
 
@@ -343,6 +405,14 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
343
405
  events: outputEvents,
344
406
  });
345
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
+ });
346
416
 
347
417
  const updatedShared = updateSharedContext(ensureRunState(state).sharedContext, turnRecord);
348
418
  const succeededRun = appendRunEvent({
@@ -360,8 +430,6 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
360
430
 
361
431
  if (shouldStop(state)) {
362
432
  state = await stopSession(state, 'completed');
363
- const summaryMd = buildMeetingSummary(state.messages, ensureRunState(state), ensureRunState(state).sharedContext);
364
- await writeMeetingSummary(sessionId, summaryMd);
365
433
  const finalRun = appendRunEvent({
366
434
  ...ensureRunState(state),
367
435
  status: 'completed',
@@ -372,6 +440,8 @@ export async function runWorkerIteration(sessionId, role, options = {}) {
372
440
  ...state,
373
441
  run: finalRun,
374
442
  });
443
+ await maybeRecordNoTurnDiagnostic(state);
444
+ state = await finalizeSessionArtifacts(state);
375
445
  }
376
446
 
377
447
  return state;
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { writeFile } from 'node:fs/promises';
4
5
  import { COLLAB_ROLES } from './constants.js';
5
6
 
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -10,7 +11,7 @@ export function roleLogPath(sessionDir, role) {
10
11
  return resolve(sessionDir, `${role}.log`);
11
12
  }
12
13
 
13
- export function runTmux(command, args, options = {}) {
14
+ function runCommand(command, args, options = {}) {
14
15
  return new Promise((resolvePromise, rejectPromise) => {
15
16
  const child = spawn(command, args, {
16
17
  cwd: options.cwd || process.cwd(),
@@ -42,6 +43,14 @@ export function runTmux(command, args, options = {}) {
42
43
  });
43
44
  }
44
45
 
46
+ function workerCommand(sessionId, role, roleLog) {
47
+ return `bash -lc 'node "${runnerPath}" agents worker --session ${sessionId} --role ${role} 2>&1 | tee -a "${roleLog}"'`;
48
+ }
49
+
50
+ export async function runTmux(command, args, options = {}) {
51
+ return runCommand(command, args, options);
52
+ }
53
+
45
54
  export async function spawnTmuxSession({ sessionName, sessionDir, sessionId }) {
46
55
  const role0 = COLLAB_ROLES[0];
47
56
  const role0Log = roleLogPath(sessionDir, role0);
@@ -52,7 +61,7 @@ export async function spawnTmuxSession({ sessionName, sessionDir, sessionId }) {
52
61
  sessionName,
53
62
  '-n',
54
63
  role0,
55
- `bash -lc 'node "${runnerPath}" agents worker --session ${sessionId} --role ${role0} 2>&1 | tee -a "${role0Log}"'`,
64
+ workerCommand(sessionId, role0, role0Log),
56
65
  ]);
57
66
 
58
67
  for (let idx = 1; idx < COLLAB_ROLES.length; idx += 1) {
@@ -63,7 +72,7 @@ export async function spawnTmuxSession({ sessionName, sessionDir, sessionId }) {
63
72
  '-t',
64
73
  sessionName,
65
74
  '-v',
66
- `bash -lc 'node "${runnerPath}" agents worker --session ${sessionId} --role ${role} 2>&1 | tee -a "${roleLog}"'`,
75
+ workerCommand(sessionId, role, roleLog),
67
76
  ]);
68
77
  }
69
78
 
@@ -80,3 +89,69 @@ export async function tmuxSessionExists(sessionName) {
80
89
  return false;
81
90
  }
82
91
  }
92
+
93
+ async function writeZellijLayout({ layoutPath, sessionId, sessionDir }) {
94
+ const panes = COLLAB_ROLES.map((role) => {
95
+ const roleLog = roleLogPath(sessionDir, role);
96
+ const cmd = workerCommand(sessionId, role, roleLog).replace(/"/g, '\\"');
97
+ return ` pane name="${role}" command="bash" args { "-lc" "${cmd}" }`;
98
+ });
99
+
100
+ const content = [
101
+ 'layout {',
102
+ ' default_tab_template {',
103
+ ' tab name="SynapseGrid" {',
104
+ ' pane split_direction="vertical" {',
105
+ ' pane split_direction="horizontal" {',
106
+ panes[0],
107
+ panes[1],
108
+ ' }',
109
+ ' pane split_direction="horizontal" {',
110
+ panes[2],
111
+ panes[3],
112
+ ' }',
113
+ ' }',
114
+ ' }',
115
+ ' }',
116
+ '}',
117
+ '',
118
+ ].join('\n');
119
+
120
+ await writeFile(layoutPath, content, 'utf8');
121
+ }
122
+
123
+ export async function spawnZellijSession({ sessionName, sessionDir, sessionId, binaryPath }) {
124
+ const layoutPath = resolve(sessionDir, 'synapsegrid-layout.kdl');
125
+ await writeZellijLayout({ layoutPath, sessionId, sessionDir });
126
+ const command = binaryPath || process.env.ACFM_ZELLIJ_BIN || 'zellij';
127
+ await runCommand(command, ['--session', sessionName, '--layout', layoutPath, '--detach']);
128
+ return { layoutPath };
129
+ }
130
+
131
+ export async function zellijSessionExists(sessionName, binaryPath) {
132
+ try {
133
+ const command = binaryPath || process.env.ACFM_ZELLIJ_BIN || 'zellij';
134
+ const result = await runCommand(command, ['list-sessions']);
135
+ const lines = result.stdout.split('\n').map((line) => line.trim()).filter(Boolean);
136
+ return lines.some((line) => line === sessionName || line.startsWith(`${sessionName} `));
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ export async function runZellij(args, options = {}) {
143
+ const command = options.binaryPath || process.env.ACFM_ZELLIJ_BIN || 'zellij';
144
+ return runCommand(command, args, options);
145
+ }
146
+
147
+ export function resolveMultiplexer(preferred = 'auto', hasTmuxCommand = false, hasZellijCommand = false) {
148
+ if (preferred === 'tmux') {
149
+ return hasTmuxCommand ? 'tmux' : null;
150
+ }
151
+ if (preferred === 'zellij') {
152
+ return hasZellijCommand ? 'zellij' : null;
153
+ }
154
+ if (hasZellijCommand) return 'zellij';
155
+ if (hasTmuxCommand) return 'tmux';
156
+ return null;
157
+ }
@@ -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();
@@ -73,6 +95,8 @@ function initialState(task, options = {}) {
73
95
  model: options.model || null,
74
96
  roleModels: sanitizeRoleModels(options.roleModels),
75
97
  opencodeBin: options.opencodeBin || null,
98
+ multiplexer: options.multiplexer || 'auto',
99
+ multiplexerSessionName: options.multiplexerSessionName || null,
76
100
  tmuxSessionName: options.tmuxSessionName || null,
77
101
  run: createRunState(options.runPolicy, Number.isInteger(options.maxRounds) ? options.maxRounds : DEFAULT_MAX_ROUNDS),
78
102
  messages: [
@@ -92,13 +116,98 @@ export async function createSession(task, options = {}) {
92
116
  await ensureSessionRoot();
93
117
  const state = initialState(task, options);
94
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);
95
125
  await mkdir(sessionDir, { recursive: true });
126
+ await mkdir(turnsDir, { recursive: true });
127
+ await mkdir(turnsRawDir, { recursive: true });
96
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');
97
155
  await writeCurrentSession(state.sessionId, state.updatedAt);
98
156
  await appendTranscript(state.sessionId, state.messages[0]);
99
157
  return state;
100
158
  }
101
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
+
102
211
  export async function appendTranscript(sessionId, message) {
103
212
  const transcriptPath = getTranscriptPath(sessionId);
104
213
  const line = JSON.stringify(message) + '\n';
@@ -110,18 +219,25 @@ export async function appendTranscript(sessionId, message) {
110
219
  }
111
220
 
112
221
  export async function appendMeetingTurn(sessionId, turnRecord) {
113
- const sessionDir = getSessionDir(sessionId);
114
- const turnsDir = getTurnsDir(sessionId);
222
+ const {
223
+ sessionDir,
224
+ turnsDir,
225
+ turnsRawDir,
226
+ meetingLogPath,
227
+ meetingLogJsonlPath,
228
+ diagnosticsPath,
229
+ } = sessionArtifactPaths(sessionId);
115
230
  await mkdir(sessionDir, { recursive: true });
116
231
  await mkdir(turnsDir, { recursive: true });
232
+ await mkdir(turnsRawDir, { recursive: true });
117
233
 
118
234
  const safeRole = String(turnRecord?.role || 'unknown').replace(/[^a-z0-9_-]/gi, '_');
119
235
  const safeRound = Number.isInteger(turnRecord?.round) ? turnRecord.round : 0;
120
236
  const turnFilePath = join(turnsDir, `${String(safeRound).padStart(3, '0')}-${safeRole}.json`);
121
237
  await writeFile(turnFilePath, JSON.stringify(turnRecord, null, 2) + '\n', 'utf8');
122
238
 
123
- const mdPath = getMeetingLogPath(sessionId);
124
- const jsonlPath = getMeetingLogJsonlPath(sessionId);
239
+ const mdPath = meetingLogPath;
240
+ const jsonlPath = meetingLogJsonlPath;
125
241
  const snippet = (turnRecord?.snippet || '').trim() || '(empty output)';
126
242
  const keyPoints = Array.isArray(turnRecord?.keyPoints) ? turnRecord.keyPoints : [];
127
243
 
@@ -147,6 +263,16 @@ export async function appendMeetingTurn(sessionId, turnRecord) {
147
263
  }
148
264
 
149
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
+
150
276
  return {
151
277
  turnFilePath,
152
278
  meetingLogPath: mdPath,
@@ -154,6 +280,47 @@ export async function appendMeetingTurn(sessionId, turnRecord) {
154
280
  };
155
281
  }
156
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
+
157
324
  export async function writeMeetingSummary(sessionId, summaryMarkdown) {
158
325
  const outputPath = getMeetingSummaryPath(sessionId);
159
326
  await writeFile(outputPath, String(summaryMarkdown || '').trimEnd() + '\n', 'utf8');
@@ -208,6 +375,8 @@ export async function listSessions(limit = 20) {
208
375
  round: state.round,
209
376
  maxRounds: state.maxRounds,
210
377
  tmuxSessionName: state.tmuxSessionName || null,
378
+ multiplexer: state.multiplexer || 'auto',
379
+ multiplexerSessionName: state.multiplexerSessionName || null,
211
380
  createdAt: state.createdAt,
212
381
  updatedAt: state.updatedAt,
213
382
  mtime: stateStats.mtimeMs,