@aiwg/cockpit 2026.7.10 → 2026.7.12

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.
@@ -357,19 +357,45 @@ async function proxy(res, method, target) {
357
357
  return json(res, r.status, body);
358
358
  }
359
359
 
360
- async function fetchJsonFirst(candidates, { method = 'GET', headers, body: requestBodyOption } = {}) {
360
+ const SAFE_FALLBACK_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
361
+
362
+ function isAbortError(err) {
363
+ return err?.name === 'AbortError' || /aborted|abort/i.test(String(err?.message ?? err));
364
+ }
365
+
366
+ function isConnectionRefusedError(err) {
367
+ const text = [
368
+ err?.code,
369
+ err?.cause?.code,
370
+ err?.message,
371
+ err?.cause?.message,
372
+ ].filter(Boolean).join(' ');
373
+ return /ECONNREFUSED|connection refused/i.test(text);
374
+ }
375
+
376
+ export async function fetchJsonFirst(candidates, { method = 'GET', headers, body: requestBodyOption, timeoutMs = 0 } = {}) {
361
377
  const failures = [];
362
378
  for (const candidate of candidates) {
363
379
  const target = typeof candidate === 'string' ? candidate : candidate.target;
364
380
  const requestMethod = typeof candidate === 'string' ? method : candidate.method ?? method;
365
381
  const requestHeaders = typeof candidate === 'string' ? headers : candidate.headers ?? headers;
366
382
  const requestBody = typeof candidate === 'string' ? requestBodyOption : candidate.body ?? requestBodyOption;
383
+ const controller = timeoutMs > 0 ? new AbortController() : null;
384
+ const timeout = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
367
385
  let r;
368
386
  try {
369
- r = await fetch(target, { method: requestMethod, headers: requestHeaders, body: requestBody });
387
+ r = await fetch(target, { method: requestMethod, headers: requestHeaders, body: requestBody, ...(controller ? { signal: controller.signal } : {}) });
370
388
  } catch (err) {
371
- failures.push(`${target} -> ${String(err?.message ?? err)}`);
389
+ const failure = isAbortError(err) && timeoutMs > 0
390
+ ? `${target} -> timeout after ${timeoutMs}ms`
391
+ : `${target} -> ${String(err?.message ?? err)}`;
392
+ failures.push(failure);
393
+ const safeToTryNext = SAFE_FALLBACK_METHODS.has(String(requestMethod).toUpperCase())
394
+ || isConnectionRefusedError(err);
395
+ if (!safeToTryNext) throw new Error(failures.join('; '));
372
396
  continue;
397
+ } finally {
398
+ if (timeout) clearTimeout(timeout);
373
399
  }
374
400
  const responseBody = await r.json().catch(() => ({}));
375
401
  if (r.ok) return { target, status: r.status, body: responseBody };
@@ -576,6 +602,138 @@ async function destroyInstance(upstreamUrl, instanceId) {
576
602
  };
577
603
  }
578
604
 
605
+ // #1778: VM-runtime counterpart of `docker exec <ctr> agent-reconnect`. Sandbox
606
+ // VM images bake qemu-guest-agent ("essential for virsh exec") and agent-rs
607
+ // handles SIGHUP as reconnect-in-place on every runtime, so delivering
608
+ // `pkill -HUP -x agent-client` through the libvirt guest-agent channel makes
609
+ // the agent re-register without touching the VM. Session survival is
610
+ // version-conditional: agentic-sandbox 2026.7.8+ agents preserve all sessions
611
+ // across reconnect; older agents preserve only detached-tmux sessions
612
+ // (agentic-sandbox#634).
613
+ async function signalVmAgentReconnect(domain) {
614
+ const execRaw = await spawnCollect('virsh', ['qemu-agent-command', domain, JSON.stringify({
615
+ execute: 'guest-exec',
616
+ arguments: { path: '/bin/sh', arg: ['-c', 'pkill -HUP -x agent-client'], 'capture-output': true },
617
+ })]);
618
+ const pid = JSON.parse(String(execRaw))?.return?.pid;
619
+ if (!Number.isInteger(pid)) throw new Error(`guest-exec returned no pid: ${String(execRaw).trim()}`);
620
+ for (let attempt = 0; attempt < 5; attempt += 1) {
621
+ const statusRaw = await spawnCollect('virsh', ['qemu-agent-command', domain, JSON.stringify({
622
+ execute: 'guest-exec-status',
623
+ arguments: { pid },
624
+ })]);
625
+ const status = JSON.parse(String(statusRaw))?.return;
626
+ if (status?.exited) return status.exitcode ?? 0;
627
+ await new Promise((resolve) => setTimeout(resolve, 200));
628
+ }
629
+ // The signal command was handed to the guest; slow exec-status reporting is
630
+ // not a delivery failure.
631
+ return 0;
632
+ }
633
+
634
+ const VM_RUNTIME_KINDS = ['vm', 'qemu', 'kvm'];
635
+
636
+ async function reconnectInstance(upstreamUrl, instanceId) {
637
+ const inventory = await getInventory(upstreamUrl).catch(() => ({ instances: [] }));
638
+ const inst = inventory.instances.find((i) => String(i.id) === String(instanceId));
639
+ const runtime = String(inst?.runtime ?? inst?.runtime_posture?.kind ?? '').toLowerCase();
640
+ const dockerName = inst?.launch_context?.name;
641
+ const candidates = [
642
+ { target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}/reconnect`, method: 'POST' },
643
+ { target: `${upstreamUrl}/admin/instances/${encodeURIComponent(instanceId)}/reconnect`, method: 'POST' },
644
+ { target: `${upstreamUrl}/api/v1/instances/${encodeURIComponent(instanceId)}/reconnect`, method: 'POST' },
645
+ ];
646
+ try {
647
+ const result = await fetchJsonFirst(candidates, { timeoutMs: 5_000 });
648
+ if (result.status < 400) return result;
649
+ } catch {
650
+ // agentic-sandbox v2026.7.6 still exposes the container reconnect as an
651
+ // in-image helper, not an HTTP endpoint. Fall through to the local-dev path.
652
+ }
653
+
654
+ if (['docker', 'container'].includes(runtime) && dockerName) {
655
+ try {
656
+ const output = await spawnCollect('docker', ['exec', dockerName, 'agent-reconnect']);
657
+ return {
658
+ target: `docker exec ${dockerName} agent-reconnect`,
659
+ status: 202,
660
+ body: {
661
+ id: instanceId,
662
+ runtime,
663
+ docker_name: dockerName,
664
+ state: 'reconnecting',
665
+ message: `Reconnect requested for ${dockerName}; inventory will refresh as the agent re-registers.`,
666
+ output: String(output).trim(),
667
+ fallback: 'docker-agent-reconnect',
668
+ },
669
+ };
670
+ } catch (err) {
671
+ return {
672
+ target: `docker exec ${dockerName} agent-reconnect`,
673
+ status: 502,
674
+ body: {
675
+ error: 'reconnect_failed',
676
+ message: `Could not run agent-reconnect in ${dockerName}. Repull/rebuild the agent image if it predates agentic-sandbox v2026.7.5.`,
677
+ detail: String(err?.message ?? err),
678
+ },
679
+ };
680
+ }
681
+ }
682
+
683
+ if (VM_RUNTIME_KINDS.includes(runtime)) {
684
+ // For VM instances the agent_id doubles as the libvirt domain name
685
+ // (agentic-sandbox provision-vm.sh registers agent_id = $vm_name).
686
+ const domain = dockerName ?? inst?.name ?? String(instanceId);
687
+ const target = `virsh qemu-agent-command ${domain} guest-exec pkill -HUP -x agent-client`;
688
+ try {
689
+ const exitcode = await signalVmAgentReconnect(domain);
690
+ if (exitcode === 0) {
691
+ return {
692
+ target,
693
+ status: 202,
694
+ body: {
695
+ id: instanceId,
696
+ runtime,
697
+ vm_domain: domain,
698
+ state: 'reconnecting',
699
+ message: `Reconnect requested for VM ${domain}; inventory will refresh as the agent re-registers. Sessions survive reconnect on agentic-sandbox 2026.7.8+ agents; older agents preserve only detached tmux sessions (agentic-sandbox#634).`,
700
+ fallback: 'virsh-guest-agent-sighup',
701
+ },
702
+ };
703
+ }
704
+ return {
705
+ target,
706
+ status: 502,
707
+ body: {
708
+ error: 'reconnect_failed',
709
+ message: `No running agent-client process found inside VM ${domain}. Restart the agent service in the guest (systemctl restart agent-client) or reprovision the VM.`,
710
+ exitcode,
711
+ },
712
+ };
713
+ } catch (err) {
714
+ return {
715
+ target,
716
+ status: 502,
717
+ body: {
718
+ error: 'reconnect_failed',
719
+ message: `Could not signal agent-client in VM ${domain} via qemu-guest-agent. The bridge host needs virsh access to the libvirt domain and the guest-agent channel must be up (agentic-sandbox#633).`,
720
+ detail: String(err?.message ?? err),
721
+ },
722
+ };
723
+ }
724
+ }
725
+
726
+ return {
727
+ target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}/reconnect`,
728
+ status: 409,
729
+ body: {
730
+ error: 'reconnect_unavailable',
731
+ message: 'Reconnect is available for Docker/container instances (docker exec) and VM instances (qemu-guest-agent). For host-runtime agents, signal the agent directly: pkill -HUP -x agent-client.',
732
+ runtime: runtime || 'unknown',
733
+ },
734
+ };
735
+ }
736
+
579
737
  function asArrayFromEnvelope(body, keys) {
580
738
  if (Array.isArray(body)) return body;
581
739
  if (!body || typeof body !== 'object') return [];
@@ -590,10 +748,22 @@ function asArrayFromEnvelope(body, keys) {
590
748
  return [];
591
749
  }
592
750
 
751
+ const AGENT_ID_CACHE_TTL_MS = Number(process.env.AIWG_COCKPIT_AGENT_CACHE_TTL_MS ?? 5_000);
752
+ const agentListCache = new Map();
753
+
754
+ async function getAgentList(executorUrl) {
755
+ const cached = agentListCache.get(executorUrl);
756
+ const now = Date.now();
757
+ if (cached && cached.expiresAt > now) return cached.agents;
758
+ const { body } = await fetchJsonFirst([`${executorUrl}/api/v1/agents`]);
759
+ const agents = asArrayFromEnvelope(body, ['agents', 'items', 'data']);
760
+ agentListCache.set(executorUrl, { agents, expiresAt: now + AGENT_ID_CACHE_TTL_MS });
761
+ return agents;
762
+ }
763
+
593
764
  async function resolveSessionAgentId(executorUrl, instanceId) {
594
765
  try {
595
- const { body } = await fetchJsonFirst([`${executorUrl}/api/v1/agents`]);
596
- const agents = asArrayFromEnvelope(body, ['agents', 'items', 'data']);
766
+ const agents = await getAgentList(executorUrl);
597
767
  const agent = agents.find((a) => String(a.instance_id ?? a.instanceId ?? '') === String(instanceId));
598
768
  return agent?.id ?? agent?.agent_id ?? agent?.agentId ?? instanceId;
599
769
  } catch {
@@ -687,7 +857,18 @@ function normalizeTransport(posture) {
687
857
  function normalizeSessionBackends(backends, runtimeKind, state = 'unknown', agentReady = false) {
688
858
  const list = Array.isArray(backends) ? backends : [];
689
859
  if (!list.length && runtimeKind === 'host') {
690
- return [{ mode: 'managed', backend: 'tmux', observe: true, drive: true, replay: false, keyframe: false, available: true, reason: 'agentic-sandbox v1 host session API default' }];
860
+ return [{
861
+ mode: 'managed',
862
+ backend: 'tmux',
863
+ observe: true,
864
+ drive: true,
865
+ replay: false,
866
+ keyframe: false,
867
+ available: agentReady,
868
+ reason: agentReady
869
+ ? 'agentic-sandbox v1 host session API default'
870
+ : 'host runtime is provisioned but the host agent has not registered; PTY sessions are not ready',
871
+ }];
691
872
  }
692
873
  if (!list.length && ['docker', 'container', 'vm', 'qemu', 'kvm'].includes(runtimeKind) && String(state).toLowerCase() === 'running') {
693
874
  return [{
@@ -751,6 +932,53 @@ function normalizeInstance(executorUrl, i) {
751
932
  };
752
933
  }
753
934
 
935
+ function defaultSessionLaunch(instance) {
936
+ const runtime = String(instance?.runtime_posture?.kind ?? instance?.runtime ?? '').toLowerCase();
937
+ if (runtime === 'host') {
938
+ // Honor the executor-reported cwd — it is valid on the target host, including
939
+ // remote worker hosts whose filesystem does not mirror the Bridge machine.
940
+ // Fall back to the operator home only when the executor reports no cwd;
941
+ // hardcoding homedir() would resolve the Bridge's local home and break
942
+ // sessions on any host runtime that is not the Bridge machine itself.
943
+ return {
944
+ command: 'bash',
945
+ args: ['-l'],
946
+ working_dir: instance?.launch_context?.cwd ?? homedir(),
947
+ };
948
+ }
949
+ if (runtime === 'container' || runtime === 'docker' || runtime === 'vm' || runtime === 'qemu' || runtime === 'kvm') {
950
+ const home = runtime === 'container' || runtime === 'docker' ? '/root' : '/home/agent';
951
+ return {
952
+ command: '/bin/bash',
953
+ args: ['-lc', `cd ${shellSingleQuote(home)} && exec /bin/bash -l`],
954
+ working_dir: home,
955
+ };
956
+ }
957
+ return {
958
+ command: 'bash',
959
+ args: ['-l'],
960
+ };
961
+ }
962
+
963
+ // Deterministic prefix + per-request nonce. The name is IDENTICAL across this
964
+ // request's fallback candidates (so a timed-out-but-created session is
965
+ // recoverable by name, and the executor's 409-by-name guard dedupes candidate
966
+ // retries) but UNIQUE across requests — operators can hold multiple concurrent
967
+ // sessions per (instance, mode, backend). A fully canonical name here silently
968
+ // reused the first session on every subsequent "New session" click.
969
+ function sessionNameFor(instanceId, { mode = 'managed', backend = 'tmux' } = {}) {
970
+ const slug = (value) => String(value || 'default')
971
+ .toLowerCase()
972
+ .replace(/[^a-z0-9]+/g, '-')
973
+ .replace(/^-+|-+$/g, '')
974
+ .slice(0, 36) || 'default';
975
+ return `cockpit-${slug(instanceId)}-${slug(mode)}-${slug(backend)}-${randomBytes(3).toString('hex')}`;
976
+ }
977
+
978
+ function shellSingleQuote(value) {
979
+ return `'${String(value).replaceAll("'", "'\"'\"'")}'`;
980
+ }
981
+
754
982
  function runtimeExtensionFromCard(card) {
755
983
  const extensions = card?.capabilities?.extensions;
756
984
  if (!Array.isArray(extensions)) return null;
@@ -780,8 +1008,7 @@ async function enrichInstanceFromAgentCard(executorUrl, instance) {
780
1008
 
781
1009
  async function getRegisteredAgents(executorUrl) {
782
1010
  try {
783
- const { body } = await fetchJsonFirst([`${executorUrl}/api/v1/agents`]);
784
- return asArrayFromEnvelope(body, ['agents', 'items', 'data']);
1011
+ return await getAgentList(executorUrl);
785
1012
  } catch {
786
1013
  return [];
787
1014
  }
@@ -1183,8 +1410,8 @@ async function getSessionEventRows(executorUrl, instances) {
1183
1410
  agent_id: session.agent_id,
1184
1411
  state: session.state ?? session.status ?? session.session_state ?? 'available',
1185
1412
  role_policy: session.role_policy,
1186
- backend: session.backend ?? session.session_backend,
1187
- mode: session.mode ?? session.session_class,
1413
+ session_backend: session.session_backend,
1414
+ session_class: session.session_class,
1188
1415
  });
1189
1416
  }
1190
1417
  }));
