@aiwg/cockpit 2026.7.11 → 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.
package/README.md CHANGED
@@ -58,7 +58,8 @@ AIWG_COCKPIT_EXECUTOR_URL=http://127.0.0.1:8122 aiwg cockpit
58
58
  - **Home** — connected-state overview, first-run flow, and a session-first entry point.
59
59
  - **Inventory** — host, container, Docker, and VM runtime targets with lifecycle controls.
60
60
  - **Running** — active work across stacks, spend posture, and task stop controls.
61
- - **Sessions** — observe-first terminal attach, explicit drive/control, and replay posture.
61
+ - **Missions** — read-only Mission Control projection: sessions, per-mission status, audit tail.
62
+ - **Sessions** — observe-first terminal attach, explicit drive/control, replay posture, and stale-agent recovery.
62
63
  - **Approvals** — unified human-in-the-loop decision inbox.
63
64
  - **Explore** — live index status/query/rebuild plus read-only AIWG capability catalog.
64
65
  - **Library** — user-owned assets cloned/imported under `~/.aiwg/cockpit/library`.
@@ -138,7 +139,8 @@ operator / CLI: aiwg cockpit
138
139
  | **Home** | Guided first-run: what-is-this, live status, the **Start a session** primary verb, first-run tour. |
139
140
  | **Inventory** | Instances + lifecycle (Start/Stop/Destroy). |
140
141
  | **Running** | Running work across stacks + cross-stack spend + per-task Stop. |
141
- | **Sessions** | Live pty terminal — observe/drive, keyframe, non-destructive replay; inline **+ capability picker**. |
142
+ | **Missions** | Read-only Mission Control projection — durable `aiwg mc` sessions merged with the live executor task session. |
143
+ | **Sessions** | Live pty terminal — observe/drive, keyframe, non-destructive replay, stale-agent recovery; inline **+ capability picker**. |
142
144
  | **Approvals** | Unified HITL inbox (`hitl-prompt/v1`); decisions = operator authorization. |
143
145
  | **Explore** | Live artifact-index status/query/rebuild plus read-only AIWG catalog search. |
144
146
  | **Library** | Your own assets — clone from the catalog / import / remove. AIWG files never overwritten. |
@@ -177,9 +179,10 @@ Set `AIWG_COCKPIT_KEYCHAIN_STRICT=1` when shells must refuse plaintext runtime
177
179
  tokens. In strict mode the Bridge exits if it cannot persist the per-launch token
178
180
  to the OS keychain, and shell-core refuses runtime files that only contain a
179
181
  plaintext token. Operator intent is also recorded in a local redacted audit log
180
- under `~/.aiwg/cockpit/audit/events.jsonl` for lifecycle, session, action-inject,
181
- and approval-response decisions; bearer material and provider credentials are
182
- redacted before write.
182
+ under `~/.aiwg/cockpit/audit/events.jsonl` for lifecycle, session, and
183
+ approval-response decisions (the web UI additionally records action injections
184
+ as operator intents); bearer material and provider credentials are redacted
185
+ before write.
183
186
 
184
187
  ## Run (dev/test, against a real agentic-sandbox executor)
185
188
 
@@ -196,6 +199,17 @@ binary before serving Cockpit. Override `AIWG_COCKPIT_EXECUTOR_URL` (default
196
199
  `http://127.0.0.1:8122`) or `PORT` (default `8140`). Set
197
200
  `AIWG_COCKPIT_EXECUTOR_COMMAND` to pin the autostart command, or
198
201
  `AIWG_COCKPIT_AUTOSTART_EXECUTOR=0` to require an already-running executor.
202
+
203
+ **Bring up both halves at once (#1634).** `npm run cockpit:up` (→
204
+ `apps/cockpit/scripts/cockpit-up.sh`) guarantees a real, current executor is
205
+ listening *before* the Bridge starts: it launches the agentic-sandbox executor
206
+ via its own `management/dev.sh` when one isn't already healthy on
207
+ `AIWG_COCKPIT_EXECUTOR_URL` (default `http://127.0.0.1:8122`), then delegates to
208
+ `cockpit-dev.sh` for the Bridge + UI. Set `AIWG_COCKPIT_ENSURE_EXECUTOR=0` to
209
+ skip the executor-ensure step when a sandbox is already running, or
210
+ `AIWG_COCKPIT_START_HOST_DAEMON=1` to also start the optional host-runtime
211
+ daemon. `cockpit-dev.sh` stays the Cockpit-only launcher.
212
+
199
213
  Equivalent manual steps:
