sneakoscope 0.6.76 → 0.6.78

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,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import fsp from 'node:fs/promises';
3
- import { spawnSync } from 'node:child_process';
3
+ import { spawn, spawnSync } from 'node:child_process';
4
4
  import { exists, nowIso, packageRoot, readJson, runProcess, sha256, sksRoot, which, writeJsonAtomic } from './fsx.mjs';
5
5
  import { getCodexInfo } from './codex-adapter.mjs';
6
6
  import { codexAppIntegrationStatus, formatCodexAppStatus } from './codex-app.mjs';
@@ -90,6 +90,10 @@ export function cmuxWorkspaceStatePath(plan = {}) {
90
90
  return path.join(path.resolve(plan.root || process.cwd()), '.sneakoscope', 'state', 'cmux-workspaces.json');
91
91
  }
92
92
 
93
+ export function cmuxTeamStatePath(root = process.cwd()) {
94
+ return path.join(path.resolve(root || process.cwd()), '.sneakoscope', 'state', 'cmux-team-workspaces.json');
95
+ }
96
+
93
97
  export function cmuxWorkspaceStateKey(plan = {}) {
94
98
  const root = path.resolve(plan.root || process.cwd());
95
99
  const workspace = sanitizeCmuxWorkspaceName(plan.workspace || defaultCmuxWorkspaceName(root));
@@ -189,6 +193,23 @@ export function cmuxBinaryCandidates() {
189
193
  return Array.from(new Set(candidates));
190
194
  }
191
195
 
196
+ function cmuxAppExecutableCandidates() {
197
+ if (process.platform !== 'darwin') return [];
198
+ const envApps = String(process.env.SKS_CMUX_APP_PATHS || '')
199
+ .split(path.delimiter)
200
+ .map((entry) => entry.trim())
201
+ .filter(Boolean);
202
+ const appBundles = [
203
+ ...envApps,
204
+ '/Applications/cmux.app',
205
+ '/Applications/Cmux.app',
206
+ '/Applications/CMUX.app',
207
+ path.join(process.env.HOME || '', 'Applications', 'cmux.app'),
208
+ '/opt/homebrew/Caskroom/cmux/latest/cmux.app'
209
+ ].filter(Boolean);
210
+ return Array.from(new Set(appBundles.map((app) => path.join(app, 'Contents', 'MacOS', 'cmux'))));
211
+ }
212
+
192
213
  export async function cmuxAvailable() {
193
214
  const bin = await findCmuxBinary();
194
215
  if (!bin) return { ok: false, bin: null, version: null, executable_ok: false, error: 'cmux CLI not found' };
@@ -267,14 +288,59 @@ export function codexLaunchCommand(root, codexBin, codexArgs = []) {
267
288
  ].join('; ');
268
289
  }
269
290
 
291
+ function echoLinesCommand(lines = []) {
292
+ return lines.map((line) => String(line) ? `echo ${shellEscape(line)}` : 'echo').join('; ');
293
+ }
294
+
295
+ export const CMUX_TEAM_LANE_STYLES = Object.freeze({
296
+ overview: Object.freeze({ role: 'overview', label: 'overview', color_name: 'Charcoal', color: '#3E4B5E', icon: 'layout-dashboard' }),
297
+ scout: Object.freeze({ role: 'scout', label: 'scout', color_name: 'Aqua', color: '#0E6B8C', icon: 'search' }),
298
+ planning: Object.freeze({ role: 'planning', label: 'plan', color_name: 'Amber', color: '#7D6608', icon: 'messages-square' }),
299
+ execution: Object.freeze({ role: 'execution', label: 'exec', color_name: 'Green', color: '#196F3D', icon: 'hammer' }),
300
+ review: Object.freeze({ role: 'review', label: 'review', color_name: 'Crimson', color: '#922B21', icon: 'shield-check' }),
301
+ safety: Object.freeze({ role: 'safety', label: 'safety', color_name: 'Magenta', color: '#AD1457', icon: 'database' })
302
+ });
303
+
304
+ export function teamLaneStyle(agentId = '') {
305
+ const id = String(agentId || '').toLowerCase();
306
+ if (!id || id === 'mission_overview' || id === 'overview') return CMUX_TEAM_LANE_STYLES.overview;
307
+ if (/analysis|scout/.test(id)) return CMUX_TEAM_LANE_STYLES.scout;
308
+ if (/debate|consensus|planner|user/.test(id)) return CMUX_TEAM_LANE_STYLES.planning;
309
+ if (/db|safety/.test(id)) return CMUX_TEAM_LANE_STYLES.safety;
310
+ if (/review|qa|validation/.test(id)) return CMUX_TEAM_LANE_STYLES.review;
311
+ if (/executor|implementation|worker|developer/.test(id)) return CMUX_TEAM_LANE_STYLES.execution;
312
+ return CMUX_TEAM_LANE_STYLES.planning;
313
+ }
314
+
315
+ function teamLaneTitle(agentId = '') {
316
+ const style = teamLaneStyle(agentId);
317
+ return `${style.label}: ${String(agentId || 'mission_overview')}`.slice(0, 80);
318
+ }
319
+
320
+ function cmuxStatusKey(agentId = '') {
321
+ return sanitizeCmuxWorkspaceName(`sks-${String(agentId || 'overview').toLowerCase()}`).slice(0, 40);
322
+ }
323
+
270
324
  export function teamAgentCommand(root, missionId, agentId, phase) {
325
+ const style = teamLaneStyle(agentId);
271
326
  return [
272
- `printf '%s\\n' ${shellEscape(`${SKS_CMUX_LOGO}\n\nTeam mission: ${missionId}\nAgent: ${agentId}\nPhase: ${phase}\n`)}`,
327
+ 'clear',
328
+ echoLinesCommand([...SKS_CMUX_LOGO.split('\n'), '', `Team mission: ${missionId}`, `Agent: ${agentId}`, `Lane: ${style.label} (${style.color_name} ${style.color})`, `Phase: ${phase}`, '']),
273
329
  `cd ${shellEscape(root)}`,
274
330
  `node ${shellEscape(path.join(packageRoot(), 'bin', 'sks.mjs'))} team lane ${shellEscape(missionId)} --agent ${shellEscape(agentId)} --phase ${shellEscape(phase)} --follow --lines 12`
275
331
  ].join('; ');
276
332
  }
277
333
 
334
+ export function teamOverviewCommand(root, missionId) {
335
+ const style = teamLaneStyle('mission_overview');
336
+ return [
337
+ 'clear',
338
+ echoLinesCommand([...SKS_CMUX_LOGO.split('\n'), '', `Team mission: ${missionId}`, 'View: live orchestration overview', `Lane: ${style.label} (${style.color_name} ${style.color})`, '']),
339
+ `cd ${shellEscape(root)}`,
340
+ `node ${shellEscape(path.join(packageRoot(), 'bin', 'sks.mjs'))} team watch ${shellEscape(missionId)} --follow --lines 18`
341
+ ].join('; ');
342
+ }
343
+
278
344
  export async function buildCmuxLaunchPlan(opts = {}) {
279
345
  const root = path.resolve(opts.root || await sksRoot());
280
346
  const workspace = sanitizeCmuxWorkspaceName(opts.workspace || opts.session || defaultCmuxWorkspaceName(root));
@@ -491,6 +557,14 @@ async function ensureCmuxDaemonReady(cmux = {}) {
491
557
  last = await cmuxSocketProbe(cmux.bin);
492
558
  if (last.ok) return { ...cmux, ok: true, error: null };
493
559
  }
560
+ if (process.env.SKS_CMUX_SOCKET_ALLOW_ALL !== '0') {
561
+ await restartCmuxApp({ socketMode: 'allowAll' });
562
+ for (let i = 0; i < 8; i++) {
563
+ await new Promise((resolve) => setTimeout(resolve, 750));
564
+ last = await cmuxSocketProbe(cmux.bin);
565
+ if (last.ok) return { ...cmux, ok: true, error: null, socket_mode: 'allowAll' };
566
+ }
567
+ }
494
568
  }
495
569
  return { ok: false, error: last.error || 'cmux socket did not become ready' };
496
570
  }