@@ -1211,7 +1438,7 @@ async function getEventSnapshot(executorUrl) {
1211
1438
  events.push({ id: `approval:${approval.id}`, type: 'hitl.approval', source: 'a2a', subject: approval.task_id ?? approval.id, state: approval.status, severity: approval.risk, ts, ref: { instance_id: approval.instance_id, approval_id: approval.id } });
1212
1439
  }
1213
1440
  for (const session of sessions ?? []) {
1214
- events.push({ id: `session:${session.instance_id}:${session.id}`, type: 'session.lifecycle', source: 'pty-session', subject: session.id, state: session.state, ts, ref: { instance_id: session.instance_id, session_id: session.id, agent_id: session.agent_id, backend: session.backend, mode: session.mode, role_policy: session.role_policy } });
1441
+ events.push({ id: `session:${session.instance_id}:${session.id}`, type: 'session.lifecycle', source: 'pty-session', subject: session.id, state: session.state, ts, ref: { instance_id: session.instance_id, session_id: session.id, agent_id: session.agent_id, session_backend: session.session_backend, session_class: session.session_class, role_policy: session.role_policy } });
1215
1442
  }
1216
1443
  for (const mission of missions.missions ?? []) {
1217
1444
  events.push({ id: `mission:${mission.id}`, type: 'mission.lifecycle', source: mission.source ?? 'aiwg-mc', subject: mission.id, state: mission.status, ts, ref: { session_id: mission.session_id, mission_id: mission.id, ralph_loop_id: mission.ralph_loop_id } });
@@ -1284,6 +1511,54 @@ async function getSessions(executorUrl, instanceId) {
1284
1511
  `${executorUrl}/api/v1/agents/${encodeURIComponent(agentId)}/sessions`,
1285
1512
  ]));