200
214
 
201
215
  ```bash
@@ -217,10 +231,10 @@ agentic-sandbox executor. The bundled mock is reserved for automated tests and
217
231
  PoCs; if a mock-like executor is detected, the Bridge refuses it unless
218
232
  `AIWG_COCKPIT_ALLOW_MOCK_EXECUTOR=1` is set by an automated harness.
219
233
 
220
- The Bridge keeps legacy admin-surface compatibility (`/admin/instances`,
221
- `/admin/running`) for automated coverage, but dev/test launches should target
222
- real agentic-sandbox v2 admin surfaces (`/api/v2/admin/instances`,
223
- `/api/v2/admin/running`). Field normalization covers snake_case and camelCase
234
+ The Bridge probes legacy (`/admin/instances`) and v2 (`/api/v2/admin/instances`)
235
+ admin surfaces as fallback candidates for inventory and lifecycle; running work
236
+ and approvals are derived from per-instance A2A task lists rather than any
237
+ admin `/running` route. Field normalization covers snake_case and camelCase
224
238
  payloads so live sandboxes can evolve without breaking the operator UI; unknown
225
239
  fields degrade to opaque posture rather than failing the screen.
226
240
 
@@ -230,6 +244,39 @@ QEMU/VM launches. Cockpit does not replace attached sessions when provisioning;
230
244
  it refreshes inventory and leaves concurrency and resource admission to
231
245
  agentic-sandbox.
232
246
 
247
+ ### Recover stale agents
248
+
249
+ When a container, Docker, or VM runtime is still running but its agent
250
+ registration has disappeared, Cockpit keeps the row visible instead of hiding
251
+ it. Inventory and Sessions show `agent unreachable` and expose **Reconnect**
252
+ for stale container/Docker **and vm/qemu/kvm** rows (#1778). Use this when an
253
+ instance is alive but session attach, running projections, or actions cannot
254
+ resolve an agent id.
255
+
256
+ The Bridge handles recovery through:
257
+
258
+ 1. executor-owned reconnect endpoints when the sandbox exposes one, then
259
+ 2. local Docker fallback: `docker exec <container> agent-reconnect`, or
260
+ 3. VM fallback (#1778): the same reconnect SIGHUP delivered through the libvirt
261
+ qemu-guest-agent channel (`virsh qemu-agent-command <domain> guest-exec
262
+ pkill -HUP -x agent-client`). Sessions survive reconnect on agentic-sandbox
263
+ 2026.7.8+ agents; older agents preserve only detached tmux sessions
264
+ (agentic-sandbox#634).
265
+
266
+ The Docker fallback requires sandbox images that include the `agent-reconnect`
267
+ helper (agentic-sandbox v2026.7.5+ images); the VM fallback requires virsh
268
+ access to the domain from the Bridge host. If **Reconnect** reports that no
269
+ reconnect path is available, rebuild or repull the sandbox image, then start the
270
+ instance again. For host targets, prefer starting Cockpit with the host daemon:
271
+
272
+ ```bash
273
+ AIWG_COCKPIT_START_HOST_DAEMON=1 npm run cockpit:up
274
+ ```
275
+
276
+ After a successful reconnect, refresh Inventory, then attach from Sessions. A
277
+ Reconnect action never creates a replacement instance and never destroys the
278
+ running container; it only attempts to restore the missing agent registration.
279
+
233
280
  `aiwg cockpit` (the operator command) will wrap this; the Bridge serves the built
234
281
  React app token-injected, falling back to a legacy page when no build is present.
235
282
 
@@ -402,6 +449,25 @@ Known state as of the 2026-06-19 host live run:
402
449
  `v2026.6.34`: VM matrix PASS — provision → vsock enroll → boot-ready → provider
403
450
  workload → clean destroy. Evidence:
404
451
  `.aiwg/testing/cockpit-vm-vsock-2026-06-27.md/.json`.
452
+ - Instance **transport posture + host-daemon** now surface from the sandbox side
453
+ (`dd97529 fix(admin-v2): expose instance transport posture`, plus `#611`
454
+ host-runtime session listing). Re-validated Cockpit-side against `v2026.7.4`
455
+ (2026-07-09): Inventory renders real posture per instance — Host + Container
456
+ `Secure transport · mtls` (Host daemon `available`), enrolled VM `Local
457
+ transport · vsock`, a mid-bootstrap VM showing transport `Unknown` with the
458
+ informative posture `bootstrap-pending` (vs the old dead `unknown`; every
459
+ enrolled instance now renders real posture). Session-list returns
460
+ cleanly (no 502 — the `#140`/`#611` endpoints are live). Runtime coverage
461
+ banner `host ✓ · docker ✓ · vm ✓`. Evidence:
462
+ `.aiwg/testing/cockpit-7.4-transport-verify-2026-07-09.md` +
463
+ `.aiwg/working/cockpit-7.4-inventory-2026-07-09.png`.
464
+ - Stale Docker/container agent recovery is wired through the Bridge and UI
465
+ (2026-07-11): stale running instances remain visible as `agent unreachable`,
466
+ Inventory and Sessions expose **Reconnect**, and the Bridge tries executor
467
+ reconnect before Docker `agent-reconnect`. Host-daemon scoped live UAT passed
468
+ against the real executor with `AIWG_COCKPIT_LIVE_PROVISION=1`,
469
+ `AIWG_COCKPIT_LIVE_MATRIX_TARGETS=host`, and Codex provider workload evidence
470
+ at `test-results/cockpit-live-host-daemon-2026-07-11.md/.json`.
405
471
  - Remaining upstream follow-ups are Claude auth-state propagation
