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.
@@ -0,0 +1,113 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ DEFAULT_ROLE_RETRIES,
4
+ DEFAULT_ROLE_TIMEOUT_MS,
5
+ DEFAULT_MAX_ROUNDS,
6
+ } from './constants.js';
7
+
8
+ export function normalizeRunPolicy(policy = {}, maxRounds = DEFAULT_MAX_ROUNDS) {
9
+ const timeoutPerRoleMs = Number.isInteger(policy.timeoutPerRoleMs) && policy.timeoutPerRoleMs > 0
10
+ ? policy.timeoutPerRoleMs
11
+ : DEFAULT_ROLE_TIMEOUT_MS;
12
+ const retryOnTimeout = Number.isInteger(policy.retryOnTimeout) && policy.retryOnTimeout >= 0
13
+ ? policy.retryOnTimeout
14
+ : DEFAULT_ROLE_RETRIES;
15
+ const fallbackOnFailure = ['retry', 'skip', 'abort'].includes(policy.fallbackOnFailure)
16
+ ? policy.fallbackOnFailure
17
+ : 'abort';
18
+ const rounds = Number.isInteger(maxRounds) && maxRounds > 0 ? maxRounds : DEFAULT_MAX_ROUNDS;
19
+
20
+ return {
21
+ timeoutPerRoleMs,
22
+ retryOnTimeout,
23
+ fallbackOnFailure,
24
+ maxRounds: rounds,
25
+ };
26
+ }
27
+
28
+ export function createRunState(policy = {}, maxRounds = DEFAULT_MAX_ROUNDS) {
29
+ return {
30
+ runId: randomUUID(),
31
+ status: 'idle',
32
+ startedAt: null,
33
+ finishedAt: null,
34
+ currentRole: null,
35
+ retriesUsed: {},
36
+ round: 1,
37
+ events: [],
38
+ finalSummary: null,
39
+ sharedContext: {
40
+ decisions: [],
41
+ openIssues: [],
42
+ risks: [],
43
+ actionItems: [],
44
+ notes: [],
45
+ },
46
+ lastError: null,
47
+ policy: normalizeRunPolicy(policy, maxRounds),
48
+ };
49
+ }
50
+
51
+ export function appendRunEvent(run, type, details = {}) {
52
+ const event = {
53
+ id: randomUUID(),
54
+ type,
55
+ timestamp: new Date().toISOString(),
56
+ ...details,
57
+ };
58
+ const events = [...(run.events || []), event];
59
+ return {
60
+ ...run,
61
+ events,
62
+ };
63
+ }
64
+
65
+ export function roleRetryCount(run, role) {
66
+ return Number(run?.retriesUsed?.[role] || 0);
67
+ }
68
+
69
+ export function incrementRoleRetry(run, role) {
70
+ return {
71
+ ...run,
72
+ retriesUsed: {
73
+ ...(run.retriesUsed || {}),
74
+ [role]: roleRetryCount(run, role) + 1,
75
+ },
76
+ };
77
+ }
78
+
79
+ export function extractFinalSummary(messages = [], run = null) {
80
+ const agentMessages = messages.filter((msg) => msg?.from && msg.from !== 'user');
81
+ if (agentMessages.length === 0) return '';
82
+ const orderedRoles = ['planner', 'critic', 'coder', 'reviewer'];
83
+ const lastByRole = new Map();
84
+ for (const msg of agentMessages) lastByRole.set(msg.from, msg.content || '');
85
+
86
+ const sections = ['# SynapseGrid Final Summary', ''];
87
+ sections.push('## Per-role last contributions');
88
+ for (const role of orderedRoles) {
89
+ const content = String(lastByRole.get(role) || '').trim();
90
+ sections.push(`- ${role}: ${content ? content.slice(0, 500) : '(none)'}`);
91
+ }
92
+
93
+ const shared = run?.sharedContext;
94
+ if (shared && typeof shared === 'object') {
95
+ const writeList = (title, items) => {
96
+ sections.push('');
97
+ sections.push(`## ${title}`);
98
+ const list = Array.isArray(items) ? items.slice(-10) : [];
99
+ if (list.length === 0) {
100
+ sections.push('- (none)');
101
+ } else {
102
+ for (const item of list) sections.push(`- ${item}`);
103
+ }
104
+ };
105
+
106
+ writeList('Decisions', shared.decisions);
107
+ writeList('Open issues', shared.openIssues);
108
+ writeList('Risks', shared.risks);
109
+ writeList('Action items', shared.actionItems);
110
+ }
111
+
112
+ return sections.join('\n').trim();
113
+ }
@@ -8,6 +8,7 @@ import {
8
8
  SESSION_ROOT_DIR,
9
9
  CURRENT_SESSION_FILE,
10
10
  } from './constants.js';