1286
1513
  const sessions = asArrayFromEnvelope(body, ['sessions', 'items', 'data']);
1514
+ return normalizeSessionRows({ sessions, executorUrl, instanceId, sessionAgentId });
1515
+ }
1516
+
1517
+ function normalizeScreenSnapshot(body, { instanceId, sessionId, source }) {
1518
+ const text = String(body?.text ?? body?.snapshot ?? body?.screen ?? body?.content ?? '');
1519
+ const rawLines = Array.isArray(body?.lines) ? body.lines : text.replace(/\r/g, '\n').split('\n');
1520
+ return {
1521
+ instance_id: instanceId,
1522
+ session_id: sessionId,
1523
+ text,
1524
+ lines: rawLines.map((line) => String(line)).filter(Boolean).slice(-80),
1525
+ seq: body?.seq ?? body?.sequence ?? body?.anchor_sequence ?? body?.anchorSequence ?? null,
1526
+ fetched_at: new Date().toISOString(),
1527
+ source,
1528
+ snapshot_format: body?.snapshot_format ?? body?.snapshotFormat ?? body?.format ?? 'text/plain',
1529
+ };
1530
+ }
1531
+
1532
+ async function getSessionScreen(executorUrl, instanceId, sessionId) {
1533
+ const sessionAgentId = await resolveSessionAgentId(executorUrl, instanceId);
1534
+ const agentIds = unique([sessionAgentId, instanceId]);
1535
+ const paths = agentIds.flatMap((agentId) => {
1536
+ const encodedAgent = encodeURIComponent(agentId);
1537
+ const encodedSession = encodeURIComponent(sessionId);
1538
+ return [
1539
+ `${executorUrl}/api/v1/agents/${encodedAgent}/sessions/${encodedSession}/screen`,
1540
+ `${executorUrl}/api/v1/agents/${encodedAgent}/sessions/${encodedSession}/screen-state`,
1541
+ `${executorUrl}/agents/${encodedAgent}/sessions/${encodedSession}/screen`,
1542
+ `${executorUrl}/agents/${encodedAgent}/sessions/${encodedSession}/screen-state`,
1543
+ ];
1544
+ });
1545
+ try {
1546
+ const { body, target, status } = await fetchJsonFirst(paths);
1547
+ return { status, body: normalizeScreenSnapshot(body, { instanceId, sessionId, source: target }) };
1548
+ } catch (e) {
1549
+ return {
1550
+ status: 404,
1551
+ body: {
1552
+ error: 'session_screen_unavailable',
1553
+ instance_id: instanceId,
1554
+ session_id: sessionId,
1555
+ detail: String(e?.message ?? e),
1556
+ },
1557
+ };
1558
+ }
1559
+ }
1560
+
1561
+ export function normalizeSessionRows({ sessions, executorUrl, instanceId, sessionAgentId = instanceId }) {
1287
1562
  const wsBase = executorUrl.replace(/^http/i, 'ws');
1288
1563
  const normalizeAttachUrl = (s, sessionId) => {
1289
1564
  const explicit = s.attach_url ?? s.attachUrl;
@@ -1304,26 +1579,83 @@ async function getSessions(executorUrl, instanceId) {
1304
1579
  // agent name is only needed for the session-list FETCH, not the attach path.
1305
1580
  return `${wsBase}/agents/${encodeURIComponent(instanceId)}/sessions/${encodeURIComponent(sessionId)}/attach`;
1306
1581
  };
1307
- // Dedup by session id: the executor can register/return the same session more
1308
- // than once (e.g. a session registered twice in its registry), which surfaced
1309
- // as duplicate rows that are impossible to tell apart in the Sessions picker.
1310
- // Keep the first occurrence of each id (and drop id-less entries).
1311
- const seen = new Set();
1312
- const deduped = [];
1582
+ const sessionAliases = (entry, sessionId) => {
1583
+ const aliases = [`session:${sessionId}`];
1584
+ const commandId = entry.command_id ?? entry.commandId;
1585
+ if (commandId) aliases.push(`command:${commandId}`);
1586
+ return aliases;
1587
+ };
1588
+ const fallbackScore = (entry) => {
1589
+ const sessionId = String(entry.id ?? entry.session_id ?? entry.sessionId ?? '');
1590
+ const commandId = String(entry.command_id ?? entry.commandId ?? '');
1591
+ const id = String(entry.id ?? '');
1592
+ const name = String(entry.session_name ?? entry.sessionName ?? '');
1593
+ const command = String(entry.command ?? '').trim();
1594
+ let score = 0;
1595
+ if (id && commandId && id === commandId) score += 2;
1596
+ if (name && (name === sessionId || name === commandId)) score += 2;
1597
+ if (/^\/?bin\/bash\s+-l$/.test(command)) score += 1;
1598
+ if (entry.has_screen === false || entry.hasScreen === false) score += 1;
1599
+ return score;
1600
+ };
1601
+ const namedScore = (entry) => {
1602
+ const name = String(entry.session_name ?? entry.sessionName ?? '');
1603
+ let score = name ? 1 : 0;
1604
+ if (/^terminal-[a-z0-9-]+$/i.test(name)) score += 2;
1605
+ if (entry.has_screen === true || entry.hasScreen === true) score += 1;
1606
+ return score;
1607
+ };
1608
+ const shouldReplace = (existing, candidate) => {
1609
+ const existingFallback = fallbackScore(existing);
1610
+ const candidateFallback = fallbackScore(candidate);
1611
+ if (existingFallback !== candidateFallback) return candidateFallback < existingFallback;
1612
+ const existingNamed = namedScore(existing);
1613
+ const candidateNamed = namedScore(candidate);
1614
+ if (existingNamed !== candidateNamed) return candidateNamed > existingNamed;
1615
+ return false;
1616
+ };
1617
+ const mergeGroups = (target, source) => {
1618
+ if (target === source) return target;
1619
+ for (const alias of source.aliases) {
1620
+ target.aliases.add(alias);
1621
+ groupsByAlias.set(alias, target);
1622
+ }
1623
+ source.merged = true;
1624
+ if (!target.value || (source.value && shouldReplace(target.value, source.value))) target.value = source.value;
1625
+ return target;
1626
+ };
1627
+ // Dedup by every stable alias we see. Docker/host fallback rows can share a
1628
+ // command id with the Cockpit-created session; QEMU fallback rows can instead
1629
+ // share only the formal session id while carrying a different command id. Keep
1630
+ // the named/screen-backed session row so the UI exposes the working attach URL.
1631
+ const groups = [];
1632
+ const groupsByAlias = new Map();
1313
1633
  for (const s of sessions) {
1314
1634
  const sessionId = s.id ?? s.session_id ?? s.sessionId;
1315
- if (!sessionId || seen.has(sessionId)) continue;
1316
- seen.add(sessionId);
1317
- deduped.push({
1635
+ if (!sessionId) continue;
1636
+ const normalized = {
1318
1637
  ...s,
1319
1638
  id: sessionId,
1320
1639
  instance_id: s.instance_id ?? s.instanceId ?? instanceId,
1321
1640
  agent_id: s.agent_id ?? s.agentId ?? sessionAgentId,
1322
1641
  role_policy: s.role_policy ?? s.rolePolicy ?? (s.default_role === 'observer' ? 'observe-default' : s.default_role) ?? 'observe-default',
1323
1642
  attach_url: normalizeAttachUrl(s, sessionId),
1324
- });
1643
+ };
1644
+ const aliases = sessionAliases(s, sessionId);
1645
+ let group = aliases.map((alias) => groupsByAlias.get(alias)).find(Boolean);
1646
+ if (!group) {
1647
+ group = { aliases: new Set(), value: null, merged: false };
1648
+ groups.push(group);
1649
+ }
1650
+ for (const alias of aliases) {
1651
+ const other = groupsByAlias.get(alias);
1652
+ if (other && other !== group) group = mergeGroups(group, other);
1653
+ group.aliases.add(alias);
1654
+ groupsByAlias.set(alias, group);
1655
+ }
1656
+ if (!group.value || shouldReplace(group.value, normalized)) group.value = normalized;
1325
1657
  }
1326
- return { instance_id: instanceId, sessions: deduped };
1658
+ return { instance_id: instanceId, sessions: groups.filter((group) => !group.merged && group.value).map((group) => group.value) };
1327
1659
  }
1328
1660
 
1329
1661
  async function endSession(executorUrl, instanceId, sessionId) {
@@ -1356,6 +1688,20 @@ async function endSession(executorUrl, instanceId, sessionId) {
1356
1688
  };
1357
1689
  }
1358
1690
 
1691
+ async function findReusableSession(executorUrl, instanceId, sessionName) {
1692
+ const sessions = (await getSessions(executorUrl, instanceId)).sessions;
1693
+ return sessions.find((s) => String(s.session_name ?? s.sessionName ?? '') === String(sessionName));
1694
+ }
1695
+
1696
+ function sessionResponseFromRow(row) {
1697
+ return {
1698
+ ...row,
1699
+ id: row.id ?? row.session_id ?? row.sessionId,
1700
+ attach_url: row.attach_url ?? row.attachUrl,
1701
+ reused: true,
1702
+ };
1703
+ }
1704
+
1359
1705
  export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = ALLOW_MOCK_EXECUTOR, token } = {}) {
1360
1706
  const upstreamUrl = executorUrl;
1361
1707
  const TOKEN = token ?? randomBytes(24).toString('hex');
@@ -1478,6 +1824,10 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1478
1824
  const { status, body } = await endSession(upstreamUrl, decodeURIComponent(m[1]), decodeURIComponent(m[2]));
1479
1825
  return json(res, status, body);
1480
1826
  }