406
472
  (roctinam/agentic-sandbox#499) and agent-scoped PTY sessions not appearing in
407
473
  the formal/global session registry (roctinam/agentic-sandbox#500).
@@ -418,9 +484,11 @@ agentic-sandbox executor through `AIWG_COCKPIT_EXECUTOR_URL`. The host target
418
484
  (agentic-sandbox#460) and direct/managed multiplexer sessions
419
485
  (agentic-sandbox#461) have landed upstream; the Bridge seam is now the
420
486
  AIWG-side integration point for #1589. Runtime-tier provisioning and
421
- host-daemon UX are tracked in roctinam/aiwg#1615, direct/managed PTY negotiation
422
- in #1616, the live real-sandbox gate in #1617, and transport-trust visibility in
423
- #1618. Secure transport details map back to agentic-sandbox#409/#410/#412; local
487
+ host-daemon surfacing (roctinam/aiwg#1615) and transport-trust visibility (#1618)
488
+ **landed and are verified against `v2026.7.4`** — transport posture and
489
+ host-daemon now render per instance (a host-daemon *detail-status* payload
490
+ remains a residual under #1615). Direct/managed PTY negotiation (#1616) and the
491
+ live real-sandbox gate (#1617) continue. Secure transport details map back to agentic-sandbox#409/#410/#412; local
424
492
  Browser/Tauri/VS Code-to-Bridge auth remains roctinam/aiwg#1595.
425
493
 
426
494
  ### Operator-wall review modes (#1622)
@@ -357,7 +357,23 @@ 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, timeoutMs = 0 } = {}) {
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;
@@ -370,7 +386,13 @@ async function fetchJsonFirst(candidates, { method = 'GET', headers, body: reque
370
386
  try {
371
387
  r = await fetch(target, { method: requestMethod, headers: requestHeaders, body: requestBody, ...(controller ? { signal: controller.signal } : {}) });
372
388
  } catch (err) {
373
- 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('; '));
374
396
  continue;
375
397
  } finally {
376
398
  if (timeout) clearTimeout(timeout);
@@ -580,6 +602,138 @@ async function destroyInstance(upstreamUrl, instanceId) {
580
602
  };
581
603
  }
582
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
+
583
737
  function asArrayFromEnvelope(body, keys) {
584
738
  if (Array.isArray(body)) return body;
585
739
  if (!body || typeof body !== 'object') return [];
@@ -594,10 +748,22 @@ function asArrayFromEnvelope(body, keys) {
594
748
  return [];
595
749
  }
596
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
+
597
764
  async function resolveSessionAgentId(executorUrl, instanceId) {
598
765
  try {
599
- const { body } = await fetchJsonFirst([`${executorUrl}/api/v1/agents`]);
600
- const agents = asArrayFromEnvelope(body, ['agents', 'items', 'data']);
766
+ const agents = await getAgentList(executorUrl);
601
767
  const agent = agents.find((a) => String(a.instance_id ?? a.instanceId ?? '') === String(instanceId));
602
768
  return agent?.id ?? agent?.agent_id ?? agent?.agentId ?? instanceId;
603
769
  } catch {
@@ -769,17 +935,23 @@ function normalizeInstance(executorUrl, i) {
769
935
  function defaultSessionLaunch(instance) {
770
936
  const runtime = String(instance?.runtime_posture?.kind ?? instance?.runtime ?? '').toLowerCase();
771
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.
772
943
  return {
773
944
  command: 'bash',
774
945
  args: ['-l'],
775
- working_dir: instance?.launch_context?.cwd,
946
+ working_dir: instance?.launch_context?.cwd ?? homedir(),
776
947
  };
777
948
  }
778
949
  if (runtime === 'container' || runtime === 'docker' || runtime === 'vm' || runtime === 'qemu' || runtime === 'kvm') {
950
+ const home = runtime === 'container' || runtime === 'docker' ? '/root' : '/home/agent';
779
951
  return {
780
952
  command: '/bin/bash',
781
- args: ['-lc', 'cd "${HOME:-/root}" && exec /bin/bash -l'],
782
- working_dir: '/root',
953
+ args: ['-lc', `cd ${shellSingleQuote(home)} && exec /bin/bash -l`],
954
+ working_dir: home,
783
955
  };
784
956
  }
785
957
  return {
@@ -788,6 +960,25 @@ function defaultSessionLaunch(instance) {
788
960
  };
789
961
  }
790
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
+
791
982
  function runtimeExtensionFromCard(card) {
792
983
  const extensions = card?.capabilities?.extensions;
793
984
  if (!Array.isArray(extensions)) return null;
@@ -817,8 +1008,7 @@ async function enrichInstanceFromAgentCard(executorUrl, instance) {
817
1008
 
818
1009
  async function getRegisteredAgents(executorUrl) {
819
1010
  try {
820
- const { body } = await fetchJsonFirst([`${executorUrl}/api/v1/agents`]);
821
- return asArrayFromEnvelope(body, ['agents', 'items', 'data']);
1011
+ return await getAgentList(executorUrl);
822
1012
  } catch {
823
1013
  return [];
824
1014
  }
@@ -1220,8 +1410,8 @@ async function getSessionEventRows(executorUrl, instances) {
1220
1410
  agent_id: session.agent_id,
1221
1411
  state: session.state ?? session.status ?? session.session_state ?? 'available',
1222
1412
  role_policy: session.role_policy,
1223
- backend: session.backend ?? session.session_backend,
1224
- mode: session.mode ?? session.session_class,
1413
+ session_backend: session.session_backend,
1414
+ session_class: session.session_class,
1225
1415
  });
1226
1416
  }
1227
1417
  }));
@@ -1248,7 +1438,7 @@ async function getEventSnapshot(executorUrl) {
1248
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 } });
1249
1439
  }
1250
1440
  for (const session of sessions ?? []) {
1251
- 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 } });
1252
1442
  }
1253
1443
  for (const mission of missions.missions ?? []) {
1254
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 } });
@@ -1324,6 +1514,50 @@ async function getSessions(executorUrl, instanceId) {
1324
1514
  return normalizeSessionRows({ sessions, executorUrl, instanceId, sessionAgentId });
1325
1515
  }
1326
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
+
1327
1561
  export function normalizeSessionRows({ sessions, executorUrl, instanceId, sessionAgentId = instanceId }) {
1328
1562
  const wsBase = executorUrl.replace(/^http/i, 'ws');
1329
1563
  const normalizeAttachUrl = (s, sessionId) => {
@@ -1454,6 +1688,20 @@ async function endSession(executorUrl, instanceId, sessionId) {
1454
1688
  };
1455
1689
  }
1456
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
+
1457
1705
  export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = ALLOW_MOCK_EXECUTOR, token } = {}) {
1458
1706
  const upstreamUrl = executorUrl;
1459
1707
  const TOKEN = token ?? randomBytes(24).toString('hex');
@@ -1576,6 +1824,10 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1576
1824
  const { status, body } = await endSession(upstreamUrl, decodeURIComponent(m[1]), decodeURIComponent(m[2]));
1577
1825
  return json(res, status, body);
1578
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
+ }
1579
1831
  // registry-bound, data-driven core — live, no app restart (#1592)