@@ -505,7 +579,7 @@ function isRecoverableCmuxSocketError(error) {
505
579
  return /socket|broken pipe|receive timeout|connection refused/i.test(String(error || ''));
506
580
  }
507
581
 
508
- async function restartCmuxApp() {
582
+ async function restartCmuxApp(opts = {}) {
509
583
  if (process.platform !== 'darwin') return { ok: false, reason: 'not_macos' };
510
584
  const quit = await runProcess('osascript', ['-e', 'tell application "cmux" to quit'], { timeoutMs: 8000, maxOutputBytes: 16 * 1024 }).catch((err) => ({ code: 1, stderr: err.message, stdout: '' }));
511
585
  if (quit.code !== 0) {
@@ -513,6 +587,21 @@ async function restartCmuxApp() {
513
587
  }
514
588
  await new Promise((resolve) => setTimeout(resolve, 1500));
515
589
  await removeStaleCmuxSocket().catch(() => null);
590
+ if (opts.socketMode) return openCmuxAppWithSocketMode(opts.socketMode);
591
+ return openCmuxApp();
592
+ }
593
+
594
+ async function openCmuxAppWithSocketMode(socketMode) {
595
+ for (const exe of cmuxAppExecutableCandidates()) {
596
+ if (!await exists(exe)) continue;
597
+ const child = spawn(exe, [], {
598
+ detached: true,
599
+ stdio: 'ignore',
600
+ env: { ...process.env, CMUX_SOCKET_MODE: socketMode }
601
+ });
602
+ child.unref();
603
+ return { ok: true, mode: socketMode, executable: exe };
604
+ }
516
605
  return openCmuxApp();
517
606
  }
518
607
 
@@ -524,7 +613,7 @@ async function removeStaleCmuxSocket() {
524
613
  }
525
614
 
526
615
  export async function launchCmuxTeamView({ root, missionId, plan = {}, promptFile = null, json = false } = {}) {
527
- const launch = await buildCmuxLaunchPlan({ root, workspace: `sks-team-${missionId}` });
616
+ const launch = await buildCmuxLaunchPlan({ root, workspace: `sks-team-${missionId}`, wakeCmux: true });
528
617
  const agents = [
529
618
  ...(plan.roster?.analysis_team || []),
530
619
  ...(plan.roster?.debate_team || []),
@@ -541,18 +630,26 @@ export async function launchCmuxTeamView({ root, missionId, plan = {}, promptFil
541
630
  }
542
631
  const commands = uniqueAgents.slice(0, Math.max(1, plan.agent_session_count || 3)).map((agentId, index) => ({
543
632
  agent: agentId,
544
- command: teamAgentCommand(launch.root, missionId, agentId, index === 0 ? 'analysis' : 'team', promptFile)
633
+ command: teamAgentCommand(launch.root, missionId, agentId, index === 0 ? 'analysis' : 'team', promptFile),
634
+ style: teamLaneStyle(agentId),
635
+ title: teamLaneTitle(agentId)
545
636
  }));
546
- const result = { ready: launch.ready, cmux: launch.cmux, workspace: launch.workspace, agents: commands, blockers: launch.blockers };
637
+ const overview = { agent: 'mission_overview', role: 'overview', command: teamOverviewCommand(launch.root, missionId), style: teamLaneStyle('mission_overview'), title: teamLaneTitle('mission_overview') };
638
+ const lanes = [overview, ...commands.map((entry) => ({ ...entry, role: entry.style.role }))];
639
+ const result = { ready: launch.ready, cmux: launch.cmux, workspace: launch.workspace, overview, agents: commands, lanes, cleanup_policy: 'collapse-agent-lanes-to-overview', blockers: launch.blockers };
547
640
  if (json || !launch.ready) return result;
548
- const first = commands[0]?.command || teamAgentCommand(launch.root, missionId, 'parent_orchestrator', 'team', promptFile);
641
+ const first = overview.command;
549
642
  const created = spawnSync(launch.cmux.bin, buildCmuxNewWorkspaceArgs(launch, first), { encoding: 'utf8', stdio: 'pipe' });
550
643
  result.created = created.status === 0;
551
644
  result.stdout = created.stdout || '';
552
645
  result.stderr = created.stderr || '';
553
- const workspaceRef = cmuxWorkspaceRefFromText(`${created.stdout || ''}\n${created.stderr || ''}`);
646
+ const createdText = `${created.stdout || ''}\n${created.stderr || ''}`;
647
+ const workspaceRef = cmuxWorkspaceRefFromText(createdText);
648
+ let overviewSurfaceRef = cmuxSurfaceRefFromText(createdText);
554
649
  if (workspaceRef) {
650
+ overviewSurfaceRef ||= firstCmuxSurfaceRef(launch.cmux.bin, workspaceRef);
555
651
  result.workspace_ref = workspaceRef;
652
+ if (overviewSurfaceRef) result.overview.surface_ref = overviewSurfaceRef;
556
653
  await writeCmuxWorkspaceRecord(launch, { ref: workspaceRef, name: launch.workspace, description: cmuxWorkspaceDescription(launch), cwd: launch.root }).catch(() => null);
557
654
  const selected = await runProcess(launch.cmux.bin, ['select-workspace', '--workspace', workspaceRef], { timeoutMs: 5000, maxOutputBytes: 16 * 1024 }).catch((err) => ({ code: 1, stdout: '', stderr: err.message }));
558
655
  result.selected = selected.code === 0;
@@ -564,7 +661,7 @@ export async function launchCmuxTeamView({ root, missionId, plan = {}, promptFil
564
661
  if (!workspaceRef) result.blockers = [...(result.blockers || []), 'cmux new-workspace did not return a workspace ref'];
565
662
  return result;
566
663
  }
567
- for (const entry of commands.slice(1)) {
664
+ for (const entry of commands) {
568
665
  const split = spawnSync(launch.cmux.bin, ['new-split', 'right', '--workspace', workspaceRef], { encoding: 'utf8', stdio: 'pipe' });
569
666
  const splitText = `${split.stdout || ''}\n${split.stderr || ''}`;
570
667
  const surfaceRef = cmuxSurfaceRefFromText(splitText);
@@ -574,6 +671,8 @@ export async function launchCmuxTeamView({ root, missionId, plan = {}, promptFil
574
671
  ok: split.status === 0 && Boolean(surfaceRef),
575
672
  pane_ref: paneRef,
576
673
  surface_ref: surfaceRef,
674
+ style: entry.style,
675
+ title: entry.title,
577
676
  stdout: split.stdout || '',
578
677
  stderr: split.stderr || ''
579
678
  };
@@ -585,10 +684,35 @@ export async function launchCmuxTeamView({ root, missionId, plan = {}, promptFil
585
684
  }
586
685
  result.splits.push(splitResult);
587
686
  }
687
+ const customizationLanes = [
688
+ { ...overview, surface_ref: overviewSurfaceRef },
689
+ ...result.splits.map((entry) => ({ agent: entry.agent, surface_ref: entry.surface_ref, pane_ref: entry.pane_ref, style: entry.style, title: entry.title }))
690
+ ];
691
+ result.customization = await applyCmuxTeamCustomization(launch.cmux.bin, workspaceRef, customizationLanes);
692
+ await writeCmuxTeamRecord(launch.root, {
693
+ mission_id: missionId,
694
+ workspace: launch.workspace,
695
+ workspace_ref: workspaceRef,
696
+ overview_surface_ref: overviewSurfaceRef || null,
697
+ cleanup_policy: result.cleanup_policy,
698
+ lanes: customizationLanes.map((entry) => ({
699
+ agent: entry.agent,
700
+ role: entry.style?.role || teamLaneStyle(entry.agent).role,
701
+ style: entry.style || teamLaneStyle(entry.agent),
702
+ title: entry.title || teamLaneTitle(entry.agent),
703
+ surface_ref: entry.surface_ref || null,
704
+ pane_ref: entry.pane_ref || null
705
+ }))
706
+ }).catch(() => null);
588
707
  result.split_count = result.splits.filter((entry) => entry.ok && entry.send_ok).length;
589
- const expectedSplits = Math.max(0, commands.length - 1);
708
+ const expectedSplits = commands.length;
590
709
  result.opened_lane_count = 1 + result.split_count;
591
710
  result.all_lanes_opened = result.created && result.selected !== false && result.split_count === expectedSplits;
711
+ result.screen_read_checks = readCmuxLaneScreens(launch.cmux.bin, workspaceRef, [
712
+ { agent: 'mission_overview', surface_ref: overviewSurfaceRef },
713
+ ...result.splits.map((entry) => ({ agent: entry.agent, surface_ref: entry.surface_ref }))
714
+ ]);
715
+ result.screen_read_ok = result.screen_read_checks.some((entry) => entry.ok);
592
716
  result.ready = Boolean(result.ready && result.all_lanes_opened);
593
717
  if (!result.all_lanes_opened) {
594
718
  result.blockers = [
@@ -600,6 +724,135 @@ export async function launchCmuxTeamView({ root, missionId, plan = {}, promptFil
600
724
  return result;
601
725
  }
602
726
 
727
+ async function applyCmuxTeamCustomization(bin, workspaceRef, lanes = []) {
728
+ if (!bin || !workspaceRef) return { ok: false, skipped: true, reason: 'missing cmux binary or workspace ref', operations: [] };
729
+ const operations = [];
730
+ const pushRun = async (label, args) => {
731
+ const run = await runProcess(bin, args, { timeoutMs: 5000, maxOutputBytes: 16 * 1024 }).catch((err) => ({ code: 1, stdout: '', stderr: err.message }));
732
+ operations.push({ label, args, ok: run.code === 0, stdout: run.stdout || '', stderr: run.stderr || '' });
733
+ return run.code === 0;
734
+ };
735
+ const overview = lanes.find((lane) => (lane.agent || '') === 'mission_overview') || lanes[0] || {};
736
+ const overviewStyle = overview.style || teamLaneStyle('mission_overview');
737
+ await pushRun('workspace-color', ['workspace-action', '--workspace', workspaceRef, '--action', 'set-color', '--color', overviewStyle.color]);
738
+ await pushRun('workspace-status', ['set-status', 'sks-team', 'Team live', '--icon', overviewStyle.icon, '--color', overviewStyle.color, '--workspace', workspaceRef]);
739
+ await pushRun('workspace-progress', ['set-progress', '0.15', '--label', 'Team running', '--workspace', workspaceRef]);
740
+ for (const lane of lanes) {
741
+ const style = lane.style || teamLaneStyle(lane.agent);
742
+ if (lane.surface_ref) await pushRun(`rename-${lane.agent}`, ['rename-tab', '--workspace', workspaceRef, '--surface', lane.surface_ref, '--title', lane.title || teamLaneTitle(lane.agent)]);
743
+ await pushRun(`status-${lane.agent}`, ['set-status', cmuxStatusKey(lane.agent), lane.title || teamLaneTitle(lane.agent), '--icon', style.icon, '--color', style.color, '--workspace', workspaceRef]);
744
+ }
745
+ return { ok: operations.some((entry) => entry.ok), operations };
746
+ }
747
+
748
+ async function writeCmuxTeamRecord(root, record = {}) {
749
+ if (!record.mission_id || !record.workspace_ref) return null;
750
+ const statePath = cmuxTeamStatePath(root);
751
+ const state = await readJson(statePath, {}).catch(() => ({}));
752
+ const now = nowIso();
753
+ const nextRecord = { ...record, schema_version: 1, root: path.resolve(root || process.cwd()), updated_at: now };
754
+ const missions = state.missions && typeof state.missions === 'object' ? state.missions : {};
755
+ await writeJsonAtomic(statePath, {
756
+ schema_version: 1,
757
+ updated_at: now,
758
+ missions: {
759
+ ...missions,
760
+ [record.mission_id]: nextRecord
761
+ }
762
+ });
763
+ return nextRecord;
764
+ }
765
+
766
+ async function readCmuxTeamRecord(root, missionId) {
767
+ const state = await readJson(cmuxTeamStatePath(root), {}).catch(() => ({}));
768
+ const missions = state.missions && typeof state.missions === 'object' ? state.missions : {};
769
+ if (missionId && missionId !== 'latest') return missions[missionId] || null;
770
+ const records = Object.values(missions).filter((entry) => entry && typeof entry === 'object');
771
+ records.sort((a, b) => String(b.updated_at || '').localeCompare(String(a.updated_at || '')));
772
+ return records[0] || null;
773
+ }
774
+
775
+ export async function cleanupCmuxTeamView({ root, missionId = 'latest', closeWorkspace = false } = {}) {
776
+ const resolvedRoot = path.resolve(root || await sksRoot());
777
+ const record = await readCmuxTeamRecord(resolvedRoot, missionId);
778
+ if (!record?.workspace_ref) return { ok: false, skipped: true, reason: 'no recorded cmux Team workspace', mission_id: missionId };
779
+ const cmux = await cmuxReadiness({ wake: true }).catch((err) => ({ ok: false, error: err.message || 'cmux readiness failed' }));
780
+ if (!cmux.ok) return { ok: false, workspace_ref: record.workspace_ref, mission_id: record.mission_id, reason: cmux.error || 'cmux not ready' };
781
+ const operations = [];
782
+ const run = async (label, args) => {
783
+ const out = await runProcess(cmux.bin, args, { timeoutMs: 5000, maxOutputBytes: 16 * 1024 }).catch((err) => ({ code: 1, stdout: '', stderr: err.message }));
784
+ operations.push({ label, ok: out.code === 0, stdout: out.stdout || '', stderr: out.stderr || '' });
785
+ return out.code === 0;
786
+ };
787
+ if (closeWorkspace) {
788
+ const closed = await run('close-workspace', ['close-workspace', '--workspace', record.workspace_ref]);
789
+ return { ok: closed, mission_id: record.mission_id, workspace_ref: record.workspace_ref, close_workspace: true, closed_workspace: closed, operations };
790
+ }
791
+ let overviewSurfaceRef = record.overview_surface_ref || record.lanes?.find((lane) => lane.agent === 'mission_overview')?.surface_ref || null;
792
+ let agentLanes = (record.lanes || []).filter((lane) => lane.surface_ref && lane.surface_ref !== overviewSurfaceRef && lane.agent !== 'mission_overview');
793
+ if (!overviewSurfaceRef) {
794
+ const agentRefs = new Set(agentLanes.map((lane) => lane.surface_ref));
795
+ overviewSurfaceRef = listCmuxWorkspaceSurfacesSync(cmux.bin, record.workspace_ref).find((surfaceRef) => !agentRefs.has(surfaceRef)) || null;
796
+ agentLanes = (record.lanes || []).filter((lane) => lane.surface_ref && lane.surface_ref !== overviewSurfaceRef && lane.agent !== 'mission_overview');
797
+ }
798
+ let closed = 0;
799
+ for (const lane of agentLanes) {
800
+ if (await run(`close-${lane.agent}`, ['close-surface', '--workspace', record.workspace_ref, '--surface', lane.surface_ref])) closed += 1;
801
+ }
802
+ const completeStyle = CMUX_TEAM_LANE_STYLES.execution;
803
+ if (overviewSurfaceRef) await run('rename-overview-complete', ['rename-tab', '--workspace', record.workspace_ref, '--surface', overviewSurfaceRef, '--title', `complete: ${record.mission_id}`.slice(0, 80)]);
804
+ await run('status-complete', ['set-status', 'sks-team', 'Team complete', '--icon', 'check-circle', '--color', completeStyle.color, '--workspace', record.workspace_ref]);
805
+ await run('progress-complete', ['set-progress', '1.0', '--label', 'Team complete', '--workspace', record.workspace_ref]);
806
+ await run('select-workspace', ['select-workspace', '--workspace', record.workspace_ref]);
807
+ await writeCmuxTeamRecord(resolvedRoot, { ...record, cleanup_completed_at: nowIso(), closed_agent_surfaces: closed }).catch(() => null);
808
+ return {
809
+ ok: true,
810
+ mission_id: record.mission_id,
811
+ workspace_ref: record.workspace_ref,
812
+ close_workspace: false,
813
+ kept_surface: overviewSurfaceRef,
814
+ requested_close_surfaces: agentLanes.length,
815
+ closed_surfaces: closed,
816
+ operations
817
+ };
818
+ }
819
+
820
+ function readCmuxLaneScreens(bin, workspaceRef, lanes = []) {
821
+ return lanes.map((lane) => {
822
+ const args = ['read-screen', '--workspace', workspaceRef, '--lines', '6'];
823
+ if (lane.surface_ref) args.splice(3, 0, '--surface', lane.surface_ref);
824
+ const read = spawnSync(bin, args, { encoding: 'utf8', stdio: 'pipe' });
825
+ const text = `${read.stdout || ''}\n${read.stderr || ''}`.trim();
826
+ return {
827
+ agent: lane.agent,
828
+ surface_ref: lane.surface_ref || null,
829
+ ok: read.status === 0 && Boolean(text),
830
+ preview: text.slice(0, 1000),
831
+ error: read.status === 0 ? null : text || 'cmux read-screen failed'
832
+ };
833
+ });
834
+ }
835
+
836
+ function firstCmuxSurfaceRef(bin, workspaceRef) {
837
+ return listCmuxWorkspaceSurfacesSync(bin, workspaceRef)[0] || '';
838
+ }
839
+
840
+ function listCmuxWorkspaceSurfacesSync(bin, workspaceRef) {
841
+ if (!bin || !workspaceRef) return [];
842
+ const panes = spawnSync(bin, ['list-panes', '--workspace', workspaceRef], { encoding: 'utf8', stdio: 'pipe' });
843
+ if (panes.status !== 0) return [];
844
+ const paneRefs = Array.from(new Set(String(`${panes.stdout || ''}\n${panes.stderr || ''}`).match(/\bpane:\d+\b/g) || []));
845
+ const surfaces = [];
846
+ for (const paneRef of paneRefs) {
847
+ const run = spawnSync(bin, ['list-pane-surfaces', '--workspace', workspaceRef, '--pane', paneRef], { encoding: 'utf8', stdio: 'pipe' });
848
+ if (run.status !== 0) continue;
849
+ for (const surfaceRef of String(`${run.stdout || ''}\n${run.stderr || ''}`).match(/\bsurface:\d+\b/g) || []) {
850
+ if (!surfaces.includes(surfaceRef)) surfaces.push(surfaceRef);
851
+ }
852
+ }
853
+ return surfaces;
854
+ }
855
+
603
856
  export async function runCmuxStatus(args = [], opts = {}) {
604
857
  const once = args.includes('--once') || !args.includes('--watch');
605
858
  do {