1827
+ if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/sessions\/([^/]+)\/screen$/)) && req.method === 'GET') {
1828
+ const { status, body } = await getSessionScreen(upstreamUrl, decodeURIComponent(m[1]), decodeURIComponent(m[2]));
1829
+ return json(res, status, body);
1830
+ }
1481
1831
  // registry-bound, data-driven core — live, no app restart (#1592)
1482
1832
  if (url.pathname === '/api/capabilities') {
1483
1833
  const q = (url.searchParams.get('q') || '').trim();
@@ -1566,27 +1916,50 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1566
1916
  if (backend) qs.set('backend', backend);
1567
1917
  if (loadout) qs.set('loadout', loadout);
1568
1918
  const sessionAgentId = await resolveSessionAgentId(upstreamUrl, id);
1919
+ const sessionName = sessionNameFor(id, { mode: mode || 'managed', backend: backend || 'tmux' });
1920
+ let sessionLaunch = defaultSessionLaunch();
1921
+ try {
1922
+ const inventory = await getInventory(upstreamUrl);
1923
+ sessionLaunch = defaultSessionLaunch(inventory.instances.find((inst) => inst.id === id));
1924
+ } catch {
1925
+ // Session creation can still proceed without an explicit cwd; the
1926
+ // executor/agent will fall back to its own process cwd.
1927
+ }
1928
+ // No pre-create reuse lookup: every create request gets its own uniquely
1929
+ // named session (multi-session per instance is supported). Recovery by
1930
+ // name below still catches a create that timed out after succeeding.
1931
+ const sessionBody = JSON.stringify({
1932
+ session_name: sessionName,
1933
+ session_backend: backend || 'tmux',
1934
+ session_class: mode || 'managed',
1935
+ command: sessionLaunch.command,
1936
+ args: sessionLaunch.args,
1937
+ ...(sessionLaunch.working_dir ? { working_dir: sessionLaunch.working_dir } : {}),
1938
+ });
1569
1939
  const candidates = unique([sessionAgentId, id]).flatMap((agentId) => [
1570
1940
  {
1571
1941
  target: `${upstreamUrl}/api/v1/agents/${encodeURIComponent(agentId)}/sessions`,
1572
1942
  method: 'POST',
1573
1943
  headers: { 'content-type': 'application/json' },
1574
- body: JSON.stringify({
1575
- session_backend: backend || 'tmux',
1576
- session_class: mode || 'managed',
1577
- command: 'bash',
1578
- args: ['-l'],
1579
- }),
1944
+ body: sessionBody,
1580
1945
  },
1581
1946
  {
1582
1947
  target: `${upstreamUrl}/agents/${encodeURIComponent(agentId)}/sessions?${qs.toString()}`,
1583
1948
  method: 'POST',
1949
+ headers: { 'content-type': 'application/json' },
1950
+ body: sessionBody,
1584
1951
  },
1585
1952
  ]);
1586
1953
  let sessionCreate;
1587
1954
  try {
1588
- sessionCreate = await fetchJsonFirst(candidates);
1955
+ sessionCreate = await fetchJsonFirst(candidates, { timeoutMs: 8000 });
1589
1956
  } catch (err) {
1957
+ try {
1958
+ const reusable = await findReusableSession(upstreamUrl, id, sessionName);
1959
+ if (reusable) return json(res, 200, sessionResponseFromRow(reusable));
1960
+ } catch {
1961
+ // Preserve the original create failure; reuse is a recovery path only.
1962
+ }
1590
1963
  return json(res, 409, {
1591
1964
  error: 'agent_not_registered',
1592
1965
  message: 'The instance is visible in inventory, but its agent has not registered yet; PTY sessions are not ready.',
@@ -1594,6 +1967,15 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1594
1967
  });
1595
1968
  }
1596
1969
  const { status, body } = sessionCreate;
1970
+ if (status < 200 || status >= 300) {
1971
+ try {
1972
+ const reusable = await findReusableSession(upstreamUrl, id, sessionName);
1973
+ if (reusable) return json(res, 200, sessionResponseFromRow(reusable));
1974
+ } catch {
1975
+ // Return the upstream non-2xx response when no reusable session is visible.
1976
+ }
1977
+ return json(res, status, body);
1978
+ }
1597
1979
  const wsBase = upstreamUrl.replace(/^http/i, 'ws');
1598
1980
  const sessionId = body.id ?? body.session_id ?? body.sessionId;
1599
1981
  if (status >= 200 && status < 300 && !sessionId && !body.attach_url && !body.attachUrl && !body.pty_ws_url && !body.ptyWsUrl) {
@@ -1609,8 +1991,8 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1609
1991
  }
1610
1992
  // Same as the list path (#1671): the attach segment must be the instance
1611
1993
  // id the executor's pty-ws route accepts, not the resolved agent name.
1612
- await appendAudit('session.start.requested', { instance_id: id, mode: mode || 'managed', backend: backend || 'tmux', loadout, status, session_id: sessionId });
1613
- return json(res, status, { ...body, id: sessionId, attach_url: attachUrl ?? `${wsBase}/agents/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}/attach` });
1994
+ await appendAudit('session.start.requested', { instance_id: id, mode: mode || 'managed', backend: backend || 'tmux', loadout, status, session_id: sessionId, session_name: sessionName });
1995
+ return json(res, status, { ...body, id: sessionId, session_name: body.session_name ?? body.sessionName ?? sessionName, attach_url: attachUrl ?? `${wsBase}/agents/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}/attach` });
1614
1996
  }