1580
1832
  if (url.pathname === '/api/capabilities') {
1581
1833
  const q = (url.searchParams.get('q') || '').trim();
@@ -1664,6 +1916,7 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1664
1916
  if (backend) qs.set('backend', backend);
1665
1917
  if (loadout) qs.set('loadout', loadout);
1666
1918
  const sessionAgentId = await resolveSessionAgentId(upstreamUrl, id);
1919
+ const sessionName = sessionNameFor(id, { mode: mode || 'managed', backend: backend || 'tmux' });
1667
1920
  let sessionLaunch = defaultSessionLaunch();
1668
1921
  try {
1669
1922
  const inventory = await getInventory(upstreamUrl);
@@ -1672,28 +1925,41 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1672
1925
  // Session creation can still proceed without an explicit cwd; the
1673
1926
  // executor/agent will fall back to its own process cwd.
1674
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
+ });
1675
1939
  const candidates = unique([sessionAgentId, id]).flatMap((agentId) => [
1676
1940
  {
1677
1941
  target: `${upstreamUrl}/api/v1/agents/${encodeURIComponent(agentId)}/sessions`,
1678
1942
  method: 'POST',
1679
1943
  headers: { 'content-type': 'application/json' },
1680
- body: JSON.stringify({
1681
- session_backend: backend || 'tmux',
1682
- session_class: mode || 'managed',
1683
- command: sessionLaunch.command,
1684
- args: sessionLaunch.args,
1685
- ...(sessionLaunch.working_dir ? { working_dir: sessionLaunch.working_dir } : {}),
1686
- }),
1944
+ body: sessionBody,
1687
1945
  },
1688
1946
  {
1689
1947
  target: `${upstreamUrl}/agents/${encodeURIComponent(agentId)}/sessions?${qs.toString()}`,
1690
1948
  method: 'POST',
1949
+ headers: { 'content-type': 'application/json' },
1950
+ body: sessionBody,
1691
1951
  },
1692
1952
  ]);
