ac-framework 1.9.6 → 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.
@@ -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 {
@@ -9,8 +10,10 @@ import {
9
10
  COLLAB_SYSTEM_NAME,
10
11
  CURRENT_SESSION_FILE,
11
12
  DEFAULT_MAX_ROUNDS,
13
+ DEFAULT_SYNAPSE_MODEL,
12
14
  SESSION_ROOT_DIR,
13
15
  } from '../agents/constants.js';
16
+ import { listOpenCodeModels, runOpenCodePrompt } from '../agents/opencode-client.js';
14
17
  import { runWorkerIteration } from '../agents/orchestrator.js';
15
18
  import {
16
19
  addUserMessage,
@@ -125,6 +128,55 @@ function printModelConfig(state) {
125
128
  }
126
129
  }
127
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
+
164
+ async function preflightModel({ opencodeBin, model, cwd }) {
165
+ const selected = normalizeModelId(model) || DEFAULT_SYNAPSE_MODEL;
166
+ try {
167
+ await runOpenCodePrompt({
168
+ prompt: 'Reply with exactly: OK',
169
+ cwd,
170
+ model: selected,
171
+ binaryPath: opencodeBin,
172
+ timeoutMs: 45000,
173
+ });
174
+ return { ok: true, model: selected };
175
+ } catch (error) {
176
+ return { ok: false, model: selected, error: error.message };
177
+ }
178
+ }
179
+
128
180
  export function agentsCommand() {
129
181
  const agents = new Command('agents')
130
182
  .description(`${COLLAB_SYSTEM_NAME} — collaborative multi-agent system powered by OpenCode`);
@@ -276,6 +328,10 @@ export function agentsCommand() {
276
328
  if (!state.tmuxSessionName) {
277
329
  throw new Error('No tmux session registered for active collaborative session');
278
330
  }
331
+ const tmuxExists = await tmuxSessionExists(state.tmuxSessionName);
332
+ if (!tmuxExists) {
333
+ throw new Error(`tmux session ${state.tmuxSessionName} no longer exists. Run: acfm agents resume`);
334
+ }
279
335
  const args = ['attach'];
280
336
  if (opts.readonly) args.push('-r');
281
337
  args.push('-t', state.tmuxSessionName);
@@ -397,6 +453,52 @@ export function agentsCommand() {
397
453
  .command('model')
398
454
  .description('Manage default SynapseGrid model configuration');
399
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
+
400
502
  model
401
503
  .command('get')
402
504
  .description('Show configured default global/per-role models')
@@ -425,6 +527,114 @@ export function agentsCommand() {
425
527
  }
426
528
  });
427
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
+
428
638
  model
429
639
  .command('set <modelId>')
430
640
  .description('Set default model globally or for a specific role')
@@ -495,7 +705,7 @@ export function agentsCommand() {
495
705
  },
496
706
  };
497
707
  if (role === 'all') {
498
- next.agents.defaultModel = null;
708
+ next.agents.defaultModel = DEFAULT_SYNAPSE_MODEL;
499
709
  next.agents.defaultRoleModels = {};
500
710
  } else {
501
711
  const currentRoles = { ...next.agents.defaultRoleModels };
@@ -523,6 +733,83 @@ export function agentsCommand() {
523
733
  }
524
734
  });
525
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
+
526
813
  agents
527
814
  .command('export')
528
815
  .description('Export collaborative transcript')
@@ -540,9 +827,11 @@ export function agentsCommand() {
540
827
  throw new Error('--format must be md or json');
541
828
  }
542
829
 
830
+ const meetingSummary = await readSessionArtifact(sessionId, 'meeting-summary.md');
831
+ const meetingLog = await readSessionArtifact(sessionId, 'meeting-log.md');
543
832
  const payload = format === 'json'