1615
1997
 
1616
1998
  // --- management surface (UC-012): lifecycle + task cancel ---
@@ -1622,6 +2004,11 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1622
2004
  await appendAudit('instance.lifecycle.requested', { instance_id: decodeURIComponent(m[1]), action: m[2], status: result.status, result: result.body });
1623
2005
  return json(res, result.status, result.body);
1624
2006
  }
2007
+ if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/reconnect$/)) && req.method === 'POST') {
2008
+ const { status, body } = await reconnectInstance(upstreamUrl, decodeURIComponent(m[1]));
2009
+ await appendAudit('instance.reconnect.requested', { instance_id: decodeURIComponent(m[1]), status, result: body });
2010
+ return json(res, status, body);
2011
+ }
1625
2012
  if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)$/)) && req.method === 'DELETE') {
1626
2013
  const { status, body } = await destroyInstance(upstreamUrl, decodeURIComponent(m[1]));
1627
2014
  await appendAudit('instance.destroy.requested', { instance_id: decodeURIComponent(m[1]), status, result: body });
@@ -3,7 +3,7 @@
3
3
  import assert from 'node:assert/strict';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { createExecutor } from '../../mock-executor/src/server.mjs';
6
- import { createBridge } from './server.mjs';
6
+ import { createBridge, normalizeSessionRows } from './server.mjs';
7
7
 
8
8
  const mock = createExecutor();
9
9
  await new Promise((r) => mock.listen(0, '127.0.0.1', r));
@@ -51,11 +51,37 @@ try {
51
51
  const demo = sess.sessions.find((s) => s.id === 'demo-shell');
52
52
  assert.ok(demo, 'demo-shell session present');
53
53
  assert.match(demo.attach_url, /^ws:\/\/.*\/agents\/.*\/sessions\/demo-shell\/attach$/, 'ws attach_url shape');
54
- assert.ok(demo.seq >= 3, 'demo session has a seeded transcript');
55
- assert.equal(demo.mode, 'direct', 'demo session mode');
56
- assert.equal(demo.backend, 'native', 'demo session backend');
54
+ assert.ok(demo.liveness.replay_newest_seq >= 3, 'demo session has a seeded transcript');
55
+ assert.equal(demo.session_class, 'direct', 'demo session class');
56
+ assert.equal(demo.session_backend, 'native', 'demo session backend');
57
57
  assert.equal(demo.role_policy, 'observe-default', 'session role policy');
58
58
 
59
+ const qemuDedup = normalizeSessionRows({
60
+ executorUrl,
61
+ instanceId: 'vm-1',
62
+ sessionAgentId: 'vm-agent-name',
63
+ sessions: [
64
+ {
65
+ id: 'sess-formal',
66
+ session_id: 'sess-formal',
67
+ command_id: 'cmd-real',
68
+ session_name: 'terminal-qemu',
69
+ command: '/bin/bash',
70
+ has_screen: true,
71
+ },
72
+ {
73
+ id: 'sess-formal',
74
+ session_id: 'sess-formal',
75
+ command_id: 'sess-formal',
76
+ session_name: 'sess-formal',
77
+ command: '/bin/bash -l',
78
+ has_screen: false,
79
+ },
80
+ ],
81
+ });
82
+ assert.equal(qemuDedup.sessions.length, 1, 'QEMU formal session + fallback row dedupe to one session');
83
+ assert.equal(qemuDedup.sessions[0].session_name, 'terminal-qemu', 'dedupe keeps the named screen-backed session');
84
+
59
85
  // missing instance param is a 400
60
86
  assert.equal((await f("/api/sessions")).status, 400, 'sessions requires instance');
61
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cockpit",
3
- "version": "2026.7.10",
3
+ "version": "2026.7.12",
4
4
  "description": "AIWG Cockpit — UX-first control plane over AIWG + multi-stack agentic sessions. Opt-in, separately published; NOT shipped in the base aiwg npm package (guarded by test/smoke/cockpit-base-footprint.test.js).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,7 +43,7 @@
43
43
  "build:web": "npm --prefix web install --no-audit --no-fund && npm --prefix web run build",
44
44
  "pack:dry": "npm pack --dry-run",
45
45
  "publish:dry": "npm publish --dry-run --access public",
46
- "smoke": "node mock-executor/src/smoke.mjs && node bridge/src/smoke.mjs && node shell-core/smoke.mjs",
46
+ "smoke": "node mock-executor/src/smoke.mjs && node bridge/src/smoke.mjs && node shell-core/smoke.mjs && node vscode/smoke.mjs",
47
47
  "poc": "node poc/kill-bridge-isolation.mjs && node poc/security-checks.mjs",
48
48
  "test:web": "npm --prefix web run typecheck && npm --prefix web run test",
49
49
  "test": "npm run smoke && npm run poc",