11
+ import { createRunState } from './run-state.js';
11
12
  import { sanitizeRoleModels } from './model-selection.js';
12
13
 
13
14
  function sleep(ms) {
@@ -38,6 +39,22 @@ function getTranscriptPath(sessionId) {
38
39
  return join(getSessionDir(sessionId), 'transcript.jsonl');
39
40
  }
40
41
 
42
+ function getTurnsDir(sessionId) {
43
+ return join(getSessionDir(sessionId), 'turns');
44
+ }
45
+
46
+ function getMeetingLogPath(sessionId) {
47
+ return join(getSessionDir(sessionId), 'meeting-log.md');
48
+ }
49
+
50
+ function getMeetingLogJsonlPath(sessionId) {
51
+ return join(getSessionDir(sessionId), 'meeting-log.jsonl');
52
+ }
53
+
54
+ function getMeetingSummaryPath(sessionId) {
55
+ return join(getSessionDir(sessionId), 'meeting-summary.md');
56
+ }
57
+
41
58
  function initialState(task, options = {}) {
42
59
  const sessionId = randomUUID();
43
60
  const createdAt = new Date().toISOString();
@@ -57,6 +74,7 @@ function initialState(task, options = {}) {
57
74
  roleModels: sanitizeRoleModels(options.roleModels),
58
75
  opencodeBin: options.opencodeBin || null,
59
76
  tmuxSessionName: options.tmuxSessionName || null,
77
+ run: createRunState(options.runPolicy, Number.isInteger(options.maxRounds) ? options.maxRounds : DEFAULT_MAX_ROUNDS),
60
78
  messages: [
61
79
  {
62
80
  from: 'user',
@@ -91,6 +109,57 @@ export async function appendTranscript(sessionId, message) {
91
109
  await appendFile(transcriptPath, line, 'utf8');
92
110
  }
93
111
 
112
+ export async function appendMeetingTurn(sessionId, turnRecord) {
113
+ const sessionDir = getSessionDir(sessionId);
114
+ const turnsDir = getTurnsDir(sessionId);
115
+ await mkdir(sessionDir, { recursive: true });
116
+ await mkdir(turnsDir, { recursive: true });
117
+
118
+ const safeRole = String(turnRecord?.role || 'unknown').replace(/[^a-z0-9_-]/gi, '_');
119
+ const safeRound = Number.isInteger(turnRecord?.round) ? turnRecord.round : 0;
120
+ const turnFilePath = join(turnsDir, `${String(safeRound).padStart(3, '0')}-${safeRole}.json`);
121
+ await writeFile(turnFilePath, JSON.stringify(turnRecord, null, 2) + '\n', 'utf8');
122
+
123
+ const mdPath = getMeetingLogPath(sessionId);
124
+ const jsonlPath = getMeetingLogJsonlPath(sessionId);
125
+ const snippet = (turnRecord?.snippet || '').trim() || '(empty output)';
126
+ const keyPoints = Array.isArray(turnRecord?.keyPoints) ? turnRecord.keyPoints : [];
127
+
128
+ const mdBlock = [
129
+ `## Round ${safeRound} - ${safeRole}`,
130
+ `- timestamp: ${turnRecord?.timestamp || new Date().toISOString()}`,
131
+ `- model: ${turnRecord?.model || '(default)'}`,
132
+ `- events: ${turnRecord?.eventCount ?? 0}`,
133
+ '',
134
+ '### Output snippet',
135
+ snippet,
136
+ '',
137
+ '### Key points',
138
+ ...(keyPoints.length > 0 ? keyPoints.map((line) => `- ${line.replace(/^[-*]\s+/, '')}`) : ['- (none)']),
139
+ '',
140
+ ].join('\n');
141
+
142
+ if (!existsSync(mdPath)) {
143
+ const header = `# SynapseGrid Meeting Log\n\nSession: ${sessionId}\n\n`;
144
+ await writeFile(mdPath, header + mdBlock, 'utf8');
145
+ } else {
146
+ await appendFile(mdPath, mdBlock, 'utf8');
147
+ }
148
+
149
+ await appendFile(jsonlPath, JSON.stringify(turnRecord) + '\n', 'utf8');
150
+ return {
151
+ turnFilePath,
152
+ meetingLogPath: mdPath,
153
+ meetingJsonlPath: jsonlPath,
154
+ };
155
+ }
156
+
157
+ export async function writeMeetingSummary(sessionId, summaryMarkdown) {
158
+ const outputPath = getMeetingSummaryPath(sessionId);
159
+ await writeFile(outputPath, String(summaryMarkdown || '').trimEnd() + '\n', 'utf8');
160
+ return outputPath;
161
+ }
162
+
94
163
  export async function loadCurrentSessionId() {
95
164
  if (!existsSync(CURRENT_SESSION_FILE)) return null;
96
165
  const raw = await readFile(CURRENT_SESSION_FILE, 'utf8');
@@ -1,7 +1,8 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
+ import inquirer from 'inquirer';
3
4
  import { existsSync } from 'node:fs';
4
- import { mkdir, writeFile } from 'node:fs/promises';
5
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
6
  import { readFileSync } from 'node:fs';
6
7
  import { dirname, resolve } from 'node:path';
7
8
  import {
@@ -12,7 +13,7 @@ import {
12
13
  DEFAULT_SYNAPSE_MODEL,
13
14
  SESSION_ROOT_DIR,
14
15
  } from '../agents/constants.js';
15
- import { runOpenCodePrompt } from '../agents/opencode-client.js';
16
+ import { listOpenCodeModels, runOpenCodePrompt } from '../agents/opencode-client.js';
16
17
  import { runWorkerIteration } from '../agents/orchestrator.js';
17
18
  import {
18
19
  addUserMessage,
@@ -127,6 +128,39 @@ function printModelConfig(state) {
127
128
  }
128
129
  }
129
130
 
131
+ function groupModelsByProvider(models) {
132
+ const grouped = new Map();
133
+ for (const model of models) {
134
+ const [provider, ...rest] = model.split('/');
135
+ if (!provider || rest.length === 0) continue;
136
+ const modelName = rest.join('/');
137
+ if (!grouped.has(provider)) grouped.set(provider, []);
138
+ grouped.get(provider).push(modelName);
139
+ }
140
+ for (const [provider, modelNames] of grouped.entries()) {
141
+ grouped.set(provider, [...new Set(modelNames)].sort((a, b) => a.localeCompare(b)));
142
+ }
143
+ return grouped;
144
+ }
145
+
146
+ function runSummary(state) {
147
+ const run = state.run || {};
148
+ const events = Array.isArray(run.events) ? run.events.length : 0;
149
+ return {
150
+ status: run.status || 'idle',
151
+ runId: run.runId || null,
152
+ currentRole: run.currentRole || null,
153
+ lastError: run.lastError || null,
154
+ events,
155
+ };
156
+ }
157
+
158
+ async function readSessionArtifact(sessionId, filename) {
159
+ const path = resolve(getSessionDir(sessionId), filename);
160
+ if (!existsSync(path)) return null;
161
+ return readFile(path, 'utf8');
162
+ }
163
+
130
164
  async function preflightModel({ opencodeBin, model, cwd }) {
131
165
  const selected = normalizeModelId(model) || DEFAULT_SYNAPSE_MODEL;
132
166
  try {
@@ -419,6 +453,52 @@ export function agentsCommand() {
419
453
  .command('model')
420
454
  .description('Manage default SynapseGrid model configuration');
421
455
 
456
+ model
457
+ .command('list')
458
+ .description('List available OpenCode models grouped by provider')
459
+ .option('--refresh', 'Refresh model cache from providers', false)
460
+ .option('--json', 'Output as JSON')
461
+ .action(async (opts) => {
462
+ try {
463
+ const opencodeBin = resolveCommandPath('opencode');
464
+ if (!opencodeBin) {
465
+ throw new Error('OpenCode binary not found. Run: acfm agents setup');
466
+ }
467
+
468
+ const models = await listOpenCodeModels({
469
+ binaryPath: opencodeBin,
470
+ refresh: Boolean(opts.refresh),
471
+ });
472
+ const grouped = groupModelsByProvider(models);
473
+ const providers = [...grouped.keys()].sort((a, b) => a.localeCompare(b));
474
+
475
+ const payload = {
476
+ count: models.length,
477
+ providers: providers.map((provider) => ({
478
+ provider,
479
+ models: grouped.get(provider).map((name) => `${provider}/${name}`),
480
+ })),
481
+ };
482
+
483
+ output(payload, opts.json);
484
+ if (!opts.json) {
485
+ console.log(chalk.bold('Available OpenCode models'));
486
+ console.log(chalk.dim(`Total: ${models.length}`));
487
+ for (const provider of providers) {
488
+ const providerModels = grouped.get(provider) || [];
489
+ console.log(chalk.cyan(`\n${provider}`));
490
+ for (const modelName of providerModels) {
491
+ console.log(chalk.dim(`- ${provider}/${modelName}`));
492
+ }
493
+ }
494
+ }
495
+ } catch (error) {
496
+ output({ error: error.message }, opts.json);
497
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
498
+ process.exit(1);
499
+ }
500
+ });
501
+
422
502
  model
423
503
  .command('get')
424
504
  .description('Show configured default global/per-role models')
@@ -447,6 +527,114 @@ export function agentsCommand() {
447
527
  }
448
528
  });
449
529
 
530
+ model
531
+ .command('choose')
532
+ .description('Interactively choose a default model by provider and role')
533
+ .option('--refresh', 'Refresh model cache from providers', false)
534
+ .option('--json', 'Output as JSON')
535
+ .action(async (opts) => {
536
+ try {
537
+ const opencodeBin = resolveCommandPath('opencode');
538
+ if (!opencodeBin) {
539
+ throw new Error('OpenCode binary not found. Run: acfm agents setup');
540
+ }
541
+
542
+ const models = await listOpenCodeModels({
543
+ binaryPath: opencodeBin,
544
+ refresh: Boolean(opts.refresh),
545
+ });
546
+ if (models.length === 0) {
547
+ throw new Error('No models returned by OpenCode. Run: opencode auth list, opencode models --refresh');
548
+ }
549
+
550
+ const grouped = groupModelsByProvider(models);
551
+ const providerChoices = [...grouped.keys()]
552
+ .sort((a, b) => a.localeCompare(b))
553
+ .map((provider) => ({
554
+ name: `${provider} (${(grouped.get(provider) || []).length})`,
555
+ value: provider,
556
+ }));
557
+
558
+ const { provider } = await inquirer.prompt([
559
+ {
560
+ type: 'list',
561
+ name: 'provider',
562
+ message: 'Select model provider',
563
+ choices: providerChoices,
564
+ },
565
+ ]);
566
+
567
+ const selectedProviderModels = grouped.get(provider) || [];
568
+ const { modelName } = await inquirer.prompt([
569
+ {
570
+ type: 'list',
571
+ name: 'modelName',
572
+ message: `Select model from ${provider}`,
573
+ pageSize: 20,
574
+ choices: selectedProviderModels.map((name) => ({ name, value: name })),
575
+ },
576
+ ]);
577
+
578
+ const roleChoices = [
579
+ { name: 'Global fallback (all roles)', value: 'all' },
580
+ ...COLLAB_ROLES.map((role) => ({ name: `Role: ${role}`, value: role })),
581
+ ];
582
+ const { role } = await inquirer.prompt([
583
+ {
584
+ type: 'list',
585
+ name: 'role',
586
+ message: 'Apply model to',
587
+ choices: roleChoices,
588
+ },
589
+ ]);
590
+
591
+ const modelId = `${provider}/${modelName}`;
592
+ const updated = await updateAgentsConfig((current) => {
593
+ const next = {
594
+ agents: {
595
+ defaultModel: current.agents.defaultModel,
596
+ defaultRoleModels: { ...current.agents.defaultRoleModels },
597
+ },
598
+ };
599
+
600
+ if (role === 'all') {
601
+ next.agents.defaultModel = modelId;
602
+ } else {
603
+ next.agents.defaultRoleModels = {
604
+ ...next.agents.defaultRoleModels,
605
+ [role]: modelId,
606
+ };
607
+ }
608
+
609
+ return next;
610
+ });
611
+
612
+ const payload = {
613
+ success: true,
614
+ selected: {
615
+ role,
616
+ provider,
617
+ model: modelId,
618
+ },
619
+ configPath: getAgentsConfigPath(),
620
+ defaultModel: updated.agents.defaultModel,
621
+ defaultRoleModels: updated.agents.defaultRoleModels,
622
+ };
623
+
624
+ output(payload, opts.json);
625
+ if (!opts.json) {
626
+ console.log(chalk.green('✓ SynapseGrid model selected and saved'));
627
+ console.log(chalk.dim(` Target: ${role === 'all' ? 'global fallback' : `role ${role}`}`));
628
+ console.log(chalk.dim(` Model: ${modelId}`));
629
+ console.log(chalk.dim(` Config: ${payload.configPath}`));
630
+ }
631
+ } catch (error) {
632
+ output({ error: error.message }, opts.json);
633
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
634
+ process.exit(1);
635
+ }
636
+ });
637
+
450
638
  model
451
639
  .command('set <modelId>')
452
640
  .description('Set default model globally or for a specific role')
@@ -545,6 +733,83 @@ export function agentsCommand() {
545
733
  }
546
734
  });
547
735
 
736
+ agents
737
+ .command('transcript')
738
+ .description('Show collaborative transcript (optionally filtered by role)')
739
+ .option('--session <id>', 'Session ID (defaults to current)')
740
+ .option('--role <role>', 'Role filter (planner|critic|coder|reviewer|all)', 'all')
741
+ .option('--limit <n>', 'Max messages to display', '40')
742
+ .option('--json', 'Output as JSON')
743
+ .action(async (opts) => {
744
+ try {
745
+ const sessionId = opts.session || await ensureSessionId(true);
746
+ const role = String(opts.role || 'all');
747
+ const limit = Number.parseInt(opts.limit, 10);
748
+ if (!Number.isInteger(limit) || limit <= 0) {
749
+ throw new Error('--limit must be a positive integer');
750
+ }
751
+ if (role !== 'all' && !COLLAB_ROLES.includes(role)) {
752
+ throw new Error('--role must be planner|critic|coder|reviewer|all');
753
+ }
754
+
755
+ const transcript = await loadTranscript(sessionId);
756
+ const filtered = transcript
757
+ .filter((msg) => role === 'all' || msg.from === role)
758
+ .slice(-limit);
759
+
760
+ output({ sessionId, count: filtered.length, transcript: filtered }, opts.json);
761
+ if (!opts.json) {
762
+ console.log(chalk.bold(`SynapseGrid transcript (${filtered.length})`));
763
+ for (const msg of filtered) {
764
+ console.log(chalk.cyan(`\n[${msg.from}] ${msg.timestamp || ''}`));
765
+ console.log(String(msg.content || '').trim() || chalk.dim('(empty)'));
766
+ }
767
+ }
768
+ } catch (error) {
769
+ output({ error: error.message }, opts.json);
770
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
771
+ process.exit(1);
772
+ }
773
+ });
774
+
775
+ agents
776
+ .command('summary')
777
+ .description('Show meeting summary generated from collaborative run')
778
+ .option('--session <id>', 'Session ID (defaults to current)')
779
+ .option('--json', 'Output as JSON')
780
+ .action(async (opts) => {
781
+ try {
782
+ const sessionId = opts.session || await ensureSessionId(true);
783
+ const state = await loadSessionState(sessionId);
784
+ const summaryFile = await readSessionArtifact(sessionId, 'meeting-summary.md');
785
+ const meetingLogFile = await readSessionArtifact(sessionId, 'meeting-log.md');
786
+ const payload = {
787
+ sessionId,
788
+ status: state.status,
789
+ finalSummary: state.run?.finalSummary || null,
790
+ sharedContext: state.run?.sharedContext || null,
791
+ summaryFile,
792
+ meetingLogFile,
793
+ };
794
+
795
+ output(payload, opts.json);
796
+ if (!opts.json) {
797
+ console.log(chalk.bold('SynapseGrid meeting summary'));
798
+ if (summaryFile) {
799
+ process.stdout.write(summaryFile.endsWith('\n') ? summaryFile : `${summaryFile}\n`);
800
+ } else if (payload.finalSummary) {
801
+ process.stdout.write(`${payload.finalSummary}\n`);
802
+ } else {
803
+ console.log(chalk.dim('No summary generated yet.'));
804
+ }
805
+ }
806
+ } catch (error) {
807
+ output({ error: error.message }, opts.json);
808
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
809
+ process.exit(1);
810
+ }
811
+ });
812
+
548
813
  agents
549
814
  .command('export')
550
815
  .description('Export collaborative transcript')
@@ -562,9 +827,11 @@ export function agentsCommand() {
562
827
  throw new Error('--format must be md or json');
563
828
  }
564
829
 
830
+ const meetingSummary = await readSessionArtifact(sessionId, 'meeting-summary.md');
831
+ const meetingLog = await readSessionArtifact(sessionId, 'meeting-log.md');
565
832
  const payload = format === 'json'
566
- ? JSON.stringify({ state, transcript }, null, 2) + '\n'
567
- : toMarkdownTranscript(state, transcript) + '\n';
833
+ ? JSON.stringify({ state, transcript, meetingSummary, meetingLog }, null, 2) + '\n'
834
+ : `${toMarkdownTranscript(state, transcript)}\n\n## Meeting Summary\n\n${meetingSummary || state.run?.finalSummary || 'No summary generated yet.'}\n\n## Meeting Log\n\n${meetingLog || 'No meeting log generated yet.'}\n`;
568
835
 
569
836
  if (opts.out) {
570
837
  const outputPath = resolve(opts.out);
@@ -723,6 +990,11 @@ export function agentsCommand() {
723
990
  console.log(chalk.dim(`Round: ${Math.min(state.round, state.maxRounds)}/${state.maxRounds}`));
724
991
  console.log(chalk.dim(`Active agent: ${state.activeAgent || 'none'}`));
725
992
  console.log(chalk.dim(`Messages: ${state.messages.length}`));
993
+ const summary = runSummary(state);
994
+ console.log(chalk.dim(`Run: ${summary.status}${summary.currentRole ? ` (role=${summary.currentRole})` : ''}, events=${summary.events}`));
995
+ if (summary.lastError?.message) {
996
+ console.log(chalk.dim(`Run error: ${summary.lastError.message}`));
997
+ }
726
998
  console.log(chalk.dim(`Global model: ${state.model || '(opencode default)'}`));
727
999
  for (const role of COLLAB_ROLES) {
728
1000
  const configured = state.roleModels?.[role] || '-';
@@ -749,6 +1021,21 @@ export function agentsCommand() {
749
1021
  const sessionId = await ensureSessionId(true);
750
1022
  let state = await loadSessionState(sessionId);
751
1023
  state = await stopSession(state, 'stopped');
1024
+ if (state.run && state.run.status === 'running') {
1025
+ state = await saveSessionState({
1026
+ ...state,
1027
+ run: {
1028
+ ...state.run,
1029
+ status: 'cancelled',
1030
+ finishedAt: new Date().toISOString(),
1031
+ currentRole: null,
1032
+ lastError: {
1033
+ code: 'RUN_CANCELLED',
1034
+ message: 'Run cancelled by user',
1035
+ },
1036
+ },
1037
+ });
1038
+ }
752
1039
  if (state.tmuxSessionName && hasCommand('tmux')) {
753
1040
  try {
754
1041
  await runTmux('tmux', ['kill-session', '-t', state.tmuxSessionName]);
@@ -765,6 +1052,33 @@ export function agentsCommand() {
765
1052
  }
766
1053
  });
767
1054
 
1055
+ agents
1056
+ .command('autopilot')
1057
+ .description('Internal headless collaborative driver (non-tmux)')
1058
+ .requiredOption('--session <id>', 'Session id')
1059
+ .option('--poll-ms <n>', 'Polling interval in ms', '900')
1060
+ .action(async (opts) => {
1061
+ const pollMs = Number.parseInt(opts.pollMs, 10);
1062
+ while (true) {
1063
+ try {
1064
+ const state = await loadSessionState(opts.session);
1065
+ if (state.status !== 'running') process.exit(0);
1066
+
1067
+ for (const role of state.roles || COLLAB_ROLES) {
1068
+ await runWorkerIteration(opts.session, role, {
1069
+ cwd: state.workingDirectory || process.cwd(),
1070
+ model: state.model || null,
1071
+ opencodeBin: state.opencodeBin || resolveCommandPath('opencode') || undefined,
1072
+ timeoutMs: state.run?.policy?.timeoutPerRoleMs || 180000,
1073
+ });
1074
+ }
1075
+ } catch (error) {
1076
+ console.error(`[autopilot] ${error.message}`);
1077
+ }
1078
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, Number.isInteger(pollMs) ? pollMs : 900));
1079
+ }
1080
+ });
1081
+
768
1082
  agents
769
1083
  .command('worker')
770
1084
  .description('Internal worker loop for a single collaborative role')