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 +18 -10
- package/package.json +1 -1
- package/src/agents/config-store.js +16 -0
- package/src/agents/orchestrator.js +74 -4
- package/src/agents/runtime.js +78 -3
- package/src/agents/state-store.js +173 -4
- package/src/commands/agents.js +547 -64
- package/src/commands/init.js +16 -5
- package/src/mcp/collab-server.js +88 -26
- package/src/services/dependency-installer.js +247 -5
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
|
|
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
|
|
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/
|
|
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
|
|
151
|
-
| `acfm agents live` | Attach to full live
|
|
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
|
@@ -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
|
-
|
|
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;
|
package/src/agents/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
114
|
-
|
|
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 =
|
|
124
|
-
const jsonlPath =
|
|
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,
|