544
- ? JSON.stringify({ state, transcript }, null, 2) + '\n'
545
- : 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`;
546
835
 
547
836
  if (opts.out) {
548
837
  const outputPath = resolve(opts.out);
@@ -611,7 +900,22 @@ export function agentsCommand() {
611
900
  ...defaultRoleModels,
612
901
  ...cliRoleModels,
613
902
  };
614
- const globalModel = cliModel || config.agents.defaultModel || null;
903
+ const globalModel = cliModel || config.agents.defaultModel || DEFAULT_SYNAPSE_MODEL;
904
+
905
+ const modelsToCheck = new Set([globalModel, ...Object.values(roleModels)]);
906
+ for (const modelToCheck of modelsToCheck) {
907
+ const preflight = await preflightModel({
908
+ opencodeBin,
909
+ model: modelToCheck,
910
+ cwd: resolve(opts.cwd),
911
+ });
912
+ if (!preflight.ok) {
913
+ throw new Error(
914
+ `Model preflight failed for ${preflight.model}: ${preflight.error}. ` +
915
+ 'Check OpenCode auth/providers with: opencode auth list, opencode models'
916
+ );
917
+ }
918
+ }
615
919
 
616
920
  const state = await createSession(opts.task, {
617
921
  roles: COLLAB_ROLES,
@@ -686,6 +990,11 @@ export function agentsCommand() {
686
990
  console.log(chalk.dim(`Round: ${Math.min(state.round, state.maxRounds)}/${state.maxRounds}`));
687
991
  console.log(chalk.dim(`Active agent: ${state.activeAgent || 'none'}`));
688
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
+ }
689
998
  console.log(chalk.dim(`Global model: ${state.model || '(opencode default)'}`));
690
999
  for (const role of COLLAB_ROLES) {
691
1000
  const configured = state.roleModels?.[role] || '-';
@@ -712,6 +1021,21 @@ export function agentsCommand() {
712
1021
  const sessionId = await ensureSessionId(true);
713
1022
  let state = await loadSessionState(sessionId);
714
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
+ }
715
1039
  if (state.tmuxSessionName && hasCommand('tmux')) {
716
1040
  try {
717
1041
  await runTmux('tmux', ['kill-session', '-t', state.tmuxSessionName]);
@@ -728,6 +1052,33 @@ export function agentsCommand() {
728
1052
  }
729
1053
  });
730
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
+
731
1082
  agents
732
1083
  .command('worker')
733
1084
  .description('Internal worker loop for a single collaborative role')
@@ -749,6 +1100,7 @@ export function agentsCommand() {
749
1100
 
750
1101
  while (true) {
751
1102
  try {
1103
+ console.log(`[${role}] polling session ${opts.session}`);
752
1104
  const state = await loadSessionState(opts.session);
753
1105
  if (!state.roles.includes(role)) {
754
1106
  console.log(`[${role}] role not configured in session. exiting.`);
@@ -759,14 +1111,20 @@ export function agentsCommand() {
759
1111
  process.exit(0);
760
1112
  }
761
1113
 
1114
+ if (state.activeAgent === role) {
1115
+ console.log(`[${role}] executing turn with model=${state.roleModels?.[role] || state.model || DEFAULT_SYNAPSE_MODEL}`);
1116
+ }
762
1117
  const nextState = await runWorkerIteration(opts.session, role, {
763
1118
  cwd: state.workingDirectory || process.cwd(),
764
1119
  model: state.model || null,
765
1120
  opencodeBin: state.opencodeBin || resolveCommandPath('opencode') || undefined,
1121
+ timeoutMs: 180000,
766
1122
  });
767
1123
  const latest = nextState.messages[nextState.messages.length - 1];
768
1124
  if (latest?.from === role) {
769
1125
  console.log(`[${role}] message emitted (${latest.content.length} chars)`);
1126
+ } else if (nextState.activeAgent && nextState.activeAgent !== role) {
1127
+ console.log(`[${role}] waiting for active role=${nextState.activeAgent}`);
770
1128
  }
771
1129
  } catch (error) {
772
1130
  console.error(`[${role}] loop error: ${error.message}`);
@@ -776,5 +1134,50 @@ export function agentsCommand() {
776
1134
  }
777
1135
  });
778
1136
 
1137
+ agents
1138
+ .command('doctor')
1139
+ .description('Run diagnostics for SynapseGrid/OpenCode runtime')
1140
+ .option('--json', 'Output as JSON')
1141
+ .action(async (opts) => {
1142
+ try {
1143
+ const opencodeBin = resolveCommandPath('opencode');
1144
+ const tmuxInstalled = hasCommand('tmux');
1145
+ const cfg = await loadAgentsConfig();
1146
+ const defaultModel = cfg.agents.defaultModel || DEFAULT_SYNAPSE_MODEL;
1147
+ const result = {
1148
+ opencodeBin,
1149
+ tmuxInstalled,
1150
+ defaultModel,
1151
+ defaultRoleModels: cfg.agents.defaultRoleModels,
1152
+ preflight: null,
1153
+ };
1154
+
1155
+ if (opencodeBin) {
1156
+ result.preflight = await preflightModel({
1157
+ opencodeBin,
1158
+ model: defaultModel,
1159
+ cwd: process.cwd(),
1160
+ });
1161
+ } else {
1162
+ result.preflight = { ok: false, error: 'OpenCode binary not found' };
1163
+ }
1164
+
1165
+ output(result, opts.json);
1166
+ if (!opts.json) {
1167
+ console.log(chalk.bold('SynapseGrid doctor'));
1168
+ console.log(chalk.dim(`opencode: ${opencodeBin || 'not found'}`));
1169
+ console.log(chalk.dim(`tmux: ${tmuxInstalled ? 'installed' : 'not installed'}`));
1170
+ console.log(chalk.dim(`default model: ${defaultModel}`));
1171
+ console.log(chalk.dim(`preflight: ${result.preflight?.ok ? 'ok' : `failed - ${result.preflight?.error || 'unknown error'}`}`));
1172
+ }
1173
+
1174
+ if (!result.preflight?.ok) process.exit(1);
1175
+ } catch (error) {
1176
+ output({ error: error.message }, opts.json);
1177
+ if (!opts.json) console.error(chalk.red(`Error: ${error.message}`));
1178
+ process.exit(1);
1179
+ }
1180
+ });
1181
+
779
1182
  return agents;
780
1183
  }