@aiwg/cockpit 2026.7.11 → 2026.7.13

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.
@@ -37,6 +37,38 @@ async function canRun(cmd) {
37
37
  }
38
38
  }
39
39
 
40
+ async function windowsPowerShell() {
41
+ if (await canRun('powershell')) return 'powershell';
42
+ if (await canRun('pwsh')) return 'pwsh';
43
+ throw new Error('no supported Windows PowerShell command found');
44
+ }
45
+
46
+ async function storeWindowsCredential(token, account) {
47
+ const ps = await windowsPowerShell();
48
+ const script = [
49
+ '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]',
50
+ '$vault = New-Object Windows.Security.Credentials.PasswordVault',
51
+ 'try { $vault.Remove($vault.Retrieve($args[0], $args[1])) } catch {}',
52
+ '$password = [Console]::In.ReadToEnd()',
53
+ '$credential = New-Object Windows.Security.Credentials.PasswordCredential -ArgumentList $args[0], $args[1], $password',
54
+ '$vault.Add($credential)',
55
+ ].join('; ');
56
+ await collect(ps, ['-NoProfile', '-NonInteractive', '-Command', script, SERVICE, account], token);
57
+ return { backend: 'windows-credential-manager', service: SERVICE, account, target: `${SERVICE}:${account}` };
58
+ }
59
+
60
+ async function readWindowsCredential(ref) {
61
+ const ps = await windowsPowerShell();
62
+ const script = [
63
+ '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]',
64
+ '$vault = New-Object Windows.Security.Credentials.PasswordVault',
65
+ '$credential = $vault.Retrieve($args[0], $args[1])',
66
+ '$credential.RetrievePassword()',
67
+ '[Console]::Out.Write($credential.Password)',
68
+ ].join('; ');
69
+ return (await collect(ps, ['-NoProfile', '-NonInteractive', '-Command', script, ref.service || SERVICE, ref.account])).trim();
70
+ }
71
+
40
72
  export async function storeCockpitToken(token, account = `bridge-${process.pid}`) {
41
73
  if (process.env.AIWG_COCKPIT_KEYCHAIN_DISABLED === '1') {
42
74
  throw new Error('OS keychain disabled by AIWG_COCKPIT_KEYCHAIN_DISABLED');
@@ -46,10 +78,8 @@ export async function storeCockpitToken(token, account = `bridge-${process.pid}`
46
78
  await collect('security', ['add-generic-password', '-a', account, '-s', SERVICE, '-w', token, '-U']);
47
79
  return { backend: 'macos-keychain', service: SERVICE, account };
48
80
  }
49
- if (os === 'win32' && await canRun('cmdkey')) {
50
- const target = `${SERVICE}:${account}`;
51
- await collect('cmdkey', [`/generic:${target}`, `/user:${account}`, `/pass:${token}`]);
52
- return { backend: 'windows-credential-manager', service: SERVICE, account, target };
81
+ if (os === 'win32') {
82
+ return storeWindowsCredential(token, account);
53
83
  }
54
84
  if (await canRun('secret-tool')) {
55
85
  await collect('secret-tool', ['store', '--label', 'AIWG Cockpit Bridge', 'service', SERVICE, 'account', account], token);
@@ -68,7 +98,7 @@ export async function readCockpitToken(ref) {
68
98
  return (await collect('security', ['find-generic-password', '-a', ref.account, '-s', ref.service || SERVICE, '-w'])).trim();
69
99
  }
70
100
  if (ref.backend === 'windows-credential-manager') {
71
- throw new Error('Windows Credential Manager read requires the shell-provided runtime token until native shell integration lands');
101
+ return readWindowsCredential(ref);
72
102
  }
73
103
  if (ref.backend === 'libsecret') {
74
104
  return (await collect('secret-tool', ['lookup', 'service', ref.service || SERVICE, 'account', ref.account])).trim();
@@ -4,43 +4,28 @@
4
4
  // Bridge — it reads the per-launch token the Bridge wrote and loads its UI.
5
5
  // CommonJS so it runs with no build step.
6
6
  const vscode = require('vscode');
7
- const fs = require('fs');
8
7
  const os = require('os');
9
8
  const path = require('path');
10
- const cp = require('child_process');
9
+ const { pathToFileURL } = require('url');
11
10
 
12
11
  function runtimeFile() {
13
12
  const override = vscode.workspace.getConfiguration('aiwg-cockpit').get('bridgeRuntimeFile');
14
13
  return override && override.length ? override : path.join(os.homedir(), '.aiwg', 'cockpit', 'runtime', 'bridge.json');
15
14
  }
16
15
 
17
- function readTokenRef(ref) {
18
- if (!ref || !ref.backend) return '';
19
- if (ref.backend === 'macos-keychain') {
20
- return cp.execFileSync('security', ['find-generic-password', '-a', ref.account, '-s', ref.service || 'aiwg-cockpit-bridge', '-w'], { encoding: 'utf8' }).trim();
21
- }
22
- if (ref.backend === 'libsecret') {
23
- return cp.execFileSync('secret-tool', ['lookup', 'service', ref.service || 'aiwg-cockpit-bridge', 'account', ref.account], { encoding: 'utf8' }).trim();
24
- }
25
- if (ref.backend === 'kwallet') {
26
- return cp.execFileSync('kwallet-query', ['-f', ref.folder || 'AIWG Cockpit', '-r', ref.account, ref.wallet || 'kdewallet'], { encoding: 'utf8' }).trim();
27
- }
28
- throw new Error(`Unsupported Cockpit keychain backend: ${ref.backend}`);
16
+ async function shellCore() {
17
+ const runtimeModule = path.join(__dirname, '..', 'shell-core', 'runtime.mjs');
18
+ return import(pathToFileURL(runtimeModule).href);
29
19
  }
30
20
 
31
21
  /** Read the Bridge connection + confirm liveness; throws with a friendly hint if down. */
32
22
  async function ensureRuntime() {
33
- let rt;
34
23
  try {
35
- const r = JSON.parse(fs.readFileSync(runtimeFile(), 'utf8'));
36
- const token = r.token || readTokenRef(r.token_ref);
37
- rt = { ...r, token, url: `http://127.0.0.1:${r.port}` };
38
- } catch {
39
- throw new Error('AIWG Cockpit Bridge not found. Start it with `aiwg cockpit` (or `node apps/cockpit/bridge/src/server.mjs`) and retry.');
24
+ const { connect } = await shellCore();
25
+ return await connect({ file: runtimeFile(), timeoutMs: 2500 });
26
+ } catch (e) {
27
+ throw new Error(`AIWG Cockpit Bridge not reachable. Start it with \`aiwg cockpit\` (or \`node apps/cockpit/bridge/src/server.mjs\`) and retry. ${(e && e.message) || ''}`.trim());
40
28
  }
41
- try { if (!(await fetch(`${rt.url}/healthz`)).ok) throw new Error(); }
42
- catch { throw new Error(`AIWG Cockpit Bridge not reachable at ${rt.url}. Is it still running?`); }
43
- return rt;
44
29
  }
45
30
 
46
31
  function activate(context) {
@@ -49,7 +34,8 @@ function activate(context) {
49
34
  let rt;
50
35
  try { rt = await ensureRuntime(); } catch (e) { return vscode.window.showWarningMessage(e.message); }
51
36
  const panel = vscode.window.createWebviewPanel('aiwgCockpit', 'AIWG Cockpit', vscode.ViewColumn.One, { enableScripts: true, retainContextWhenHidden: true });
52
- const u = `${rt.url}/?token=${encodeURIComponent(rt.token)}`;
37
+ const { webviewUrl } = await shellCore();
38
+ const u = webviewUrl(rt);
53
39
  panel.webview.html = `<!doctype html><html><head><meta charset="utf-8" />
54
40
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; frame-src http://127.0.0.1:* http://localhost:*; style-src 'unsafe-inline';" />
55
41
  <style>html,body,iframe{margin:0;height:100vh;width:100%;border:0}</style></head>
@@ -58,11 +44,12 @@ function activate(context) {
58
44
  vscode.commands.registerCommand('aiwg-cockpit.auditIssues', async () => {
59
45
  let rt;
60
46
  try { rt = await ensureRuntime(); } catch (e) { return vscode.window.showWarningMessage(e.message); }
61
- vscode.env.openExternal(vscode.Uri.parse(`${rt.url}/?token=${encodeURIComponent(rt.token)}#actions`));
47
+ const { webviewUrl } = await shellCore();
48
+ vscode.env.openExternal(vscode.Uri.parse(`${webviewUrl(rt)}#actions`));
62
49
  }),
63
50
  );
64
51
  }
65
52
 
66
53
  function deactivate() {}
67
54
 
68
- module.exports = { activate, deactivate };
55
+ module.exports = { activate, deactivate, _private: { ensureRuntime, runtimeFile, shellCore } };
@@ -0,0 +1,47 @@
1
+ import assert from 'node:assert/strict';
2
+ import Module from 'node:module';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { createRequire } from 'node:module';
7
+
8
+ const runtimeDir = await mkdtemp(join(tmpdir(), 'cockpit-vscode-smoke-'));
9
+ const runtimePath = join(runtimeDir, 'bridge.json');
10
+ await writeFile(runtimePath, JSON.stringify({ token: 'vscode-shell-core-token-1234567890', port: 8159 }), { mode: 0o600 });
11
+
12
+ const requests = [];
13
+ globalThis.fetch = async (input, init = {}) => {
14
+ requests.push({ url: String(input), init });
15
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
16
+ };
17
+
18
+ const originalLoad = Module._load;
19
+ Module._load = function patchedLoad(request, parent, isMain) {
20
+ if (request === 'vscode') {
21
+ return {
22
+ workspace: { getConfiguration: () => ({ get: () => runtimePath }) },
23
+ window: { showWarningMessage: () => undefined, createWebviewPanel: () => ({ webview: { html: '' } }) },
24
+ commands: { registerCommand: () => ({ dispose: () => undefined }) },
25
+ env: { openExternal: () => undefined },
26
+ Uri: { parse: (value) => ({ value }) },
27
+ ViewColumn: { One: 1 },
28
+ };
29
+ }
30
+ return originalLoad.call(this, request, parent, isMain);
31
+ };
32
+
33
+ try {
34
+ const require = createRequire(import.meta.url);
35
+ const extension = require('./extension.js');
36
+ const rt = await extension._private.ensureRuntime();
37
+ assert.equal(rt.port, 8159, 'VS Code shell uses shell-core runtime connection');
38
+ assert.equal(rt.token, 'vscode-shell-core-token-1234567890', 'VS Code shell receives the resolved runtime token');
39
+ assert.ok(
40
+ requests.some((r) => r.url.endsWith('/api/health') && r.init.headers?.authorization === `Bearer ${rt.token}`),
41
+ 'shell-core performs the authenticated Bridge health check',
42
+ );
43
+ console.log('SMOKE OK - VS Code shell delegates runtime resolution to shell-core');
44
+ } finally {
45
+ Module._load = originalLoad;
46
+ await rm(runtimeDir, { recursive: true, force: true });
47
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react';
3
- import { App } from './App';
3
+ import { App, waitForSessionReady } from './App';
4
4
 
5
5
  // Rendered-DOM coverage (the a11y assertions deferred from T2, and a guard against the
6
6
  // "blank render" class of bug). The Welcome tab fetches inventory/running/approvals on
@@ -45,6 +45,44 @@ describe('App shell (rendered DOM)', () => {
45
45
  expect(screen.getByText(/start a session automatically/i)).toBeTruthy();
46
46
  });
47
47
 
48
+ it('counts qemu and kvm instances as VM runtime coverage in the header (#1782)', async () => {
49
+ for (const kind of ['qemu', 'kvm']) {
50
+ cleanup();
51
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
52
+ const url = String(input);
53
+ if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
54
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [instance(`${kind}-1`, kind, 'full-suite')] });
55
+ if (url.includes('/api/running')) return jsonResponse({ count: 0, running: [] });
56
+ if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
57
+ if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 0, output_tokens: 0, usd: 0 }, per_instance: [] });
58
+ return jsonResponse({});
59
+ }) as typeof fetch;
60
+
61
+ render(<App />);
62
+ expect((await screen.findByTitle('Runtime target coverage')).textContent).toContain('vm ✓');
63
+ }
64
+ });
65
+
66
+ it('does not bind launch session creation to the first unrelated running instance (#1743)', async () => {
67
+ vi.useFakeTimers();
68
+ try {
69
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
70
+ const url = String(input);
71
+ if (url.includes('/api/operations/op-1')) return jsonResponse({ id: 'op-1', state: 'running', result: { runtime: 'docker' } });
72
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [instance('busy-existing', 'container', 'Existing stack')] });
73
+ return jsonResponse({});
74
+ }) as typeof fetch;
75
+
76
+ const ready = waitForSessionReady(undefined, 'op-1');
77
+ const rejection = expect(ready).rejects.toThrow(/waiting for launch operation to report instance id/i);
78
+ for (let i = 0; i < 151; i += 1) await vi.advanceTimersByTimeAsync(1_000);
79
+ await rejection;
80
+ expect(globalThis.fetch).not.toHaveBeenCalledWith(expect.stringContaining('/api/instances/busy-existing/sessions'), expect.anything());
81
+ } finally {
82
+ vi.useRealTimers();
83
+ }
84
+ });
85
+
48
86
  it('renders durable Missions projection from aiwg mc state and live executor work', async () => {
49
87
  globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
50
88
  const url = String(input);
@@ -384,6 +422,86 @@ describe('App shell (rendered DOM)', () => {
384
422
  expect((destroy as HTMLButtonElement).disabled).toBe(false);
385
423
  });
386
424
 
425
+ it('offers Reconnect for a running Docker row whose agent is not registered', async () => {
426
+ const stale = {
427
+ ...instance('stale-dkr-2', 'docker', 'full-suite'),
428
+ agent_ready: false,
429
+ session_backends: [{
430
+ mode: 'managed',
431
+ backend: 'tmux',
432
+ available: false,
433
+ observe: true,
434
+ drive: true,
435
+ reason: 'container is running but the agent has not registered; PTY sessions are not ready',
436
+ }],
437
+ };
438
+ const inventory = { instances: [stale], count: 1, fetched_at: new Date().toISOString() };
439
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
440
+ const url = String(input);
441
+ if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
442
+ if (url.includes('/api/inventory')) return jsonResponse(inventory);
443
+ if (url.includes('/api/running')) return jsonResponse({ count: 0, running: [] });
444
+ if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
445
+ if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 0, output_tokens: 0, usd: 0 }, per_instance: [] });
446
+ if (url.includes('/api/instances/stale-dkr-2/reconnect') && init?.method === 'POST') {
447
+ return jsonResponse({ state: 'reconnecting', message: 'Reconnect requested for stale-dkr-2; inventory will refresh as the agent re-registers.' });
448
+ }
449
+ return jsonResponse({});
450
+ });
451
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
452
+
453
+ render(<App />);
454
+ fireEvent.click(screen.getByRole('tab', { name: 'Inventory' }));
455
+ expect(await screen.findByText('agent unreachable')).toBeTruthy();
456
+ fireEvent.click(await screen.findByRole('button', { name: /reconnect agent for stale-dkr-2/i }));
457
+
458
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
459
+ expect.stringContaining('/api/instances/stale-dkr-2/reconnect'),
460
+ expect.objectContaining({ method: 'POST' }),
461
+ ));
462
+ expect((await screen.findByRole('status')).textContent).toMatch(/reconnect requested/i);
463
+ });
464
+
465
+ it('offers Reconnect for a running VM row whose agent is not registered (#1778)', async () => {
466
+ const staleVm = {
467
+ ...instance('stale-vm-1', 'vm', 'full-suite'),
468
+ agent_ready: false,
469
+ session_backends: [{
470
+ mode: 'managed',
471
+ backend: 'tmux',
472
+ available: false,
473
+ observe: true,
474
+ drive: true,
475
+ reason: 'VM is running but the agent has not registered; PTY sessions are not ready',
476
+ }],
477
+ };
478
+ const inventory = { instances: [staleVm], count: 1, fetched_at: new Date().toISOString() };
479
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
480
+ const url = String(input);
481
+ if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
482
+ if (url.includes('/api/inventory')) return jsonResponse(inventory);
483
+ if (url.includes('/api/running')) return jsonResponse({ count: 0, running: [] });
484
+ if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
485
+ if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 0, output_tokens: 0, usd: 0 }, per_instance: [] });
486
+ if (url.includes('/api/instances/stale-vm-1/reconnect') && init?.method === 'POST') {
487
+ return jsonResponse({ state: 'reconnecting', message: 'Reconnect requested for VM stale-vm-1; inventory will refresh as the agent re-registers.' });
488
+ }
489
+ return jsonResponse({});
490
+ });
491
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
492
+
493
+ render(<App />);
494
+ fireEvent.click(screen.getByRole('tab', { name: 'Inventory' }));
495
+ expect(await screen.findByText('agent unreachable')).toBeTruthy();
496
+ fireEvent.click(await screen.findByRole('button', { name: /reconnect agent for stale-vm-1/i }));
497
+
498
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
499
+ expect.stringContaining('/api/instances/stale-vm-1/reconnect'),
500
+ expect.objectContaining({ method: 'POST' }),
501
+ ));
502
+ expect((await screen.findByRole('status')).textContent).toMatch(/reconnect requested/i);
503
+ });
504
+
387
505
  it('each tab has a matching labelled tabpanel (controls/labelledby pairing)', () => {
388
506
  render(<App />);
389
507
  for (const tab of screen.getAllByRole('tab')) {
package/web/src/App.tsx CHANGED
@@ -2,6 +2,7 @@ import { useEffect, useState, type ReactNode } from 'react';
2
2
  import { useSession } from './useSession';
3
3
  import { api, TOKEN } from './api';
4
4
  import type { Approval, Instance, ResponseNeeded } from './types';
5
+ import { runtimeFamily } from './util';
5
6
  import { Welcome } from './components/Welcome';
6
7
  import { Inventory } from './components/Inventory';
7
8
  import { Running } from './components/Running';
@@ -15,6 +16,7 @@ import { Telemetry } from './components/Telemetry';
15
16
  import { Memory } from './components/Memory';
16
17
  import { StartSessionModal } from './components/StartSessionModal';
17
18
  import { LaunchInstanceModal } from './components/LaunchInstanceModal';
19
+ import { registryResponseNeededItems, useSessionRegistry } from './sessionRegistry';
18
20
 
19
21
  const TABS = [
20
22
  { id: 'welcome', label: 'Home' },
@@ -48,6 +50,8 @@ export function App() {
48
50
  return TABS.some((t) => t.id === hash) ? hash as TabId : 'welcome';
49
51
  });
50
52
  const session = useSession();
53
+ const sessionRegistry = useSessionRegistry();
54
+ const registryResponses = registryResponseNeededItems(sessionRegistry).filter((response) => response.id !== `pty:${sessionRegistry.activeKey}`);
51
55
  const [composer, setComposer] = useState('');
52
56
  const [chrome, setChrome] = useState<ChromeStatus | null>(null);
53
57
  const [startOpen, setStartOpen] = useState(false);
@@ -72,15 +76,16 @@ export function App() {
72
76
  api<{ approvals: Approval[] }>('/api/approvals?status=pending').catch(() => ({ approvals: [] as Approval[] })),
73
77
  ]);
74
78
  if (cancelled) return;
75
- const kinds = inv.instances.map((i) => i.runtime_posture.kind);
79
+ const families = inv.instances.map((i) => runtimeFamily(i.runtime_posture?.kind ?? i.runtime));
80
+ const attachedResponseCount = session.responseNeeded.needed ? 1 : 0;
76
81
  setChrome({
77
82
  executor: health.executor_url,
78
83
  instances: inv.instances.length,
79
84
  running: run.count,
80
- responses: apr.approvals.length + (session.responseNeeded.needed ? 1 : 0),
81
- host: kinds.includes('host'),
82
- container: kinds.includes('container') || kinds.includes('docker'),
83
- vm: kinds.includes('vm'),
85
+ responses: apr.approvals.length + registryResponses.length + attachedResponseCount,
86
+ host: families.includes('host'),
87
+ container: families.includes('container'),
88
+ vm: families.includes('vm'),
84
89
  });
85
90
  } catch {
86
91
  if (!cancelled) setChrome(null);
@@ -89,7 +94,7 @@ export function App() {
89
94
  load();
90
95
  const timer = window.setInterval(load, 15_000);
91
96
  return () => { cancelled = true; window.clearInterval(timer); };
92
- }, [session.responseNeeded.needed, refreshTick]);
97
+ }, [session.responseNeeded.needed, refreshTick, registryResponses.length]);
93
98
 
