ac-framework 1.9.7 → 1.9.8

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.
@@ -7,6 +7,11 @@
7
7
 
8
8
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
9
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
+ import { spawn } from 'node:child_process';
11
+ import { existsSync } from 'node:fs';
12
+ import { readFile } from 'node:fs/promises';
13
+ import { dirname, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
10
15
  import { z } from 'zod';
11
16
  import { COLLAB_ROLES } from '../agents/constants.js';
12
17
  import { buildEffectiveRoleModels, sanitizeRoleModels } from '../agents/model-selection.js';
@@ -25,6 +30,43 @@ import {
25
30
  } from '../agents/state-store.js';
26
31
  import { hasCommand, resolveCommandPath } from '../services/dependency-installer.js';
27
32
 
33
+ const __dirname = dirname(fileURLToPath(import.meta.url));
34
+ const runnerPath = resolve(__dirname, '../../bin/acfm.js');
35
+
36
+ function summarizeRun(state) {
37
+ const run = state.run || null;
38
+ return {
39
+ status: run?.status || 'idle',
40
+ runId: run?.runId || null,
41
+ currentRole: run?.currentRole || state.activeAgent || null,
42
+ round: run?.round || state.round,
43
+ policy: run?.policy || null,
44
+ lastError: run?.lastError || null,
45
+ eventCount: Array.isArray(run?.events) ? run.events.length : 0,
46
+ };
47
+ }
48
+
49
+ function latestRunEvent(state) {
50
+ const events = state?.run?.events;
51
+ if (!Array.isArray(events) || events.length === 0) return null;
52
+ return events[events.length - 1];
53
+ }
54
+
55
+ async function readSessionArtifact(sessionId, filename) {
56
+ const path = resolve(getSessionDir(sessionId), filename);
57
+ if (!existsSync(path)) return null;
58
+ return readFile(path, 'utf8');
59
+ }
60
+
61
+ function launchAutopilot(sessionId) {
62
+ const child = spawn('node', [runnerPath, 'agents', 'autopilot', '--session', sessionId], {
63
+ cwd: process.cwd(),
64
+ detached: true,
65
+ stdio: 'ignore',
66
+ });
67
+ child.unref();
68
+ }
69
+
28
70
  class MCPCollabServer {
29
71
  constructor() {
30
72
  this.server = new McpServer({
@@ -51,8 +93,13 @@ class MCPCollabServer {
51
93
  }).partial().optional().describe('Optional per-role models (provider/model)'),
52
94
  cwd: z.string().optional().describe('Working directory for agents'),
53
95
  spawnWorkers: z.boolean().default(true).describe('Create tmux workers and panes'),
96
+ runPolicy: z.object({
97
+ timeoutPerRoleMs: z.number().int().positive().optional(),
98
+ retryOnTimeout: z.number().int().min(0).optional(),
99
+ fallbackOnFailure: z.enum(['retry', 'skip', 'abort']).optional(),
100
+ }).partial().optional().describe('Optional run execution policy'),
54
101
  },
55
- async ({ task, maxRounds, model, roleModels, cwd, spawnWorkers }) => {
102
+ async ({ task, maxRounds, model, roleModels, cwd, spawnWorkers, runPolicy }) => {
56
103
  try {
57
104
  const workingDirectory = cwd || process.cwd();
58
105
  const opencodeBin = resolveCommandPath('opencode');
@@ -71,6 +118,7 @@ class MCPCollabServer {
71
118
  roleModels: sanitizeRoleModels(roleModels),
72
119
  workingDirectory,
73
120
  opencodeBin,
121
+ runPolicy,
74
122
  });
75
123
  let updated = state;
76
124
  if (spawnWorkers) {
@@ -93,6 +141,7 @@ class MCPCollabServer {
93
141
  model: updated.model || null,
94
142
  roleModels: updated.roleModels || {},
95
143
  effectiveRoleModels: buildEffectiveRoleModels(updated, updated.model || null),
144
+ run: summarizeRun(updated),
96
145
  tmuxSessionName,
97
146
  attachCommand,
98
147
  }, null, 2),
@@ -104,6 +153,186 @@ class MCPCollabServer {
104
153
  }
105
154
  );
106
155
 
156
+ this.server.tool(
157
+ 'collab_invoke_team',
158
+ 'Invoke full 4-agent collaborative run and return async run handle',
159
+ {
160
+ sessionId: z.string().optional().describe('Session ID (defaults to current session)'),
161
+ waitMs: z.number().int().min(0).max(30000).default(0).describe('Optional wait for progress before returning'),
162
+ },
163
+ async ({ sessionId, waitMs }) => {
164
+ try {
165
+ const id = sessionId || await loadCurrentSessionId();
166
+ if (!id) throw new Error('No active session found');
167
+ let state = await loadSessionState(id);
168
+ if (state.status !== 'running') {
169
+ throw new Error(`Session is ${state.status}. Resume/start before invoking.`);
170
+ }
171
+
172
+ if (!state.tmuxSessionName) {
173
+ launchAutopilot(state.sessionId);
174
+ }
175
+
176
+ if (waitMs > 0) {
177
+ const started = Date.now();
178
+ const initialEvents = state.run?.events?.length || 0;
179
+ while (Date.now() - started < waitMs) {
180
+ await new Promise((r) => setTimeout(r, 250));
181
+ state = await loadSessionState(id);
182
+ const currentEvents = state.run?.events?.length || 0;
183
+ if (state.status !== 'running' || currentEvents > initialEvents) break;
184
+ }
185
+ }
186
+
187
+ return {
188
+ content: [{
189
+ type: 'text',
190
+ text: JSON.stringify({
191
+ success: true,
192
+ sessionId: state.sessionId,
193
+ status: state.status,
194
+ run: summarizeRun(state),
195
+ latestEvent: latestRunEvent(state),
196
+ tmuxSessionName: state.tmuxSessionName || null,
197
+ attachCommand: state.tmuxSessionName ? `tmux attach -t ${state.tmuxSessionName}` : null,
198
+ }, null, 2),
199
+ }],
200
+ };
201
+ } catch (error) {
202
+ return { content: [{ type: 'text', text: JSON.stringify({ error: error.message }) }], isError: true };
203
+ }
204
+ }
205
+ );
206
+
207
+ this.server.tool(
208
+ 'collab_wait_run',
209
+ 'Wait for collaborative run to complete/fail or timeout',
210
+ {
211
+ sessionId: z.string().optional().describe('Session ID (defaults to current session)'),
212
+ waitMs: z.number().int().positive().max(60000).default(10000).describe('Max wait in milliseconds'),
213
+ },
214
+ async ({ sessionId, waitMs }) => {
215
+ try {
216
+ const id = sessionId || await loadCurrentSessionId();
217
+ if (!id) throw new Error('No active session found');
218
+ const started = Date.now();
219
+ let state = await loadSessionState(id);
220
+
221
+ while (Date.now() - started < waitMs) {
222
+ const runStatus = state.run?.status || 'idle';
223
+ if (state.status !== 'running' || ['completed', 'failed', 'cancelled'].includes(runStatus)) break;
224
+ await new Promise((r) => setTimeout(r, 300));
225
+ state = await loadSessionState(id);
226
+ }
227
+
228
+ return {
229
+ content: [{
230
+ type: 'text',
231
+ text: JSON.stringify({
232
+ success: true,
233
+ sessionId: state.sessionId,
234
+ status: state.status,
235
+ run: summarizeRun(state),
236
+ latestEvent: latestRunEvent(state),
237
+ timedOut: state.status === 'running' && !['completed', 'failed', 'cancelled'].includes(state.run?.status || 'idle'),
238
+ }, null, 2),
239
+ }],
240
+ };
241
+ } catch (error) {
242
+ return { content: [{ type: 'text', text: JSON.stringify({ error: error.message }) }], isError: true };
243
+ }
244
+ }
245
+ );
246
+
247
+ this.server.tool(
248
+ 'collab_get_result',
249
+ 'Get final consolidated team output and run diagnostics',
250
+ {
251
+ sessionId: z.string().optional().describe('Session ID (defaults to current session)'),
252
+ },
253
+ async ({ sessionId }) => {
254
+ try {
255
+ const id = sessionId || await loadCurrentSessionId();
256
+ if (!id) throw new Error('No active session found');
257
+ const state = await loadSessionState(id);
258
+ const transcript = await loadTranscript(id);
259
+ const run = summarizeRun(state);
260
+
261
+ return {
262
+ content: [{
263
+ type: 'text',
264
+ text: JSON.stringify({
265
+ success: true,
266
+ sessionId: state.sessionId,
267
+ status: state.status,
268
+ run,
269
+ latestEvent: latestRunEvent(state),
270
+ finalSummary: state.run?.finalSummary || null,
271
+ sharedContext: state.run?.sharedContext || null,
272
+ meetingSummary: await readSessionArtifact(id, 'meeting-summary.md'),
273
+ meetingLog: await readSessionArtifact(id, 'meeting-log.md'),
274
+ lastMessage: state.messages?.[state.messages.length - 1] || null,
275
+ transcriptCount: transcript.length,
276
+ }, null, 2),
277
+ }],
278
+ };
279
+ } catch (error) {
280
+ return { content: [{ type: 'text', text: JSON.stringify({ error: error.message }) }], isError: true };
281
+ }
282
+ }
283
+ );
284
+
285
+ this.server.tool(
286
+ 'collab_cancel_run',
287
+ 'Cancel active collaborative run while keeping session state',
288
+ {
289
+ sessionId: z.string().optional().describe('Session ID (defaults to current session)'),
290
+ },
291
+ async ({ sessionId }) => {
292
+ try {
293
+ const id = sessionId || await loadCurrentSessionId();
294
+ if (!id) throw new Error('No active session found');
295
+ const state = await loadSessionState(id);
296
+ const run = state.run || {};
297
+ const updated = await saveSessionState({
298
+ ...state,
299
+ status: 'running',
300
+ activeAgent: null,
301
+ run: {
302
+ ...run,
303
+ status: 'cancelled',
304
+ currentRole: null,
305
+ finishedAt: new Date().toISOString(),
306
+ lastError: {
307
+ code: 'RUN_CANCELLED',
308
+ message: 'Cancelled by MCP caller',
309
+ },
310
+ events: [
311
+ ...(Array.isArray(run.events) ? run.events : []),
312
+ {
313
+ id: `evt-${Date.now()}`,
314
+ type: 'run_cancelled',
315
+ timestamp: new Date().toISOString(),
316
+ },
317
+ ],
318
+ },
319
+ });
320
+ return {
321
+ content: [{
322
+ type: 'text',
323
+ text: JSON.stringify({
324
+ success: true,
325
+ sessionId: updated.sessionId,
326
+ run: summarizeRun(updated),
327
+ }, null, 2),
328
+ }],
329
+ };
330
+ } catch (error) {
331
+ return { content: [{ type: 'text', text: JSON.stringify({ error: error.message }) }], isError: true };
332
+ }
333
+ }
334
+ );
335
+
107
336
  this.server.tool(
108
337
  'collab_send_message',
109
338
  'Send a user message to the active collaborative session',
@@ -150,6 +379,7 @@ class MCPCollabServer {
150
379
  const state = await runWorkerIteration(id, role, {
151
380
  cwd: process.cwd(),
152
381
  opencodeBin: loaded.opencodeBin || resolveCommandPath('opencode') || undefined,
382
+ timeoutMs: loaded.run?.policy?.timeoutPerRoleMs || 180000,
153
383
  });
154
384
 
155
385
  return {
@@ -164,6 +394,8 @@ class MCPCollabServer {
164
394
  model: state.model || null,
165
395
  roleModels: state.roleModels || {},
166
396
  effectiveRoleModels: buildEffectiveRoleModels(state, state.model || null),
397
+ run: summarizeRun(state),
398
+ latestEvent: latestRunEvent(state),
167
399
  messageCount: state.messages.length,
168
400
  }, null, 2),
169
401
  }],
@@ -244,6 +476,9 @@ class MCPCollabServer {
244
476
  text: JSON.stringify({
245
477
  state,
246
478
  effectiveRoleModels: buildEffectiveRoleModels(state, state.model || null),
479
+ run: summarizeRun(state),
480
+ latestEvent: latestRunEvent(state),
481
+ sharedContext: state.run?.sharedContext || null,
247
482
  transcript,
248
483
  }, null, 2),
249
484
  }],
@@ -254,6 +489,71 @@ class MCPCollabServer {
254
489
  }
255
490
  );
256
491
 
492
+ this.server.tool(
493
+ 'collab_get_transcript',
494
+ 'Get transcript messages with optional role filtering',
495
+ {
496
+ sessionId: z.string().optional().describe('Session ID (defaults to current session)'),
497
+ role: z.enum(['planner', 'critic', 'coder', 'reviewer', 'all']).default('all').describe('Role filter'),
498
+ limit: z.number().int().positive().max(500).default(100).describe('Max messages to return'),
499
+ },
500
+ async ({ sessionId, role, limit }) => {
501
+ try {
502
+ const id = sessionId || await loadCurrentSessionId();
503
+ if (!id) throw new Error('No active session found');
504
+ const transcript = await loadTranscript(id);
505
+ const filtered = transcript
506
+ .filter((msg) => role === 'all' || msg.from === role)
507
+ .slice(-limit);
508
+
509
+ return {
510
+ content: [{
511
+ type: 'text',
512
+ text: JSON.stringify({
513
+ sessionId: id,
514
+ role,
515
+ count: filtered.length,
516
+ transcript: filtered,
517
+ }, null, 2),
518
+ }],
519
+ };
520
+ } catch (error) {
521
+ return { content: [{ type: 'text', text: JSON.stringify({ error: error.message }) }], isError: true };
522
+ }
523
+ }
524
+ );
525
+
526
+ this.server.tool(
527
+ 'collab_get_meeting_log',
528
+ 'Get generated meeting log and summary artifacts',
529
+ {
530
+ sessionId: z.string().optional().describe('Session ID (defaults to current session)'),
531
+ },
532
+ async ({ sessionId }) => {
533
+ try {
534
+ const id = sessionId || await loadCurrentSessionId();
535
+ if (!id) throw new Error('No active session found');
536
+ const state = await loadSessionState(id);
537
+
538
+ return {
539
+ content: [{
540
+ type: 'text',
541
+ text: JSON.stringify({
542
+ sessionId: id,
543
+ status: state.status,
544
+ finalSummary: state.run?.finalSummary || null,
545
+ sharedContext: state.run?.sharedContext || null,
546
+ meetingSummary: await readSessionArtifact(id, 'meeting-summary.md'),
547
+ meetingLog: await readSessionArtifact(id, 'meeting-log.md'),
548
+ }, null, 2),
549
+ }],
550
+ };
551
+ } catch (error) {
552
+ return { content: [{ type: 'text', text: JSON.stringify({ error: error.message }) }], isError: true };
553
+ }
554
+ }
555
+ );
556
+
257
557
  this.server.tool(
258
558
  'collab_stop_session',
259
559
  'Stop current collaborative session',
@@ -271,7 +571,12 @@ class MCPCollabServer {
271
571
  return {
272
572
  content: [{
273
573
  type: 'text',
274
- text: JSON.stringify({ success: true, sessionId: id, status: updated.status }, null, 2),
574
+ text: JSON.stringify({
575
+ success: true,
576
+ sessionId: id,
577
+ status: updated.status,
578
+ run: summarizeRun(updated),
579
+ }, null, 2),
275
580
  }],
276
581
  };
277
582
  } catch (error) {