1693
1953
  let sessionCreate;
1694
1954
  try {
1695
1955
  sessionCreate = await fetchJsonFirst(candidates, { timeoutMs: 8000 });
1696
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
+ }
1697
1963
  return json(res, 409, {
1698
1964
  error: 'agent_not_registered',
1699
1965
  message: 'The instance is visible in inventory, but its agent has not registered yet; PTY sessions are not ready.',
@@ -1701,6 +1967,15 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1701
1967
  });
1702
1968
  }
1703
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
+ }
1704
1979
  const wsBase = upstreamUrl.replace(/^http/i, 'ws');
1705
1980
  const sessionId = body.id ?? body.session_id ?? body.sessionId;
1706
1981
  if (status >= 200 && status < 300 && !sessionId && !body.attach_url && !body.attachUrl && !body.pty_ws_url && !body.ptyWsUrl) {
@@ -1716,8 +1991,8 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1716
1991
  }
1717
1992
  // Same as the list path (#1671): the attach segment must be the instance
1718
1993
  // id the executor's pty-ws route accepts, not the resolved agent name.
1719
- await appendAudit('session.start.requested', { instance_id: id, mode: mode || 'managed', backend: backend || 'tmux', loadout, status, session_id: sessionId });
1720
- 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` });
1721
1996
  }
1722
1997
 
1723
1998
  // --- management surface (UC-012): lifecycle + task cancel ---
@@ -1729,6 +2004,11 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1729
2004
  await appendAudit('instance.lifecycle.requested', { instance_id: decodeURIComponent(m[1]), action: m[2], status: result.status, result: result.body });
1730
2005
  return json(res, result.status, result.body);
1731
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
+ }
1732
2012
  if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)$/)) && req.method === 'DELETE') {
1733
2013
  const { status, body } = await destroyInstance(upstreamUrl, decodeURIComponent(m[1]));
1734
2014
  await appendAudit('instance.destroy.requested', { instance_id: decodeURIComponent(m[1]), status, result: body });
@@ -51,9 +51,9 @@ 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
59
  const qemuDedup = normalizeSessionRows({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cockpit",
3
- "version": "2026.7.11",
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",
@@ -26,8 +26,9 @@ Every shell (browser, VS Code, Tauri) resolves the Bridge the same way — see
26
26
  ## Security
27
27
 
28
28
  - The per-launch token is written to the OS credential backend when one is available:
29
- macOS Keychain (`security`), Windows Credential Manager (`cmdkey`), Linux libsecret
30
- (`secret-tool`), or opt-in KDE Wallet (`AIWG_COCKPIT_ENABLE_KWALLET=1`).
29
+ macOS Keychain (`security`), Windows Credential Manager via PowerShell
30
+ PasswordVault, Linux libsecret (`secret-tool`), or opt-in KDE Wallet
31
+ (`AIWG_COCKPIT_ENABLE_KWALLET=1`).
31
32
  - `bridge.json` holds **only the overlay's own per-launch token or token reference** —
32
33
  never a provider or stack credential (verified by
33
34
  `apps/cockpit/poc/security-checks.mjs`, property I1). Set