94
99
  useEffect(() => {
95
100
  if (typeof EventSource === 'undefined' || !TOKEN) return;
@@ -115,22 +120,36 @@ export function App() {
115
120
  const requestStart = (instanceId?: string) => { setStartInst(instanceId); setStartOpen(true); };
116
121
  const handleLaunched = async (instanceId?: string, openSession?: boolean, operationId?: string) => {
117
122
  setRefreshTick((t) => t + 1);
118
- if (openSession) {
119
- const inst = await waitForSessionReady(instanceId, operationId);
120
- const backend = inst.session_backends.find((b) => b.available !== false && b.drive !== false)
121
- ?? inst.session_backends.find((b) => b.available !== false)
122
- ?? inst.session_backends[0];
123
- if (!backend || backend.available === false) throw new Error(backend?.reason ?? 'No available session backend for the new instance.');
124
- const qs = new URLSearchParams({ mode: backend.mode, backend: backend.backend });
125
- const s = await api<{ id: string; attach_url: string }>(
126
- `/api/instances/${encodeURIComponent(inst.id)}/sessions?${qs}`, { method: 'POST' },
127
- );
128
- session.attach(s.attach_url, false, backend.drive === false ? 'observer' : 'controller');
129
- setTab('sessions');
130
- } else {
123
+ if (!openSession) {
131
124
  setTab('inventory');
125
+ setRefreshTick((t) => t + 1);
126
+ return;
132
127
  }
133
- setRefreshTick((t) => t + 1);
128
+ // Readiness can take minutes for heavy loadouts (e.g. full-suite) or stall if the
129
+ // executor is degraded. Never block the launch modal on it: switch to the Sessions
130
+ // workspace now and run the wait+attach in the background. If it doesn't complete,
131
+ // the instance is still visible under Inventory to start a session from manually.
132
+ setTab('sessions');
133
+ void (async () => {
134
+ try {
135
+ const inst = await waitForSessionReady(instanceId, operationId);
136
+ const backend = inst.session_backends.find((b) => b.available !== false && b.drive !== false)
137
+ ?? inst.session_backends.find((b) => b.available !== false)
138
+ ?? inst.session_backends[0];
139
+ if (!backend || backend.available === false) throw new Error(backend?.reason ?? 'No available session backend for the new instance.');
140
+ const qs = new URLSearchParams({ mode: backend.mode, backend: backend.backend });
141
+ const s = await api<{ id: string; attach_url: string }>(
142
+ `/api/instances/${encodeURIComponent(inst.id)}/sessions?${qs}`, { method: 'POST' },
143
+ );
144
+ session.attach(s.attach_url, false, backend.drive === false ? 'observer' : 'controller', { instanceId: inst.id, sessionId: s.id });
145
+ } catch (e) {
146
+ // Non-blocking: surface via console; the instance remains in Inventory.
147
+ console.warn('auto-session after launch did not complete:', (e as Error).message);
148
+ } finally {
149
+ setRefreshTick((t) => t + 1);
150
+ }
151
+ })();
152
+ return;
134
153
  };
135
154
  const copyLaunchCommand = async () => {
136
155
  await navigator.clipboard?.writeText('aiwg cockpit');
@@ -169,14 +188,14 @@ export function App() {
169
188
  </div>
170
189
  <main>
171
190
  <Panel id="welcome" tab={tab}><Welcome onStartSession={() => requestStart()} onLaunchInstance={() => setLaunchOpen(true)} goTo={(t) => setTab(t as TabId)} /></Panel>
172
- <Panel id="inventory" tab={tab}><Inventory onStartSession={requestStart} onLaunchInstance={() => setLaunchOpen(true)} /></Panel>
191
+ <Panel id="inventory" tab={tab}><Inventory onStartSession={requestStart} onLaunchInstance={() => setLaunchOpen(true)} refreshTick={refreshTick} /></Panel>
173
192
  <Panel id="running" tab={tab}><Running refreshTick={refreshTick} /></Panel>
174
193
  <Panel id="missions" tab={tab}><Missions refreshTick={refreshTick} /></Panel>
175
194
  {/* Sessions stays mounted so the WebSocket survives tab switches */}
176
195
  <section id="panel-sessions" role="tabpanel" aria-labelledby="tab-sessions" hidden={tab !== 'sessions'}>
177
196
  <Sessions session={session} composer={composer} setComposer={setComposer} onRequestStart={requestStart} />
178
197
  </section>
179
- <Panel id="approvals" tab={tab}><Approvals refreshTick={refreshTick} responses={session.responseNeeded.needed ? [sessionResponse(session)] : []} goSessions={() => setTab('sessions')} /></Panel>
198
+ <Panel id="approvals" tab={tab}><Approvals refreshTick={refreshTick} responses={[...registryResponses, ...(session.responseNeeded.needed ? [sessionResponse(session)] : [])]} goSessions={() => setTab('sessions')} /></Panel>
180
199
  <Panel id="explore" tab={tab}><Explore /></Panel>
181
200
  <Panel id="library" tab={tab}>
182
201
  <Library session={session} setComposer={setComposer} goSessions={() => setTab('sessions')} />
@@ -222,7 +241,7 @@ const SESSION_READY_TIMEOUT_S = (() => {
222
241
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 150;
223
242
  })();
224
243
 
225
- async function waitForSessionReady(instanceId?: string, operationId?: string) {
244
+ export async function waitForSessionReady(instanceId?: string, operationId?: string) {
226
245
  let last = '';
227
246
  let operationDetail = '';
228
247
  for (let i = 0; i < SESSION_READY_TIMEOUT_S; i += 1) {
@@ -238,10 +257,22 @@ async function waitForSessionReady(instanceId?: string, operationId?: string) {
238
257
  }
239
258
  }
240
259
  const inv = await api<{ instances: Instance[] }>('/api/inventory');
260
+ // Fast-fail: if the instance is visible but has settled into a terminal,
261
+ // non-running state (e.g. a container whose agent never enrolled → 'stopped'),
262
+ // its session will never come up. Abort instead of blocking the launch modal
263
+ // for the full readiness window. A short grace (>3s) avoids tripping on the
264
+ // transient 'provisioning'/'created' states a healthy instance passes through.
265
+ const TERMINAL = new Set(['stopped', 'failed', 'error', 'terminated', 'destroyed', 'exited', 'dead']);
266
+ const present = instanceId ? inv.instances.find((inst) => inst.id === instanceId) : null;
267
+ if (present && i > 3 && TERMINAL.has(String(present.state).toLowerCase())) {
268
+ throw new Error(
269
+ `Instance ${present.id} did not come online — it settled to '${present.state}' instead of running`
270
+ + (operationDetail ? ` (${operationDetail})` : '')
271
+ + '. Its agent likely failed to register; check the runtime and try again.',
272
+ );
273
+ }
241
274
  const candidates = inv.instances.filter((inst) => String(inst.state).toLowerCase() === 'running');
242
- const selected = instanceId
243
- ? candidates.find((inst) => inst.id === instanceId)
244
- : candidates[0];
275
+ const selected = instanceId ? candidates.find((inst) => inst.id === instanceId) : null;
245
276
  if (selected) {
246
277
  const backend = selected.session_backends.find((b) => b.available !== false) ?? selected.session_backends[0];
247
278
  if (backend && backend.available !== false) return selected;
@@ -249,7 +280,7 @@ async function waitForSessionReady(instanceId?: string, operationId?: string) {
249
280
  } else {
250
281
  last = [
251
282
  operationDetail,
252
- instanceId ? `instance ${instanceId} not visible in inventory yet` : 'no running instance visible in inventory yet',
283
+ instanceId ? `instance ${instanceId} not visible in inventory yet` : 'waiting for launch operation to report instance id',
253
284
  ].filter(Boolean).join('; ');
254
285
  }
255
286
  await sleep(1_000);
@@ -40,7 +40,7 @@ export function Actions({ refreshTick = 0, session, setComposer, goSessions }: {
40
40
  },
41
41
  }),
42
42
  }).catch(() => undefined);
43
- if (session.isController && session.sendInput(command)) {
43
+ if (session.isController && session.state.target && session.sendInput(command, session.state.target)) {
44
44
  setNote(`Injected "${command}" into the attached session — the agent runs it.`);
45
45
  } else {
46
46
  setComposer(command); // prefill the session composer; attach (drive) or start one, then Send
@@ -5,7 +5,7 @@ import type { Instance } from '../types';
5
5
 
6
6
  interface Inv { count: number; fetched_at: string; instances: Instance[] }
7
7
 
8
- export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession?: (instanceId?: string) => void; onLaunchInstance?: () => void }) {
8
+ export function Inventory({ onStartSession, onLaunchInstance, refreshTick = 0, refreshMs = 5_000 }: { onStartSession?: (instanceId?: string) => void; onLaunchInstance?: () => void; refreshTick?: number; refreshMs?: number }) {
9
9
  const [data, setData] = useState<Inv | null>(null);
10
10
  const [err, setErr] = useState('');
11
11
  const [actionErr, setActionErr] = useState('');
@@ -14,13 +14,19 @@ export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession
14
14
  const load = useCallback(() => {
15
15
  api<Inv>('/api/inventory').then((d) => { setData(d); setErr(''); }).catch((e) => setErr((e as Error).message));
16
16
  }, []);
17
- useEffect(() => { load(); }, [load]);
17
+ // Poll (and react to the app-wide refreshTick) so instances launched after this
18
+ // tab first mounted appear without a manual reload — matches the other data tabs.
19
+ useEffect(() => {
20
+ load();
21
+ const timer = window.setInterval(load, refreshMs);
22
+ return () => window.clearInterval(timer);
23
+ }, [load, refreshMs, refreshTick]);
18
24
 
19
- const control = (path: string, method: string) =>
25
+ const control = (path: string, method: string, fallbackMessage = '') =>
20
26
  api<{ already_gone?: boolean; message?: string }>(path, { method })
21
27
  .then((result) => {
22
28
  setActionErr('');
23
- setActionMsg(result.already_gone ? (result.message ?? 'Instance already removed; inventory refreshed.') : '');
29
+ setActionMsg(result.message ?? (result.already_gone ? 'Instance already removed; inventory refreshed.' : fallbackMessage));
24
30
  load();
25
31
  })
26
32
  .catch((e) => {
@@ -45,13 +51,13 @@ export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession
45
51
  <div className="section-toolbar">
46
52
  <div>
47
53
  <h2>Agent instances</h2>
48
- <p className="hint">{data.count} target(s) · {new Date(data.fetched_at).toLocaleTimeString()}</p>
54
+ <p className="hint">{data.count} {data.count === 1 ? 'target' : 'targets'} · {new Date(data.fetched_at).toLocaleTimeString()}</p>
49
55
  </div>
50
- {onLaunchInstance && <button className="cta" onClick={onLaunchInstance}>+ New instance + session</button>}
56
+ {onLaunchInstance && <button className="cta" onClick={onLaunchInstance}>New instance</button>}
51
57
  </div>
52
58
  {actionErr && <p className="err">Action failed: {actionErr}</p>}
53
59
  {actionMsg && <p className="hint" role="status">{actionMsg}</p>}
54
- <table>
60
+ <table className="inventory-table">
55
61
  <caption>Available instance deployments</caption>
56
62
  <thead>
57
63
  <tr>
@@ -69,13 +75,15 @@ export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession
69
75
  {(() => {
70
76
  const sessionReady = i.session_backends?.some((b) => b.available);
71
77
  const unavailableReason = i.session_backends?.find((b) => !b.available)?.reason;
78
+ const reconnectable = isReconnectable(i);
79
+ const health = instanceHealth(i);
72
80
  return (
73
81
  <>
74
- <td>
82
+ <td className="instance-cell">
75
83
  <code title={i.id}>{i.launch_context?.name ?? fmtId(i.id)}</code>
76
84
  {i.launch_context?.name && <div className="cell-note">{fmtId(i.id)}</div>}
77
85
  </td>
78
- <td>
86
+ <td className="runtime-cell">
79
87
  <span className={`badge isolation-${i.runtime_posture.isolation}`} title={i.runtime_posture.warning || i.runtime_posture.label}>
80
88
  {i.runtime_posture.label}
81
89
  </span>
@@ -92,12 +100,17 @@ export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession
92
100
  </span>
93
101
  <div className="cell-note">{i.transport.mode}{i.transport.stale ? ' · stale' : ''}</div>
94
102
  </td>
95
- <td>
103
+ <td className="daemon-cell">
96
104
  <span className={`badge daemon-${i.host_daemon.status}`}>{i.host_daemon.status.replace('_', ' ')}</span>
97
105
  {i.host_daemon.detail && <div className="cell-note">{i.host_daemon.detail}</div>}
98
106
  {i.host_daemon.operator_command && <code title="Operator start command">{i.host_daemon.operator_command}</code>}
99
107
  </td>
100
- <td><span className={`state ${i.state}`}><span className="dot" aria-hidden="true" />{i.state}</span></td>
108
+ <td>
109
+ <span className={`state ${health.kind === 'stale-agent' ? 'degraded' : i.state}`} title={health.detail}>
110
+ <span className="dot" aria-hidden="true" />{health.label}
111
+ </span>
112
+ {health.detail && health.kind !== 'healthy' && <div className="cell-note">{health.detail}</div>}
113
+ </td>
101
114
  <td>{i.tenant}</td>
102
115
  <td className="manage-actions">
103
116
  {i.state === 'running' && onStartSession && (
@@ -108,12 +121,21 @@ export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession
108
121
  title={!sessionReady ? unavailableReason : undefined}
109
122
  onClick={() => onStartSession(i.id)}
110
123
  >
111
- New Session
124
+ Session
125
+ </button>
126
+ )}{' '}
127
+ {reconnectable && (
128
+ <button
129
+ aria-label={`Reconnect agent for ${fmtId(i.id)}`}
130
+ title={unavailableReason ?? 'Ask the running agent to re-register without restarting the instance.'}
131
+ onClick={() => control(`/api/instances/${encodeURIComponent(i.id)}/reconnect`, 'POST', 'Reconnect requested; inventory will refresh shortly.')}
132
+ >
133
+ Reconnect
112
134
  </button>
113
135
  )}{' '}
114
136
  {i.state === 'running'
115
- ? <button aria-label={`Stop instance ${fmtId(i.id)}`} onClick={() => control(`/api/instances/${encodeURIComponent(i.id)}/stop`, 'POST')}>Stop Instance</button>
116
- : <button aria-label={`Start instance ${fmtId(i.id)}`} onClick={() => control(`/api/instances/${encodeURIComponent(i.id)}/start`, 'POST')}>Start Instance</button>}{' '}
137
+ ? <button aria-label={`Stop instance ${fmtId(i.id)}`} onClick={() => control(`/api/instances/${encodeURIComponent(i.id)}/stop`, 'POST')}>Stop</button>
138
+ : <button aria-label={`Start instance ${fmtId(i.id)}`} onClick={() => control(`/api/instances/${encodeURIComponent(i.id)}/start`, 'POST')}>Start</button>}{' '}
117
139
  <button
118
140
  aria-label={`Destroy instance ${fmtId(i.id)}`}
119
141
  title={i.state !== 'running' && i.runtime === 'docker' ? 'Stopped Docker row — Destroy removes the container directly (admin-v2 has no instance record).' : undefined}
@@ -132,3 +154,28 @@ export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession
132
154
  </>
133
155
  );
134
156
  }
157
+
158
+ // VM runtimes included per #1778 — the bridge signals the in-guest agent via
159
+ // qemu-guest-agent, the container/docker path via docker exec.
160
+ const RECONNECTABLE_RUNTIMES = ['docker', 'container', 'vm', 'qemu', 'kvm'];
161
+
162
+ function isReconnectable(i: Instance): boolean {
163
+ const runtime = String(i.runtime_posture?.kind ?? i.runtime).toLowerCase();
164
+ const running = String(i.state).toLowerCase() === 'running';
165
+ const agentMissing = i.agent_ready === false || i.session_backends?.some((b) => b.available === false);
166
+ return running && RECONNECTABLE_RUNTIMES.includes(runtime) && Boolean(agentMissing);
167
+ }
168
+
169
+ function instanceHealth(i: Instance): { kind: 'healthy' | 'stale-agent'; label: string; detail?: string } {
170
+ const running = String(i.state).toLowerCase() === 'running';
171
+ const unavailableReason = i.session_backends?.find((b) => b.available === false)?.reason;
172
+ const agentMissing = i.agent_ready === false || Boolean(unavailableReason);
173
+ if (running && agentMissing) {
174
+ return {
175
+ kind: 'stale-agent',
176
+ label: 'agent unreachable',
177
+ detail: unavailableReason ?? 'Runtime is still running, but the agent is not registered.',
178
+ };
179
+ }
180
+ return { kind: 'healthy', label: i.state };
181
+ }